Cache-aside in one picture
Think of a librarian with a small shelf of popular books next to the desk. When someone asks for a book:
- Check the shelf (the cache). If it’s there, hand it over: fast. That’s a hit.
- If not (a miss), walk to the archive (the database), bring the book, and put a copy on the shelf for the next person.
- When a book is revised, take the old copy off the shelf, so nobody gets the outdated one.
- Copies on the shelf get thrown out after a while anyway (the TTL, time to live), so even a forgotten old copy doesn’t stay forever.
This pattern is called cache-aside. Redis is the shelf: an in-memory key-value store that every server of your API shares.
Spring’s cache abstraction implements cache-aside declaratively: @Cacheable looks up a key derived from the arguments and only invokes the method on a miss, @CacheEvict and @CachePut handle writes, and RedisCacheManager stores entries in Redis with per-cache TTLs and a configurable serializer.
JCache (JSR-107, package javax.cache) implements cache-aside declaratively: @CacheResult looks up a key built from the @CacheKey parameters (all parameters by default) and only invokes the method on a miss, @CacheRemove and @CachePut handle writes, and the provider (Redisson for Redis, or Hazelcast, Infinispan) stores the entries, with expiry configured per cache. Jakarta EE itself has no caching specification, and runtimes differ: Payara ships JCache built in, Quarkus has its own @CacheResult / @CacheInvalidate with a Redis backend.
The engineering is in what the annotations don’t decide: keys (everything the result depends on, including the user), staleness you can accept per data type, invalidation races between readers and writers, stampedes when hot keys expire, serialization compatibility across deploys, and what happens when Redis is slow or down. The Detailed version covers each.
@Configuration
@EnableCaching
class CacheConfig {
@Bean
fun cacheConfiguration(): RedisCacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // stale data disappears on its own
.disableCachingNullValues()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()),
)
}
@Service
class ProductService(private val products: ProductRepository) {
@Cacheable(cacheNames = ["products"], key = "#id")
@Transactional(readOnly = true)
fun find(id: Long): ProductResponse =
products.findByIdOrNull(id)?.let(ProductResponse::from) ?: throw ProductNotFoundException(id)
@CacheEvict(cacheNames = ["products"], key = "#id")
@Transactional
fun updatePrice(id: Long, priceCents: Long): ProductResponse {
val product = products.findByIdOrNull(id) ?: throw ProductNotFoundException(id)
product.priceCents = priceCents
return ProductResponse.from(product)
}
}@Configuration
@EnableCaching
class CacheConfig {
@Bean
RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // stale data disappears on its own
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())); // JSON, readable in redis-cli
}
}
@Service
public class ProductService {
private final ProductRepository products;
public ProductService(ProductRepository products) { this.products = products; }
@Cacheable(cacheNames = "products", key = "#id")
@Transactional(readOnly = true)
public ProductResponse find(long id) {
return products.findById(id).map(ProductResponse::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@CacheEvict(cacheNames = "products", key = "#id")
@Transactional
public ProductResponse updatePrice(long id, long priceCents) {
Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
product.setPriceCents(priceCents);
return ProductResponse.from(product);
}
}With spring-boot-starter-data-redis and spring-boot-starter-cache on the classpath and spring.data.redis.host set, that’s all:
- The first
find(42)runs the method and stores the result in Redis under the keyproducts::42. Every laterfind(42)within 10 minutes returns the stored copy without touching the database. updatePrice(42, …)deletesproducts::42, so the nextfind(42)loads the new price.- The cache stores the DTO (
ProductResponse), not the JPA entity: entities have lazy proxies and database state that don’t belong in Redis.
@ApplicationScoped
public class CacheSetup {
// Create the cache with a TTL at startup. Caching.getCachingProvider() finds the JCache
// provider on the classpath, here Redisson, configured to talk to Redis.
void onStart(@Observes @Initialized(ApplicationScoped.class) Object event) {
Caching.getCachingProvider().getCacheManager().createCache("products",
new MutableConfiguration<Long, ProductResponse>()
.setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(new Duration(TimeUnit.MINUTES, 10))));
}
}
@ApplicationScoped
public class ProductService {
@PersistenceContext EntityManager em;
@CacheResult(cacheName = "products") // javax.cache.annotation (JSR-107)
public ProductResponse find(@CacheKey long id) {
Product product = em.find(Product.class, id);
if (product == null) throw new ProductNotFoundException(id);
return ProductResponse.from(product);
}
@CacheRemove(cacheName = "products")
@Transactional
public ProductResponse updatePrice(@CacheKey long id, long priceCents) {
Product product = em.find(Product.class, id);
if (product == null) throw new ProductNotFoundException(id);
product.setPriceCents(priceCents);
return ProductResponse.from(product);
}
}Kotlin note
Jakarta EE examples are in Java, the language almost all Jakarta EE code is written in. The same annotations work from Kotlin with the allopen compiler plugin for CDI beans.
With a JCache provider for Redis (Redisson) and the JCache CDI interceptors on the classpath (some servers, like Payara, include them), that’s all:
- The first
find(42)runs the method and stores the result in theproductscache under the key42. Every laterfind(42)within 10 minutes returns the stored copy without touching the database. updatePrice(42, …)removes the entry for42.@CacheKeymarks which parameters make up the key: just theid, not the price.- The cache stores the DTO (
ProductResponse), not the JPA entity: entities have lazy proxies and database state that don’t belong in Redis.
When the cache forgets to forget
Predict the output
Product 42 costs 20 EUR and is cached. A nightly import sets its price to 25 with productRepository.save(). What does find(42) return the next morning, if the request comes 5 minutes after the import?
// 23:00, request: cached
productService.find(42) // 20 EUR, now in Redis for 10 minutes
// 02:00, nightly import job (no @CacheEvict anywhere on this path)
val p = productRepository.findById(42).orElseThrow()
p.priceCents = 2500
productRepository.save(p)
// 02:05, request
println(productService.find(42).priceCents)// 23:00, request: cached
productService.find(42); // 20 EUR, now in Redis for 10 minutes
// 02:00, nightly import job (no @CacheEvict anywhere on this path)
Product p = productRepository.findById(42L).orElseThrow();
p.setPriceCents(2500);
productRepository.save(p);
// 02:05, request
System.out.println(productService.find(42).priceCents());The import changed the database directly through the repository, bypassing updatePrice and its @CacheEvict. Redis still holds the old ProductResponse under products::42, and @Cacheable returns it without asking the database, until the 10-minute TTL expires. Every code path that changes cached data must evict (or the import must clear the cache when it finishes); the TTL is the safety net that bounds how long a missed eviction can lie.
Predict the output
Product 42 costs 20 EUR and is cached. A nightly import sets its price to 25 directly through the EntityManager. What does find(42) return the next morning, if the request comes 5 minutes after the import?
// 23:00, request: cached
productService.find(42); // 20 EUR, now in Redis for 10 minutes
// 02:00, nightly import job (no @CacheRemove anywhere on this path)
Product p = em.find(Product.class, 42L);
p.setPriceCents(2500); // written at commit
// 02:05, request
System.out.println(productService.find(42).priceCents());The import changed the database directly, bypassing updatePrice and its @CacheRemove. Redis still holds the old ProductResponse under 42, and @CacheResult returns it without asking the database, until the 10-minute expiry. Every code path that changes cached data must evict (or the import must clear the cache when it finishes); the TTL is the safety net that bounds how long a missed eviction can lie.
(In this exact timeline the 23:00 entry would have expired by 02:05; the point is any request in the 10 minutes before the import. With a 24-hour TTL, the wrong price would be shown all day.)
Consistency: what eviction can’t fix
Cache-aside has a classic race even with perfect evictions:
- Reader A misses and loads price 20 from the database.
- Writer B updates the price to 25 and evicts the key.
- Reader A, delayed, writes 20 into the cache after the eviction.
The cache now serves 20 until the TTL expires. Mitigations, from simplest: keep TTLs short enough that the window is acceptable; evict after commit (an @CacheEvict on a @Transactional method evicts before the commit unless the cache manager is transaction-aware: RedisCacheManager.builder(...).transactionAware()), optionally with a delayed second eviction; or store a version with the value and refuse to overwrite a newer version. Prices at checkout, balances and permissions should be read from the source of truth, not the cache.
The cache now serves 20 until the TTL expires. Mitigations, from simplest: keep TTLs short enough that the window is acceptable; evict after commit (a @CacheRemove on a @Transactional method runs before the commit, so a reader can reload the old value in between: evict in a CDI observer with @Observes(during = TransactionPhase.AFTER_SUCCESS) instead), optionally with a delayed second eviction; or store a version with the value and refuse to overwrite a newer version. Prices at checkout, balances and permissions should be read from the source of truth, not the cache.
- Evict after the database commit, and keep TTLs short enough to bound staleness: a slow reader can still store a value it loaded before the write.
- Narrow the race with delayed double eviction or versioned values; cache DTOs, never entities.
Check this part
What happens on a cache miss with @Cacheable (Spring) or @CacheResult (JCache)?
Why give every cached entry a TTL?
Why can a cache hold a stale value even when every write evicts?
A write updates the database inside a transaction and evicts the cache before the commit. What can go wrong?