Mastering Two-Level Caching in Spring Boot with CacheFrontend & Caffeine

This guide walks through implementing a two‑level cache in Spring Boot 2.7 using Lettuce’s CacheFrontend together with Caffeine, covering dependency setup, configuration, custom CacheAccessor, bean definitions, and sample endpoints that demonstrate automatic synchronization and eviction between local JVM cache and Redis.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Mastering Two-Level Caching in Spring Boot with CacheFrontend & Caffeine

Environment: SpringBoot 2.7.16

This article explains how to use CacheFrontend and Caffeine to implement a secondary cache.

1. Introduction

CacheFrontend is a front‑end component that can automatically synchronize a local JVM cache with Redis. If data exists locally it is returned directly; otherwise it is fetched from Redis.

CacheFrontend works by storing data retrieved from Redis in a local cache to reduce backend calls, and by updating or invalidating the local cache when the backend data changes.

2. Practical Example

2.1 Dependency Management

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

2.2 Configuration File

spring:
  redis:
    host: localhost
    password: xxxooo
    database: 10
    port: 6379

2.3 Local Cache Bean

@Bean
public Cache<String, String> localCache() {
    return Caffeine.newBuilder()
        .initialCapacity(100)
        .maximumSize(1000)
        .build();
}

2.4 RedisClient Bean

@Bean
public RedisClient redisClient(RedisProperties props) {
    RedisURI clientResources = RedisURI.Builder
        .redis(props.getHost(), props.getPort())
        .withPassword(props.getPassword().toCharArray())
        .withDatabase(props.getDatabase())
        .build();
    RedisClient client = RedisClient.create(clientResources);
    return client;
}

2.5 CacheFrontend Bean

@Bean
public CacheFrontend<String, String> cacheFrontend(RedisClient redisClient, Cache<String, String> localCache) {
    StatefulRedisConnection<String, String> connection = redisClient.connect();
    // add listener for invalidation events
    connection.addListener(new PushListener() {
        @Override
        public void onPushMessage(PushMessage message) {
            String type = message.getType();
            if ("invalidate".equals(type)) {
                System.out.println("...");
            }
            List<Object> contents = message.getContent();
            Object content = contents.get(0);
            if (content instanceof ByteBuffer) {
                ByteBuffer buf = (ByteBuffer) content;
                System.out.printf("response content: %s%n", StringCodec.UTF8.decodeValue(buf));
            }
        }
    });
    // custom CacheAccessor
    CacheAccessor<String, String> cacheAccessor = new CacheAccessor<String, String>() {
        @Override
        public String get(String key) {
            @Nullable
            String present = localCache.getIfPresent(key);
            System.out.printf("get operator: %s%n", present);
            return present;
        }
        @Override
        public void put(String key, String value) {
            localCache.put(key, value);
            System.out.printf("put operator: key = %s, value = %s%n", key, value);
        }
        @Override
        public void evict(String key) {
            localCache.invalidate(key);
            System.out.printf("evict operator: %s%n", key);
        }
    };
    CacheFrontend<String, String> frontend = ClientSideCaching.enable(
        cacheAccessor,
        connection,
        TrackingArgs.Builder.enabled());
    return frontend;
}

2.6 Test Controllers

@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedisClient redisClient;
@Resource
private CacheFrontend<String, String> cf;

@GetMapping("/c1")
public Object cache1() {
    stringRedisTemplate.opsForValue().set("name", "张三", Duration.ofSeconds(60));
    return "c1";
}

@GetMapping("/c2")
public Object cache2() {
    StatefulRedisConnection<String, String> conn = this.redisClient.connect(StringCodec.UTF8);
    conn.sync().set("name", String.valueOf(System.currentTimeMillis()));
    conn.close();
    return "c2";
}

@GetMapping("/get")
public Object get() {
    return this.cf.get("name");
}

Initial Redis data:

Accessing /get returns the value from the local cache after synchronization.

Console output shows the cache miss on the first request, followed by data being stored locally.

After invoking /c1 or /c2, the local cache is automatically invalidated, as shown below.

Subsequent calls to /get retrieve the refreshed value from Redis, confirming that the local and Redis caches stay in sync.

In summary, combining a local Caffeine cache with Lettuce’s CacheFrontend provides performance gains, reduces database load, and supports high concurrency. Proper configuration and continuous monitoring are essential for maintaining system stability and efficiency.

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.

redisSpring BootCaffeineCacheFrontendTwo-level caching
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

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.