# 18 — Ehcache 3 second-level cache configuration [← Previous: 17 — Bootstrapping EntityManager](17-entitymanager-bootstrap.md) | [Back to README →](../README.md) 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](06-natural-ids.md#turning-on-l2-hibernate-jcache--ehcache), 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`](../src/test/java/com/ankurm/hibernatedemo/cache/CacheApiNamespaceTest.java) 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)](output/18-cache-api-namespace.txt) 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 ```java @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`](../src/main/java/com/ankurm/hibernatedemo/cache/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`](../src/test/resources/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`](../src/test/java/com/ankurm/hibernatedemo/cache/EntityL2CacheTest.java) 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)](output/18-entity-l2-cache.txt)
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`](../src/main/java/com/ankurm/hibernatedemo/cache/UncachedProduct.java), 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)](output/18-query-cache-without-entity-cache.txt), source: [`QueryCacheWithoutEntityCacheTest`](../src/test/java/com/ankurm/hibernatedemo/cache/QueryCacheWithoutEntityCacheTest.java) 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. - Going deeper: [Hibernate's own query cache documentation](https://docs.jboss.org/hibernate/orm/7.4/userguide/html_single/Hibernate_User_Guide.html#caching-query) (`rel="nofollow"`) on how query-cache regions store result tuples. ## 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`](../src/test/java/com/ankurm/hibernatedemo/cache/BulkUpdateBypassesCacheTest.java), 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)](output/18-bulk-update-hql.txt) 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)](output/18-bulk-update-native.txt) 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`](../src/test/java/com/ankurm/hibernatedemo/cache/MissingUpdateTimestampsRegionTest.java) boots a query-cache-enabled `SessionFactory` against [`ehcache-chapter18-missing-timestamps.xml`](../src/test/resources/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)](output/18-missing-timestamps-region.txt) `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: ```java .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)](output/18-missing-timestamps-region-strict.txt) - 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](09-testing-in-memory-databases.md#hibernate-jcache-on-the-classpath-turns-on-l2-for-everyone-whether-you-asked-or-not) 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](17-entitymanager-bootstrap.md) | [Back to README →](../README.md)