A Spring Boot 4.1.1 module whose test suite is the evidence for the article: 20 tests producing 22 transcripts under docs/output/, plus 12 documentation chapters. Findings the build pins: - @EnableCaching has no exposeProxy attribute; the widely-copied @EnableCaching(exposeProxy = true) does not compile. - Two methods sharing a cache name and an argument type share a key space, and one silently serves the other's answers. - The documented cache-provider detection order does not match CacheType's enum order in 4.1.1: COUCHBASE before INFINISPAN, and CACHE2K before CAFFEINE. - beforeInvocation = true is NOT deferred by TransactionAwareCacheManagerProxy on 7.0.9 - doEvict picks evictIfPresent, which the decorator does not intercept. - Four of five invalid declarations start a clean context and throw at the first call. - Caffeine on the classpath silently displaces the simple provider.
3.8 KiB
← providers and TTL · next: transactions →
9. This is not the Hibernate second-level cache
They are both called caching, they both make things faster, and they solve different problems at different layers. Mixing them up produces designs that cache the wrong thing.
Three layers, three scopes
| Persistence context (L1) | Hibernate second-level cache (L2) | Spring cache abstraction | |
|---|---|---|---|
| Scope | one EntityManager / transaction |
one SessionFactory, all sessions |
one CacheManager, whatever you annotate |
| Unit | managed entity instance | dehydrated entity state, by id | whatever object a method returned |
| Lookup by | identity map | entity id (plus query and collection regions) | a key the KeyGenerator built |
| Turned on by | always on | hibernate.cache.use_second_level_cache + @Cache |
@EnableCaching + @Cacheable |
| Invalidated by | end of transaction | Hibernate, on write, automatically | you, with @CacheEvict |
| Knows about your database | yes | yes | no |
Ankur's Hibernate articles cover the first two in detail: the first-level cache, the second-level cache and the three ways it stales your data, and configuring Ehcache 3 under it.
The difference that bites
Hibernate's L2 stores dehydrated state — a flat array of column values keyed by entity id. On a hit it rehydrates that state into a managed entity attached to the current session. Lazy associations still work, because the resulting entity is live.
The Spring cache abstraction stores the object your method returned, as-is. If that object is a
JPA entity, what you cached is a detached entity with whatever its proxies were holding at the
moment the transaction closed. docs/output/14-cached-entity.txt:
loadEntity(1) -> Alice (com.ankurm.caching.jpa.Customer)
second call returns the same instance? true
--- touching the lazy collection outside the session ---
org.hibernate.LazyInitializationException
Cannot lazily initialize collection of role 'com.ankurm.caching.jpa.Customer.orders' with key '1' (no session)
Worse, it is the same instance every time. Every caller shares one mutable entity. One of them calls a setter, and every other caller sees it — and if someone merges it back, they merge a version number that may be stale.
The rule
Cache DTOs, not entities. Map to a record inside the transaction and cache that. It is immutable, it serialises cleanly to a distributed cache, it has no session affinity, and it makes the cached shape an explicit decision rather than an accident of your mapping.
If you genuinely want entity caching — read-mostly reference data, keyed by id, invalidated
automatically on write — that is precisely what Hibernate's L2 is for, and it will do it better
than @Cacheable because it understands the writes.
Which one to reach for
| You want to avoid | Use |
|---|---|
| re-loading the same entity by id across requests | Hibernate L2 (@Cache on the entity) |
| re-running the same query that returns entities | Hibernate query cache, carefully — it needs L2 for the entities too |
| re-running an expensive computation, HTTP call or aggregation | the Spring cache abstraction |
| re-building a response DTO from several sources | the Spring cache abstraction |
The two compose. A service method cached with @Cacheable that internally loads entities served
from L2 is a perfectly reasonable stack — just be clear about which layer is answering, because
only one of them will notice when the row changes.