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.
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
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;
}
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
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
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
<!-- 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.
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();
}
-- 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.
@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.
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!
@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!
| Solution | Query Count | Code Change | Risk |
|---|---|---|---|
| No fix (lazy) | N+1 = 101 | None | High (DB overload) |
| Batch fetch (50) | ~3 queries | 1 config line | Low |
| JOIN FETCH | 1 query | New JPQL query | Cartesian product |
| @EntityGraph | 1 query | Annotation only | Cartesian 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.
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.
| Aspect | L1 Cache (First-Level) | L2 Cache (Second-Level) |
|---|---|---|
| Scope | Per EntityManager (request/transaction) | SessionFactory-wide (all sessions) |
| Lifetime | Discarded at end of transaction | Configurable TTL, survives restarts (distributed) |
| Enabled by default | Yes — always on | No — must configure explicitly |
| Stores | Entity objects (managed state) | Disassembled entity state (not managed objects) |
| Cache strategies | N/A | READ_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.
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<classifier>jakarta</classifier>
</dependency>
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
<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
@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.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-hibernate-6</artifactId>
<version>3.27.0</version>
</dependency>
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
@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).
// 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).
@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
- 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
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.