Spring Cache 深度实践:自定义 Cache 与 CacheManager 的生产级方案

欢迎你来读这篇博客,这篇博客主要是关于 Spring Cache 自定义 Cache 与 CacheManager 的生产级实践

Spring Cache 入门很简单,一个 @Cacheable 就能让方法结果进入缓存。但真正到了业务系统里,问题就不再是“能不能缓存”,而是:

  • 缓存 key 怎么设计?
  • Redis 序列化为什么是一堆乱码?
  • 不同缓存如何设置不同 TTL?
  • 为什么加了 @Cacheable 却没有生效?
  • 本地缓存和 Redis 缓存能不能组合?
  • 多个 CacheManager 怎么路由?
  • 缓存如何清理、监控、灰度、排查?
  • 自定义 Cache 到底应该怎么写?

缓存不是魔法,是债务。写得好是加速器,写不好就是分布式 bug 孵化器。本文不只讲注解怎么用,而是站在工程落地角度,把 Spring Cache
的核心抽象、自定义 CacheManager、自定义 CacheResolver、Redis 配置、多级缓存和生产注意事项完整梳理一遍。

序言

很多 Spring Cache 教程会从下面这段代码开始:

1
2
3
4
5
6
@Cacheable(cacheNames = "user", key = "#id")
public UserDTO getUserById(Long id) {
return userRepository.findById(id)
.map(UserConverter::toDTO)
.orElse(null);
}

第一次调用查数据库,第二次调用走缓存。看起来优雅得像开了挂。

但实际项目里,缓存很快会变成下面这些问题:

1
@Cacheable(cacheNames = "user", key = "#id")

这行代码背后其实隐藏了很多决策:

  • cacheNames = "user" 对应的缓存区域是谁创建的?
  • 这个缓存存在 Redis、Caffeine、ConcurrentHashMap,还是其它存储?
  • Redis key 最终长什么样?
  • value 使用 JDK 序列化还是 JSON 序列化?
  • TTL 是多久?
  • null 是否缓存?
  • 多租户场景下 key 会不会串?
  • 修改用户后缓存怎么删?
  • 如果多个节点都有本地缓存,怎么保证不脏读?

所以,Spring Cache 的重点不是注解,而是理解它背后的三个核心接口:

1
2
3
org.springframework.cache.Cache
org.springframework.cache.CacheManager
org.springframework.cache.interceptor.CacheResolver

一句话概括:

Cache 负责真正的缓存读写,CacheManager 负责根据缓存名找到对应的 CacheCacheResolver 负责在运行时决定本次操作到底使用哪些
Cache

正文

chapter 1:Spring Cache 的核心模型

1.1 Spring Cache 是抽象,不是缓存实现

Spring Cache 本身不存数据。它只是定义了一套统一接口,然后屏蔽底层差异。

你可以把它理解成 JDBC:

  • JDBC 本身不是数据库;
  • Spring Cache 本身也不是缓存;
  • Redis、Caffeine、Ehcache、ConcurrentMap 才是真正的缓存存储。

Spring Cache 的核心模型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
业务方法
|
| @Cacheable / @CachePut / @CacheEvict
v
Spring Cache AOP 拦截器
|
v
CacheResolver
|
v
CacheManager
|
v
Cache
|
v
Redis / Caffeine / Ehcache / ConcurrentMap / 自定义存储

1.2 Cache 接口负责什么?

Cache 是最底层的缓存操作接口,常见方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface Cache {

String getName();

Object getNativeCache();

ValueWrapper get(Object key);

<T> T get(Object key, Class<T> type);

<T> T get(Object key, Callable<T> valueLoader);

void put(Object key, Object value);

ValueWrapper putIfAbsent(Object key, Object value);

void evict(Object key);

void clear();
}

它关心的是:

1
2
3
4
给我一个 key,我能不能拿到 value?
给我一个 key 和 value,我能不能写进去?
给我一个 key,我能不能删掉?
整个缓存区域能不能清空?

所以,如果你要实现自己的缓存存储,例如:

  • 把缓存写入数据库;
  • 把缓存写入远程 HTTP 服务;
  • 做 Redis + Caffeine 两级缓存;
  • 给所有缓存加日志、统计、监控;
  • 给缓存操作做降级兜底;

本质上就是实现或包装 Cache

1.3 CacheManager 负责什么?

CacheManager 是缓存管理器,核心方法很简单:

1
2
3
4
5
6
public interface CacheManager {

Cache getCache(String name);

Collection<String> getCacheNames();
}

它关心的是:

1
根据 cacheName,找到对应的 Cache 对象。

比如:

1
2
Cache userCache = cacheManager.getCache("user");
userCache.put(1L, userDTO);

当你写:

1
@Cacheable(cacheNames = "user", key = "#id")

Spring 最终会通过 CacheManager 找到名为 userCache,再执行 getput

1.4 CacheResolver 负责什么?

默认情况下,Spring 使用简单的 CacheResolver,它会根据注解上的 cacheNames 去当前 CacheManager 里找缓存。

但如果你有多个缓存管理器,比如:

1
2
3
4
5
userHot       -> Caffeine 本地缓存
user -> Redis 分布式缓存
order -> Redis 分布式缓存
config -> Caffeine 本地缓存
tenant:user -> 根据租户动态选择缓存

这时只靠一个 CacheManager 不够灵活,就可以自定义 CacheResolver

简单说:

1
2
CacheManager:根据名字拿 Cache。
CacheResolver:根据运行时上下文决定用哪个 CacheManager、哪些 Cache。

chapter 2:Spring Cache 注解快速复盘

2.1 @Cacheable:读缓存,缓存未命中才执行方法

1
2
3
4
5
6
@Cacheable(cacheNames = "user", key = "#id", unless = "#result == null")
public UserDTO getUserById(Long id) {
return userRepository.findById(id)
.map(UserConverter::toDTO)
.orElse(null);
}

执行逻辑:

1
2
3
4
5
1. 根据 cacheNames 找到缓存区域。
2. 根据 key 找缓存数据。
3. 如果命中,直接返回缓存值,方法不会执行。
4. 如果未命中,执行方法。
5. 方法返回后,把结果写入缓存。

常用属性:

1
2
3
4
5
6
7
@Cacheable(
cacheNames = "user",
key = "#id",
condition = "#id != null && #id > 0",
unless = "#result == null",
sync = true
)

解释:

属性 含义
cacheNames / value 缓存区域名
key SpEL 表达式生成缓存 key
keyGenerator 指定自定义 key 生成器
condition 方法执行前判断,true 才走缓存逻辑
unless 方法执行后判断,true 则不写入缓存
sync 缓存未命中时,同一个 key 是否只允许一个线程执行加载逻辑
cacheManager 指定缓存管理器
cacheResolver 指定缓存解析器

注意:

1
2
key 和 keyGenerator 不能同时使用。
cacheManager 和 cacheResolver 不能同时使用。

2.2 @CachePut:一定执行方法,然后更新缓存

1
2
3
4
5
@CachePut(cacheNames = "user", key = "#result.id", unless = "#result == null")
public UserDTO updateUser(UserUpdateCommand command) {
User user = userRepository.save(command.toEntity());
return UserConverter.toDTO(user);
}

执行逻辑:

1
2
1. 不管缓存有没有,方法一定执行。
2. 方法执行成功后,将返回值写入缓存。

适合:

1
写操作后,明确知道缓存值应该是什么。

比如:

  • 新增用户后,把新用户对象写入缓存;
  • 修改用户后,把最新用户对象写入缓存。

不适合:

1
返回值不是完整缓存对象的场景。

比如方法返回 booleanintvoid 时,通常不要用 @CachePut 更新业务对象缓存。

2.3 @CacheEvict:删除缓存

1
2
3
4
@CacheEvict(cacheNames = "user", key = "#id")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}

清空整个缓存区域:

1
2
3
4
@CacheEvict(cacheNames = "user", allEntries = true)
public void reloadUserCache() {
// 触发 user 缓存整体失效
}

方法执行前就删除:

1
2
3
4
@CacheEvict(cacheNames = "user", key = "#id", beforeInvocation = true)
public void deleteUser(Long id) {
userRepository.deleteById(id);
}

beforeInvocation = true 的含义是:

1
不管方法后面是否执行成功,缓存都会先删除。

适合:

  • 数据一致性优先;
  • 方法可能抛异常,但你仍然希望缓存失效;
  • 修复脏缓存。

2.4 @Caching:组合多个缓存操作

1
2
3
4
5
6
7
8
9
10
11
12
@Caching(
put = {
@CachePut(cacheNames = "user", key = "#result.id")
},
evict = {
@CacheEvict(cacheNames = "userList", allEntries = true)
}
)
public UserDTO updateUser(UserUpdateCommand command) {
User user = userRepository.save(command.toEntity());
return UserConverter.toDTO(user);
}

适合一个写操作影响多个缓存区域的场景。

比如更新用户时:

1
2
3
4
user::1           需要更新
userList::* 需要删除
userDetail::* 需要删除
tenantUser::* 需要删除

2.5 @CacheConfig:类级别共享配置

1
2
3
4
5
6
7
8
9
@Service
@CacheConfig(cacheNames = "user", keyGenerator = "bizKeyGenerator")
public class UserService {

@Cacheable(unless = "#result == null")
public UserDTO getUserById(Long id) {
return null;
}
}

适合减少重复配置。

但我个人建议:业务复杂后,不要过度依赖 @CacheConfig,因为缓存名和 key 规则过度隐藏之后,排查问题会变得很难。

缓存最怕“谁都觉得自己懂,最后谁也说不清”。

chapter 3:为什么需要自定义 CacheManager?

简单项目里,Spring Boot 自动配置就够了。

比如引入 Redis 后,Spring Boot 会自动配置 RedisCacheManager

但生产项目里,通常会遇到这些需求。

3.1 需求一:Redis value 不想用 JDK 序列化

默认 Redis 缓存可能使用 JDK 序列化,表现是 Redis 里看到一堆乱码。

例如:

1
\xac\xed\x00\x05sr\x00...

这不是 Redis 坏了,是序列化方式不适合排查。

生产环境更推荐 JSON 序列化:

1
2
3
4
5
{
"@class": "com.demo.user.UserDTO",
"id": 1,
"name": "Mario"
}

好处:

  • Redis 里可读;
  • 排查方便;
  • 跨语言可迁移性更好;
  • 数据结构更直观。

3.2 需求二:不同缓存需要不同 TTL

比如:

缓存名 业务含义 TTL
user 用户基础信息 30 分钟
dict 字典配置 6 小时
tokenUser token 对应用户 10 分钟
shopBill 财务单据 5 分钟
hotSku 热点商品 1 分钟

如果只配置:

1
2
3
4
spring:
cache:
redis:
time-to-live: 30m

这是全局 TTL,粒度不够。

真正生产里更常见的是:

1
按 cacheName 配置不同 TTL。

3.3 需求三:缓存 key 需要统一前缀

比如不同环境共用 Redis:

1
2
3
dev:user::1
test:user::1
prod:user::1

如果不加环境前缀,后果可能是:

1
测试环境读到了生产缓存。

这事听起来离谱,但线上故障里不缺这种名场面。

3.4 需求四:需要本地缓存 + Redis 两级缓存

Redis 是分布式缓存,但每次访问 Redis 也有网络开销。

对于热点数据,可以做:

1
2
3
L1:Caffeine 本地缓存,极快,但只在当前 JVM 内有效。
L2:Redis 分布式缓存,多个节点共享。
DB:数据库。

访问路径:

1
2
3
4
5
先查 Caffeine
命中 -> 返回
未命中 -> 查 Redis
Redis 命中 -> 回填 Caffeine -> 返回
Redis 未命中 -> 查 DB -> 写 Redis -> 写 Caffeine -> 返回

适合:

  • 字典数据;
  • 配置信息;
  • 热点 SKU;
  • 用户基础信息;
  • 权限菜单;
  • 组织架构;
  • 低频变更、高频读取的数据。

3.5 需求五:需要运行时动态选择 CacheManager

比如:

1
2
3
4
5
6
7
8
@Cacheable(cacheNames = "local:user")
走 Caffeine

@Cacheable(cacheNames = "redis:user")
走 Redis

@Cacheable(cacheNames = "tenant:user")
根据当前租户选择不同 Redis 前缀

这种场景就适合自定义 CacheResolver

chapter 4:生产级 RedisCacheManager 配置

下面给一套可直接参考的配置。

4.1 Maven 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

<dependencies>
<!-- Spring Cache 抽象 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Jackson Java 8 时间类型支持 -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies>

如果你还要使用 Caffeine 本地缓存:

1
2
3
4
5

<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>

4.2 application.yml

Spring Boot 3.x 推荐 Redis 配置路径是 spring.data.redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
spring:
cache:
type: redis

data:
redis:
host: 127.0.0.1
port: 6379
database: 2
timeout: 3s
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 2s

management:
endpoints:
web:
exposure:
include: health,info,caches,metrics,prometheus
endpoint:
caches:
enabled: true

如果是 Spring Boot 2.x,Redis 配置常见写法是:

1
2
3
4
spring:
redis:
host: 127.0.0.1
port: 6379

4.3 定义缓存名常量

不要在业务代码里到处写字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.cache;

public final class CacheNames {

private CacheNames() {
}

public static final String USER = "user";
public static final String USER_DETAIL = "userDetail";
public static final String DICT = "dict";
public static final String SHOP_BILL = "shopBill";
public static final String HOT_SKU = "hotSku";
}

4.4 定义缓存规格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.cache;

import java.time.Duration;

public enum CacheSpec {

USER(CacheNames.USER, Duration.ofMinutes(30)),
USER_DETAIL(CacheNames.USER_DETAIL, Duration.ofMinutes(10)),
DICT(CacheNames.DICT, Duration.ofHours(6)),
SHOP_BILL(CacheNames.SHOP_BILL, Duration.ofMinutes(5)),
HOT_SKU(CacheNames.HOT_SKU, Duration.ofMinutes(1));

private final String cacheName;
private final Duration ttl;

CacheSpec(String cacheName, Duration ttl) {
this.cacheName = cacheName;
this.ttl = ttl;
}

public String getCacheName() {
return cacheName;
}

public Duration getTtl() {
return ttl;
}
}

4.5 自定义 RedisCacheManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
package com.example.cache.config;

import com.example.cache.CacheSpec;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.cache.CacheManagerCustomizer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;

@Configuration(proxyBeanMethods = false)
@EnableCaching
public class RedisCacheConfig {

@Value("${spring.profiles.active:default}")
private String activeProfile;

/**
* 默认缓存配置。
*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration(ObjectMapper objectMapper) {
ObjectMapper redisObjectMapper = objectMapper.copy();

/*
* 说明:
* 1. Spring Cache 的 Redis value 类型通常是 Object。
* 2. 如果没有类型信息,反序列化复杂对象时可能变成 LinkedHashMap。
* 3. GenericJackson2JsonRedisSerializer 会通过类型信息解决这个问题。
*
* 注意:
* 缓存里的 JSON 类型信息只应该来自可信系统,不要把不可信外部输入直接作为缓存反序列化来源。
*/
redisObjectMapper.activateDefaultTyping(
redisObjectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);

GenericJackson2JsonRedisSerializer valueSerializer =
new GenericJackson2JsonRedisSerializer(redisObjectMapper);

StringRedisSerializer keySerializer = new StringRedisSerializer();

return RedisCacheConfiguration.defaultCacheConfig()
// 默认 TTL,具体缓存可以覆盖
.entryTtl(Duration.ofMinutes(30))
// 不缓存 null,避免缓存穿透时把 null 长期固化
.disableCachingNullValues()
// 强烈建议保留 cacheName 前缀,避免不同缓存区域 key 冲突
.computePrefixWith(cacheName -> activeProfile + ":" + cacheName + "::")
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(keySerializer)
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer)
);
}

/**
* 完全接管 RedisCacheManager。
*/
@Bean("redisCacheManager")
public RedisCacheManager redisCacheManager(
RedisConnectionFactory redisConnectionFactory,
RedisCacheConfiguration defaultCacheConfiguration
) {
Map<String, RedisCacheConfiguration> cacheConfigurations =
Arrays.stream(CacheSpec.values())
.collect(Collectors.toMap(
CacheSpec::getCacheName,
spec -> defaultCacheConfiguration.entryTtl(spec.getTtl())
));

return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfiguration)
.withInitialCacheConfigurations(cacheConfigurations)
// 事务提交后再执行缓存 put/evict,避免数据库回滚但缓存已更新
.transactionAware()
.build();
}
}

4.6 使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.user.service;

import com.example.cache.CacheNames;
import com.example.user.dto.UserDTO;
import com.example.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;

@Cacheable(
cacheNames = CacheNames.USER,
key = "#id",
unless = "#result == null",
cacheManager = "redisCacheManager"
)
public UserDTO getUserById(Long id) {
return userRepository.findById(id)
.map(UserDTO::from)
.orElse(null);
}

@Transactional(rollbackFor = Exception.class)
@CachePut(
cacheNames = CacheNames.USER,
key = "#result.id",
unless = "#result == null",
cacheManager = "redisCacheManager"
)
public UserDTO updateUser(UserDTO dto) {
var user = userRepository.save(dto.toEntity());
return UserDTO.from(user);
}

@Transactional(rollbackFor = Exception.class)
@CacheEvict(
cacheNames = CacheNames.USER,
key = "#id",
cacheManager = "redisCacheManager"
)
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
}

chapter 5:自定义 KeyGenerator

5.1 为什么需要自定义 key?

默认 key 规则:

1
2
3
无参数:SimpleKey.EMPTY
一个参数:参数本身
多个参数:SimpleKey(params...)

简单方法没问题,但复杂业务中容易出现问题:

1
2
3
4
@Cacheable(cacheNames = "user")
public UserDTO getUser(Long id, Integer tenantId) {
return null;
}

默认 key 可能不够直观。

更推荐统一成:

1
类名:方法名:参数1:参数2

比如:

1
UserService:getUser:1:10001

如果业务里有租户、企业、门店维度,可以加入上下文:

1
ent:10001:UserService:getUser:1

5.2 实现 KeyGenerator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package com.example.cache.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.DigestUtils;

import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.stream.Collectors;

@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
public class CacheKeyConfig {

private final ObjectMapper objectMapper;

@Bean("bizKeyGenerator")
public KeyGenerator bizKeyGenerator() {
return new BizKeyGenerator(objectMapper);
}

static class BizKeyGenerator implements KeyGenerator {

private final ObjectMapper objectMapper;

BizKeyGenerator(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}

@Override
public Object generate(Object target, Method method, Object... params) {
String className = target.getClass().getSimpleName();
String methodName = method.getName();

String rawParams = Arrays.stream(params)
.map(this::toStableString)
.collect(Collectors.joining(":"));

String rawKey = className + ":" + methodName + ":" + rawParams;

/*
* 如果参数很长,不建议直接作为 Redis key。
* 可以保留业务前缀 + MD5。
*/
String md5 = DigestUtils.md5DigestAsHex(rawKey.getBytes(StandardCharsets.UTF_8));

return className + ":" + methodName + ":" + md5;
}

private String toStableString(Object param) {
if (param == null) {
return "null";
}
if (param instanceof CharSequence || param instanceof Number || param instanceof Boolean) {
return String.valueOf(param);
}
try {
return objectMapper.writeValueAsString(param);
} catch (Exception ex) {
return String.valueOf(param);
}
}
}
}

5.3 使用自定义 KeyGenerator

1
2
3
4
5
6
7
8
@Cacheable(
cacheNames = CacheNames.USER_DETAIL,
keyGenerator = "bizKeyGenerator",
unless = "#result == null"
)
public UserDTO getUserDetail(Long userId, Long entId) {
return null;
}

注意:

1
@Cacheable(key = "#id", keyGenerator = "bizKeyGenerator")

这种写法是错误的,keykeyGenerator 不能同时使用。

chapter 6:多个 CacheManager 的场景

6.1 同时配置 Redis 和 Caffeine

Redis 适合分布式共享,Caffeine 适合 JVM 本地热点缓存。

先添加 Caffeine 依赖:

1
2
3
4
5

<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>

配置本地缓存管理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.cache.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

@Configuration(proxyBeanMethods = false)
public class CaffeineCacheConfig {

@Bean("caffeineCacheManager")
public CacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();

cacheManager.setCaffeine(
Caffeine.newBuilder()
.initialCapacity(128)
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
);

return cacheManager;
}
}

使用时指定 cacheManager

1
2
3
4
5
6
7
8
9
@Cacheable(
cacheNames = "localDict",
key = "#code",
cacheManager = "caffeineCacheManager",
unless = "#result == null"
)
public DictDTO getLocalDict(String code) {
return null;
}

Redis 使用:

1
2
3
4
5
6
7
8
9
@Cacheable(
cacheNames = CacheNames.USER,
key = "#id",
cacheManager = "redisCacheManager",
unless = "#result == null"
)
public UserDTO getUserById(Long id) {
return null;
}

这种方式简单直接,但缺点是每个注解都要指定 cacheManager

chapter 7:自定义 CacheResolver:运行时动态路由

7.1 需求

希望通过缓存名前缀自动选择缓存管理器:

1
2
3
local:dict     -> Caffeine
redis:user -> Redis
user -> 默认 Redis

7.2 实现 CacheResolver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package com.example.cache.resolver;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.interceptor.CacheOperationInvocationContext;
import org.springframework.cache.interceptor.CacheResolver;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

@Component("bizCacheResolver")
@RequiredArgsConstructor
public class BizCacheResolver implements CacheResolver {

@Qualifier("caffeineCacheManager")
private final CacheManager caffeineCacheManager;

@Qualifier("redisCacheManager")
private final CacheManager redisCacheManager;

@Override
public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {
Collection<String> cacheNames = context.getOperation().getCacheNames();

if (cacheNames == null || cacheNames.isEmpty()) {
throw new IllegalStateException("cacheNames must not be empty");
}

List<Cache> caches = new ArrayList<>();

for (String cacheName : cacheNames) {
CacheManager cacheManager = selectCacheManager(cacheName);

String realCacheName = normalizeCacheName(cacheName);

Cache cache = cacheManager.getCache(realCacheName);
if (cache == null) {
throw new IllegalStateException("Cannot find cache: " + realCacheName);
}

caches.add(cache);
}

return caches;
}

private CacheManager selectCacheManager(String cacheName) {
if (cacheName.startsWith("local:")) {
return caffeineCacheManager;
}
if (cacheName.startsWith("redis:")) {
return redisCacheManager;
}
return redisCacheManager;
}

private String normalizeCacheName(String cacheName) {
if (cacheName.startsWith("local:")) {
return cacheName.substring("local:".length());
}
if (cacheName.startsWith("redis:")) {
return cacheName.substring("redis:".length());
}
return cacheName;
}
}

7.3 使用 CacheResolver

1
2
3
4
5
6
7
8
9
@Cacheable(
cacheNames = "local:dict",
key = "#code",
cacheResolver = "bizCacheResolver",
unless = "#result == null"
)
public DictDTO getDict(String code) {
return null;
}
1
2
3
4
5
6
7
8
9
@Cacheable(
cacheNames = "redis:user",
key = "#id",
cacheResolver = "bizCacheResolver",
unless = "#result == null"
)
public UserDTO getUser(Long id) {
return null;
}

注意:

1
@Cacheable(cacheManager = "redisCacheManager", cacheResolver = "bizCacheResolver")

这种写法是错误的。cacheManagercacheResolver 不能同时使用。

chapter 8:自定义 Cache:实现 Redis + Caffeine 两级缓存

现在进入重点:自定义 Cache

8.1 两级缓存访问流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
读取:
1. 查 L1 Caffeine。
2. L1 命中,直接返回。
3. L1 未命中,查 L2 Redis。
4. L2 命中,回填 L1,然后返回。
5. L2 未命中,执行原方法。
6. 原方法返回后,写入 L2 Redis 和 L1 Caffeine。

写入:
1. 写 Redis。
2. 写 Caffeine。

删除:
1. 删除 Redis。
2. 删除 Caffeine。

为什么写 Redis 再写 Caffeine?

因为 Redis 是多节点共享的事实源缓存。如果本地先写成功、Redis 后写失败,当前节点会短暂看到新值,其它节点看不到,问题更难排查。

8.2 TwoLevelCache 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
package com.example.cache.twolevel;

import org.springframework.cache.Cache;
import org.springframework.lang.Nullable;

import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class TwoLevelCache implements Cache {

private final String name;
private final Cache l1Cache;
private final Cache l2Cache;

/**
* 防止同一个 key 在缓存未命中时被多个线程同时加载。
*/
private final ConcurrentMap<Object, Object> keyLocks = new ConcurrentHashMap<>();

public TwoLevelCache(String name, Cache l1Cache, Cache l2Cache) {
this.name = name;
this.l1Cache = l1Cache;
this.l2Cache = l2Cache;
}

@Override
public String getName() {
return name;
}

@Override
public Object getNativeCache() {
return Map.of(
"l1", l1Cache.getNativeCache(),
"l2", l2Cache.getNativeCache()
);
}

@Override
@Nullable
public ValueWrapper get(Object key) {
ValueWrapper l1Value = l1Cache.get(key);
if (l1Value != null) {
return l1Value;
}

ValueWrapper l2Value = l2Cache.get(key);
if (l2Value != null) {
l1Cache.put(key, l2Value.get());
return l2Value;
}

return null;
}

@Override
@Nullable
public <T> T get(Object key, @Nullable Class<T> type) {
T l1Value = l1Cache.get(key, type);
if (l1Value != null) {
return l1Value;
}

T l2Value = l2Cache.get(key, type);
if (l2Value != null) {
l1Cache.put(key, l2Value);
return l2Value;
}

return null;
}

@Override
@Nullable
public <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper cached = get(key);
if (cached != null) {
@SuppressWarnings("unchecked")
T value = (T) cached.get();
return value;
}

Object lock = keyLocks.computeIfAbsent(key, k -> new Object());

synchronized (lock) {
try {
ValueWrapper secondCheck = get(key);
if (secondCheck != null) {
@SuppressWarnings("unchecked")
T value = (T) secondCheck.get();
return value;
}

T loadedValue = valueLoader.call();
put(key, loadedValue);
return loadedValue;
} catch (Exception ex) {
throw new ValueRetrievalException(key, valueLoader, ex);
} finally {
keyLocks.remove(key, lock);
}
}
}

@Override
public void put(Object key, @Nullable Object value) {
/*
* 先写 L2,再写 L1。
* 如果 Redis 写失败,不建议让本地缓存独自成功。
*/
l2Cache.put(key, value);
l1Cache.put(key, value);
}

@Override
@Nullable
public ValueWrapper putIfAbsent(Object key, @Nullable Object value) {
ValueWrapper existing = l2Cache.putIfAbsent(key, value);

if (existing == null) {
l1Cache.put(key, value);
return null;
}

l1Cache.put(key, existing.get());
return existing;
}

@Override
public void evict(Object key) {
l2Cache.evict(key);
l1Cache.evict(key);
}

@Override
public boolean evictIfPresent(Object key) {
boolean l2Result = l2Cache.evictIfPresent(key);
boolean l1Result = l1Cache.evictIfPresent(key);
return l1Result || l2Result;
}

@Override
public void clear() {
l2Cache.clear();
l1Cache.clear();
}

@Override
public boolean invalidate() {
boolean l2Result = l2Cache.invalidate();
boolean l1Result = l1Cache.invalidate();
return l1Result || l2Result;
}
}

8.3 TwoLevelCacheManager 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.cache.twolevel;

import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;

import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class TwoLevelCacheManager implements CacheManager {

private final CacheManager l1CacheManager;
private final CacheManager l2CacheManager;
private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>();

public TwoLevelCacheManager(CacheManager l1CacheManager, CacheManager l2CacheManager) {
this.l1CacheManager = l1CacheManager;
this.l2CacheManager = l2CacheManager;
}

@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, this::createTwoLevelCache);
}

private Cache createTwoLevelCache(String name) {
Cache l1 = l1CacheManager.getCache(name);
Cache l2 = l2CacheManager.getCache(name);

if (l1 == null) {
throw new IllegalStateException("L1 cache does not exist: " + name);
}
if (l2 == null) {
throw new IllegalStateException("L2 cache does not exist: " + name);
}

return new TwoLevelCache(name, l1, l2);
}

@Override
public Collection<String> getCacheNames() {
Set<String> names = new LinkedHashSet<>();
names.addAll(l1CacheManager.getCacheNames());
names.addAll(l2CacheManager.getCacheNames());
names.addAll(cacheMap.keySet());
return names;
}
}

8.4 配置 TwoLevelCacheManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.example.cache.config;

import com.example.cache.twolevel.TwoLevelCacheManager;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import java.time.Duration;

@Configuration(proxyBeanMethods = false)
public class TwoLevelCacheConfig {

@Bean("l1CaffeineCacheManager")
public CacheManager l1CaffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();

/*
* L1 本地缓存 TTL 一定要短。
* 因为多个应用节点之间,本地缓存不会天然同步。
*/
cacheManager.setCaffeine(
Caffeine.newBuilder()
.initialCapacity(128)
.maximumSize(20_000)
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats()
);

return cacheManager;
}

@Bean("twoLevelCacheManager")
@Primary
public CacheManager twoLevelCacheManager(
@Qualifier("l1CaffeineCacheManager") CacheManager l1CacheManager,
@Qualifier("redisCacheManager") CacheManager l2CacheManager
) {
return new TwoLevelCacheManager(l1CacheManager, l2CacheManager);
}
}

8.5 使用两级缓存

1
2
3
4
5
6
7
8
9
10
11
12
@Cacheable(
cacheNames = CacheNames.USER,
key = "#id",
cacheManager = "twoLevelCacheManager",
unless = "#result == null",
sync = true
)
public UserDTO getUserById(Long id) {
return userRepository.findById(id)
.map(UserDTO::from)
.orElse(null);
}

8.6 两级缓存最大的问题:本地缓存一致性

两级缓存很快,但有一致性问题。

假设有两个节点:

1
2
Node A
Node B

两个节点都有自己的 Caffeine 本地缓存。

当 Node A 更新用户并删除 Redis 缓存时:

1
2
3
Node A 的 L1 删除了
Redis 删除了
Node B 的 L1 可能还没删除

这时 Node B 可能继续读到旧值。

解决方案有三个:

方案一:L1 设置很短 TTL

例如:

1
2
L1 TTL = 10 秒 ~ 60 秒
L2 TTL = 5 分钟 ~ 30 分钟

这是最简单、最稳定的方案。

适合允许短暂不一致的业务。

方案二:更新时发消息广播

例如使用:

  • Redis Pub/Sub;
  • RocketMQ;
  • Kafka;
  • Spring Cloud Bus。

流程:

1
2
3
4
5
节点 A 更新数据
节点 A 删除 Redis
节点 A 发布 cache-evict 消息
节点 B 收到消息
节点 B 删除本地 Caffeine

方案三:不对强一致业务使用 L1

比如:

  • 余额;
  • 库存;
  • 支付状态;
  • 财务结算状态;
  • 权限变更立即生效场景。

这些业务不要为了快,把自己送进故障复盘 PPT。

chapter 9:缓存管理与清理

9.1 使用 Actuator 管理缓存

引入:

1
2
3
4
5

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

配置:

1
2
3
4
5
6
7
8
management:
endpoints:
web:
exposure:
include: health,info,caches,metrics,prometheus
endpoint:
caches:
enabled: true

查看所有缓存:

1
curl http://localhost:8080/actuator/caches

清空所有缓存:

1
curl -X DELETE http://localhost:8080/actuator/caches

清空指定缓存:

1
curl -X DELETE "http://localhost:8080/actuator/caches/user?cacheManager=twoLevelCacheManager"

生产建议:

1
2
3
不要把 actuator caches endpoint 直接暴露到公网。
不要让普通用户访问缓存清理接口。
建议只允许内网、网关白名单、管理员权限访问。

9.2 自定义缓存管理接口

如果不想直接暴露 Actuator,也可以自己写一个内部接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.example.cache.admin;

import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CacheAdminService {

private final CacheManager cacheManager;

public void clearCache(String cacheName) {
Cache cache = cacheManager.getCache(cacheName);
if (cache == null) {
throw new IllegalArgumentException("Cache not found: " + cacheName);
}
cache.clear();
}

public void clearAll() {
for (String cacheName : cacheManager.getCacheNames()) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.clear();
}
}
}

public Object getNativeCacheInfo(String cacheName) {
Cache cache = cacheManager.getCache(cacheName);
if (cache == null) {
throw new IllegalArgumentException("Cache not found: " + cacheName);
}
return cache.getNativeCache().getClass().getName();
}
}

Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.cache.admin;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/internal/cache")
@RequiredArgsConstructor
public class CacheAdminController {

private final CacheAdminService cacheAdminService;

@DeleteMapping("/{cacheName}")
public String clearCache(@PathVariable String cacheName) {
cacheAdminService.clearCache(cacheName);
return "ok";
}

@DeleteMapping
public String clearAll() {
cacheAdminService.clearAll();
return "ok";
}

@GetMapping("/{cacheName}/native")
public Object nativeInfo(@PathVariable String cacheName) {
return cacheAdminService.getNativeCacheInfo(cacheName);
}
}

生产环境必须加权限控制:

1
/internal/cache/** 只允许管理员访问。

chapter 10:缓存常见坑

10.1 同类方法内部调用,缓存不生效

错误示例:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class UserService {

public UserDTO query(Long id) {
return this.getUserById(id);
}

@Cacheable(cacheNames = "user", key = "#id")
public UserDTO getUserById(Long id) {
return null;
}
}

this.getUserById(id) 不会走 Spring 代理,所以缓存不生效。

解决方案:

方案一:拆到另一个 Bean

1
2
3
4
5
6
7
8
9
@Service
public class UserQueryFacade {

private final UserService userService;

public UserDTO query(Long id) {
return userService.getUserById(id);
}
}

方案二:通过代理对象调用

不太推荐,代码会变丑。

方案三:使用 AspectJ 模式

复杂度更高,一般业务系统没必要。

10.2 private 方法加缓存不生效

错误示例:

1
2
3
4
@Cacheable(cacheNames = "user", key = "#id")
private UserDTO getUserById(Long id) {
return null;
}

Spring Cache 默认基于代理,通常只拦截外部 public 方法调用。

建议:

1
缓存注解加在 public service 方法上。

10.3 @Cacheable 和 @CachePut 不要随便放同一个方法上

不推荐:

1
2
3
4
5
@Cacheable(cacheNames = "user", key = "#id")
@CachePut(cacheNames = "user", key = "#id")
public UserDTO getUser(Long id) {
return null;
}

原因:

1
2
3
@Cacheable 可能跳过方法执行。
@CachePut 一定要执行方法并更新缓存。
两个语义冲突。

建议:

1
2
读方法:@Cacheable
写方法:@CachePut 或 @CacheEvict

10.4 缓存 null 要谨慎

缓存 null 的好处:

1
防止缓存穿透。

坏处:

1
后续数据真的创建出来了,但缓存里还是 null。

建议:

  • 默认不缓存 null;
  • 如果要做防穿透,使用短 TTL 的空值缓存;
  • 或者用布隆过滤器、参数校验、热点 key 保护。

10.5 list 查询缓存最容易脏

比如:

1
2
3
4
@Cacheable(cacheNames = "userList", key = "#query")
public List<UserDTO> listUsers(UserQuery query) {
return null;
}

问题是:

1
2
3
新增用户,哪些 list 缓存要删除?
修改用户,哪些 list 缓存受影响?
删除用户,哪些分页缓存受影响?

如果查询条件复杂,删除会很困难。

建议:

1
2
3
4
单对象缓存优先。
列表缓存谨慎。
高频固定列表可以缓存。
复杂搜索列表不要轻易缓存。

10.6 缓存 key 不能太随意

不推荐:

1
@Cacheable(cacheNames = "user", key = "#user")

如果 user.toString() 不稳定,key 就不稳定。

推荐:

1
@Cacheable(cacheNames = "user", key = "#user.id")

或者:

1
@Cacheable(cacheNames = "user", keyGenerator = "bizKeyGenerator")

10.7 Redis key 一定要有业务边界

推荐格式:

1
应用名:环境:租户:缓存名::业务key

例如:

1
finance:prod:ent10001:user::1

不要让 key 像野草一样乱长:

1
2
3
4
5
user:1
order:1
cache:1
test
abc

这种 Redis 迟早变成公共厕所,谁都能来,谁都不想管。

chapter 11:缓存雪崩、击穿、穿透

11.1 缓存穿透

含义:

1
2
3
请求的数据不存在。
每次都查缓存未命中。
然后每次都打到数据库。

解决:

  • 参数校验;
  • 短 TTL 缓存空结果;
  • 布隆过滤器;
  • 对异常 key 限流。

示例:

1
2
3
4
5
6
7
8
9
@Cacheable(
cacheNames = "user",
key = "#id",
condition = "#id != null && #id > 0",
unless = "#result == null"
)
public UserDTO getUserById(Long id) {
return null;
}

如果要缓存空值,不要长期缓存,建议单独配置短 TTL。

11.2 缓存击穿

含义:

1
2
一个热点 key 失效。
大量并发同时打到数据库。

解决:

  • @Cacheable(sync = true)
  • 分布式锁;
  • 热点 key 永不过期 + 后台刷新;
  • Caffeine 本地缓存兜底。

示例:

1
2
3
4
5
6
7
8
9
@Cacheable(
cacheNames = "hotSku",
key = "#skuId",
sync = true,
unless = "#result == null"
)
public SkuDTO getHotSku(Long skuId) {
return null;
}

11.3 缓存雪崩

含义:

1
2
大量 key 同时过期。
请求同时打到数据库。

解决:

  • TTL 加随机抖动;
  • 不同缓存设置不同 TTL;
  • 热点数据提前预热;
  • 限流降级;
  • 多级缓存。

TTL 抖动可以在自定义写入逻辑中实现。Spring Cache 的标准 RedisCacheConfiguration 是按 cacheName 配 TTL,不适合对每个 key
动态设置随机 TTL。如果强需求,可以:

  • 直接使用 RedisTemplate
  • 自定义 RedisCacheWriter
  • 封装业务缓存组件;
  • 使用支持动态 TTL 的缓存框架。

chapter 12:事务与缓存一致性

12.1 典型问题

1
2
3
4
5
6
@Transactional
@CacheEvict(cacheNames = "user", key = "#id")
public void updateUser(Long id) {
userRepository.update(id);
throw new RuntimeException("模拟异常");
}

如果缓存先删了,但数据库事务回滚了,会怎样?

1
2
3
数据库没变。
缓存被删。
下一次查询重新加载旧数据。

这个问题还算能接受。

更危险的是:

1
2
数据库事务还没提交,缓存已经写入新值。
随后事务回滚,缓存里却是新值。

12.2 建议

如果使用 RedisCacheManager,建议开启:

1
.transactionAware()

让缓存操作尽量在事务提交后执行。

但注意:

1
2
3
缓存和数据库不是同一个事务资源。
transactionAware 不是分布式事务。
它只是降低事务未提交时提前操作缓存的风险。

强一致场景,不要只靠 Spring Cache。

chapter 13:测试缓存是否真的生效

13.1 使用 SpyBean 验证数据库只查一次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@SpringBootTest
class UserServiceCacheTest {

@Autowired
private UserService userService;

@SpyBean
private UserRepository userRepository;

@Test
void should_hit_cache_when_query_same_user_twice() {
Long userId = 1L;

userService.getUserById(userId);
userService.getUserById(userId);

verify(userRepository, times(1)).findById(userId);
}
}

13.2 直接检查 CacheManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootTest
class CacheManagerTest {

@Autowired
private CacheManager cacheManager;

@Test
void should_put_and_get_cache() {
Cache cache = cacheManager.getCache("user");
assertNotNull(cache);

cache.put(1L, "Mario");

String value = cache.get(1L, String.class);
assertEquals("Mario", value);
}
}

13.3 Redis 命令检查

1
2
3
4
5
redis-cli
select 2
keys '*user*'
ttl 'prod:user::1'
get 'prod:user::1'

生产环境不要使用:

1
keys *

大 key 多的时候会阻塞 Redis。

建议用:

1
scan 0 match '*user*' count 100

chapter 14:生产建议清单

14.1 缓存设计前先问 8 个问题

问题 说明
缓存什么? 单对象、列表、聚合结果、配置?
key 怎么设计? 是否包含租户、环境、业务主键?
TTL 多久? 是否允许短暂不一致?
什么时候删除? 新增、修改、删除分别影响哪些缓存?
是否缓存 null? 是否存在穿透风险?
是否需要本地缓存? 是否是热点数据?
是否需要监控? 命中率、大小、延迟、异常?
是否可降级? Redis 挂了是否允许查 DB?

14.2 推荐缓存分类

类型 示例 推荐方案
字典配置 枚举、地区、业务配置 Caffeine + Redis
用户基础信息 用户名、头像、状态 Redis,可加短 L1
权限菜单 用户权限、角色菜单 Redis,权限变更主动删除
财务单据状态 结算单、应收应付状态 谨慎缓存,优先短 TTL
列表分页 复杂查询列表 谨慎缓存,容易脏
热点商品 SKU、库存展示 Caffeine + Redis + 短 TTL
库存余额 强一致数据 不建议用普通 Spring Cache

14.3 key 命名推荐

1
{app}:{env}:{tenant}:{cacheName}::{bizKey}

示例:

1
2
finance:prod:10001:user::1
finance:prod:10001:shopBill::20260604:shop88

14.4 TTL 推荐

数据类型 TTL 建议
本地 L1 热点缓存 10 秒 ~ 5 分钟
用户基础信息 10 分钟 ~ 1 小时
字典配置 1 小时 ~ 24 小时
财务状态类数据 30 秒 ~ 5 分钟
搜索列表 30 秒 ~ 3 分钟
空值缓存 30 秒 ~ 2 分钟

14.5 不建议缓存的内容

  • 强一致余额;
  • 精确库存扣减;
  • 支付状态最终判断;
  • 正在流转的审批状态;
  • 高组合条件分页查询;
  • 数据权限变更立即生效的结果;
  • 返回值特别大的对象。

chapter 15:一套推荐落地方案

如果你是普通 Spring Boot 业务系统,我建议这样落地:

第一阶段:基础 Redis 缓存

  • 使用 RedisCacheManager
  • JSON 序列化;
  • 统一 key 前缀;
  • 按 cacheName 配 TTL;
  • 不缓存 null;
  • 使用 @Cacheable@CacheEvict

适合大多数业务。

第二阶段:热点数据加 Caffeine

  • 字典、配置、热点 SKU 使用 Caffeine;
  • Redis 做共享缓存;
  • L1 TTL 控制在 30 秒左右;
  • 更新时主动删除 Redis 和本地缓存。

适合读多写少场景。

第三阶段:自定义 CacheResolver

  • 根据 cacheName 前缀路由;
  • 支持 local:redis:two:
  • 统一缓存治理。

适合缓存类型较多的系统。

第四阶段:自定义 TwoLevelCache

  • 统一封装 L1 + L2;
  • 业务代码仍然只写 @Cacheable
  • 内部自动完成 L1/L2 读取、回填、删除。

适合中大型系统。

第五阶段:缓存治理平台化

  • 暴露内部缓存管理接口;
  • 接入 Actuator;
  • 接入 Prometheus/Grafana;
  • 统计命中率、miss、evict、size;
  • 支持按缓存名清理;
  • 支持灰度关闭某些缓存。

适合生产规模较大的系统。

结论

Spring Cache 的核心不是 @Cacheable,而是这套抽象:

1
2
3
4
5
Cache
CacheManager
CacheResolver
KeyGenerator
CacheErrorHandler

简单使用时,注解很香;复杂业务里,真正决定缓存质量的是:

1
2
3
4
5
6
7
缓存名设计
key 设计
TTL 设计
序列化策略
一致性策略
缓存清理策略
监控治理能力

如果只是为了少查一次数据库,加个 @Cacheable 就够了。

如果是生产系统,尤其是财务、订单、库存、权限这类业务,必须把缓存当成一个完整模块设计,而不是随手往方法上贴注解。

我的建议是:

1
2
3
能不用缓存,先别用。
必须用缓存,先设计失效。
设计完失效,再写注解。

缓存的最高境界不是“快”,而是“快,并且出事的时候你知道它为什么快、哪里脏、怎么清”。

参考资料

  • Spring Framework 官方文档:Declarative Annotation-based Caching
  • Spring Framework Javadoc:Cache、CacheManager
  • Spring Boot 官方文档:Caching
  • Spring Data Redis 官方文档:Redis Cache
  • Spring Framework Javadoc:CaffeineCacheManager
  • Spring Boot Actuator 官方文档:Caches Endpoint
  • 博客园:SpringBoot 缓存、Redis 缓存、自定义 CacheManager
  • CSDN:Spring Boot 缓存 Cache 入门

启示录

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


Spring Cache 深度实践:自定义 Cache 与 CacheManager 的生产级方案
https://allendericdalexander.github.io/2026/06/04/spring_cache/
作者
AtLuoFu
发布于
2026年6月4日
许可协议