Cache Concurrency Strategies and Their Real Semantics
Cache Concurrency Strategies and Their Real Semantics
There are four concurrency strategies. Most developers pick READ_WRITE because it sounds safe, without understanding what “safe” means in this context or what it costs.
READ_ONLY
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
Cached entries are never updated. Any attempt to modify a cached entity throws an exception. This is not a hint. Hibernate enforces it at the persistence context level.
Use this for truly immutable data: country codes, currency definitions, enum lookup tables. If the data changes once a year, you still cannot use READ_ONLY unless you are willing to restart the application or manually evict the cache region.
Runtime overhead: zero. No locking, no soft locks, no versioning. Reads are a direct memory lookup.
NONSTRICT_READ_WRITE
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
Hibernate updates the cache after the transaction commits. Between the database commit and the cache update, another transaction can read stale data from the cache. The staleness window is small (milliseconds), but it exists.
No locking is involved. If two transactions update the same entity concurrently, the cache ends up with whichever update reaches the cache put last. This can be the older update if thread scheduling is unfavorable.
Use this for data where eventual consistency within a few milliseconds is acceptable. Product catalog prices, user profile display names, article view counts.
READ_WRITE
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
Hibernate uses a “soft lock” mechanism. When a transaction modifies a cached entity:
- Before the database UPDATE, Hibernate replaces the cache entry with a soft lock marker
- Other transactions that read the entity see the soft lock and fall through to the database
- After the transaction commits, Hibernate replaces the soft lock with the updated dehydrated state
- If the transaction rolls back, the soft lock is released and the old cache entry is restored
// Thread A: Updates product price
@Transactional
public void updatePrice(Long productId, BigDecimal newPrice) {
Product product = entityManager.find(Product.class, productId);
// Cache entry for productId is replaced with a soft lock
product.setPrice(newPrice);
// On commit: soft lock is replaced with new state
}
// Thread B: Reads the same product during Thread A's transaction
@Transactional(readOnly = true)
public Product getProduct(Long productId) {
// Finds soft lock in cache, falls through to database
return entityManager.find(Product.class, productId);
}
The soft lock prevents stale reads but does not prevent cache misses. During the update window, every read hits the database. If the entity is hot (read 1,000 times per second), a single update causes 1,000 cache misses during the transaction duration.
READ_WRITE does not use JTA. It works with JDBC transactions. But it does not provide strict transactional semantics. If the application crashes between the database commit and the cache update, the cache can contain a stale soft lock that times out after a configured period (default: 2 minutes in most providers). During that timeout, every read falls through to the database.
TRANSACTIONAL
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.TRANSACTIONAL)
Uses JTA (XA transactions) to coordinate the cache and the database. The cache update and the database commit are part of the same distributed transaction. If either fails, both roll back.
The cost: XA transaction overhead. Two-phase commit for every cache update. This is measurable: 2-10ms per transaction depending on the transaction manager and cache provider.
Use this only when stale reads are genuinely unacceptable and you are already using JTA for other reasons. In practice, fewer than 5% of applications need TRANSACTIONAL cache concurrency.
The Cost Model
| Strategy | Stale Read Window | Lock Overhead | Use Case |
|---|---|---|---|
| READ_ONLY | Never (immutable) | None | Reference data |
| NONSTRICT_READ_WRITE | Milliseconds | None | Read-heavy, tolerates brief staleness |
| READ_WRITE | Zero (soft lock) | Soft lock per write | Default for mutable entities |
| TRANSACTIONAL | Zero (XA) | XA 2PC per write | Financial/regulatory |
For most applications, READ_WRITE is the right choice for mutable entities. The soft lock overhead is negligible compared to the database round trips it saves. NONSTRICT_READ_WRITE is worth considering when write frequency is high enough that soft locks cause measurable cache miss rates.