本文紧接上篇,是阶段 2的技术复盘,涵盖 Spring Cache 注解体系GenericJackson2JsonRe
disSerializer配置陷阱、SpEL 动态Key设计、缓存一致性策略等实战要点。全文包含 4 个踩坑记录 + 3 个业务的完整缓存策略 + 手把手验证方法,适合正在学习 Redis 缓存的 Spring Boot 朋友们阅读。
一、前言:为什么需要缓存
上篇文章我们给系统装上了【门禁系统】------JWT + Spring Security 认证授权。系统能用了,但性能如何?
来看一个真实场景:
家属端首页 → 查询老人健康记录 → SELECT * FROM health_record WHERE elderly_id = 1
→ 每次请求都要走 MySQL
→ 100 个家属同时查 → 100 次相同 SQL
数据库就像一间办公室,每次查数据都要跑一趟。缓存就是在前台放一本便签本------查过的数据记下来,下次有人问直接翻便签,不用再跑办公室。
阶段 2 的目标就是给高频查询装上这层「便签本」:
-
老人信息查询 → 加缓存
-
健康记录查询 → 加缓存
-
阈值配置查询 → 加缓存
技术栈:Spring Boot 3.5 + Spring Cache + Redis + GenericJackson2JsonRedisSerializer + Knife4j
二、整体架构:一次查询的完整旅行
加缓存后,一个查询请求经历了什么?
GET /api/elderly?page=1&pageSize=10
│
▼
ElderlyController
│
▼
ElderlyServiceImpl.pageQuery()
│
▼
┌─────────────────────────────────────┐
│ Spring CacheInterceptor (AOP 拦截) │
│ │
│ "elderly:list::1:10:" 这个 key │
│ 在 Redis 里有吗? │
│ │ │
│ ┌───┴───┐ │
│ ▼ ▼ │
│ 有缓存 无缓存 │
│ │ │ │
│ 直接返回 查 MySQL │
│ │ │
│ ▼ │
│ GenericJackson2Json │
│ RedisSerializer │
│ 把 Page<Elderly> → JSON │
│ │ │
│ ▼ │
│ 存入 Redis,TTL 5分钟 │
│ 下次查到直接返回 │
└─────────────────────────────────────┘
读流程:请求 → Controller → Service → CacheInterceptor 拦截 → Redis 有 → 直接返回 / Redis 无 → 查 DB → 写 Redis → 返回
写流程:请求 → Controller → Service → CacheInterceptor 拦截 → 执行方法(insert/update/delete)→ 删除 Redis 对应 key → 下次读重建
几个关键角色:
| 角色 | 对应技术 | 职责 |
|---|---|---|
| 便签本 | Redis | 存储缓存数据 |
| 抄写员 | GenericJackson2JsonRedisSerializer | Java 对象 ↔ JSON 互转 |
| 便签规则 | @Cacheable / @CacheEvict |
什么时候写便签、什么时候撕便签 |
| AOP 门禁 | CacheInterceptor | 方法执行前拦截,决定走缓存还是查库 |
三、RedisConfig 配置:整个缓存体系的发动机
这是阶段 2 最核心、踩坑最多的一个类。先看完整配置,再逐段解释。
3.1 完整配置
java
@Configuration
public class RedisConfig {
// ========== CacheManager:给 @Cacheable / @CacheEvict 注解用 ==========
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
// 三件套:时间模块 + 类型标记 + 序列化器
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule()); // ① 处理 LocalDateTime
mapper.activateDefaultTyping( // ② 开启 @class
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(mapper); // ③ 传入配置好的 mapper
// 默认缓存规则:10 分钟过期、不缓存 null
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(
SerializationPair.fromSerializer(
new StringRedisSerializer()))
.serializeValuesWith(
SerializationPair.fromSerializer(serializer))
.disableCachingNullValues();
// 差异化 TTL
Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
configMap.put("elderly:list", defaultConfig.entryTtl(Duration.ofMinutes(5)));
configMap.put("threshold", defaultConfig.entryTtl(Duration.ofMinutes(30)));
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(configMap)
.build();
}
}
3.2 配置要点逐一解释
① CacheManager vs RedisTemplate
这个类里实际上有两个 Bean:
| Bean | 用途 | 谁在用 |
|---|---|---|
CacheManager |
给注解 @Cacheable / @CacheEvict 提供缓存管理 |
Spring Cache 自动使用 |
RedisTemplate |
给手动操作 Redis 提供 API | 开发者手动调用 |
阶段 2 全程用的是 CacheManager(注解驱动),RedisTemplate 是为后续 Redis 进阶预留的。
② 为什么 Key 用 StringRedisSerializer
Key 本身就是一个字符串(例如 "elderly:list::1:10:"),用 String 序列化器即可。用 JSON 序列化反而会多加一层引号和转义,浪费空间、降低可读性。
java
用 StringRedisSerializer: elderly:list::1:10:
用 JSON 序列化器: "elderly:list::1:10:" ← 多了双引号
③ 为什么 Value 必须用 GenericJackson2JsonRedisSerializer
CacheManager 不知道缓存里会存什么类型。今天是 Page<Elderly> ,明天是 List<HealthRecord> ,后天是 ThresholdConfig 。只有 Generic 类型的序列化器能通过 @class 自动识别。
普通的 Jackson2JsonRedisSerializer 需要在构造时指定 .class,无法用于 CacheManager。
④ 差异化 TTL:为什么不同缓存不同过期时间
| 缓存名 | TTL | 理由 |
|---|---|---|
elderly:list |
5 分钟 | 老人信息偶尔变动,5 分钟延迟可接受 |
| 默认(单条 elderly 等) | 10 分钟 | 单条记录查询频率低于列表 |
threshold |
30 分钟 | 阈值配置几乎不变,最长缓存 |
没有标准答案,TTL 是业务取舍:越短数据越新、数据库压力越大;越长性能越好、数据可能越旧。
四、三个 Service 的缓存策略
4.1 ElderlyServiceImpl
老人信息涉及增删改查全套操作,缓存设计需要覆盖读和写两个方向:
java
读操作:
getById(id) → @Cacheable(value="elderly", key="#id")
pageQuery(...) → @Cacheable(value="elderly:list", key="#page + ':' + #pageSize + ':' + #keyword")
写操作:
addElderly(...) → @CacheEvict(value="elderly:list", allEntries=true)
updateById(entity) → @Caching(evict={
@CacheEvict(value="elderly", key="#entity.id"),
@CacheEvict(value="elderly:list", allEntries=true)
})
removeById(id) → @Caching(evict={
@CacheEvict(value="elderly", key="#id"),
@CacheEvict(value="elderly:list", allEntries=true)
})
设计决策 1:为什么 updateById 和 removeById 要重写父类方法?
Controller 里直接调了 elderlyService.updateById(entity) 和 elderlyService.removeById(id),这是 MyBatis Plus ServiceImpl 的内置方法,本身没有缓存注解。不符写加不了注解,必须重写。
java
@Override
@Caching(evict = {
@CacheEvict(value = "elderly", key = "#entity.id"),
@CacheEvict(value = "elderly:list", allEntries = true)
})
public boolean updateById(Elderly entity) {
return super.updateById(entity);
}
设计决策 2:为什么用 @Caching 组合两个 @CacheEvict?
更新一个老人时,涉及两级缓存:
java
elderly::1 = {id:1, name:"张三", phone:"138xxx", ...} ← 单条缓存,数据变了
elderly:list::1:10: = [{id:1, name:"张三", ...}, ...] ← 列表缓存,含旧数据
一个 @CacheEvict 只能清除一个缓存名。@Caching 把多个缓存操作打包,同步清除两级缓存。
设计决策 3:为什么列表用 allEntries = true?
elderly:list 下可能有几十上百个 key:
java
elderly:list::1:10: ← page=1, size=10, 无搜索
elderly:list::1:10:张三 ← page=1, size=10, 搜"张三"
elderly:list::1:20: ← page=1, size=20
elderly:list::2:10: ← page=2, size=10
...
更新一条记录后,无法精确定位所有受影响的 key(因为搜索关键词组合无限多)。allEntries = true 一把全清,简单可靠。
代价:多删了一些没变的数据。但对于老人表(不多的数据),影响可以忽略。
4.2 HealthRecordServiceImpl ------ 参数最多,Key 最复杂
健康记录的查询总是限定某个老人,且有日期范围:
java
// 分页查询
@Cacheable(value = "healthRecord:list",
key = "#elderlyId + ':' + #page + ':' + #size + ':'" +
" + (#startDate != null ? #startDate : '') + ':'" +
" + (#endDate != null ? #endDate : '')")
public IPage<HealthRecord> pageQuery(Long elderlyId, Integer page, Integer size,
LocalDate startDate, LocalDate endDate) { ... }
// 趋势查询
@Cacheable(value = "healthRecord:trend",
key = "#elderlyId + ':' + (#startDate != null ? #startDate : '')" +
" + ':' + (#endDate != null ? #endDate : '')",
unless = "#result == null || #result.isEmpty()")
public List<HealthRecord> getTrendData(Long elderlyId, LocalDate startDate,
LocalDate endDate) { ... }
// 新增/更新
@Override
@Caching(evict = {
@CacheEvict(value = "healthRecord:list", allEntries = true),
@CacheEvict(value = "healthRecord:trend", allEntries = true)
})
public boolean saveOrUpdate(HealthRecord entity) {
return super.saveOrUpdate(entity);
}
设计要点:
-
unless = "#result == null || #result.isEmpty()":不缓存空结果。老人没有健康记录时,每次都查库,避免缓存空列表浪费空间。 -
日期参数可能为 null(Controller 里
required = false),SpEL 用三元表达式兜底成空字符串。 -
saveOrUpdate全清两个缓存,因为新增一条记录会影响分页和趋势图。
4.3 ThresholdConfigServiceImpl ------ 最简单,只有一条记录
阈值配置表永远只有一行数据,缓存设计最简单:
java
@Cacheable(value = "threshold", key = "'config'")
public ThresholdConfig getConfig() { ... }
@CacheEvict(value = "threshold", key = "'config'")
public boolean saveOrUpdateConfig(ThresholdConfig config) { ... }
Key 写死 'config',因为表里永远只有一条记录。注意 SpEL 中字符串字面量用单引号包裹,"'config'" 外层是 Java 双引号,内层是 SpEL 单引号。
五、核心注解速查
5.1 @Cacheable ------ 查缓存,没有再查库
java
@Cacheable(value = "缓存名", key = "缓存key的SpEL表达式")
执行流程:
java
方法被调用
│
▼
用 key 去 Redis 查
│
┌───┴───┐
▼ ▼
命中 未命中
│ │
直接返回 执行方法体
│
▼
把返回值存 Redis
│
▼
返回给调用方
两个常用属性:
| 属性 | 作用 | 示例 |
|---|---|---|
unless |
满足条件时不缓存 | unless = "#result == null" |
condition |
满足条件时才缓存 | condition = "#page < 5" |
5.2 @CacheEvict ------ 执行方法后删除缓存
java
@CacheEvict(value = "缓存名", key = "要删的key" 或 allEntries = true)
allEntries = true 表示清空整个缓存名下所有 key。
5.3 @Caching ------ 组合多个缓存操作
java
@Caching(evict = {
@CacheEvict(value = "elderly", key = "#id"), // 清除单条
@CacheEvict(value = "elderly:list", allEntries = true) // 清除列表
})
一个 @CacheEvict 只能操作一个缓存名,多个缓存名需要用 @Caching 打包。
5.4 @CachePut ------ 更新缓存
@CachePut 强制执行方法体,然后把返回值覆盖到缓存中。
重要:@CachePut 缓存的是方法的返回值!
java
public boolean updateById(Elderly entity) { // 返回 boolean
...
}
// 如果加 @CachePut → 缓存里存的是 true/false,不是 Elderly 对象!
这就是为什么项目中所有写操作都用 @CacheEvict 而不是 @CachePut。updateById 返回 boolean,存进缓存会覆盖原有的 Elderly 对象,下次 getById 读到 true → 类型转换异常。
六、SpEL 动态 Key:为什么 key 里能写表达式
@Cacheable 的 key 不能写死(每次参数不同,key 也不同),所以 Spring 用了 SpEL(Spring Expression Language)在运行时动态计算。
java
// 方法参数
public IPage<Elderly> pageQuery(Integer page, Integer pageSize, String keyword) {
// ↑#page ↑#pageSize ↑#keyword
SpEL 中用**#参数名**引用方法参数。运行时 Spring 通过反射读参数名,替换为实际值。
java
运行时计算过程:
方法调用:pageQuery(1, 10, "张三")
SpEL 表达式:#page + ':' + #pageSize + ':' + (#keyword != null ? #keyword : '')
替换参数:
#page → 1
#pageSize → 10
#keyword → "张三"
三元运算:
#keyword != null → true → "张三"
拼接结果:"1:10:张三"
最终 Redis Key:"elderly:list::1:10:张三"
参数之间要加分隔符(
:),否则page=1, size=10拼成110和page=11, size=0拼成110看起来也头疼。
七、踩坑记录(4 个坑,每个都配了图)
坑 1:GenericJackson2JsonRedisSerializer配置两难 ------ 坑
现象:连续两次报不同的错,修好一个冒出另一个。
| 尝试 | 配置方式 | 报错 |
|---|---|---|
| 第 1 次 | 无参构造 | Java 8 date/time type not supported |
| 第 2 次 | 传自定义 mapper | LinkedHashMap cannot be cast to IPage |
| 最终 | 传自定义 mapper + 手动三件套 | 正常 |
根因:两个构造器各管一半。
java
// 无参构造:自动开启 @class,但没注册 JavaTimeModule
new GenericJackson2JsonRedisSerializer()
// → @class ✅ LocalDateTime ❌
// 传 mapper:完全信任你的 mapper,不加额外配置
new GenericJackson2JsonRedisSerializer(mapper)
// → LocalDateTime ✅(你配了就有) @class ❌(你没配就没有)
最终方案------三件套缺一不可:
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule()); // ← 弥补无参构造的缺陷
mapper.activateDefaultTyping( // ← 弥补传参构造的缺陷
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
new GenericJackson2JsonRedisSerializer(mapper); // ← 大全套传进去
教训:用 GenericJackson2JsonRedisSerializer 时,要么无参构造+另外处理时间,要么传 mapper+手动配齐 type 标记。没有"缺省配置刚好能用"的中间态。
坑 2:@class 缺失 → 反序列化变 LinkedHashMap
现象 :第一次查询正常,第二次相同查询报错:LinkedHashMap cannot be cast to IPage。
根因 :JSON 里没有 @class 字段,反序列化时 Jackson 不知道目标类型,默认用 LinkedHashMap 兜底。
java
// ❌ 没有 @class
{"records":[...],"total":13,"current":1}
// Jackson 反序列化:"records 是数组,里面是对象,但我不知道是什么类 → LinkedHashMap"
// ✅ 有 @class
{"@class":"com.baomidou.mybatisplus...Page","records":[...],"total":13}
// Jackson 反序列化:"@class 写了是 Page 类型 → 按 Page 反序列化"
@class 就像一个快递盒上的标签------没标签,快递员不知道里面是手机还是书籍,默认按普通包裹处理;有标签,按标签说明处理。
解决 :用 mapper.activateDefaultTyping() 开启自动写入 @class。
坑 3:改序列化配置后旧缓存不兼容
现象 :修复配置后重启,第一次查询报 Could not resolve subtype: missing type id property '@class'。
根因 :旧配置存的 JSON 没有 @class,新配置的序列化器期望有 @class。读旧数据时找不到 @class 字段,报错。
解决:
bash
redis-cli
flushdb
清空当前数据库,用新配置重新构建所有缓存。
教训:改了序列化配置后要清缓存,否则旧数据不兼容。
坑 4:缓存日志级别是 TRACE,不是 DEBUG
现象 :application.yml 配了 logging.level.org.springframework.cache: DEBUG,但控制台看不到 Cache hit / miss 日志。
根因:Spring Cache 的命中/未命中日志在 TRACE 级别。
正确配置:
bash
logging:
level:
org.springframework.cache: TRACE
日志效果:
# 未命中
No cache entry for key '1:10:' in cache(s) [elderly:list]
Creating cache entry for key '1:10:' in cache(s) [elderly:list]
# 命中
Cache entry for key '1:10:' found in cache(s) [elderly:list]
# 清除
Invalidating entire cache for operation ... caches=[elderly:list]
八、缓存验证:三管齐下
方法 1:TRACE 日志(最直观)
改了配置后,控制台直接看:
java
第一次 GET /api/elderly?page=1&pageSize=10 → Creating cache entry(新建缓存)
第二次 GET /api/elderly?page=1&pageSize=10 → Cache entry found(命中)
POST /api/elderly 新增 → Invalidating(清除)
第三次 GET /api/elderly?page=1&pageSize=10 → No cache entry → Creating(重建)
方法 2:Redis CLI 直接查看
bash
redis-cli
# 查看所有缓存 key
KEYS *
# 查看缓存内容
GET "elderly:list::1:10:"
# 查看剩余过期时间(秒)
TTL "elderly:list::1:10:"
# 刚创建时约 300 秒(5分钟)
方法 3:验证不同 TTL
bash
TTL "elderly:list::1:10:" # 约 300 秒(5 分钟)
TTL "elderly::1" # 约 600 秒(10 分钟,如果查过单条)
TTL "threshold::config" # 约 1800 秒(30 分钟)
三个缓存名三种 TTL,说明差异化配置生效。
九、Spring Cache 注解 vs RedisTemplate 手动操作
既然有 @Cacheable 注解这么方便,为什么还要使唤RedisTemplate?
| @Cacheable 注解 | RedisTemplate 手动 | |
|---|---|---|
| 代码量 | 一行注解 | 需要写缓存逻辑 |
| 灵活性 | 低 | 高(SCAN + 模糊删除) |
| 精确清除 | 不支持 | 支持 |
| 适用场景 | 简单读写缓存 | 复杂缓存策略 |
当前阶段用注解,后续数据量大了用 RedisTemplate 做精确清除
十、阶段收获
理解层面
-
Spring Cache 注解体系 :
@Cacheable(读缓存)、@CacheEvict(删缓存)、@Caching(组合操作)、@CachePut(更新缓存,坑多慎用)的使用场景和区别 -
GenericJackson2JsonRedisSerializer:无参构造和传参构造各管一半,必须手动配齐 JavaTimeModule + activateDefaultTyping 三件套
-
@class 类型标记 :JSON 本身不带类型信息,反序列化时必须靠
@class告诉 Jackson 目标类型 -
SpEL 动态 Key :
#参数名引用方法参数,运行时拼接缓存 key
工程层面
-
能独立为一个 Service 设计缓存策略(读用 @Cacheable,写用 @CacheEvict)
-
能排查序列化问题(LinkedHashMap 强转失败 → 缺 @class;LocalDateTime 报错 → 缺 JavaTimeModule)
-
能用 TRACE 日志 + Redis CLI 独立验证缓存是否生效
面试层面
能讲清楚的问题:
-
Spring Cache 的 @Cacheable 原理?AOP 拦截 → 查 Redis → 有则返回 / 无则执行方法并写入
-
Redis 缓存序列化怎么配?GenericJackson2JsonRedisSerializer + JavaTimeModule + activateDefaultTyping
-
为什么用 @CacheEvict 而不用 @CachePut?updateById 返回 boolean 会污染缓存 + 列表缓存无法同步
-
@class 是什么?Jackson 的类型标记,JSON 反序列化时靠它还原 Java 类型
-
缓存不一致怎么办?写操作删缓存,读操作重建;allEntries 全清简单但粗糙
十一、自检清单
- Spring Cache 的四个注解分别用于什么场景?
@Cacheable的执行流程?(先查缓存 → 有则返回 → 无则执行方法 → 写缓存)- SpEL 中
#参数名和'字符串'分别怎么写? - GenericJackson2JsonRedisSerializer 无参和传参构造器的区别?
@class字段的作用?没有它会怎样?- 为什么改了序列化配置要 FLUSHDB?
- 缓存验证怎么看日志?(TRACE 级别)
allEntries = true有什么代价?什么时候不能用?unless和condition的区别?