Two-Level Cache in Spring Boot: Boost Performance with Caffeine & Redis

Learn how to implement a two‑level caching architecture in Spring Boot using Caffeine as a local cache and Redis as a remote cache, covering manual implementations, annotation‑driven approaches with @Cacheable/@CachePut/@CacheEvict, and a custom @DoubleCache annotation to minimize code intrusion while improving response times.

macrozheng
macrozheng
macrozheng
Two-Level Cache in Spring Boot: Boost Performance with Caffeine & Redis

In high‑performance service architectures, caching is essential. Remote caches such as Redis or MemCache store hot data and only query the database when a cache miss occurs, reducing latency and database load.

When remote caches alone are 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 improves response speed.

Advantages and Issues

Local cache resides in JVM memory, providing extremely fast access for data with low change frequency.

Using a local cache reduces network I/O between the application and remote cache ( Redis), decreasing latency.

Data consistency must be handled: updates to the database must also refresh or invalidate both local and remote caches.

In distributed environments, local caches on different nodes need synchronization, often via Redis pub/sub.

Preparation

Add the required dependencies for Caffeine, Spring Boot Redis, and connection pooling:

<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>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.8.1</version>
</dependency>

Configure Redis connection in application.yml:

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

V1.0 – Manual Two‑Level Cache

Define a Caffeine bean:

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

Typical service method without caching:

@Service
@AllArgsConstructor
public class OrderServiceImpl implements OrderService {
    private final OrderMapper orderMapper;

    @Override
    public Order getOrderById(Long id) {
        return orderMapper.selectOne(new LambdaQueryWrapper<Order>().eq(Order::getId, id));
    }

    @Override
    public void updateOrder(Order order) {
        orderMapper.updateById(order);
    }

    @Override
    public void deleteOrder(Long id) {
        orderMapper.deleteById(id);
    }
}

Wrap the method with two‑level cache logic:

public Order getOrderById(Long id) {
    String key = CacheConstant.ORDER + id;
    Order order = (Order) cache.get(key, k -> {
        // 1. Try Redis
        Object obj = redisTemplate.opsForValue().get(k);
        if (obj != null) {
            log.info("get data from redis");
            return obj;
        }
        // 2. Fallback to DB
        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;
}

Update and delete operations manually synchronize both caches:

public void updateOrder(Order order) {
    log.info("update order data");
    String key = CacheConstant.ORDER + order.getId();
    orderMapper.updateById(order);
    redisTemplate.opsForValue().set(key, order, 120, TimeUnit.SECONDS);
    cache.put(key, order);
}

public void deleteOrder(Long id) {
    log.info("delete order");
    orderMapper.deleteById(id);
    String key = CacheConstant.ORDER + id;
    redisTemplate.delete(key);
    cache.invalidate(key);
}

V2.0 – Spring Cache Annotations

Configure a CaffeineCacheManager bean:

@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;
    }
}

Enable Spring caching in the main application class:

@SpringBootApplication
@EnableCaching
public class MallApplication { ... }

Apply annotations to service methods:

@Cacheable(value = "order", key = "#id")
public Order getOrderById(Long id) {
    // only DB logic; Redis handling omitted for brevity
    return orderMapper.selectOne(new LambdaQueryWrapper<Order>().eq(Order::getId, id));
}

@CachePut(cacheNames = "order", key = "#order.id")
public Order updateOrder(Order order) {
    orderMapper.updateById(order);
    redisTemplate.opsForValue().set(CacheConstant.ORDER + order.getId(), order, 120, TimeUnit.SECONDS);
    return order;
}

@CacheEvict(cacheNames = "order", key = "#id")
public void deleteOrder(Long id) {
    orderMapper.deleteById(id);
    redisTemplate.delete(CacheConstant.ORDER + id);
}

With these annotations, Spring automatically handles the local Caffeine cache, while the Redis update is still performed manually.

V3.0 – Custom @DoubleCache Annotation

Define the annotation:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DoubleCache {
    String cacheName();
    String key(); // supports SpringEL
    long l2TimeOut() default 120; // Redis expiration
    CacheType type() default CacheType.FULL; // FULL, PUT, DELETE
}

Enum for cache operation type:

public enum CacheType {
    FULL,   // read‑write
    PUT,    // write only
    DELETE  // delete only
}

Utility to parse SpringEL expressions:

public static String parse(String elString, TreeMap<String, Object> map) {
    elString = String.format("#{%s}", elString);
    ExpressionParser parser = new SpelExpressionParser();
    EvaluationContext context = new StandardEvaluationContext();
    map.forEach(context::setVariable);
    Expression expression = parser.parseExpression(elString, new TemplateParserContext());
    return expression.getValue(context, String.class);
}

Aspect that implements the caching logic:

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

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

    @Around("cacheAspect()")
    public Object doAround(ProceedingJoinPoint point) throws Throwable {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        String[] paramNames = signature.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);

        // PUT only
        if (ann.type() == CacheType.PUT) {
            Object result = point.proceed();
            redisTemplate.opsForValue().set(realKey, result, ann.l2TimeOut(), TimeUnit.SECONDS);
            cache.put(realKey, result);
            return result;
        }
        // DELETE only
        if (ann.type() == CacheType.DELETE) {
            redisTemplate.delete(realKey);
            cache.invalidate(realKey);
            return point.proceed();
        }
        // FULL (read‑write)
        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;
    }
}

Apply the custom annotation to service methods, eliminating manual cache code:

@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);
}

Summary

The article demonstrates three progressively less intrusive ways to manage a two‑level cache in a Spring Boot application: a fully manual approach, Spring’s built‑in cache annotations, and a custom @DoubleCache annotation powered by an AOP aspect. Choosing the right method depends on project complexity, consistency requirements, and the desired balance between control and code cleanliness.

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.

Spring BootCaffeineAspect Oriented Programmingtwo-level cacheCache Annotation
macrozheng
Written by

macrozheng

Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.

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.