Skip to content
Commitwise
Menu
Java Backend

Caching with Redis

Speed up a Spring Boot or Jakarta EE API with Redis and cache annotations (Spring's or JCache's), choose keys and TTLs, keep cached data correct after updates, and survive stampedes, deploys and Redis outages.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

“There are only two hard things in computer science: cache invalidation and naming things.” The joke is old because it’s true. Adding a cache is one annotation; keeping it correct is the work: users seeing yesterday’s price, one customer seeing another’s cart, every server hitting the database at once when a popular entry expires.

This tutorial adds cachingCachingKeeping a copy of data somewhere faster, so you don't fetch or compute it again every time.Open in glossary → to a Spring Boot or Jakarta EE API with Redis, and then goes through the ways caches lie, and how to stop them.

New to some of these? Open what you need, or skip ahead.

Basics:What is a cache?

A cache keeps a copy of data somewhere faster to reach than its original source. Instead of asking the database for the same popular product 1,000 times a second, the server keeps it in memory (or in a cache server like Redis) and answers from there.

Two questions come with every cache: how long may a copy be used before it might be out of date (its time to live, or TTL), and what happens when the original changes (the copy must be removed or updated).

Basics:What is Spring Boot?

Spring Boot is the most popular framework for Java and Kotlin backends. You write classes and mark them with annotations; Spring creates the objects (called beans), connects them to each other (dependency injection: a controller gets the service it needs through its constructor) and runs a web server that sends HTTP requests to your methods.

Starters (spring-boot-starter-web, spring-boot-starter-data-jpa…) add features by adding a dependency, with sensible defaults you can change in application.yml.

Basics:What is Jakarta EE?

Jakarta EE (formerly Java EE) is a set of standard APIs for Java backends: Jakarta REST for HTTP endpoints, CDI for creating objects and connecting them (dependency injection), Jakarta Persistence (JPA) for databases.

A runtime provides the implementation: an application server such as WildFly, Payara or Open Liberty, or a lighter runtime built on the same APIs, such as Quarkus or Helidon. Jakarta EE has no caching standard of its own; the usual choice is JCache (JSR-107), a Java caching API with annotations, and a provider such as Redisson that stores the entries in Redis.

Basics:What do annotations like @Service mean?

An annotation (the @Something in Java and Kotlin) attaches extra information to a class, method, field or parameter. It doesn’t run code by itself; tools and frameworks read it and act on it.

@Service (Spring) or @ApplicationScoped (Jakarta EE) says “create one of these and manage it”, @GetMapping("/books") (Spring) or @GET @Path("/books") (Jakarta EE) says “call this method for GET requests to /books”, and @Entity tells Hibernate “this class is a database table”. Swift has a related idea in attributes and property wrappers, like @State and @MainActor.

Basics:What is JSON?

JSON is the text format most APIs use to send data. Objects are written in braces with "key": value pairs, lists in square brackets:

{ "id": 7, "name": "Mug", "price": 9.5, "tags": ["kitchen", "gift"], "discount": null }

Values can be strings (in quotes), numbers, true/false, null, objects or lists. Turning JSON into your own types is called decoding (or parsing, or deserializing); the opposite is encoding.

Basics:What are threads and the main thread?

A thread is a line of work the computer runs; a program can run several at the same time. Apps have one special thread, the main thread (also called the UI thread), which draws the screen and handles taps.

If code on the main thread waits for something slow, like the network, the screen freezes: no scrolling, no animations. On Android, a few seconds of that shows the “App isn’t responding” dialog. That’s why slow work must happen without blocking the main thread.

Basics:What does serializing mean?

Serializing turns an object in memory into something you can store or send: a JSON string, bytes in a file, a value in Redis. Deserializing turns it back into an object.

Both sides must agree on the format. If the code that reads data was changed (a field renamed, a class restructured) while old data is still stored somewhere, deserializing can fail, which is why formats like JSON with clear field names are preferred over storing raw objects.

Part Not started

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:

  1. Check the shelf (the cache). If it’s there, hand it over: fast. That’s a hit.
  2. If not (a miss), walk to the archive (the database), bring the book, and put a copy on the shelf for the next person.
  3. When a book is revised, take the old copy off the shelf, so nobody gets the outdated one.
  4. 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 key products::42. Every later find(42) within 10 minutes returns the stored copy without touching the database.
  • updatePrice(42, …) deletes products::42, so the next find(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 the products cache under the key 42. Every later find(42) within 10 minutes returns the stored copy without touching the database.
  • updatePrice(42, …) removes the entry for 42. @CacheKey marks which parameters make up the key: just the id, 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());

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());

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

  1. Reader A misses and loads price 20 from the database.
  2. Writer B updates the price to 25 and evicts the key.
  3. 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

  1. What happens on a cache miss with @Cacheable (Spring) or @CacheResult (JCache)?

  2. Why give every cached entry a TTL?

  3. Why can a cache hold a stale value even when every write evicts?

  4. A write updates the database inside a transaction and evicts the cache before the commit. What can go wrong?

Cache-aside: check the cache, load from the database on a miss, store the result with a TTL, and evict when the data changes. The TTL bounds how long the cache can lie when an eviction is missed.

Part Not started

Two methods, one shelf

Spot the bugClassCastException on the product page

Tap the line with the bug.

@Cacheable("products")
fun find(id: Long): ProductResponse =
    ProductResponse.from(products.findById(id).orElseThrow())

@Cacheable("products")
fun reviews(productId: Long): List<ReviewResponse> =
    reviewRepository.findByProductId(productId).map(ReviewResponse::from)
@Cacheable("products")
public ProductResponse find(long id) {
    return ProductResponse.from(products.findById(id).orElseThrow());
}

@Cacheable("products")
public List<ReviewResponse> reviews(long productId) {
    return reviewRepository.findByProductId(productId).stream().map(ReviewResponse::from).toList();
}

Spot the bugClassCastException on the product page

Tap the line with the bug.

@CacheResult(cacheName = "products")
public ProductResponse find(long id) {
    return ProductResponse.from(em.find(Product.class, id));
}

@CacheResult(cacheName = "products")
public List<ReviewResponse> reviews(long productId) {
    return reviews.forProduct(productId).stream().map(ReviewResponse::from).toList();
}

Practice: deciding what to cache

Not everything should be cached, and not for the same time. Step by step, for each piece of data:

  1. How often is it read compared with written? Caching pays off for data read many times per change: product details, category trees, configuration.
  2. How stale may it be? A product description can be 10 minutes old; a price shown at checkout must be exact; a bank balance should not be cached at all.
  3. What does the result depend on? The id, the language, the currency, the user. Every one of those goes into the key.
  4. Who changes it? Every write path needs an eviction, and the TTL bounds the damage when one is missed.
DataCache?TTLKey
Product detailsyes10 min, evict on updateproducts::{id}:{locale}
Category treeyes1 hour, evict on admin changecategories::{locale}
Stock levelshort or no5 seconds, or read livestock::{id}
Price at checkoutno—— (read from the database in the transaction)
A user’s cartper user, or no30 mincart::{userId}

The last row is where a classic bug lives:

Spot the bugEveryone sees the same cart

Tap the line with the bug.

@Cacheable("cart")
fun currentCart(): CartResponse {
    val userId = currentUser.id()                 // from the security context
    return CartResponse.from(carts.findByUserId(userId))
}
@Cacheable("cart")
public CartResponse currentCart() {
    long userId = currentUser.id();               // from the security context
    return CartResponse.from(carts.findByUserId(userId));
}

Spot the bugEveryone sees the same cart

Tap the line with the bug.

@CacheResult(cacheName = "cart")
public CartResponse currentCart() {
    long userId = currentUser.id();               // from the security context
    return CartResponse.from(carts.forUser(userId));
}

Exerciseeasy

Caching a product page

A product page calls GET /api/products/{id}?locale=hr and shows the name and description (translated), the price, and “only 3 left” when stock is low. Product data changes a few times a day; stock changes with every order. Load is 500 requests per second, most for the same 1,000 products.

Write the service methods and annotations: what’s cached, under which key, for how long, and how updates evict it.

›

Split the response by how fast the data changes: product details (slow) and stock (fast) shouldn’t share a cache entry or a TTL.

›

The locale changes the result, so it belongs in the key, and an update to a product must evict every locale of it. A key like #id + ':' + #locale with a separate eviction strategy, or evicting the whole small cache, both work.

The locale changes the result, so it belongs in the key (mark both parameters @CacheKey), and an update to a product must evict every locale of it. @CacheRemoveAll on a small cache is the simple way.

SolutionTry it yourself first
@Service
class ProductPageService(
    private val products: ProductRepository,
    private val stock: StockRepository,
) {
    @Cacheable(cacheNames = ["product-details"], key = "#id + ':' + #locale")
    @Transactional(readOnly = true)
    fun details(id: Long, locale: String): ProductDetails =
        products.findDetails(id, locale) ?: throw ProductNotFoundException(id)

    fun stockLevel(id: Long): Int = stock.findLevel(id)        // not cached

    @CacheEvict(cacheNames = ["product-details"], allEntries = true)
    @Transactional
    fun update(id: Long, update: ProductUpdate) {
        val product = products.findByIdOrNull(id) ?: throw ProductNotFoundException(id)
        product.apply(update)
    }
}

// The controller calls both, so details() is invoked through the proxy and actually cached:
@GetMapping("/api/products/{id}")
fun page(@PathVariable id: Long, @RequestParam locale: String): ProductPage {
    val level = pageService.stockLevel(id)
    return ProductPage(pageService.details(id, locale), level.takeIf { it <= 5 })
}
@Service
public class ProductPageService {
    private final ProductRepository products;
    private final StockRepository stock;

    public ProductPageService(ProductRepository products, StockRepository stock) {
        this.products = products;
        this.stock = stock;
    }

    // Slow-changing, translated details: cached per product and locale (TTL from a per-cache config: 10 min).
    @Cacheable(cacheNames = "product-details", key = "#id + ':' + #locale")
    @Transactional(readOnly = true)
    public ProductDetails details(long id, String locale) {
        return products.findDetails(id, locale).orElseThrow(() -> new ProductNotFoundException(id));
    }

    // Fast-changing stock: not cached; one indexed primary-key read.
    public int stockLevel(long id) {
        return stock.findLevel(id);
    }

    public ProductPage page(long id, String locale) {
        int level = stockLevel(id);
        return new ProductPage(details(id, locale), level <= 5 ? level : null);
    }

    // Admin edits: evict all locales of all products (a small cache: simpler and safer than tracking locales).
    @CacheEvict(cacheNames = "product-details", allEntries = true)
    @Transactional
    public void update(long id, ProductUpdate update) {
        products.findById(id).orElseThrow(() -> new ProductNotFoundException(id)).apply(update);
    }
}

@Bean
RedisCacheManagerBuilderCustomizer cacheTtls() {
    return builder -> builder
        .withCacheConfiguration("product-details",
            RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(10)));
}

Notes on the choices:

  • Two kinds of data, two strategies: details are cached for 10 minutes; stock is read live because “only 3 left” being wrong costs sales or oversells. At 500 requests per second, a primary-key read of one integer is cheap for the database.
  • The locale is in the key: otherwise the first visitor’s language is served to everyone.
  • allEntries = true on a small cache is a deliberate simplification: admin edits are rare, so briefly reloading 1,000 products is cheaper than tracking every locale key.
  • Proxy calls: in the Java version, page() calls details() on this, which bypasses the cache proxy, so it would never be cached. The Kotlin version calls both from the controller; in Java, do the same or move details to its own bean. Self-invocation silently disables @Cacheable, just like @Transactional.
@ApplicationScoped
public class ProductPageService {
    @PersistenceContext EntityManager em;

    // Slow-changing, translated details: cached per product and locale (cache created with a 10-minute expiry).
    @CacheResult(cacheName = "product-details")
    public ProductDetails details(@CacheKey long id, @CacheKey String locale) {
        return products.findDetails(id, locale).orElseThrow(() -> new ProductNotFoundException(id));
    }

    // Fast-changing stock: not cached; one indexed primary-key read.
    public int stockLevel(long id) {
        return em.find(Stock.class, id).getLevel();
    }

    // Admin edits: evict all locales of all products (a small cache: simpler and safer than tracking locales).
    @CacheRemoveAll(cacheName = "product-details")
    @Transactional
    public void update(long id, ProductUpdate update) {
        Product product = em.find(Product.class, id);
        if (product == null) throw new ProductNotFoundException(id);
        product.apply(update);
    }
}

// The resource calls both methods on the injected bean:
@GET
@Path("/api/products/{id}")
public ProductPage page(@PathParam("id") long id, @QueryParam("locale") String locale) {
    int level = pageService.stockLevel(id);
    return new ProductPage(pageService.details(id, locale), level <= 5 ? level : null);
}

Notes on the choices:

  • Two kinds of data, two strategies: details are cached for 10 minutes; stock is read live because “only 3 left” being wrong costs sales or oversells. At 500 requests per second, a primary-key read of one integer is cheap for the database.
  • The locale is in the key: otherwise the first visitor’s language is served to everyone. With JCache, every @CacheKey parameter becomes part of the key.
  • @CacheRemoveAll on a small cache is a deliberate simplification: admin edits are rare, so briefly reloading 1,000 products is cheaper than tracking every locale key.
  • Calls through the bean: the resource calls details() on the injected pageService, so the caching interceptor runs. (A call on this would skip the interceptor in standard CDI, as it skips Spring’s proxy; Quarkus is the exception, intercepting self-calls as a non-standard feature.)

Stampedes and hot keys

When a popular key expires, every request that misses at that moment loads it. With 2,000 requests per second for one product and a 200 ms query, that’s 400 identical queries at once, often enough to tip the database over, which makes queries slower, which lets more requests miss.

  • @Cacheable(sync = true) makes concurrent misses for the same key in one JVM wait for a single load (the puzzle shows what that does and doesn’t cover).
  • JCache’s @CacheResult has no option to make concurrent misses wait for one load (the puzzle shows the cost). For hot keys, use a read-through cache (a CacheLoader configured on the cache, if your provider runs one load per key) or a local cache like Caffeine, whose get(key, loader) loads each key once per JVM.
  • Jitter the TTL (10 min ± 10%) so entries cached together don’t expire together.
  • Refresh ahead for the hottest keys: reload in the background before expiry, so readers never miss.
  • Hot keys concentrate load on one Redis shard: add a small local cache (Caffeine, a few seconds) in front of Redis for the top items.

Operating Redis as a cache

  • Set maxmemory and an eviction policy (allkeys-lru or allkeys-lfu for a pure cache); noeviction (the default) turns a full cache into write errors.
  • Redis being down must not mean the API is down. Configure short command timeouts (tens of milliseconds, not the default 60 seconds) and a CacheErrorHandler that logs and treats errors as misses, and know that “every request goes to the database” is itself a load spike: size for it or shed load.
  • Redis being down must not mean the API is down. Configure short command timeouts (tens of milliseconds) on the client, and know that JCache annotations have no error handler: a failing cache call fails the request. For anything critical, call the cache explicitly and treat errors as misses, and know that “every request goes to the database” is itself a load spike: size for it or shed load.
  • Avoid big values (megabyte lists) and unbounded key sets (a key per search query); both make memory and latency unpredictable.
  • Serialization is a contract across deploys: JSON over JDK serialization, and a version in the key prefix when the DTO’s shape changes (puzzle below).

Synchronized, per instance

Predict the output

find(42) has @Cacheable(cacheNames = "products", sync = true). The entry expires, and 100 requests for product 42 arrive at the same moment, spread over 4 API instances. How many times does the database load product 42?

@Cacheable(cacheNames = ["products"], key = "#id", sync = true)
@Transactional(readOnly = true)
fun find(id: Long): ProductResponse {
    log.info("Loading product {} from the database", id)
    return ProductResponse.from(products.findById(id).orElseThrow())
}
@Cacheable(cacheNames = "products", key = "#id", sync = true)
@Transactional(readOnly = true)
public ProductResponse find(long id) {
    log.info("Loading product {} from the database", id);
    return ProductResponse.from(products.findById(id).orElseThrow());
}

Predict the output

find(42) has @CacheResult(cacheName = "products"). The entry expires, and 100 requests for product 42 arrive at the same moment, spread over 4 API instances. How many times does the database load product 42?

@CacheResult(cacheName = "products")
public ProductResponse find(@CacheKey long id) {
    log.infof("Loading product %d from the database", id);
    return ProductResponse.from(em.find(Product.class, id));
}

The deploy that broke the cache

Spot the bugEvery product request fails after a deploy

Tap the line with the bug.

@Bean
fun cacheConfiguration(): RedisCacheConfiguration =
    RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofHours(24))

// v1: data class ProductResponse(val id: Long, val name: String, val priceCents: Long) : Serializable
// v2 (today's deploy): a field was added
data class ProductResponse(val id: Long, val name: String, val priceCents: Long, val badge: String?) : Serializable
@Bean
RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofHours(24));
}

// v1: public record ProductResponse(long id, String name, long priceCents) implements Serializable {}
// v2 (today's deploy): a field was added
public record ProductResponse(long id, String name, long priceCents, String badge) implements Serializable {}

Spot the bugEvery product request fails after a deploy

Tap the line with the bug.

// redisson.yaml, used by Redisson's JCache provider
// codec: !<org.redisson.codec.SerializationCodec> {}     <- JDK serialization
// the "products" cache expires entries after 24 hours

// v1: public record ProductResponse(long id, String name, long priceCents) implements Serializable {}
// v2 (today's deploy): a field was added
public record ProductResponse(long id, String name, long priceCents, String badge) implements Serializable {}

Exercisehard

A catalog cache at 50,000 requests per second

A marketplace serves product pages at 50,000 requests per second across 40 API instances. 80% of traffic goes to 2,000 products. Prices change several times a minute during sales, and sellers expect a new price to be visible within 5 seconds. A previous incident: a flash sale expired a hot product’s cache entry, and the database fell over.

Design the caching layers, invalidation and failure behavior.

›

At this rate, even Redis round trips for the hottest 2,000 products are a lot of network traffic, and one hot key lands on one Redis shard. Consider a small in-process cache in front of Redis.

›

An in-process cache can’t be evicted by an eviction on another instance. Either keep its TTL shorter than the freshness requirement, or broadcast invalidations.

SolutionTry it yourself first
@Service
public class CatalogCache {
    // L1: per instance, tiny and short-lived. Bounds staleness on its own.
    private final Cache<Long, ProductResponse> local = Caffeine.newBuilder()
        .maximumSize(5_000)
        .expireAfterWrite(Duration.ofSeconds(3))
        .build();

    private final RedisTemplate<String, ProductResponse> redis;     // L2: shared, minutes, versioned keys
    private final ProductRepository products;

    public ProductResponse get(long id) {
        return local.get(id, key -> {                               // Caffeine coalesces concurrent loads per key
            ProductResponse cached = safeGet("v3:product:" + key);
            if (cached != null) return cached;
            ProductResponse fresh = ProductResponse.from(products.findById(key).orElseThrow());
            safeSet("v3:product:" + key, fresh, jitter(Duration.ofMinutes(5)));
            return fresh;
        });
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onPriceChanged(PriceChanged event) {
        redis.delete("v3:product:" + event.productId());            // L2: evict after commit
        redis.convertAndSend("catalog-invalidations", event.productId());   // L1 on all instances
    }

    // Registered for the "catalog-invalidations" channel with a RedisMessageListenerContainer (wiring omitted).
    public void onInvalidation(Long productId) {
        local.invalidate(productId);
    }

    private ProductResponse safeGet(String key) {
        try { return redis.opsForValue().get(key); }                // command timeout: 50 ms
        catch (RuntimeException e) { metrics.cacheError(); return null; }   // fail open: treat as a miss
    }
    // safeSet, jitter …
}
@ApplicationScoped
public class CatalogCache {
    // L1: per instance, tiny and short-lived. Bounds staleness on its own.
    private final Cache<Long, ProductResponse> local = Caffeine.newBuilder()
        .maximumSize(5_000)
        .expireAfterWrite(Duration.ofSeconds(3))
        .build();

    @Inject RedisCommands<String, String> redis;                     // L2: shared (Lettuce), minutes, versioned keys
    @Inject RedisPubSubCommands<String, String> pubsub;
    @PersistenceContext EntityManager em;
    private final Jsonb json = JsonbBuilder.create();

    public ProductResponse get(long id) {
        return local.get(id, key -> {                               // Caffeine coalesces concurrent loads per key
            ProductResponse cached = safeGet("v3:product:" + key);
            if (cached != null) return cached;
            ProductResponse fresh = ProductResponse.from(em.find(Product.class, key));
            safeSet("v3:product:" + key, fresh, jitter(Duration.ofMinutes(5)));
            return fresh;
        });
    }

    void onPriceChanged(@Observes(during = TransactionPhase.AFTER_SUCCESS) PriceChanged event) {
        redis.del("v3:product:" + event.productId());                // L2: evict after commit
        pubsub.publish("catalog-invalidations", String.valueOf(event.productId()));   // L1 on all instances
    }

    // Called by a Redis pub/sub listener subscribed to "catalog-invalidations" (wiring omitted).
    public void onInvalidation(long productId) {
        local.invalidate(productId);
    }

    private ProductResponse safeGet(String key) {
        try {
            String value = redis.get(key);                              // command timeout: 50 ms
            return value == null ? null : json.fromJson(value, ProductResponse.class);
        } catch (RuntimeException e) {
            metrics.cacheError();
            return null;                                                // fail open: treat as a miss
        }
    }
    // safeSet, jitter …
}

The design, layer by layer:

LayerHoldsFreshnessWhy
L1 Caffeine (per instance)hottest ~5,000 products3 s TTL + pub/sub invalidationremoves most network round trips and spreads hot-key load across 40 instances
L2 Redis (shared)all recently viewed productsminutes, jittered, evicted after commitprotects the database; one load per product per few minutes
Databasesource of truth—checkout always reads prices here, inside the order transaction
  • The 5-second requirement is met even if a pub/sub message is lost: L1 entries live 3 seconds, and L2 is evicted after commit (a lost eviction is bounded by the L2 TTL, so keep it modest during sales or add version checks).
  • Stampede protection: Caffeine’s get(key, loader) lets one thread per instance load a key; jittered L2 TTLs avoid synchronized expiry; for the top 100 sale items, a scheduler can refresh L2 ahead of expiry.
  • Failure modes: Redis timeouts are 50 ms and errors count as misses (with a circuit breaker so a dead Redis isn’t retried on every request). If Redis is gone, L1 still absorbs most traffic, and a rate limiter in front of the database sheds the rest rather than letting it collapse.
  • Deploys: the v3: prefix isolates incompatible DTO versions; L1 is empty on start, so roll instances gradually to avoid a cold-start spike.
  • Correctness boundary: caches serve pages; money decisions (cart totals, checkout) read the database. A slightly stale price on a listing is acceptable; a stale price on a payment is not.

Deep dives

Deep diveRefreshing before expiry: probabilistic early expirationInterview favorite

When a popular key expires, every request misses at once and recomputes it (a stampede). Locks and single flight fix it inside one process; across many servers, a neat trick is to let requests refresh the value early, with a probability that grows as expiry approaches.

The “XFetch” rule: recompute when now - delta * beta * ln(random()) >= expiry, where delta is how long the computation takes and beta (usually 1) tunes eagerness. Because ln(random()) is negative, each request occasionally “sees” expiry a bit early, so usually one request refreshes the value before it expires, without any coordination between servers.

ConcernPractice
Read@Cacheable(cacheNames = "x", key = "..."), one cache name per data type, explicit keys including user/locale
Write@CacheEvict on every write path (after commit: transactionAware() or AFTER_COMMIT events)
TTLper cache via RedisCacheManagerBuilderCustomizer; jitter; TTL bounds every missed eviction
Stampedesync = true (per JVM), refresh-ahead for hot keys, local L1 for hot items
SerializationJSON serializer, versioned key prefix, CacheErrorHandler → miss
Redis opsmaxmemory + allkeys-lru/lfu, short command timeouts, no huge values or unbounded keys
Don’t cachemoney at checkout, permissions, anything read via hidden inputs (security context)

Gotchas: default keys ignore hidden inputs (one user’s cart for everyone), two methods sharing a cache name, self-invocation bypasses @Cacheable, evict-before-commit races, JDK serialization across deploys, Redis outage = database stampede.

@Bean
fun caches(): RedisCacheManagerBuilderCustomizer {
    val json = RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer())
    val base = RedisCacheConfiguration.defaultCacheConfig().serializeValuesWith(json)
        .computePrefixWith { "v2:$it::" }
    return RedisCacheManagerBuilderCustomizer { builder ->
        builder.cacheDefaults(base.entryTtl(Duration.ofMinutes(5)))
            .withCacheConfiguration("product-details", base.entryTtl(Duration.ofMinutes(10)))
            .withCacheConfiguration("categories", base.entryTtl(Duration.ofHours(1)))
            .transactionAware()                                // evictions wait for commit
    }
}
@Bean
RedisCacheManagerBuilderCustomizer caches() {
    var json = RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer());
    var base = RedisCacheConfiguration.defaultCacheConfig().serializeValuesWith(json)
        .computePrefixWith(name -> "v2:" + name + "::");
    return builder -> builder.cacheDefaults(base.entryTtl(Duration.ofMinutes(5)))
        .withCacheConfiguration("product-details", base.entryTtl(Duration.ofMinutes(10)))
        .withCacheConfiguration("categories", base.entryTtl(Duration.ofHours(1)))
        .transactionAware();                                   // evictions wait for commit
}
ConcernPractice
APIJCache (JSR-107, javax.cache) with a Redis provider (Redisson); or the runtime’s own (Quarkus @CacheResult); Jakarta EE has no caching spec
Read@CacheResult(cacheName = "x") + @CacheKey on every parameter the result depends on; one cache name per data type
Write@CacheRemove / @CacheRemoveAll on every write path; after commit via @Observes(during = AFTER_SUCCESS)
TTLper cache: MutableConfiguration.setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(…)); jitter; TTL bounds every missed eviction
Stampedeno single-flight in @CacheResult: read-through loader, Caffeine L1, refresh-ahead
SerializationJSON codec, versioned cache names; wrap critical reads to treat errors as misses
Redis opsmaxmemory + allkeys-lru/lfu, short command timeouts, no huge values or unbounded keys
Don’t cachemoney at checkout, permissions, anything read via hidden inputs (security context)

Gotchas: default keys ignore hidden inputs (one user’s cart for everyone), two methods sharing a cache name, concurrent misses all hit the database, evict-before-commit races, JDK serialization across deploys, annotation-driven cache errors fail requests, Redis outage = database stampede.

void onStart(@Observes @Initialized(ApplicationScoped.class) Object event) {
    CacheManager caches = Caching.getCachingProvider().getCacheManager();   // Redisson, JSON codec
    caches.createCache("products-v2", ttl(Duration.FIVE_MINUTES));
    caches.createCache("product-details-v2", ttl(Duration.TEN_MINUTES));
    caches.createCache("categories-v2", ttl(Duration.ONE_HOUR));
}

private static <K, V> MutableConfiguration<K, V> ttl(Duration duration) {   // javax.cache.expiry.Duration
    return new MutableConfiguration<K, V>().setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(duration));
}

Check this part

  1. Two cached methods use the same cache name, "products", and both take an id. What happens?

  2. A cached method has no parameters and reads the current user. What goes wrong?

  3. In Spring, what does @Cacheable(sync = true) guarantee?

  4. What should happen when Redis times out?

Keys come from method arguments, so every cached method needs its own cache name and every value that changes the result (like the user) in its key. In production, protect against stampedes, Redis outages and serialization changes across deploys.

Key takeaways

  • Cache-aside: check the cache, load on a miss, store with a TTL, evict when the data changes.
  • Give every kind of data its own cache name, and put everything the result depends on (id, locale, user) into the key.
  • Cache slow-changing data like product details; read money, stock and permissions from the database.
  • Every write path must evict; the TTL limits the damage when one doesn’t.
  • Evict after commit and keep TTLs short where correctness matters; cache-aside has inherent read/write races.
  • Protect against stampedes: one load per key (Spring’s sync, a read-through loader or Caffeine), jittered TTLs, refresh-ahead and a small local cache for hot keys.
  • Treat serialization as a versioned contract (JSON, key prefixes), and cache errors as misses.
  • Plan for Redis being slow or down: short timeouts, circuit breakers, and load shedding in front of the database.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.