Implementing a Two‑Level Cache with Caffeine and Redis in Spring Boot

This article explains how to design and implement a two‑level caching architecture in Spring Boot by combining a local Caffeine cache with a remote Redis cache, covering manual approaches, annotation‑driven management with Spring Cache, and a custom AOP solution to minimize code intrusion.

Code Ape Tech Column
Code Ape Tech Column
Code Ape Tech Column
Implementing a Two‑Level Cache with Caffeine and Redis in Spring Boot

In high‑performance service architectures, caching is essential; hot data is first stored in a remote cache such as Redis or MemCache, and only when a cache miss occurs is the database queried.

When remote caches become insufficient, a local cache (e.g., Guava or Caffeine) can be added as a first‑level cache, forming a two‑level cache architecture that further reduces latency.

V1.0 – Manual implementation – Define a Caffeine bean, configure dependencies, and write explicit cache logic in the service layer. Example configuration:

@Configuration
public class CaffeineConfig {
    @Bean
    public Cache<String,Object> caffeineCache() {
        return Caffeine.newBuilder()
                .initialCapacity(128)
                .maximumSize(1024)
                .expireAfterWrite(60, TimeUnit.SECONDS)
                .build();
    }
}

POM dependencies:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.9.2</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

Application.yml Redis connection:

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 10000ms
    lettuce:
      pool:
        max-active: 8
        max-wait: -1ms
        max-idle: 8
        min-idle: 0

Service method with manual two‑level cache handling:

public Order getOrderById(Long id) {
    String key = CacheConstant.ORDER + id;
    Order order = (Order) cache.get(key, k -> {
        Object obj = redisTemplate.opsForValue().get(k);
        if (obj != null) {
            log.info("get data from redis");
            return obj;
        }
        log.info("get data from database");
        Order dbOrder = orderMapper.selectOne(new LambdaQueryWrapper<Order>().eq(Order::getId, id));
        redisTemplate.opsForValue().set(k, dbOrder, 120, TimeUnit.SECONDS);
        return dbOrder;
    });
    return order;
}

V2.0 – Spring Cache annotations – Enable caching and use @Cacheable, @CachePut, and @CacheEvict to let Spring manage the cache, reducing manual code. Example:

@Cacheable(value = "order", key = "#id")
public Order getOrderById(Long id) { /* business logic */ }

@CachePut(cacheNames = "order", key = "#order.id")
public Order updateOrder(Order order) { /* business logic */ }

@CacheEvict(cacheNames = "order", key = "#id")
public void deleteOrder(Long id) { /* business logic */ }

Cache manager configuration for Caffeine:

@Configuration
public class CacheManagerConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
                .initialCapacity(128)
                .maximumSize(1024)
                .expireAfterWrite(60, TimeUnit.SECONDS));
        return manager;
    }
}

V3.0 – Custom annotation with AOP – Define @DoubleCache to describe cache name, key (SpringEL), timeout, and operation type, then implement an aspect that parses the key, checks Caffeine first, then Redis, and finally the database.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DoubleCache {
    String cacheName();
    String key();
    long l2TimeOut() default 120;
    CacheType type() default CacheType.FULL;
}

Aspect handling the annotation:

@Component
@Aspect
@AllArgsConstructor
public class CacheAspect {
    private final Cache cache;
    private final RedisTemplate redisTemplate;

    @Pointcut("@annotation(com.cn.dc.annotation.DoubleCache)")
    public void cacheAspect() {}

    @Around("cacheAspect()")
    public Object doAround(ProceedingJoinPoint point) throws Throwable {
        MethodSignature sig = (MethodSignature) point.getSignature();
        Method method = sig.getMethod();
        String[] paramNames = sig.getParameterNames();
        Object[] args = point.getArgs();
        TreeMap<String,Object> map = new TreeMap<>();
        for (int i = 0; i < paramNames.length; i++) {
            map.put(paramNames[i], args[i]);
        }
        DoubleCache ann = method.getAnnotation(DoubleCache.class);
        String realKey = ann.cacheName() + ":" + ElParser.parse(ann.key(), map);
        if (ann.type() == CacheType.PUT) {
            Object obj = point.proceed();
            redisTemplate.opsForValue().set(realKey, obj, ann.l2TimeOut(), TimeUnit.SECONDS);
            cache.put(realKey, obj);
            return obj;
        }
        if (ann.type() == CacheType.DELETE) {
            redisTemplate.delete(realKey);
            cache.invalidate(realKey);
            return point.proceed();
        }
        Object local = cache.getIfPresent(realKey);
        if (local != null) {
            log.info("get data from caffeine");
            return local;
        }
        Object remote = redisTemplate.opsForValue().get(realKey);
        if (remote != null) {
            log.info("get data from redis");
            cache.put(realKey, remote);
            return remote;
        }
        log.info("get data from database");
        Object result = point.proceed();
        if (result != null) {
            redisTemplate.opsForValue().set(realKey, result, ann.l2TimeOut(), TimeUnit.SECONDS);
            cache.put(realKey, result);
        }
        return result;
    }
}

Applying the custom annotation makes service code concise:

@DoubleCache(cacheName = "order", key = "#id", type = CacheType.FULL)
public Order getOrderById(Long id) { return orderMapper.selectOne(new LambdaQueryWrapper<Order>().eq(Order::getId, id)); }

@DoubleCache(cacheName = "order", key = "#order.id", type = CacheType.PUT)
public Order updateOrder(Order order) { orderMapper.updateById(order); return order; }

@DoubleCache(cacheName = "order", key = "#id", type = CacheType.DELETE)
public void deleteOrder(Long id) { orderMapper.deleteById(id); }

The article concludes that two‑level caching can improve latency and reduce database load, but it introduces complexity such as consistency, expiration policies, and concurrency handling; therefore, developers should evaluate whether the added local cache is truly needed for their specific workload.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaCacheSpring Boottwo-level cache
Code Ape Tech Column
Written by

Code Ape Tech Column

Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.