The Second-Level Cache Illusion
The Second-Level Cache Illusion
The Lie
Enable the second-level cache, annotate your entities with @Cacheable, and Hibernate caches your objects in memory. Reads become fast. Database load drops. Performance improves.
The Reality
The second-level cache does not store objects. It stores dehydrated state: arrays of column values indexed by entity ID. When you “read from cache,” Hibernate still instantiates a new Java object, injects the cached column values, and wires up proxies for associations. The object construction cost is not eliminated. The database round trip is.
The query cache, which is a separate feature, caches the list of IDs returned by a query. Not the entities. Not the results. When you execute a cached query, Hibernate gets the ID list from the query cache, then looks up each entity by ID in the second-level cache. If any entity is missing from the L2 cache (evicted, expired, or never cached), Hibernate hits the database for that entity. If half your result set has been evicted, you get a cache that is slower than no cache at all: one L2 lookup per ID, then one database query per miss, instead of a single query returning all results.
The diagram shows the relationship between per-request L1 caches and the shared L2 cache. The critical detail is at the bottom: the L2 cache stores dehydrated state (raw column values), not live Java objects. Every cache hit still requires object instantiation. And the query cache’s dependency on entity cache state means that an empty L2 region turns a fast query-cache hit into N individual database lookups.
The Evidence
@Entity
@Table(name = "products")
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
private String category;
}
Configuration:
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
use_query_cache: true
region:
factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
generate_statistics: true
jakarta:
persistence:
sharedCache:
mode: ENABLE_SELECTIVE
// First call: hits the database, populates L2 cache
Product p1 = entityManager.find(Product.class, 1L);
// Generated SQL:
// select p1_0.id, p1_0.name, p1_0.price, p1_0.category from products p1_0 where p1_0.id=?
entityManager.clear(); // Clear the first-level cache (persistence context)
// Second call: hits L2 cache, no SQL generated
Product p2 = entityManager.find(Product.class, 1L);
// No SQL generated - served from L2 cache
That works. Now add the query cache:
// BAD: Query cache with low L2 cache hit rate
@Query("SELECT p FROM Product p WHERE p.category = :category")
@QueryHint(name = "org.hibernate.cacheable", value = "true")
List<Product> findByCategory(@Param("category") String category);
// First execution: runs query, caches ID list [1, 2, 3, 4, 5]
// select p1_0.id, p1_0.name, p1_0.price, p1_0.category from products p1_0 where p1_0.category=?
// Second execution: reads ID list from query cache, looks up each in L2 cache
// If all 5 are in L2: 0 SQL queries. Fast.
// If product 3 was evicted from L2: 1 SQL query for product 3
// If all 5 were evicted: 5 individual SQL queries by primary key
// That is WORSE than the original query that would have returned all 5 in one SELECT
The query cache has a nuclear invalidation strategy: any INSERT, UPDATE, or DELETE on the products table invalidates every cached query that touches products. Not just queries whose results changed. All of them. A single UPDATE products SET price = price * 1.1 WHERE category = 'electronics' invalidates cached queries for every category.
The Fix
For the L2 entity cache: use it for read-heavy, rarely-modified entities with a small cardinality. Configuration tables, country codes, product categories. Not for transactional entities that change frequently.
// BETTER: Cache only stable reference data
@Entity
@Table(name = "countries")
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public class Country {
@Id
private String code;
private String name;
private String currency;
// This table has ~250 rows and changes never. Perfect L2 cache candidate.
}
For the query cache: almost never use it. The invalidation granularity is too coarse for most applications. The only scenario where it helps: a query that runs thousands of times per minute against a table that is written to less than once per minute. That is a narrow window.
// BETTER: If you must use the query cache, pair it with proper L2 cache TTL
// ehcache.xml or caffeine config
// Region: "default-query-results-region"
// maxEntries: 1000
// timeToLive: 300 seconds
// Region: "com.example.Product"
// maxEntries: 5000
// timeToLive: 600 seconds (must be >= query cache TTL)
The L2 cache TTL must be longer than the query cache TTL. If entity cache entries expire before query cache entries, the query cache returns ID lists that point to expired entity cache entries, causing individual database lookups.
The Cost Model
L2 entity cache, 10,000 products, 200 bytes each:
- Memory: ~2MB (dehydrated state is compact)
- Hit: 0.01ms (in-process memory lookup + object hydration)
- Miss: 1-20ms (database round trip)
- Invalidation: per-entity (fine-grained)
Query cache, 100 cached queries:
- Memory: negligible (stores ID lists only)
- Hit with full L2 hit: 0.1ms (ID list lookup + N entity cache lookups)
- Hit with L2 misses: potentially worse than no cache
- Invalidation: per-table (coarse-grained, nuclear)
The second-level cache helps when:
- The entity is read 100x more than it is written
- The entity count fits comfortably in memory
- The cache concurrency strategy matches your write pattern
The second-level cache hurts when:
- Write frequency is high (constant invalidation)
- Entity count exceeds cache capacity (eviction thrashing)
- You enable the query cache without understanding the invalidation model
This diagram shows the layered cache architecture: the first-level cache (persistence context) scoped to a single transaction, the second-level cache shared across sessions storing dehydrated entity state, and the query cache storing ID lists that reference back into the L2 cache. The critical path to understand is the query cache miss scenario, where Hibernate must individually look up each entity ID, potentially hitting the database for each cache miss.