Appearance
JPA Part 4: The Second-Level Cache
Part 3 ended with the first-level cache's limit: each HTTP request has its own EntityManager, and so its own persistence context, and request 3 cannot see what request 2 loaded. A second-level cache sits above the persistence contexts and is shared by all of them. This part sets one up and compares its concurrency strategies.
The problem
java
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
UserDetailsService userDetailsService;
@PostMapping("/user")
public UserDetails insertUser(@RequestBody UserDetails userDetails) {
return userDetailsService.saveUser(userDetails);
}
@GetMapping("/user/{id}")
public UserDetails getUser(@PathVariable Long id) {
return userDetailsService.findByID(id);
}
}java
@Service
public class UserDetailsService {
@Autowired
UserDetailsRepository userDetailsRepository;
public UserDetails saveUser(UserDetails user) {
return userDetailsRepository.save(user);
}
public UserDetails findByID(Long primaryKey) {
return userDetailsRepository.findById(primaryKey).get();
}
}POST /api/user: anINSERT.GET /api/user/1: aSELECT.GET /api/user/1again: anotherSELECT.
Each HTTP request has its own EntityManager and so its own persistence context (part 3). Request 3 cannot see what request 2 loaded.
The second-level (L2) cache sits above all persistence contexts and is shared by all of them:
A lookup checks L1 (this EntityManager), then L2, and only then the database. With L2, request 2 misses, loads the row from the database and puts it in L2; request 3 misses its own L1 but hits L2: no SELECT.
Setup
Dependencies
xml
<!-- the cache implementation (provider) -->
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<classifier>jakarta</classifier>
</dependency>
<!-- the bridge between Hibernate and JCache -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<!-- the JCache API (interfaces) -->
<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
</dependency>The Spring Boot parent manages all three versions. Why three:
ehcache: the provider, which actually stores the data. Others exist: Caffeine, Hazelcast, Infinispan. (Thejakartaclassifier is the build that works with Spring Boot 3; it is needed to read anehcache.xml.)cache-api: JCache, a standard set of cache interfaces (create cache, get, put...). Providers implement it. Code that uses these interfaces does not change if you switch from Ehcache to another JCache provider: loose coupling.hibernate-jcache: the bridge between Hibernate and JCache. Hibernate itself does not know when to create a cache, get or put. This library does: it understands@Cacheand the concurrency strategies below, and calls the JCache API accordingly.
application.properties
properties
# L2 is off by default
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
# who manages the cache regions: the JCache bridge
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
# which JCache provider (optional when only one is on the classpath)
spring.jpa.properties.hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider
# to see cache hits and misses in the log
logging.level.org.hibernate.cache.spi=DEBUGYou could also use Ehcache's own region factory directly and skip JCache, but going through JCache lets you change providers without code changes.
The entity
Only entities marked with @Cache (from org.hibernate.annotations) are cached:
java
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "userDetailsCache")
public class UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// constructors, getters and setters
}Now:
- Insert: goes to the database. Here (with
IDENTITYids) it does not fill the cache. - Get: L2 is checked. On a miss, the row is read from the database and put into L2. On a hit, it is returned without a query.
So POST, GET, GET gives one INSERT, one SELECT, and then a cache hit.
Regions
A region is a named group of cached data (entities, collections or query results). Each region has its own settings:
- how many entries it may hold
- the TTL (time to live) of an entry
- which entries to evict when it is full
- the concurrency strategy (on the
@Cacheannotation)
java
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "userDetailsCache")
public class UserDetails { ... }
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "orderDetailsCache")
public class OrderDetails { ... }Configure the regions in src/main/resources/ehcache.xml, in Ehcache 3 format:
xml
<config xmlns="http://www.ehcache.org/v3">
<cache alias="userDetailsCache">
<expiry><ttl unit="seconds">60</ttl></expiry>
<heap unit="entries">100</heap>
</cache>
<cache alias="orderDetailsCache">
<expiry><ttl unit="seconds">200</ttl></expiry>
<heap unit="entries">1000</heap>
</cache>
</config>and point Hibernate to it:
properties
spring.jpa.properties.hibernate.javax.cache.uri=classpath://ehcache.xmlUser details live 60 seconds, 100 at most; orders 200 seconds, 1000 at most. Ehcache itself chooses which entries to evict when the heap is full. (Older examples use Ehcache 2 syntax: <ehcache> with maxElementsInMemory and evictionStrategy. Ehcache 3 rejects that file: "Cannot find the declaration of element 'ehcache'".) A region with no configuration is created with defaults, and Hibernate logs a warning: "Missing cache[...] was created on-the-fly".
Cache concurrency strategies
usage decides how inserts, updates and deletes affect cached data when requests run in parallel: how much locking there is, and whether a read can ever see stale data.
| Strategy | Updates allowed | Stale reads | Typical use |
|---|---|---|---|
READ_ONLY | No | No | Static data |
READ_WRITE | Yes; cache updated after commit | No | Data that changes and must be correct |
NONSTRICT_READ_WRITE | Yes; cache entry invalidated after commit | Possible, briefly | Read-heavy data where brief staleness is fine |
TRANSACTIONAL | Yes | No | Fully transactional caches under JTA |
READ_ONLY
For static data that never changes. Any update of such an entity fails. With an update endpoint:
java
public UserDetails updateUser(Long id, UserDetails user) {
UserDetails existingUser = userDetailsRepository.findById(id).get();
existingUser.setName(user.getName());
existingUser.setEmail(user.getEmail());
return userDetailsRepository.save(existingUser);
}the PUT fails with "Could not commit JPA transaction", caused by UnsupportedOperationException: Can't update readonly object, and the change is rolled back.
READ_WRITE
For data that is updated and must not be read stale. Reads take no lock. An update works like this:
- Before changing the row, Hibernate puts a lock (a "soft lock") on that entity's cache entry. While it is locked, other requests do not use the cache for it; they read from the database.
- The database is updated.
- Commit succeeds: the cache entry is replaced with the new data and unlocked.
- Rollback: the entry is just unlocked, and stays invalid. The next read goes to the database and refreshes the cache.
Caching is best effort: failing to update the cache never fails the transaction; the invalid entry simply forces a fresh read later.
Test: POST (insert), GET (miss, SELECT, cached), PUT (UPDATE, cache updated), GET: a cache hit with the new data, no SELECT.
NONSTRICT_READ_WRITE
More lenient, for read-heavy data. Nothing locks reads. After an update commits, the cache entry is invalidated (removed), not refreshed; the next read goes to the database. On rollback, nothing is done.
The same test: after the PUT, the next GET runs a new SELECT. And between the database update and the invalidation there is a short window in which a parallel read can get stale data from the cache.
TRANSACTIONAL
The strictest: the cache itself takes part in the transaction, so cache and database change together. It is meant for fully transactional cache providers in a JTA environment (such as Infinispan). With Ehcache through JCache in an ordinary application, you do not get those guarantees; use READ_WRITE instead.
Next: Parts 2 to 4 used Hibernate's default table and column names. Part 5 takes control of the mapping: table and column names, primary keys and generated ids.
Check yourself
- Why does a second
GETin a new request run aSELECTagain, even though the firstGETloaded the same row? - In what order are L1, L2 and the database checked?
- What does each of the three dependencies do?
- Which property turns L2 on, and which one sets the region factory?
- Which entities are cached?
- What is a region, and what can you configure per region?
- What is wrong with an
ehcache.xmlthat uses<ehcache>andmaxElementsInMemoryin a Spring Boot 3 project? - What happens when you update an entity cached with
READ_ONLY? - With
READ_WRITE, what do other requests do while an entry is locked for an update? What happens to the entry on rollback? - After an update, why does
NONSTRICT_READ_WRITEcause aSELECTon the next read, whileREAD_WRITEdoes not? - When can
NONSTRICT_READ_WRITEreturn stale data?
Answers
- Each request has its own
EntityManagerand L1 cache; without L2, nothing is shared between requests. - L1 (persistence context), then L2, then the database.
ehcache: the provider that stores data.cache-api: the JCache interfaces, so the provider can change.hibernate-jcache: the bridge that makes Hibernate use JCache according to@Cacheand the strategy.hibernate.cache.use_second_level_cache=true;hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory(both underspring.jpa.properties.).- Only those annotated with
@Cache. - A named group of cached data. Size, TTL, eviction (and the concurrency strategy per entity).
- It is Ehcache 2 syntax; Ehcache 3 rejects it ("Cannot find the declaration of element 'ehcache'"). Use the
<config xmlns="http://www.ehcache.org/v3">format. - The commit fails (
Can't update readonly object) and the transaction is rolled back. - They bypass the cache and read from the database. The entry is unlocked but stays invalid, so the next read refreshes it from the database.
NONSTRICT_READ_WRITEonly invalidates the entry after commit;READ_WRITEputs the new data into the cache.- In the short window after the database is updated but before the cache entry is invalidated, a parallel read can get the old cached value.