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 的区别?


相关推荐
Devin~Y1 小时前
大厂 Java 面试实战:从 Spring Boot 微服务到 AI RAG 音视频平台全链路解析
java·spring boot·redis·spring cloud·微服务·rag·spring ai
我登哥MVP2 小时前
SpringCloud 核心组件解析:服务注册与发现
java·spring boot·后端·spring·spring cloud·java-ee·maven
_未闻花名_2 小时前
PostgreSQL的若干扩展安装和使用
spring boot·postgresql·postgis·timescaledb·pg_cron·pgmq·zhparser
正经教主2 小时前
【docker基础】Redis的docker部署
redis·docker·容器
努力成为AK大王2 小时前
计算机底层核心原理:CPU、总线、缓存与内存深度解析
缓存·内存·cpu
闪电悠米2 小时前
黑马点评-Redis 消息队列-04_stream_seckill_order
数据库·redis·分布式·缓存·oracle·junit·lua
成为你的宁宁2 小时前
【基于 Prometheus Operator 实现 K8s 环境下 Redis Cluster 集群监控部署】
redis·kubernetes·prometheus
bmjIjFNC82 小时前
Redis分布式锁进第九十一篇
数据库·redis·分布式
砍材农夫2 小时前
物联网实战:Spring Boot + Netty 搭建 MQTT 统一接入层
java·网络·spring boot·后端·物联网·spring