Second-Level Cache In Depth
Configuring a second-level cache provider, cache regions and concurrency strategies, and when caching helps vs hurts.
What the second-level cache actually caches
The first-level cache (the persistence context) is mandatory and scoped to one session — covered on the Sessions & Caching page. The second-level cache is optional, configured explicitly, and shared across every session in the application, letting one request benefit from data a completely different request already loaded:
Session A: loads Product #42 --> hits the database, populates the 2nd-level cache
Session B: loads Product #42 --> finds it in the 2nd-level cache, NO database hit at all
It's a cache of entity state keyed by ID (and, separately, of query results if explicitly enabled) — not a cache of live Java object references, since those are still recreated per session from the cached state.
Configuring a cache provider
Hibernate doesn't implement caching storage itself — it delegates to a JCache (JSR-107) provider. Ehcache is the most common choice in the Spring ecosystem:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.internal.JCacheRegionFactory
spring.jpa.properties.hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider
Marking an entity cacheable
Caching is opt-in per entity — nothing is cached just because the provider is configured:
@Entity
@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;
}
@Cacheable is the standard JPA annotation opting the entity into the second-level cache at all; @Cache (Hibernate-specific) selects a concurrency strategy — how the cache behaves under concurrent reads and writes to the same entity.
Cache regions and concurrency strategies
Each cacheable entity type gets its own region — a logically separate cache space, individually sized, configured, and evicted:
| Concurrency strategy | Behavior | Fit for |
|---|---|---|
READ_ONLY |
Entity is cached and never updated after first load; an update on a read-only-cached entity throws | Truly immutable reference data (country codes, a finalized historical record) |
READ_WRITE |
Supports updates, using a locking mechanism to keep the cache consistent with concurrent writes | The common case — data that's read far more often than it's written, but does change occasionally |
NONSTRICT_READ_WRITE |
Updates the cache without strict locking, accepting a brief window of possible staleness | Data where an occasional stale read for a moment after a write is an acceptable trade-off for lower overhead |
TRANSACTIONAL |
Fully transactional, JTA-integrated caching | Rare — only when the cache provider and full XA transaction support are already in place |
When it helps
- Rarely-changing, frequently-read reference/lookup data — product categories, country lists, configuration flags, a "plans" table. Many sessions repeatedly reading the exact same small set of rows is the textbook case.
- Read-heavy entities with an expensive load path — an entity whose query involves several joins or computed columns, read far more often than it changes.
When it hurts
- Frequently-updated entities — every write to a cached entity has to invalidate (or update) the cache entry across the whole application, adding overhead to every write for a read-side benefit that may not be worth it if reads aren't actually that frequent.
- Large entities/collections cached wholesale — the cache consumes memory proportional to what's cached; caching something huge for a marginal hit-rate improvement is a poor trade.
- Data with strict consistency requirements — anything where a cached, slightly-stale read would be a real correctness problem (e.g. real-time inventory counts during checkout) shouldn't be cached at the entity level without very careful thought about the concurrency strategy's staleness window.
| Helps | Hurts | |
|---|---|---|
| Read/write ratio | Read-heavy | Write-heavy |
| Data volatility | Rarely changes | Changes often |
| Size | Small-to-moderate, fits comfortably in memory | Very large datasets |
| Consistency needs | Tolerant of brief staleness | Requires strict, immediate consistency |
Common mistakes
- Enabling the second-level cache application-wide and marking every entity
@Cacheable"for performance," including entities that are written to constantly — this adds invalidation overhead to every write without a meaningful read-side win. - Choosing
READ_ONLYfor an entity that does, in fact, get updated occasionally — an update attempt against a read-only-cached entity is a runtime error, not just a missed optimization. - Treating the second-level cache as a substitute for proper indexing or query tuning — it helps avoid repeating identical loads across sessions, but does nothing for a genuinely slow, unindexed query the very first time it runs.
- Forgetting that the second-level cache is per-
SessionFactory(per application instance) unless a distributed cache backend is used — in a multi-instance deployment, each instance has its own independent cache, so a write on one instance doesn't automatically invalidate another instance's cached copy without additional distributed-cache configuration.