DEV Community

Renato Fialho
Renato Fialho

Posted on

One Load per Key: Collapsing Duplicate Lookups with Spring Boot's Cache and Caffeine

Rule engines, aggregators and validation pipelines share a hidden cost. Many independent components ask the same downstream question, with the same key, at the same moment. Each component is correct on its own; together they multiply the load on whatever sits behind them.

This article shows how to collapse those calls into a single load using Spring's cache abstraction backed by Caffeine. It also covers what tends to bite afterwards: cached empty results, metrics that change meaning, and tests that pass for the wrong reason.

The scenario

Take a promotion eligibility service. To decide whether a product qualifies for a promotion, it evaluates about 20 rules in parallel: in stock, price above a threshold, seller in good standing, category not excluded, and so on.

List<CompletableFuture<Boolean>> results = rules.stream()
        .map(rule -> CompletableFuture.supplyAsync(() -> rule.test(productId), executor))
        .toList();
Enter fullscreen mode Exit fullscreen mode

Each rule depends on a small, focused service:

@Component
class InStockRule implements Rule {

    private final StockService stockService;

    @Override
    public boolean test(String productId) {
        return stockService.isInStock(productId);
    }
}

@Service
class StockService {

    private final ProductCatalogue catalogue;

    boolean isInStock(String productId) {
        return catalogue.findProduct(productId)
                .map(product -> product.stock() > 0)
                .orElse(false);
    }
}
Enter fullscreen mode Exit fullscreen mode

PricingService, SellerService and CategoryService look the same: each one reads a different field from the same Product, which comes from one place:

@Service
public class ProductCatalogue {

    // Reads from a shared cache (Redis, DynamoDB, ...), falls back to the catalogue API on a miss.
    public Optional<Product> findProduct(String productId) {
        return store.get(productId).or(() -> fetchAndStore(productId));
    }
}
Enter fullscreen mode Exit fullscreen mode

The design is clean: every rule is small, testable and independent. But one evaluation of one product calls findProduct about 20 times:

  • Warm path: 20 reads from the shared cache, plus 20 deserialisations of the same object.
  • Cold path: all 20 calls miss at the same time, so the catalogue API gets 20 concurrent requests for the same product. That's a cache stampede, and it hits hardest when the downstream system is already struggling.

The calls don't need 20 answers. They need one.

Why not request scope?

The first idea is usually a request-scoped cache: memoise the response for the lifetime of the HTTP request. That works less well than it sounds:

  • Thread hopping. The rules run on executor threads. Request attributes are bound to the request thread, so request-scoped beans only work if you propagate the context to every executor (for example with a TaskDecorator).
  • No request at all. Queue consumers, Kafka listeners and scheduled jobs have no HTTP request. A @RequestScope bean there fails with No thread-bound request found.

A short-lived, in-process cache gives the same effect on every entry point. The price is bounded staleness, and with a short enough TTL that price is negligible.

Setup

Dependencies

spring-boot-starter-cache brings Spring's cache abstraction, but not Caffeine. Add both. Spring Boot manages the Caffeine version.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("com.github.ben-manes.caffeine:caffeine")
Enter fullscreen mode Exit fullscreen mode

Enable caching

@SpringBootApplication
@EnableCaching
public class Application { }
Enter fullscreen mode Exit fullscreen mode

Configure the cache

spring:
  cache:
    type: caffeine
    cache-names: products
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=30s,recordStats
Enter fullscreen mode Exit fullscreen mode

Each line matters:

  • type: caffeine makes the application fail at startup if Caffeine is missing. Without it, Spring Boot silently falls back to ConcurrentMapCacheManager. That cache ignores caffeine.spec, so it has no size limit and entries never expire: unbounded memory and unbounded staleness, a worse problem than the one you set out to fix. Dependencies move around, so don't rely on Caffeine always being on the classpath.
  • cache-names creates the listed caches at startup and turns off dynamic creation: a @Cacheable that uses an unlisted name fails at runtime. It's also what lets Spring Boot register Micrometer metrics for the cache, because only caches that exist at startup are bound.
  • spec: maximumSize caps memory, expireAfterWrite caps staleness, and recordStats turns on the hit/miss statistics behind the metrics.

The annotation

@Cacheable(cacheNames = "products", key = "#productId", sync = true)
public Optional<Product> findProduct(String productId) {
    return store.get(productId).or(() -> fetchAndStore(productId));
}
Enter fullscreen mode Exit fullscreen mode

Two attributes do most of the work here.

sync = true is the difference between caching and collapsing. Without it, Spring checks the cache, misses, calls the method and stores the result. When 20 threads miss at the same moment, all 20 call the method. With sync = true, Spring hands the load to Caffeine's atomic get(key, loader): the first thread runs the method, and the other 19 wait for its result.

key = "#productId" makes the key explicit. The default key generator uses the bare argument for single-argument methods, but the moment someone adds a second parameter, the key changes shape and any matching @CacheEvict silently stops working. Referring to parameters by name needs the -parameters compiler flag (Spring Boot's build plugins enable it); otherwise use #p0.

Evicting works the same way:

@CacheEvict(cacheNames = "products", key = "#productId")
public void evict(String productId) { }

@CacheEvict(cacheNames = "products", allEntries = true)
public void evictAll() { }
Enter fullscreen mode Exit fullscreen mode

A classic mistake: @CacheEvict("products") on a method with no arguments evicts the key SimpleKey.EMPTY, which clears nothing. Use allEntries = true to clear the whole cache.

What will surprise you

1. Empty results are cached too

Spring unwraps Optional: present values are stored as the value, and empty ones are stored as null. CaffeineCacheManager allows null values by default, so "product not found" is memoised for the full TTL.

This is often what you want: without it, a missing product is looked up again by every one of the 20 callers. But decide it deliberately. The usual escape hatch, unless = "#result == null", isn't allowed together with sync = true. If you can't accept caching empty results, you'll need a different shape, for example throwing a not-found exception (exceptions are never cached) or using the Cache API directly.

2. Failures are not shared

Exceptions aren't cached. That's good: a transient failure doesn't stick for the next 30 seconds. But it also means that when the load fails, the waiting callers don't get the error. They retry the load one after another. Under a sustained outage, put a circuit breaker or a stale-fallback strategy inside the loaded method. Don't count on the cache to absorb the failures.

3. Every caller gets the same instance

Before the cache, each caller got its own freshly deserialised copy. Now all callers within the TTL share one object, across threads. If anything modifies it, you have a data race between components that don't know about each other. Prefer immutable types (records, or immutable generated classes). If you can't, say so in a comment on the cached method.

4. Self-invocation bypasses the cache

@Cacheable works through a Spring proxy. A call from inside the same class (this.findProduct(...)) skips the proxy and the cache. Check that every hot caller goes through the bean.

5. The cache is per instance

Each pod or instance has its own cache. An eviction endpoint only clears the instance that received the request; the others keep serving the old value until the TTL expires. That's acceptable when the TTL is short. Write it down for whoever operates the endpoint.

6. Your existing metrics change meaning

Any counter incremented inside the cached method (misses on the shared cache, remote calls, fallbacks) now only fires on in-memory misses. On the day you deploy, dashboards show a sudden drop that looks like an outage or a miracle. It's neither. Warn people before the deploy, and add the cache's own metrics, which recordStats exposes:

  • cache.gets{cache="products", result="hit"|"miss"}
  • cache.puts
  • cache.evictions
  • cache.size

7. Pick the TTL for the unit of work

The goal is to collapse the calls within one unit of work, not to build a long-lived performance cache. So the TTL needs to be long enough to cover one evaluation including slow paths (retries and their backoff), and short enough that the extra staleness is negligible compared with how fresh the data behind it is anyway. Seconds to tens of seconds is usually right. If you want a longer TTL, that's a different cache, and it needs its own invalidation design.

8. Virtual threads on JDK 21–23

With sync = true, the load runs inside Caffeine's atomic compute, which holds a synchronized lock. On JDK 21–23, a virtual thread that blocks on I/O inside synchronized pins its carrier thread. Since carrier threads are few (one per core by default), many concurrent cold loads can starve the scheduler. JDK 24 fixed this (JEP 491). On older JDKs, load-test the cold path with -Djdk.tracePinnedThreads=full.

Testing it

Prove that concurrent misses collapse

A naive test fires 20 calls and checks that the loader ran once. It usually passes even without sync = true: the stubbed load returns instantly, so the calls run one after another and the first one fills the cache before the second arrives. The test has to keep callers inside the loader until all of them have arrived:

@SpringJUnitConfig(ProductCatalogueCachingTest.Config.class)
class ProductCatalogueCachingTest {

    private static final int CALLERS = 20;

    @Autowired
    ProductCatalogue catalogue;
    @MockitoBean
    ProductStore store;

    @Test
    void concurrentMissesLoadOnce() throws Exception {
        var allCallersLoading = new CountDownLatch(CALLERS);
        var release = new CountDownLatch(1);
        // Every caller that reaches the loader waits here, so without sync = true all of them would load.
        when(store.get("p-1")).thenAnswer(invocation -> {
            allCallersLoading.countDown();
            release.await(5, TimeUnit.SECONDS);
            return Optional.of(new Product("p-1", 3));
        });

        try (var executor = Executors.newFixedThreadPool(CALLERS)) {
            var futures = IntStream.range(0, CALLERS)
                    .mapToObj(i -> executor.submit(() -> catalogue.findProduct("p-1")))
                    .toList();
            allCallersLoading.await(500, TimeUnit.MILLISECONDS);
            release.countDown();
            for (var future : futures) {
                assertThat(future.get(5, TimeUnit.SECONDS)).isPresent();
            }
        }
        verify(store, times(1)).get("p-1");
    }

    @Configuration
    @EnableCaching
    static class Config {

        @Bean
        CacheManager cacheManager() {
            return new CaffeineCacheManager("products");
        }

        @Bean
        ProductCatalogue catalogue(ProductStore store) {
            return new ProductCatalogue(store);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

With sync = true, the first caller enters the loader and the other 19 wait in Caffeine, so get runs once. Without it, all 20 enter the loader and the test fails with Wanted 1 time, but was 20 times. Remove sync = true once to see that failure: a test you've never seen fail hasn't proven anything.

Prove that the shipped configuration is the one you think

The test above builds its own CaffeineCacheManager, so it never touches application.yml. A typo in cache-names, or a missing type, would only show up in production. ApplicationContextRunner can load the real configuration file with only the cache auto-configuration:

@Test
void applicationConfigurationCreatesBoundedCaffeineCache() {
    new ApplicationContextRunner()
            .withInitializer(new ConfigDataApplicationContextInitializer())
            .withConfiguration(AutoConfigurations.of(CacheAutoConfiguration.class))
            .withUserConfiguration(CachingEnabled.class)
            .run(context -> {
                var manager = context.getBean(CacheManager.class);
                assertThat(manager).isInstanceOf(CaffeineCacheManager.class);

                var cache = (com.github.benmanes.caffeine.cache.Cache<?, ?>)
                        manager.getCache("products").getNativeCache();
                assertThat(cache.policy().eviction().orElseThrow().getMaximum()).isEqualTo(10_000);
                assertThat(cache.policy().expireAfterWrite().orElseThrow().getExpiresAfter())
                        .isEqualTo(Duration.ofSeconds(30));
            });
}

@Configuration
@EnableCaching
static class CachingEnabled { }
Enter fullscreen mode Exit fullscreen mode

CacheAutoConfiguration lives in org.springframework.boot.cache.autoconfigure in Spring Boot 4, and in org.springframework.boot.autoconfigure.cache in Spring Boot 3.

Also add one test each for eviction (evict, call again, check the loader ran twice) and for whichever empty-result behaviour you chose.

Checklist

  • [ ] spring-boot-starter-cache and caffeine declared, @EnableCaching on
  • [ ] spring.cache.type: caffeine so a missing Caffeine fails at startup
  • [ ] cache-names declared (and new names added there)
  • [ ] maximumSize, expireAfterWrite and recordStats in the spec
  • [ ] @Cacheable(sync = true) with an explicit key
  • [ ] @CacheEvict uses the same key; clear-all uses allEntries = true
  • [ ] Empty-result caching decided and tested
  • [ ] Cached return type immutable, or documented as shared
  • [ ] No self-invocation on hot paths
  • [ ] Dashboard owners warned about counters inside the cached method
  • [ ] Concurrency test that fails without sync = true
  • [ ] Configuration test against the real application.yml
  • [ ] On JDK 21–23 with virtual threads: cold path load-tested for pinning

Wrapping up

The change itself is a dependency, a few lines of YAML and one annotation. Most of the value is in the rest: deciding what gets cached (including "not found"), making the configuration fail loudly instead of degrading silently, warning the people who read the metrics, and writing tests that actually fail when the behaviour breaks. Get those right, and twenty components asking the same question cost the same as one.

Top comments (0)