Backend Development 24 min read

Mastering Spring Cache: From Hard‑Coded to Multi‑Level Caching with Redis and Caffeine

This article walks through adapting a custom Redis client to Spring Cache, explains the cache abstraction, demonstrates annotation‑driven caching, shows how to integrate Caffeine and Redisson, and builds a simple two‑level cache to illustrate advanced scenarios for Spring Cache users.

macrozheng
macrozheng
macrozheng
Mastering Spring Cache: From Hard‑Coded to Multi‑Level Caching with Redis and Caffeine

1. Hard coding

Before learning Spring Cache the author used hard‑coded cache logic, manually constructing cache keys and calling Redis commands for CRUD operations.

<code>@Autowire
private UserMapper userMapper;
@Autowire
private StringCommand stringCommand;
// query user
public User getUserById(Long userId) {
    String cacheKey = "userId_" + userId;
    User user = stringCommand.get(cacheKey);
    if (user != null) {
        return user;
    }
    user = userMapper.getUserById(userId);
    if (user != null) {
        stringCommand.set(cacheKey, user);
        return user;
    }
    // update user
    public void updateUser(User user) {
        userMapper.updateUser(user);
        String cacheKey = "userId_" + user.getId();
        stringCommand.set(cacheKey, user);
    }
    // delete user
    public void deleteUserById(Long userId) {
        userMapper.deleteUserById(userId);
        String cacheKey = "userId_" + userId.getId();
        stringCommand.del(cacheKey);
    }
}
</code>

Repeated code for key generation and cache calls makes the implementation verbose.

Cache logic is tightly coupled with business code, leading to high intrusiveness during debugging or when swapping cache providers.

2. Cache abstraction

Spring Cache is not a concrete cache implementation; it is a cache abstraction that decouples business logic from the underlying cache technology.

2.1 Spring AOP

Spring AOP is proxy‑based. A normal method call is replaced by a proxy that can intercept the call, obtain arguments, and handle the return value, enabling transparent caching.

<code>Pojo pojo = new SimplePojo();
pojo.foo();
</code>
<code>ProxyFactory factory = new ProxyFactory(new SimplePojo());
factory.addInterface(Pojo.class);
factory.addAdvice(new RetryAdvice());
Pojo pojo = (Pojo) factory.getProxy();
pojo.foo();
</code>

The proxy executes the original method while allowing Spring to insert caching logic before and after the call.

2.2 Cache declaration

Cacheable annotations mark methods that should be cached and define the caching strategy.

@Cacheable – caches the method result based on parameters.

@CachePut – always executes the method and updates the cache.

@CacheEvict – removes entries from the cache.

@Caching – combines multiple cache annotations.

@CacheConfig – shares common cache configuration at the class level.

The article focuses on the three core annotations.

2.2.1 @Cacheable

@Cacheable adds caching to a method.

<code>@Cacheable(value="user_cache", key="#userId", unless="#result == null")
public User getUserById(Long userId) {
    User user = userMapper.getUserById(userId);
    return user;
}
</code>

If the method returns a non‑null User, the result is stored under the generated key; subsequent calls with the same

userId

retrieve the value from the cache.

Cache key generation

Spring Cache uses a

KeyGenerator

when no explicit key is provided. The default algorithm works as follows:

If there are no parameters,

SimpleKey.EMPTY

is used.

If there is a single parameter, that parameter becomes the key.

For multiple parameters, a

SimpleKey

containing all arguments is used.

Custom key generation can be achieved by implementing

org.springframework.cache.interceptor.KeyGenerator

and referencing it via the

keyGenerator

attribute.

<code>Object generate(Object target, Method method, Object... params);
</code>
<code>@Cacheable(value="user_cache", keyGenerator="myKeyGenerator", unless="#result == null")
public User getUserById(Long userId) {
    // ...
}
</code>

Cache condition

The

condition

attribute evaluates a SpEL expression before method execution; if it returns

false

, caching is skipped.

<code>@Cacheable(cacheNames="book", condition="#name.length() < 32")
public Book findBook(String name) {
    // ...
}
</code>

The

unless

attribute performs a similar check after method execution.

<code>@Cacheable(value="user_cache", key="#userId", unless="#result == null")
public User getUserById(Long userId) {
    // ...
}
</code>

2.2.2 @CachePut

@CachePut updates the cache every time the method is invoked.

<code>@CachePut(value = "user_cache", key = "#user.id", unless = "#result != null")
public User updateUser(User user) {
    userMapper.updateUser(user);
    return user;
}
</code>

If the

unless

condition is omitted, the returned value is always stored.

2.2.3 @CacheEvict

@CacheEvict removes a cache entry when the annotated method is called.

<code>@CacheEvict(value = "user_cache", key = "#id")
public void deleteUserById(Long id) {
    userMapper.deleteUserById(id);
}
</code>

2.3 Cache configuration

Spring Cache abstracts the underlying storage; various implementations can be plugged in.

A

CacheManager

controls cache instances.

<code>public interface CacheManager {
    @Nullable
    Cache getCache(String name);
    Collection<String> getCacheNames();
}
</code>

Spring Boot auto‑configures a

ConcurrentMapCacheManager

for the default in‑memory implementation.

The manager creates

ConcurrentCacheMap

objects that implement

org.springframework.cache.Cache

.

Implementing the cache abstraction requires two interfaces:

org.springframework.cache.CacheManager

org.springframework.cache.Cache

3. Getting started example

Create a project named

spring-cache-demo

.

3.1 Integrate Caffeine

3.1.1 Maven dependency

<code>&lt;dependency&gt;
  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
  &lt;artifactId&gt;spring-boot-starter-cache&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
  &lt;groupId&gt;com.github.ben-manes.caffeine&lt;/groupId&gt;
  &lt;artifactId&gt;caffeine&lt;/artifactId&gt;
  &lt;version&gt;2.7.0&lt;/version&gt;
&lt;/dependency&gt;
</code>

3.1.2 Caffeine cache configuration

<code>@Configuration
@EnableCaching
public class MyCacheConfig {
    @Bean
    public Caffeine caffeineConfig() {
        return Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(60, TimeUnit.MINUTES);
    }
    @Bean
    public CacheManager cacheManager(Caffeine caffeine) {
        CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
        caffeineCacheManager.setCaffeine(caffeine);
        return caffeineCacheManager;
    }
}
</code>

The configuration sets a maximum of 10,000 entries and a 60‑minute TTL.

The class is annotated with @EnableCaching to activate annotation‑driven caching.

3.1.3 Business code

<code>@Cacheable(value = "user_cache", unless = "#result == null")
public User getUserById(Long id) {
    return userMapper.getUserById(id);
}

@CachePut(value = "user_cache", key = "#user.id", unless = "#result == null")
public User updateUser(User user) {
    userMapper.updateUser(user);
    return user;
}

@CacheEvict(value = "user_cache", key = "#id")
public void deleteUserById(Long id) {
    userMapper.deleteUserById(id);
}
</code>

Compared with the hard‑coded version, the code is much shorter.

When the controller calls

getUserById

, the first invocation logs the SQL query, while the second call hits the cache and no SQL is printed.

<code>Preparing: select * FROM user t where t.id = ?
Parameters: 1(Long)
Total: 1
</code>

3.2 Integrate Redisson

3.2.1 Maven dependency

<code>&lt;dependency&gt;
    &lt;groupId&gt;org.Redisson&lt;/groupId&gt;
    &lt;artifactId&gt;Redisson&lt;/artifactId&gt;
    &lt;version&gt;3.12.0&lt;/version&gt;
&lt;/dependency&gt;
</code>

3.2.2 Redisson cache configuration

<code>@Bean(destroyMethod = "shutdown")
public RedissonClient Redisson() {
    Config config = new Config();
    config.useSingleServer()
        .setAddress("redis://127.0.0.1:6201")
        .setPassword("ts112GpO_ay");
    return Redisson.create(config);
}

@Bean
CacheManager cacheManager(RedissonClient RedissonClient) {
    Map<String, CacheConfig> config = new HashMap<>();
    // create "user_cache" spring cache with ttl = 24 minutes and maxIdleTime = 12 minutes
    config.put("user_cache",
        new CacheConfig(24 * 60 * 1000, 12 * 60 * 1000));
    return new RedissonSpringCacheManager(RedissonClient, config);
}
</code>

Switching from Caffeine to Redisson only requires changing the

CacheManager

bean; business code stays unchanged.

When

getUserById

is called, the cached entry appears in Redis Desktop Manager as a hash.

Redisson uses FstCodec by default, resulting in an encoded key like

\xF6\x01

. The codec can be changed:

<code>public RedissonClient Redisson() {
    Config config = new Config();
    config.useSingleServer()
        .setAddress("redis://127.0.0.1:6201")
        .setPassword("ts112GpO_ay");
    config.setCodec(new JsonJacksonCodec());
    return Redisson.create(config);
}
</code>

After changing the codec, the key becomes

["java.lang.Long",1]

.

3.3 Understanding list cache

List caching can be done in two ways: caching the whole list or caching each element individually.

Cache the entire list.

Cache each entry and aggregate results on read.

Spring Cache treats a method returning a collection as a single cache entry.

<code>@Cacheable(value = "user_cache")
public List<User> getUserList(List<Long> idList) {
    return userMapper.getUserByIds(idList);
}
</code>

Executing the method with

[1,3]

stores the whole list under the cache name; the list cache and individual entry cache are independent.

Developers have proposed a

@CollectionCacheable

annotation to handle list caching more granularly, but the Spring team declined to keep the abstraction simple.

<code>@Cacheable("myCache")
public String findById(String id) {
    // access DB
}

@CollectionCacheable("myCache")
public Map<String, String> findByIds(Collection<String> ids) {
    // access DB, return map
}
</code>

4. Custom two‑level cache

4.1 Application scenario

In high‑concurrency environments, a multi‑level cache (local + distributed) improves latency, reduces remote calls, and saves bandwidth.

Closer to the user → faster access.

Reduces distributed cache query frequency, lowering CPU for (de)serialization.

Significantly cuts network I/O and bandwidth usage.

The flow: check level‑1 (local) cache → if miss, check level‑2 (distributed) → if hit, back‑fill level‑1 → if miss, load from DB and populate both levels.

Spring Cache lacks built‑in two‑level support, so a demo implementation is provided.

4.2 Design idea

MultiLevelCacheManager – manages multi‑level caches.

MultiLevelChannel – wraps Caffeine and RedissonClient.

MultiLevelCache – implements

org.springframework.cache.Cache

.

MultiLevelCacheConfig – holds expiration settings.

The manager implements

getCache

and

getCacheNames

.

Level‑1 uses Caffeine; level‑2 uses Redisson’s

RedissonCache

backed by an

RMap

(hash).

Key query logic:

<code>@Override
public ValueWrapper get(Object key) {
    Object result = getRawResult(key);
    return toValueWrapper(result);
}

public Object getRawResult(Object key) {
    logger.info("Query level‑1 cache key:" + key);
    Object result = localCache.getIfPresent(key);
    if (result != null) {
        return result;
    }
    logger.info("Query level‑2 cache key:" + key);
    result = RedissonCache.getNativeCache().get(key);
    if (result != null) {
        localCache.put(key, result);
    }
    return result;
}
</code>

Store logic:

<code>public void put(Object key, Object value) {
    logger.info("Write level‑1 cache key:" + key);
    localCache.put(key, value);
    logger.info("Write level‑2 cache key:" + key);
    RedissonCache.put(key, value);
}
</code>

After configuring the manager, existing business code remains unchanged.

Sample log for the first

getUserById(1)

call:

<code>- From level‑1 cache key:1
- From level‑2 cache key:1
- ==> Preparing: select * FROM user t where t.id = ?
- ==> Parameters: 1(Long)
- <== Total: 1
- Write level‑1 cache key:1
- Write level‑2 cache key:1
</code>

Second call hits only level‑1:

<code>- From level‑1 cache key:1
</code>

After the local cache expires (e.g., 30 s), the next call checks level‑2 before falling back to the database:

<code>- From level‑1 cache key:1
- From level‑2 cache key:1
</code>

The demo demonstrates a simple two‑level cache.

5 When to choose Spring Cache

Spring Cache shines in scenarios where cache granularity is not extremely fine‑grained, such as homepage banners, static listings, or leaderboards—cases that tolerate modest staleness and benefit from rapid development.

For high‑concurrency, large‑scale workloads requiring precise control (multi‑level caches, list caches, cache listeners), extending Spring Cache or adopting specialized libraries (j2cache, jetcache) is advisable.

Multi‑level caching.

List caching.

Cache change listeners.

JavaRediscachingcaffeinemulti-level cacheSpring Cache
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

login 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.