Files
hibernate-demo/docs/18-ehcache-l2-configuration.md
T

15 KiB

18 — Ehcache 3 second-level cache configuration

← Previous: 17 — Bootstrapping EntityManager | Back to README →

Backs the rewrite of ankurm.com post 4884 (Ehcache 3 L2 cache configuration).

Chapter 06 already turned on the L2 cache once, for @NaturalIdCache. This chapter is the general case -- plain entity caching, query caching, and the four specific pitfalls the original article warned about -- each one actually reproduced (or, in three of the four cases, disproven) rather than repeated as received wisdom. Every test here boots its own isolated, raw StandardServiceRegistry/SessionFactory (the same pattern chapter 09 established), never the shared Spring context, so none of this leaks into any other chapter's tests.

The dependency question this chapter does NOT re-litigate

The original article's warning -- "without the jakarta classifier, Ehcache 3 ships the older javax.cache JCache API; Hibernate 7 requires jakarta.cache; using the wrong artifact causes a ClassNotFoundException" -- is false, and chapter 06 already established why: JSR-107 (JCache) was never migrated to the Jakarta namespace, unlike JPA or Bean Validation. javax.cache.Caching is the one and only JCache API entry point, with or without Ehcache's own jakarta classifier.

This chapter re-verifies that claim independently rather than just linking to it, because it's central enough to the post to deserve its own primary-source check: CacheApiNamespaceTest confirms javax.cache.Caching loads fine on this classpath, and that jakarta.cache.Cache throws ClassNotFoundException -- there is no such package, ever, regardless of classifier:

RESULT[cache-api-namespace]: javax.cache.Caching loads fine from this classpath (jar: file:/.../cache-api-1.1.1.jar)
RESULT[cache-api-no-jakarta-namespace]: Class.forName("jakarta.cache.Cache") -> ClassNotFoundException -- JSR-107 was never renamed to a jakarta.cache package, with or without Ehcache's own "jakarta" classifier on org.ehcache:ehcache.

(full transcript)

So what is the jakarta classifier for? Comparing ehcache-3.10.8.jar and ehcache-3.10.8-jakarta.jar directly -- their Gradle module metadata, their .pom (identical, both declare javax.cache:cache-api as a dependency), and disassembled classes in org.ehcache.xml.model and ConfigurationParser.class -- shows the difference is entirely about which JAXB runtime major version Ehcache uses internally, to parse its own ehcache.xml config file: the plain jar imports javax.xml.bind, the jakarta jar imports jakarta.xml.bind. Nothing in either jar touches javax.cache vs. a jakarta.cache package, because the latter doesn't exist. Pick the classifier that matches whichever JAXB runtime is already on your classpath (Jakarta EE 9+ stacks, including Spring Boot 3+/4+, want the jakarta classifier) -- not because of JCache.

The smallest thing that works: caching one entity

@Entity
@jakarta.persistence.Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "productCache")
public class CacheProduct {
    @Id @GeneratedValue private Long id;
    @Column(nullable = false) private String name;
    private Double price;
    // ...
}

CacheProduct.java

Two annotations, both required: @jakarta.persistence.Cacheable is the portable JPA marker that says "this entity may be cached"; @org.hibernate.annotations.Cache is Hibernate's own annotation that actually turns caching on and picks the concurrency strategy and region name. Leaving either one off means no L2 caching happens for this entity at all.

The Ehcache config backing productCache lives in ehcache-chapter18.xml: a 30-minute TTL, 1000 heap entries, 10MB offheap. default-update-timestamps-region and default-query-results-region are also declared explicitly -- more on why that matters below.

Surprise: persist() + commit() already puts the entity in the cache

The natural mental model is "L2 fills on the first cache miss -- so the very first get() after inserting a row still costs one SELECT, a cold read, before later reads become free." That model is wrong, and this chapter is the second time this exact repository has caught it: chapter 06 already documented it for @NaturalIdCache, and EntityL2CacheTest confirms it generalizes to plain @Cacheable/@Cache entity caching too. persist() + commit() populates the L2 region itself, so even the "first" get() for that id afterward, from a brand-new session, is already a cache hit with zero SQL statements:

RESULT[cache-entity-l2]: session1 (first get() after persist+commit) cumulative queries=0 | session2 (brand-new session, same id) cumulative queries=0, L2 entity cache hits=2

(full transcript)

Trap: if you're benchmarking L2 cache savings by comparing a "cold" first get() against a "warm" second one, you're measuring the wrong thing for any entity you just inserted in the same test. The real cold read only happens for a row that existed in the database before the current process ever touched it in a session -- reboot the SessionFactory (or evict the region) between the insert and the "cold" read if you want a fair baseline.

Query cache without entity cache: NOT the N+1 the article claimed

The article's second warning: "Query Cache without Entity Cache causes N+1 selects." The scenario is a query with setCacheable(true) over an entity that has no @Cacheable/@Cache of its own -- UncachedProduct, built specifically to have no L2 region at all. The claim was that resolving the query cache's remembered id list from a brand-new session would cost one SELECT per row.

Measured directly, that's false. A second, brand-new session repeating the identical setCacheable(true) query costs zero SQL statements, not five:

RESULT[cache-query-without-entity-cache]: first run (cold, new session) SQL statements=1, query-cache misses=1 | second run (new session, query-cache HIT) SQL statements=0, query-cache hits=1, L2 entity-cache hits=0 -- contrary to the original article, the second run costs ZERO SQL statements: the query cache stores the full row tuples, not just the 5 ids, and Hibernate rebuilds UncachedProduct instances straight from that stored data. The zero L2 entity-cache hits confirm this does not depend on UncachedProduct having its own L2 region at all -- it has none.

(full transcript), source: QueryCacheWithoutEntityCacheTest

The mechanism: the query cache region doesn't store just the matching row ids. It stores the full hydrated tuple data for each result row at the moment the query first ran, and Hibernate reconstitutes entity instances directly from that stored data on a cache hit -- confirmed here by getSecondLevelCacheHitCount() staying at zero even on the hit, which rules out a secretly entity-cached explanation. This test doesn't rule out an N+1 appearing for a query returning associations Hibernate must still initialize per row, or a partial-hit scenario after individual entries are evicted -- only the specific, simple case the article described is measured and corrected here.

Bulk mutations: also not what the article claimed, in two different ways

The article's third warning: "Bulk HQL mutations bypass cache -- a bulk update executes direct SQL, so an already-cached entity goes stale until manually evicted." Measured with BulkUpdateBypassesCacheTest, this is false for both the HQL/JPQL form and, once actually tested, the native SQL form too.

HQL/JPQL bulk update, via Session.createMutationQuery(...).executeUpdate(): a fresh session sees the new value immediately.

RESULT[cache-bulk-update-hql-not-stale]: price in the database after the bulk HQL update=999.0 | price a brand-new session's get() actually returns=999.0 -- contrary to the original article, a bulk HQL update via createMutationQuery does NOT leave the L2 cache stale. BulkOperationCleanupAction evicts CacheProduct's entire region automatically once the statement's transaction commits, so no manual sessionFactory.getCache().evictEntityData(...) call is needed for this case.

(full transcript)

Verified by disassembling hibernate-core-7.4.5.Final.jar: every bulk HQL/JPQL update/delete registers an org.hibernate.action.internal.BulkOperationCleanupAction as an after-transaction-completion process. Its EntityCleanup inner class calls EntityDataAccess.lockRegion() then EntityDataAccess.removeAll(session) for every entity type the statement's parsed "query spaces" (tables) touch -- the entire region, not just the one row. Hibernate can do this because createMutationQuery parses the HQL/JPQL itself before running it.

The natural follow-up question -- and the working hypothesis going into the second test below -- was whether raw/native SQL escapes this, since Hibernate's HQL parser never sees a native statement and so can't name the affected query spaces. Measuring it disproves that hypothesis too:

RESULT[cache-bulk-update-native-not-stale]: price in the database after the native SQL update=999.0 | price a brand-new session's get() actually returns=999.0 | L2 puts recorded before the native update=1 | L2 hits before/after the post-update read=1/1 -- the original hypothesis (native SQL bypasses query-space tracking, so the L2 entry survives stale) was WRONG. The entity WAS cached (one L2 put), yet the post-update read is not an L2 hit at all: Hibernate cannot verify a native statement's affected tables, so it conservatively invalidates every region it knows about rather than none of them.

(full transcript)

Hibernate's actual default for an unqualified native DML statement is the conservative opposite of "do nothing": unable to prove which regions the statement leaves safe, it invalidates every region it knows about. The entity cache ends up empty either way -- an HQL bulk update reaches that outcome by naming exactly what it evicts, a native statement reaches the same outcome by assuming it must evict everything.

Trap this creates in the other direction: that "invalidate everything" default means a native SQL statement executed through Hibernate can quietly evict L2 regions for entities that statement never touched, in a high-traffic app with many cached entity types. If you need to avoid that blast radius, narrow it explicitly with NativeQuery#addSynchronizedEntityClass()/addSynchronizedQuerySpace() rather than relying on the conservative default.
  • Going deeper: docs/output/18-bulk-update-hql.txt and 18-bulk-update-native.txt both include the manual sessionFactory.getCache().evictEntityData(...) call and its result, for the case where you do need to force an eviction explicitly.

The missing-region warning that isn't a startup failure

The article's fourth warning: "Missing default-update-timestamps-region: required for the Query Cache -- omitting it causes startup errors." Also false, with the defaults this repo pins. MissingUpdateTimestampsRegionTest boots a query-cache-enabled SessionFactory against ehcache-chapter18-missing-timestamps.xml, which deliberately omits that region entirely:

RESULT[cache-missing-timestamps-region]: SessionFactory built with NO error -- contrary to the original article, the default MissingCacheStrategy (CREATE_WARN) auto-creates the missing default-update-timestamps-region and only logs HHH90001006

(full transcript)

hibernate-jcache's default MissingCacheStrategy is CREATE_WARN (external representation "create-warn", confirmed by disassembling MissingCacheStrategy.class in hibernate-jcache-7.4.5.Final.jar, which also confirms the other two values, FAIL and CREATE): a missing region is created on the fly with provider-specific default policies, and Hibernate only logs HHH90001006. If you actually want the hard failure the article describes -- useful in CI, to catch a forgotten region definition before production -- it's an explicit opt-in, not the default:

.applySetting("hibernate.javax.cache.missing_cache_strategy", "fail")
RESULT[cache-missing-timestamps-region-strict]: hibernate.javax.cache.missing_cache_strategy=fail -> org.hibernate.service.spi.ServiceException: On-the-fly creation of JCache Cache objects is not supported [default-update-timestamps-region] -- this is how to opt into the hard-failure behavior the original article assumed was the default.

(full transcript)

  • Going deeper: set missing_cache_strategy to create (rather than the default create-warn) if you want the auto-create behavior without the WARN log line at all.

Production checklist

  • Pin hibernate.cache.use_second_level_cache: false at the application-wide level if you're not deliberately turning L2 on everywhere -- see chapter 09 for why having hibernate-jcache merely on the classpath is enough to auto-enable it via ServiceLoader discovery, with zero explicit configuration.
  • Match the Ehcache classifier to your JAXB runtime (jakarta for Jakarta EE 9+/Spring Boot 3+/4+), not to any assumption about the JCache API namespace -- there is only one, javax.cache.
  • Decide missing_cache_strategy deliberately: create-warn (the default) is forgiving in dev, fail catches a missing region definition in CI before it reaches production.
  • If you rely on native SQL DML for bulk changes, know that Hibernate's conservative cache-invalidation default means a native statement can evict L2 regions for entities it never touched -- narrow the blast radius with addSynchronizedEntityClass() if that matters at your scale.
  • Remember the persist-then-get() surprise when benchmarking: a row inserted in the same session or process is already cached by the time you "cold" get() it.

← Previous: 17 — Bootstrapping EntityManager | Back to README →