JPA N+1 Problem & Hibernate Second-Level Cache: Complete Fix Guide for Spring Boot (2026)

A comprehensive guide to diagnosing and fixing the JPA N+1 query problem with JOIN FETCH, @EntityGraph, and batch fetching — then supercharging performance further with Hibernate's second-level cache using EHCache and Redis as distributed L2 cache providers.

JPA N+1 Problem Hibernate Second Level Cache Spring Boot 2026
TL;DR: N+1 = 1 query for N parents + N queries for children. Fix with JOIN FETCH (JPQL), @EntityGraph (Spring Data), or default_batch_fetch_size (Hibernate config). Add Hibernate L2 cache (EHCache or Redis) to cache entity state across transactions — transparent to your repository code. Enable Hibernate statistics or p6spy to detect problems first.

1. What is the N+1 Problem? (with SQL Count Demo)

The N+1 problem is the single most common JPA performance killer. It occurs when lazy-loading a collection association triggers one SQL query per parent entity instead of a single JOIN.

// Entity definitions — Order has many OrderItems (lazy by default)
@Entity
public class Order {
    @Id private Long id;
    private String customerName;

    // LAZY is the JPA default for collections — the N+1 trap
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items;
}

@Entity
public class OrderItem {
    @Id private Long id;
    private String productName;
    private BigDecimal price;

    @ManyToOne
    private Order order;
}
// ❌ N+1 in action — this innocent code fires 101 SQL queries for 100 orders
List<Order> orders = orderRepository.findAll();  // 1 query: SELECT * FROM orders
for (Order order : orders) {
    // LAZY proxy triggered here — 1 query per order!
    int itemCount = order.getItems().size();      // 100 queries: SELECT * FROM order_items WHERE order_id = ?
    System.out.println(order.getId() + ": " + itemCount + " items");
}
// Total: 1 + 100 = 101 SQL queries for 100 orders
// Scale to 1000 orders = 1001 queries — DB under siege
# SQL output in logs — the smoking gun (showing first 3 of 101 queries)
Hibernate: select o.* from orders o
Hibernate: select oi.* from order_items oi where oi.order_id=1
Hibernate: select oi.* from order_items oi where oi.order_id=2
Hibernate: select oi.* from order_items oi where oi.order_id=3
... (97 more identical queries with different IDs)

The fix is not to change FetchType.LAZY to FetchType.EAGER — that causes N+1 on every load, even when you don't need the collection. Instead, use explicit JOIN FETCH in the queries where you need the association.

2. Detecting N+1: Hibernate Statistics, p6spy & datasource-proxy

# application.yml — enable Hibernate statistics (dev/staging only)
spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true    # enable StatisticsService
        format_sql: true
    show-sql: true

logging:
  level:
    org.hibernate.stat: DEBUG        # prints stats per session
    org.hibernate.SQL: DEBUG         # all SQL statements
    org.hibernate.type.descriptor.sql: TRACE  # bind parameter values
// p6spy — intercept all JDBC calls with full SQL + execution time
<!-- pom.xml -->
<dependency>
    <groupId>com.github.gavlyukovskiy</groupId>
    <artifactId>p6spy-spring-boot-starter</artifactId>
    <version>1.9.2</version>
    <scope>runtime</scope>
</dependency>

<!-- spy.properties in resources/ -->
<!-- appender=com.p6spy.engine.spy.appender.Slf4JLogger -->
<!-- logMessageFormat=%(currentTime)|%(executionTime)ms|%(sql) -->

// datasource-proxy — programmatic query count assertion in tests
@Bean
public DataSource dataSource(DataSourceProperties props) {
    DataSource real = props.initializeDataSourceBuilder().build();
    return ProxyDataSourceBuilder.create(real)
        .name("DS-Proxy")
        .logQueryBySlf4j(SLF4JLogLevel.DEBUG)
        .countQuery()
        .build();
}

// In tests — assert maximum query count
@Test
void loadOrdersWithItems_shouldNotCauseN1() {
    QueryCountHolder.clear();
    orderService.getOrdersWithItems();
    QueryCount qc = QueryCountHolder.getGrandTotal();
    assertThat(qc.getSelect()).isLessThanOrEqualTo(2); // 1 for orders + 1 JOIN
}

3. Fix with JOIN FETCH (JPQL)

JOIN FETCH loads the parent and associated collection in a single SQL JOIN query, eliminating the N additional queries completely.

// OrderRepository.java — JOIN FETCH in JPQL query
public interface OrderRepository extends JpaRepository<Order, Long> {

    // ✅ Single query — loads orders and items in one JOIN
    @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
    List<Order> findByStatusWithItems(@Param("status") OrderStatus status);

    // ✅ Nested JOIN FETCH — load order + items + product in one query
    @Query("SELECT DISTINCT o FROM Order o " +
           "JOIN FETCH o.items i " +
           "JOIN FETCH i.product p " +
           "WHERE o.customerId = :customerId")
    List<Order> findByCustomerWithItemsAndProducts(@Param("customerId") Long customerId);

    // ⚠️ CAUTION: multiple JOIN FETCH creates Cartesian product
    // If order has 3 items and 4 tags: 3 * 4 = 12 rows returned per order
    // Fix with DISTINCT + QueryHint:
    @QueryHints(@QueryHint(name = HINT_PASS_DISTINCT_THROUGH, value = "false"))
    @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items JOIN FETCH o.tags")
    List<Order> findAllWithItemsAndTags();
}
# SQL generated — one query instead of 101
-- Without JOIN FETCH: 1 + N queries
SELECT * FROM orders WHERE status = 'PENDING';
SELECT * FROM order_items WHERE order_id = 1;
SELECT * FROM order_items WHERE order_id = 2;
-- ... (N more)

-- With JOIN FETCH: 1 query
SELECT DISTINCT o.*, oi.*
FROM orders o
INNER JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'PENDING';

4. Fix with @EntityGraph (Spring Data)

@EntityGraph applies a fetch plan on top of any existing repository method without modifying the query. It's ideal for Spring Data derived queries and findById operations.

// Named EntityGraph on entity + usage in repository
@Entity
@NamedEntityGraph(
    name = "Order.withItemsAndCustomer",
    attributeNodes = {
        @NamedAttributeNode("items"),
        @NamedAttributeNode("customer")
    }
)
public class Order { /* ... */ }

// Repository — apply the named graph to any method
public interface OrderRepository extends JpaRepository<Order, Long> {

    // ✅ findById with eager items + customer — no N+1
    @EntityGraph(value = "Order.withItemsAndCustomer")
    Optional<Order> findById(Long id);

    // ✅ Inline entity graph — no @NamedEntityGraph needed
    @EntityGraph(attributePaths = {"items", "items.product", "customer"})
    List<Order> findByCustomerId(Long customerId);

    // ✅ Works on derived query methods too
    @EntityGraph(attributePaths = {"items"})
    List<Order> findByStatusAndCreatedAtAfter(OrderStatus status, LocalDateTime after);
}

// EntityGraph types:
// FETCH (default) — listed attributes EAGER, rest remain as declared
// LOAD             — listed attributes EAGER, rest use their declared fetch type

5. Batch Fetching: default_batch_fetch_size

Batch fetching is a Hibernate-level solution that batches lazy-load SQL queries using the SQL IN clause. Instead of N queries, you get ceil(N/batchSize) queries. It requires zero changes to your entity mappings or queries.

# application.yml — global batch fetch size (applies to all collections)
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 50   # batch up to 50 IDs per IN query
        # Result: loading 100 orders' items = 2 queries (100 / 50)
        # Instead of 100 queries!
// Fine-grained @BatchSize per association or entity
@Entity
public class Order {
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 100)  // override global setting for this collection
    private List<OrderItem> items;
}

// SQL generated with batch_fetch_size=50 for 100 orders:
// SELECT * FROM order_items WHERE order_id IN (1,2,3,...,50)
// SELECT * FROM order_items WHERE order_id IN (51,52,...,100)
// 2 queries instead of 100 — 98% reduction!
SolutionQuery CountCode ChangeRisk
No fix (lazy)N+1 = 101NoneHigh (DB overload)
Batch fetch (50)~3 queries1 config lineLow
JOIN FETCH1 queryNew JPQL queryCartesian product
@EntityGraph1 queryAnnotation onlyCartesian product

6. DTO Projections: Skip Entity Loading Entirely

When you only need a subset of fields (e.g., for a list view), projections avoid loading full entity graphs — drastically reducing memory usage and query complexity.

// Option 1: Interface projection — Spring Data generates proxy at runtime
public interface OrderSummary {
    Long getId();
    String getCustomerName();
    @Value("#{target.items.size()}")
    int getItemCount();  // computed expression
}

// Repository — returns projections, not entities — no L1 cache pollution
List<OrderSummary> findByStatus(OrderStatus status);

// Option 2: Class-based DTO projection — explicit, type-safe
public record OrderSummaryDTO(Long id, String customerName, int itemCount) {}

// JPQL constructor expression
@Query("SELECT new com.example.dto.OrderSummaryDTO(o.id, o.customerName, SIZE(o.items)) " +
       "FROM Order o WHERE o.status = :status")
List<OrderSummaryDTO> findSummaryByStatus(@Param("status") OrderStatus status);

// Option 3: Spring Data native query projection
@Query(value = "SELECT o.id, o.customer_name, COUNT(oi.id) as item_count " +
               "FROM orders o LEFT JOIN order_items oi ON oi.order_id = o.id " +
               "WHERE o.status = :status GROUP BY o.id, o.customer_name",
       nativeQuery = true)
List<OrderSummaryProjection> findNativeSummary(@Param("status") String status);

7. Hibernate L1 vs L2 Cache: Concepts & Architecture

Fixing N+1 reduces query count. Adding L2 cache eliminates repeat DB hits entirely for frequently accessed entities.

AspectL1 Cache (First-Level)L2 Cache (Second-Level)
ScopePer EntityManager (request/transaction)SessionFactory-wide (all sessions)
LifetimeDiscarded at end of transactionConfigurable TTL, survives restarts (distributed)
Enabled by defaultYes — always onNo — must configure explicitly
StoresEntity objects (managed state)Disassembled entity state (not managed objects)
Cache strategiesN/AREAD_ONLY, NONSTRICT_READ_WRITE, READ_WRITE, TRANSACTIONAL

Cache strategy guide: Use READ_ONLY for immutable reference data (currencies, countries) — fastest, no locking overhead. Use READ_WRITE for mutable entities that can tolerate soft-lock invalidation. Use NONSTRICT_READ_WRITE for data where brief inconsistency is acceptable (product descriptions, ratings).

8. EHCache Integration with Spring Boot

EHCache 3 (JSR-107 compliant) is the most common embedded L2 cache for Hibernate — zero external infrastructure required, perfect for single-node or development environments.

// pom.xml — EHCache 3 + Hibernate JCache integration
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <classifier>jakarta</classifier>
</dependency>
# application.yml — enable L2 cache with EHCache
spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          use_query_cache: true
          region:
            factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
        javax:
          cache:
            provider: org.ehcache.jsr107.EhcacheCachingProvider
            uri: classpath:ehcache.xml
// ehcache.xml — cache region configuration
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="http://www.ehcache.org/v3">
  <!-- Default cache — entities without explicit config -->
  <cache-template name="default">
    <expiry><ttl unit="minutes">10</ttl></expiry>
    <heap unit="entries">1000</heap>
  </cache-template>

  <!-- Product entity cache — read-heavy reference data -->
  <cache alias="com.example.entity.Product" uses-template="default">
    <expiry><ttl unit="hours">2</ttl></expiry>
    <heap unit="entries">5000</heap>
  </cache>

  <!-- Hibernate default query cache region -->
  <cache alias="default-query-results-region">
    <expiry><ttl unit="minutes">5</ttl></expiry>
    <heap unit="entries">500</heap>
  </cache>
</config>
// Entity — mark for L2 caching with @Cache annotation
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)   // mutable entity
public class Product {
    @Id private Long id;
    private String name;
    private BigDecimal price;

    @OneToMany
    @Cache(usage = CacheConcurrencyStrategy.READ_ONLY) // immutable association
    private List<Category> categories;
}

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)    // never changes
@Immutable
public class Country {
    @Id private String code;
    private String name;
}

9. Redis as Hibernate L2 Cache Provider

For multi-node deployments, EHCache is a per-node in-memory cache — cache invalidation doesn't propagate across nodes. Redis as L2 cache is shared across all application nodes, making it the right choice for microservices clusters and Kubernetes pods.

// pom.xml — Redisson Hibernate L2 cache provider
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-hibernate-6</artifactId>
    <version>3.27.0</version>
</dependency>
# application.yml — Redisson as Hibernate L2 cache
spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          use_query_cache: true
          region:
            factory_class: org.redisson.hibernate.RedissonRegionFactory
        redisson:
          fallback: true          # degrade to DB if Redis unavailable
          config: redisson.yaml   # Redisson config file

# redisson.yaml (in resources/)
# singleServerConfig:
#   address: redis://localhost:6379
# clusterServersConfig:
#   nodeAddresses:
#     - redis://redis-1:6379
#     - redis://redis-2:6379
// Redisson per-region TTL config (in HibernateProperties bean)
@Bean
public HibernatePropertiesCustomizer hibernateCacheCustomizer() {
    return properties -> {
        // Per-entity cache region TTL (milliseconds)
        properties.put("hibernate.cache.redisson.entity.com.example.Product.expiration.time.to.live", "3600000");
        properties.put("hibernate.cache.redisson.entity.com.example.Category.expiration.time.to.live", "86400000");
        // Max entries in L2 cache per region
        properties.put("hibernate.cache.redisson.entity.com.example.Product.eviction.max.entries", "10000");
    };
}

10. Query Cache vs Collection Cache

Hibernate's query cache stores the list of entity IDs returned by a JPQL/HQL query. On a cache hit, it retrieves IDs from the query cache and hydrates entities from the L2 entity cache (or DB if not cached).

// Enabling query cache per query
// Spring Data — @QueryHints to cache query results
@QueryHints({
    @QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true"),
    @QueryHint(name = org.hibernate.annotations.QueryHints.CACHE_REGION, value = "product.listByCategory")
})
@Query("SELECT p FROM Product p WHERE p.categoryId = :catId ORDER BY p.name")
List<Product> findByCategoryIdCached(@Param("catId") Long catId);

// EntityManager — programmatic query cache hint
TypedQuery<Product> q = em.createQuery("SELECT p FROM Product p WHERE p.active = true", Product.class);
q.setHint("org.hibernate.cacheable", true);
q.setHint("org.hibernate.cacheRegion", "active.products");
List<Product> products = q.getResultList();

// IMPORTANT: Query cache is invalidated whenever ANY entity in the FROM clause
// is inserted/updated/deleted. For very frequently mutating tables,
// query cache has no value — only use for stable data.

11. Cache Regions and Eviction

Cache regions allow you to group entities into independently configurable and evictable namespaces. Evict programmatically when you know an entity has changed (e.g., after admin updates).

// Programmatic L2 cache eviction via SessionFactory
@Service
public class ProductAdminService {

    @Autowired private EntityManagerFactory emf;
    @Autowired private ProductRepository productRepository;

    public Product updateProduct(Product product) {
        Product saved = productRepository.save(product);

        // Evict specific entity from L2 cache
        Cache l2Cache = emf.getCache();
        l2Cache.evict(Product.class, product.getId());

        // Evict entire entity class from L2 cache
        l2Cache.evict(Product.class);

        // Evict all L2 cache regions (nuclear option — use sparingly)
        l2Cache.evictAll();

        return saved;
    }
}

// Check if entity is cached — useful in tests
boolean isCached = emf.getCache().contains(Product.class, productId);

// Custom cache region on entity — overrides default region name
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "premium.products")
public class PremiumProduct { /* ... */ }

12. Production Checklist

✅ JPA N+1 & Hibernate L2 Cache Production Checklist
  • Enable Hibernate stats in staging to find N+1
  • Add p6spy or datasource-proxy for SQL visibility
  • Assert query count in repository tests
  • All @OneToMany use LAZY fetch (default)
  • JOIN FETCH only where collection is needed
  • @EntityGraph on findById for parent+child loads
  • default_batch_fetch_size=50 as safety net
  • Use DTO projections for list/summary endpoints
  • L2 cache enabled for read-heavy entities
  • @Cache READ_ONLY for immutable entities
  • Query cache only for stable, infrequently-mutated data
  • Redisson for multi-node distributed L2 cache
  • Per-region TTL tuned per entity change rate
  • Monitor L2 hit ratio via Hibernate Statistics MBean
  • Evict L2 cache in admin update operations
  • No FetchType.EAGER on @OneToMany
Performance Impact in Production

A team reduced their product listing endpoint from 450ms to 12ms by: (1) fixing N+1 with JOIN FETCH (45ms → 18ms), (2) adding DTO projection to avoid hydrating full entities (18ms → 12ms), (3) enabling L2 cache for Product entities (12ms cached vs 18ms cold). The DB query count went from 201 queries per page to 1 query per page on warm cache.

Tags:
jpa n+1 problem hibernate second level cache spring boot jpa n+1 fix hibernate l2 cache 2026 ehcache hibernate join fetch entitygraph

Leave a Comment

Related Posts

System Design

MySQL InnoDB Internals: Buffer Pool & MVCC

Microservices

Redis Caching in Spring Boot: Production Guide

System Design

PostgreSQL Query Optimization

System Design

MongoDB with Spring Boot Production Guide

Back to Blog Last updated: April 11, 2026