Skip to content
Chapter 26 · Spring Boot

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();
    }
}
  1. POST /api/user: an INSERT.
  2. GET /api/user/1: a SELECT.
  3. GET /api/user/1 again: another SELECT.

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:

  1. ehcache: the provider, which actually stores the data. Others exist: Caffeine, Hazelcast, Infinispan. (The jakarta classifier is the build that works with Spring Boot 3; it is needed to read an ehcache.xml.)
  2. 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.
  3. 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 @Cache and 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=DEBUG

You 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:

  1. Insert: goes to the database. Here (with IDENTITY ids) it does not fill the cache.
  2. 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 @Cache annotation)
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.xml

User 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.

StrategyUpdates allowedStale readsTypical use
READ_ONLYNoNoStatic data
READ_WRITEYes; cache updated after commitNoData that changes and must be correct
NONSTRICT_READ_WRITEYes; cache entry invalidated after commitPossible, brieflyRead-heavy data where brief staleness is fine
TRANSACTIONALYesNoFully 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:

  1. 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.
  2. The database is updated.
  3. Commit succeeds: the cache entry is replaced with the new data and unlocked.
  4. 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 ​

  1. Why does a second GET in a new request run a SELECT again, even though the first GET loaded the same row?
  2. In what order are L1, L2 and the database checked?
  3. What does each of the three dependencies do?
  4. Which property turns L2 on, and which one sets the region factory?
  5. Which entities are cached?
  6. What is a region, and what can you configure per region?
  7. What is wrong with an ehcache.xml that uses <ehcache> and maxElementsInMemory in a Spring Boot 3 project?
  8. What happens when you update an entity cached with READ_ONLY?
  9. With READ_WRITE, what do other requests do while an entry is locked for an update? What happens to the entry on rollback?
  10. After an update, why does NONSTRICT_READ_WRITE cause a SELECT on the next read, while READ_WRITE does not?
  11. When can NONSTRICT_READ_WRITE return stale data?
Answers
  1. Each request has its own EntityManager and L1 cache; without L2, nothing is shared between requests.
  2. L1 (persistence context), then L2, then the database.
  3. 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 @Cache and the strategy.
  4. hibernate.cache.use_second_level_cache=true; hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory (both under spring.jpa.properties.).
  5. Only those annotated with @Cache.
  6. A named group of cached data. Size, TTL, eviction (and the concurrency strategy per entity).
  7. 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.
  8. The commit fails (Can't update readonly object) and the transaction is rolled back.
  9. 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.
  10. NONSTRICT_READ_WRITE only invalidates the entry after commit; READ_WRITE puts the new data into the cache.
  11. 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.