Here’s the English translation:


You added @Cacheable, ran it, and the response got noticeably faster right away. Satisfied with that, are you shipping Caffeine to production with every setting left at default? In fact, Caffeine has a mechanism for configuring expiration and size limits in detail on a per-cache basis, and if you don’t use it, you’ll end up struggling with memory growth and declining hit rates down the line. This time, rather than covering how to use @Cacheable itself, we’ll focus exclusively on Caffeine-specific configuration, tuning, and monitoring.

What happens if you leave the default settings as-is

If you create a CacheManager with just Caffeine.newBuilder().build(), neither a size limit nor a TTL is set at all. The number of entries can theoretically grow without bound, and because the same lifespan gets applied to data with different update frequencies, frequently changing data stays stale while rarely changing master data expires too quickly, dragging down the hit rate. If you see heap usage climbing steadily upward in production, suspect Caffeine’s size limit settings first.

Separating settings per cache

If you set common baseline settings for all caches as the default via setCaffeine(), and override only individual caches with registerCustomCache, you can prevent configuration omissions while still accommodating the differing characteristics of each cache.

@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();

        // Common default: baseline settings for infrequently updated data
        manager.setCaffeine(Caffeine.newBuilder()
                .expireAfterWrite(Duration.ofMinutes(30))
                .maximumSize(5_000)
                .recordStats());

        // Override individually only for caches where you want to extend TTL based on access frequency
        manager.registerCustomCache("userSessionCache",
                Caffeine.newBuilder()
                        .expireAfterAccess(Duration.ofMinutes(10))
                        .maximumSize(20_000)
                        .recordStats()
                        .build());

        return manager;
    }
}

Keeping the defaults consolidated in one place prevents the accident of adding a new cache name and shipping it to production with the default (unlimited) values due to a missed configuration.

When to use expireAfterWrite vs. expireAfterAccess

expireAfterWrite expires entries based on elapsed time since the write occurred. It’s suited to infrequently updated data like master data. expireAfterAccess, on the other hand, expires entries based on the period during which there’s no access, so it’s suited to session information you want to keep alive as long as it’s being actively used. If you swap these two, you’ll end up with heavily accessed product information expiring quickly and continuously hammering the database in the background, or conversely, abandoned session caches lingering indefinitely.

Note that expireAfterAccess effectively never expires an entry that is continuously accessed. As in the example above, always combine it with maximumSize to separately guarantee an upper bound on the number of entries.

Size management with maximumSize and Weigher

If entry sizes are roughly uniform, you can simply cap them with maximumSize(long). Internally, an eviction algorithm based on access frequency (Window TinyLFU) is at work, which is also notable for tending to achieve a higher hit rate than simple LRU.

However, if the size of individual entries varies widely, you’ll want to cap by capacity rather than by count. In that case, combine maximumWeight with a Weigher.

Caffeine.newBuilder()
        .maximumWeight(10_000_000) // The unit depends on the value returned by the Weigher
        .weigher((String key, String value) -> value.length())
        .recordStats()
        .build();

maximumSize and maximumWeight cannot be specified together, so you need to choose one or the other.

Asynchronous refresh with refreshAfterWrite

With expireAfterWrite, the moment an entry expires, reads are blocked and a load runs on the spot. If this becomes heavy under access spikes, there’s an approach using refreshAfterWrite to return the old value while asynchronously reloading it in the background.

CacheLoader<String, Product> loader = key ->
        productRepository.findById(key)
                .orElseThrow(() -> new EntityNotFoundException(key));

LoadingCache<String, Product> cache = Caffeine.newBuilder()
        .refreshAfterWrite(Duration.ofMinutes(5))
        .maximumSize(5_000)
        .recordStats()
        .build(loader);

Since findById returns an Optional<Product>, it’s unwrapped with orElseThrow to match the CacheLoader<String, Product> signature. By default, the refresh runs on the shared ForkJoinPool.commonPool(), so if the load operation is heavy, it’s safer to specify a dedicated Executor via Caffeine.executor().

Combining expireAfterWrite and refreshAfterWrite

When both are configured, once the expireAfterWrite deadline passes, the value is fully expired, and the stale value is no longer served via refreshAfterWrite. When combining them, the basic pattern is to set the refreshAfterWrite interval shorter than expireAfterWrite. If you reverse this, the entry fully expires before a refresh ever runs, resulting in a synchronous load anyway — defeating the purpose of setting refreshAfterWrite in the first place.

Comparing caches with recordStats()

Enabling recordStats() lets you obtain CacheStats from cache.stats(). It’s hard to notice anomalies by looking at just one cache, so the trick is to line up multiple caches for comparison.

cacheManager.getCacheNames().forEach(name -> {
    CaffeineCache springCache = (CaffeineCache) cacheManager.getCache(name);
    CacheStats stats = springCache.getNativeCache().stats();
    log.info("cache={} hitRate={} evictionCount={} avgLoadMillis={}",
            name, stats.hitRate(), stats.evictionCount(),
            stats.averageLoadPenalty() / 1_000_000);
});

Even within the same app, it’s common for master-data caches to normally exceed a 90% hit rate, while session-related caches top out around 70%. Rather than judging all caches against a single threshold, deciding on a normal range for each cache’s characteristics improves the accuracy of anomaly detection. If a cache shows a disproportionately high averageLoadPenalty, suspect that the load operation itself (a DB query or external API call) is slow.

Registering per-cache metrics with Micrometer

Logs alone make it hard to continuously track trends in production, so register metrics with the MeterRegistry via CaffeineCacheMetrics.

@Configuration
public class CacheMetricsConfig {

    @Bean
    public MeterBinder cacheMetrics(CacheManager cacheManager) {
        return registry -> {
            for (String name : cacheManager.getCacheNames()) {
                CaffeineCache springCache = (CaffeineCache) cacheManager.getCache(name);
                CaffeineCacheMetrics.monitor(registry, springCache.getNativeCache(), name);
            }
        };
    }
}

Since cache.gets and cache.evictions are output tagged with the cache name, overlaying cache.evictions with cache.size in Grafana makes it immediately obvious which caches are suffering from an insufficient size limit due to evictions occurring. For instructions on setting up Prometheus/Grafana, refer to Building Spring Boot Monitoring with Micrometer + Prometheus + Grafana.

Memory sizing before going to production

Before deciding on maximumSize or maximumWeight, it’s worth estimating memory usage, even roughly. The approach is simple: estimate as the expected number of entries × the average size per entry. The quickest way to measure the actual object size of keys and values is with a heap dump or JOL (Java Object Layout). If you’re running multiple caches in the same app, sum up the estimates for each cache and check whether the total fits within your overall heap budget. If estimates tend to be off for caches with non-uniform entry sizes, consider switching from the count-based maximumSize to the capacity-based limit using the Weigher described earlier.

How to isolate declining hit rates vs. growing memory usage

If you notice an anomaly in production, checking the likely culprits separately by symptom will get you to the root cause faster.

  • Low hitRate: Check whether your choice of expireAfterWrite/expireAfterAccess is mismatched with the access pattern. Typical causes are applying a short TTL to infrequently updated data, or conversely, applying a long TTL to frequently updated data.
  • High evictionCount: maximumSize (or maximumWeight) is likely too small relative to the actual working set. Redo the memory sizing described above and consider raising the limit.
  • Memory keeps growing: Suspect a cache where maximumSize/maximumWeight was forgotten, or where a Weigher is in use but not correctly calculating entry size. Review your CacheConfig bean definitions for any cache names missing configuration.

Also note that, like @Transactional and @Async, @Cacheable also operates via an AOP proxy, so there are cases where caching doesn’t take effect on self-invocation from within the same class. If the hit rate is close to 0%, suspect this before suspecting a configuration mistake. For details, see Why @Transactional/@Cacheable/@Async Don’t Work.

Summary

Caffeine works even with default settings, but it’s only once you configure expireAfterWrite/Access and maximumSize (or a Weigher) per cache that it becomes something you can safely rely on in production. If you build a habit of continuously comparing caches using recordStats() and Micrometer, you should be able to catch early signs of declining hit rates or growing memory usage. For the basics of using @Cacheable, also check out How to Use Spring Boot’s Cache Abstraction.