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.
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
userIdretrieve the value from the cache.
Cache key generation
Spring Cache uses a
KeyGeneratorwhen no explicit key is provided. The default algorithm works as follows:
If there are no parameters,
SimpleKey.EMPTYis used.
If there is a single parameter, that parameter becomes the key.
For multiple parameters, a
SimpleKeycontaining all arguments is used.
Custom key generation can be achieved by implementing
org.springframework.cache.interceptor.KeyGeneratorand referencing it via the
keyGeneratorattribute.
<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
conditionattribute 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
unlessattribute 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
unlesscondition 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
CacheManagercontrols cache instances.
<code>public interface CacheManager {
@Nullable
Cache getCache(String name);
Collection<String> getCacheNames();
}
</code>Spring Boot auto‑configures a
ConcurrentMapCacheManagerfor the default in‑memory implementation.
The manager creates
ConcurrentCacheMapobjects 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><dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.7.0</version>
</dependency>
</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><dependency>
<groupId>org.Redisson</groupId>
<artifactId>Redisson</artifactId>
<version>3.12.0</version>
</dependency>
</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
CacheManagerbean; business code stays unchanged.
When
getUserByIdis 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
@CollectionCacheableannotation 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
getCacheand
getCacheNames.
Level‑1 uses Caffeine; level‑2 uses Redisson’s
RedissonCachebacked 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.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.