Hibernate 7 Second-Level Cache: When to Turn It On, How to Configure Ehcache 3, and the Three Ways It Will Stale Your Data

The question that matters before enabling L2 cache is not “can I make it faster” but “can I tolerate stale data, and for how long”. The cache sits between Hibernate and the database, serving data that may have been written by a different JVM process, a batch job, a DBA running a script, or another application instance. Every one of those paths can invalidate the cache without Hibernate knowing. If your answer to the staleness question is “no, I cannot tolerate any staleness” — skip L2 entirely. If the answer is “yes, but only for these entities and within these bounds” — read on.

This post covers what L2 actually stores, how to configure Ehcache 3 as a JCache provider, what changed between Hibernate 6 and 7, the three specific staleness modes that catch production teams off guard, and why the query cache is almost always the wrong additional layer.

1. L2 Mechanics: What Gets Stored Where

The second-level cache operates at the SessionFactory level. It is shared across all sessions, all threads, and — with a distributed provider like Ehcache with Terracotta or Redisson — across all JVM instances in a cluster.

Data is stored in regions. By default, Hibernate creates one region per entity class (com.example.Product region) and one region per collection (com.example.Order.items region). Regions are independent: configuring a TTL on the Product region does not affect the Order.items region.

The storage format is destructured: not the Java object itself, but an array of its persistent field values (column-mapped values), serialised to the cache provider’s native format. When a session requests an entity from L2, Hibernate reconstructs a fresh Java instance from the cached column values. This means there are no object identity guarantees between L2 hits in different sessions — two sessions loading the same entity from L2 get different Java objects with equal field values.

The lookup order is: L1 (identity map, per session) → L2 (region, per factory) → database. On a cache hit, the cached column values are loaded into the current session’s identity map and returned. On a miss, the database is queried, the result populates both L1 and L2, and subsequent sessions can benefit from the L2 population.

2. Cache Concurrency Strategies

The strategy controls what happens when an entity is updated while another session might be reading it from the cache.

StrategyUse caseHow it worksStaleness risk
READ_ONLYStatic reference data: countries, currencies, permission definitionsNo locking, no invalidation on update (entity must never be updated — Hibernate throws on update attempt)None if discipline holds; catastrophic if violated
NONSTRICT_READ_WRITEEntities updated infrequently, eventual consistency acceptableInvalidates cache on update; no locking during update windowShort window where stale data can be served
READ_WRITEMost regularly updated entitiesSoft lock placed on cache entry during update; other readers skip cache or waitMinimal; protected by soft lock
TRANSACTIONALJTA/XA environments onlyFull transactional guarantee between cache and DBNone; highest cost

For most production Hibernate applications, the practical choice is: READ_ONLY for reference tables that are never updated by the application (populated by migrations, read by the app), and READ_WRITE for everything else that deserves caching. NONSTRICT_READ_WRITE is a performance optimisation that trades correctness for throughput; use it when you can model the update frequency and acceptable staleness window explicitly.

3. Ehcache 3 Setup: Dependencies, Configuration, and Entity Annotation

Maven dependencies

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
    <version>7.0.0.Final</version>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.10.8</version>
    <classifier>jakarta</classifier>  <!-- required for Jakarta namespace compatibility -->
</dependency>

Hibernate properties

# application.properties (Spring Boot) 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.uri=classpath:ehcache.xml spring.jpa.properties.hibernate.generate_statistics=true

ehcache.xml

<config xmlns="http://www.ehcache.org/v3"
        xmlns:jsr107="http://www.ehcache.org/v3/jsr107">

  <!-- Default template for entity regions -->
  <cache-template name="entity-default">
    <expiry>
      <ttl unit="minutes">10</ttl>
    </expiry>
    <heap unit="entries">1000</heap>
  </cache-template>

  <!-- Country reference data: longer TTL, READ_ONLY strategy -->
  <cache alias="com.example.Country" uses-template="entity-default">
    <expiry>
      <ttl unit="hours">24</ttl>
    </expiry>
    <heap unit="entries">300</heap>
  </cache>

  <!-- Product cache: standard READ_WRITE -->
  <cache alias="com.example.Product" uses-template="entity-default">
    <heap unit="entries">5000</heap>
  </cache>

</config>

Entity annotation

@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;
    // ...
}

The @Cacheable annotation (from jakarta.persistence) tells JPA the entity is eligible for caching. The @Cache annotation (Hibernate-specific) sets the concurrency strategy and optionally a custom region name. Without both, Hibernate will not cache the entity regardless of the factory-level settings.

4. Hibernate 6 to 7 Migration: What Changed in Region Naming

If you are migrating from Hibernate 6 (or from an Ehcache 2-based setup), there are two Ehcache-related changes to be aware of.

Region naming defaults changed. In Hibernate 6, entity regions default to the fully qualified class name (com.example.Product). Hibernate 7 continues this convention. However, if you were using Ehcache 2 with the older hibernate-ehcache integration, Ehcache 2 used short names by default. If you have explicit ehcache.xml cache blocks from an Ehcache 2 setup, they need to use fully qualified names or you will get cache misses (Hibernate creates the region name as FQCN; Ehcache finds no config for it and creates an unconfigured region with unlimited size).

The dependency artifact changed. Hibernate 5/6 with Ehcache 3 used hibernate-jcache with the javax classifier on the Ehcache dependency. Hibernate 7 with the Jakarta namespace requires the jakarta classifier. Mixing classifiers produces ClassCastException at startup with a confusing error about CacheManager types.

The JCache provider property key changed. In older Hibernate setups, the Ehcache configuration path used hibernate.javax.cache.uri. In Hibernate 7, the property is still hibernate.javax.cache.uri (the JCache/JSR-107 standard preserved the javax prefix in the key name even after the Jakarta migration). This is a common gotcha: the package is now jakarta, but the property key still says javax.

5. The Three Staleness Modes That Bite in Production

Mode 1: Cluster nodes with separate L2s and no invalidation

You deploy two application instances. Both have Ehcache configured in local mode (the default). Instance A updates a Product price; Hibernate invalidates the com.example.Product region on Instance A. Instance B’s cache still holds the old price. Every user routed to Instance B sees stale data until the TTL expires.

The fix is a distributed Ehcache configuration with Terracotta clustering, or switching to a network-backed provider like Infinispan or Redisson. If you are running more than one instance and using local Ehcache, you do not have a shared cache — you have two independent caches that will diverge as soon as any write happens.

Mode 2: ORM-bypassing native queries

A batch job, a reporting query, or a database trigger updates rows directly using JDBC or SQL without going through Hibernate. Hibernate has no way to intercept these writes. The L2 cache is not invalidated. Sessions loading those entities from L2 get the pre-update values.

The detection and fix: if you need to run a native DML that bypasses Hibernate, call sessionFactory.getCache().evictEntityData(Product.class) after the DML completes. This evicts the entire entity region. For single-row updates: sessionFactory.getCache().evictEntityData(Product.class, productId). In practice, teams running batch jobs alongside a Hibernate application either accept the short staleness window (valid if TTL is short) or disable L2 for the affected entities entirely.

Mode 3: Database triggers

A trigger fires after an INSERT or UPDATE and modifies related rows (audit table, cascade update, computed column). Hibernate writes the originating entity and invalidates its cache region. The triggered rows change, but Hibernate neither knows nor invalidates those regions. Any entity loaded from L2 that reflects a trigger-modified column will show the pre-trigger value.

The only safe approach with trigger-managed columns is to either: exclude those columns from L2 caching (@Column(insertable=false, updatable=false) combined with @Transient on the derived field and a separate SQL lookup), or not cache the entity in L2 at all. Triggers and L2 cache do not compose cleanly.

6. Query Cache: Why Most Teams Should Leave It Off

The query cache stores the result of a JPQL or HQL query as a list of entity IDs and their associated timestamps. On a cache hit, Hibernate takes those IDs and fetches each entity from the L2 entity cache (or the database if they’re not cached). If the entities are not cached in L2, a query cache hit produces N+1 entity reads — worse than a single query without the cache.

The second problem is invalidation: any write to any entity in the result region invalidates the entire query region for that entity type. In a system with moderate write traffic, the query cache invalidates constantly and the hit rate approaches zero. Hibernate statistics will show this clearly: enable hibernate.generate_statistics=true and check getQueryCacheHitCount() vs getQueryCacheMissCount().

The query cache earns its keep in one specific scenario: read-only reference data with parameterised queries. Fetching the list of active payment methods (SELECT m FROM PaymentMethod m WHERE m.active = true), where the result rarely changes, is a reasonable candidate. But even there, the same effect can be achieved by caching the entities individually with READ_ONLY strategy and accepting the initial per-entity lookup cost on the first request.

7. The Latency Win: When It’s Worth It

For read-heavy reference data with low update frequency, the performance return is substantial. Typical numbers on a local PostgreSQL instance for a 100-row entity table (currencies, categories, country codes):

  • Database round-trip for a single entity: ~5ms
  • L2 hit (Ehcache local): ~50μs
  • Ratio: approximately 100:1

For a REST endpoint that loads the same 10 reference entities on every request, across 500 concurrent users, that is 500 * 10 = 5,000 DB lookups per second replaced by in-memory reads. At 5ms per DB lookup, that is 25 seconds of DB time per second of application time — a request backlog that grows faster than it clears. The same load with L2 enabled takes 5,000 * 0.05ms = 250ms total. The database is not involved at all.

The ROI disappears for frequently updated data. A Product with a price that updates every hour has a cache population cost (SELECT + write to L2), a cache invalidation cost on update, and a re-population cost. For entities updated more than every few minutes under load, the cache overhead typically exceeds the savings.

8. Legacy: Migrating from CacheProvider

If you are on a codebase that predates Hibernate 4, you may encounter references to org.hibernate.cache.CacheProvider. This interface was deprecated in Hibernate 3.3, when the RegionFactory abstraction replaced it, and was fully removed in Hibernate 4.0. Any application hitting NoClassDefFoundError: org/hibernate/cache/CacheProvider is running Hibernate code from before 2012 against a Hibernate 4+ JAR.

The migration path: replace the hibernate.cache.provider_class property with hibernate.cache.region.factory_class pointing to a RegionFactory implementation. For Ehcache 3 via JCache, that is org.hibernate.cache.jcache.internal.JCacheRegionFactory. Any Ehcache 1 or 2 CacheProvider implementation class names should be replaced with the equivalent Ehcache 3 provider. The XML schema for ehcache.xml also changed significantly between versions 2 and 3.

See Also

Frequently Asked Questions

Which entities should I cache in L2?

Reference data that is: read frequently, updated rarely, and does not change based on user-specific context. Countries, currencies, permission definitions, product categories, configuration parameters. Exclude: transactional entities (orders, payments), entities with trigger-managed columns, entities updated by batch jobs that bypass Hibernate.

How do I measure whether L2 is helping?

Enable hibernate.generate_statistics=true and call sessionFactory.getStatistics(). Check getSecondLevelCacheHitCount() and getSecondLevelCacheMissCount(). In Spring Boot, these are exposed as Micrometer metrics under hibernate.second_level_cache.hit. A hit ratio below 70% for a supposedly cacheable entity suggests either high write frequency (wrong candidate for L2), cache size too small (entries evicting before reuse), or TTL shorter than the access pattern requires.

Does L2 work with Spring Boot’s @Transactional?

Yes. Spring Boot’s auto-configured EntityManagerFactory is the same factory that Hibernate uses for L2 regions. L2 is transparent to @Transactional: within a transaction, L1 is consulted first; L2 is consulted on L1 miss; on commit, Hibernate updates or invalidates L2 regions based on what was written. No changes to your @Transactional methods are needed to use L2.

Conclusion

The second-level cache is the right tool for a specific problem: reducing database round-trips for read-heavy, rarely-changing data shared across many concurrent sessions. For reference tables (countries, currencies, categories), the performance return can be dramatic — 100x latency reduction is realistic for fully-warm cache scenarios. The cost is staleness risk and operational complexity: cluster invalidation requires a distributed provider, ORM-bypassing writes require explicit eviction calls, and triggers are fundamentally incompatible. Configure Ehcache 3 with the jakarta classifier, set per-region TTLs and size limits in ehcache.xml, use READ_ONLY for genuinely immutable data and READ_WRITE for everything else, and leave the query cache off unless you have measured evidence that your specific query patterns would benefit. Monitor the hit ratio in production before declaring the cache a success.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.