Spring Boot + Redis 缓存实战:@Cacheable、序列化踩坑、缓存一致性,一次讲透

本文紧接上篇,是阶段 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);
}

设计要点

  1. unless = "#result == null || #result.isEmpty()":不缓存空结果。老人没有健康记录时,每次都查库,避免缓存空列表浪费空间。

  2. 日期参数可能为 null(Controller 里 required = false),SpEL 用三元表达式兜底成空字符串。

  3. 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 而不是 @CachePutupdateById 返回 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 拼成 110page=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 做精确清除


十、阶段收获

理解层面

  1. Spring Cache 注解体系@Cacheable(读缓存)、@CacheEvict(删缓存)、@Caching(组合操作)、@CachePut(更新缓存,坑多慎用)的使用场景和区别

  2. GenericJackson2JsonRedisSerializer:无参构造和传参构造各管一半,必须手动配齐 JavaTimeModule + activateDefaultTyping 三件套

  3. @class 类型标记 :JSON 本身不带类型信息,反序列化时必须靠 @class 告诉 Jackson 目标类型

  4. SpEL 动态 Key#参数名 引用方法参数,运行时拼接缓存 key

工程层面

  1. 能独立为一个 Service 设计缓存策略(读用 @Cacheable,写用 @CacheEvict)

  2. 能排查序列化问题(LinkedHashMap 强转失败 → 缺 @class;LocalDateTime 报错 → 缺 JavaTimeModule)

  3. 能用 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 有什么代价?什么时候不能用?
  • unlesscondition 的区别?


相关推荐
Flittly1 小时前
【AgentScope Java新手村系列】(16)从RAG到多路检索
java·spring boot·spring
人活一口气6 小时前
从JVM调优到MCP协议:Java全栈技术体系深度总结与企业级架构实践
java·spring boot
Java陈序员1 天前
企业级!一个基于 Java 开发的开源 AI 应用开发平台!
spring boot·agent·mcp
杨运交1 天前
[041][公共模块]分布式唯一ID生成器设计与实现:一款灵活可扩展的雪花算法框架
spring boot
用户3074596982072 天前
Redis 延时队列详解
redis
烤代码的吐司君2 天前
Redis 数据结构 ZSet, BIT, HyperLogLog,Geo 空间数据
redis·后端
Flittly2 天前
【AgentScope Java新手村系列】(14)人机交互
java·spring boot·spring
Flynt3 天前
从Spring Boot 4.0升到4.1,我在Maven和gRPC上栽了跟头
java·spring boot·后端
掉鱼的猫4 天前
Spring Boot → Solon 注解迁移实战指南:一张对照表说清楚
java·spring boot
leeyi4 天前
Checkpoint 机制:Agent 怎么在断电后接着跑
redis·aigc·agent