All Articles

poor man's cache stats reporting

We had quite a few caffeine-based caches lying around in our services, and needed to quickly have something set up to see how they were doing. The easiest thing to do in the beginning, was to create a registry, and set up a scheduler that would report those stats for all caches in the registry in the logs. This was meant as a temporary solution, but ended-up being there for at least a couple of years, before switching to a proper api for cache reporting and management.

This is what the registry with two sample caches looked like:

import java.util.List;
import java.util.concurrent.TimeUnit;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.stats.CacheStats;

public class CacheRegistry {
    public static final String CACHE_COUNTRY_BY_CODE = "countryByCode";
    public static final String CACHE_PERSON_BY_ID = "personById";

    public static final List<String> CACHE_BEAN_NAMES = List.of(

    @Bean(name = CACHE_COUNTRY_BY_CODE)
    public CaffeineCache buildCountryByCdCache() {
        return new CaffeineCache(CACHE_COUNTRY_BY_CODE,
                        .expireAfterWrite(1, TimeUnit.HOURS)

    @Bean(name = CACHE_PERSON_BY_ID)
    public CaffeineCache buildPersonCache() {
        return new CaffeineCache(CACHE_PERSON_BY_ID,
                        .expireAfterWrite(1, TimeUnit.HOURS)

    // Report stats every 60 minutes.
    static class CacheStatsReporter {
        private static final Logger LOGGER = LoggerFactory.getLogger(CacheStatsReporter.class);

        private static final long RATE = 60 * 60 * 1000L;

        private final ApplicationContext context;

        public CacheStatsReporter(ApplicationContext context) {
            this.context = context;

        @Scheduled(fixedRate = RATE)
        public void report() {
            CacheRegistry.CACHE_BEAN_NAMES.forEach(name -> {
                Cache<Object, Object> nativeCache = context.getBean(name, CaffeineCache.class).getNativeCache();
                CacheStats stats = nativeCache.stats();
                long size = nativeCache.estimatedSize();

                String statString = String.format("%s{size=%d, requestCount=%d, hitCount=%d, hitRate=%d%%, "
                        + "missCount=%d, missRate=%d%%, averageLoadPenalty=%dms, loadSuccessCount=%d, loadFailureCount=%d, "
                        + "evictionCount=%d}",
                        (short) (stats.hitRate() * 100),
                        (short) (stats.missRate() * 100),
                        TimeUnit.NANOSECONDS.toMillis((long) stats.averageLoadPenalty()),


A CacheLoader would then use the cache registry simply like:

public class CacheLoaders {
    private final CountryService countryService;
    private final PersonService personService;

    public CacheLoaders(CountryService countryService, PersonService personSerice) {
        this.countryService = countryService;
        this.personService = personService;

    @Cacheable(cacheNames = CacheRegistry.CACHE_PERSON_BY_ID)
    public Person getPerson(String personId) {
        return personService.findPerson(personId);

    @Cacheable(cacheNames = CacheRegistry.CACHE_COUNTRY_BY_CODE)
    public CountryDto loadCountry(String countryCode) {
        return countryService.getCountry(countryCode);

Not very flexible, but did its job for (quite) a while.