Note 17. 性能加速:Spring Cache 与 Redis 分布式缓存

Note 17. 性能加速:Spring Cache 抽象层与多级缓存实战

摘要:在掌握了数据一致性(Note 16)之后,本章我们将致力于解决系统的“慢”问题。缓存是高性能架构的基石,但直接使用 Redis 并不是唯一的答案。本章将从 Spring Cache 的 核心抽象 出发,先掌握业界最高性能的进程内缓存 Caffeine,再平滑迁移至 Redis 分布式缓存。我们将深入剖析“序列化乱码”、“AOP 失效”、“多租户 TTL 配置”等生产级难题,构建一套可扩展的缓存体系。

本章环境与版本锁定

为了确保大家在实战中不遇到奇怪的兼容性问题,我们将严格锁定以下技术组件的版本。Spring Boot 3.x 对 Redis 的底层支持做了较大调整,请务必对齐环境。

技术组件版本号说明
Java (JDK)21 LTS推荐使用最新 LTS 版本,支持虚拟线程
Spring Boot3.3.02025 主流稳定版
Redis7.2支持 ACL 与最新 RDB 协议
Caffeine3.1.8高性能本地缓存库
Lettuce6.3.0Spring Boot 默认的高性能 Redis 客户端
Commons Pool22.12.0Redis 连接池管理工具(生产环境必选)

本章学习路径

  1. 抽象思维:理解 spring-boot-starter-cache 与具体实现(Caffeine/Redis)的解耦关系。
  2. 本地缓存:集成 Caffeine 实现毫秒级响应,理解 W-TinyLFU 淘汰算法。
  3. 分布式迁移:引入 Redis,配置连接池与 JSON 序列化,解决“二进制乱码”问题。
  4. 注解精讲:掌握 @Cacheable@CachePut@CacheEvict 的 SpEL 表达式与生命周期。
  5. 高阶实战:解决 AOP 内部调用失效、缓存穿透、自定义 TTL 等生产问题。

17.1. 缓存抽象层:Spring Cache 下的门面设计

在上一章中,我们处理了数据库层面的事务一致性问题,保证了数据的“正确性”。但在高并发的互联网架构中,仅仅“正确”是不够的,系统必须“快”。当数据库成为瓶颈时,引入缓存是必然选择。

然而,许多开发者习惯于直接引入 spring-boot-starter-data-redis 并开始在 Service 层注入 RedisTemplate。这种做法虽然直接,但导致了业务代码与中间件的强耦合。如果有一天我们需要将缓存从 Redis 迁移到 Memcached,或者在开发环境只想用内存 Map 跑单测,就需要修改大量业务代码。

本节我们将学习 Spring Framework 提供的 Spring Cache Abstraction(缓存抽象层)。它的核心价值在于“依赖倒置”:业务层只依赖标准的缓存注解,而具体的缓存实现(Redis、Caffeine、Ehcache)通过配置注入,实现真正的热插拔。

17.1.1. Spring Cache 的设计哲学

Spring Cache 的设计灵感来源于 JDBC。就像我们写 SQL 时不需要关心底层是 MySQL 还是 Oracle 一样,使用 Spring Cache 时,我们只需要关心“哪些数据需要缓存”,而不需要关心“数据存在哪里”。

它通过 AOP(面向切面编程)技术,在方法执行前后插入缓存逻辑,从而实现了对侵入性代码的屏蔽。业务开发人员只需要在方法上标记 @Cacheable,Spring 就会自动处理 Key 的生成、Value 的序列化、连接的获取与释放。

17.1.2. 核心组件深度解析

Spring Cache 的世界由两个核心接口支撑,理解它们是掌握自定义缓存配置的钥匙。我们可以把它们的关系理解为 “工厂”“产品” 的关系。

核心组件 1:CacheManager(缓存管理器)

CacheManager 是整个缓存机制的 入口工厂。它的主要职责是管理(创建、获取、销毁)具体的 Cache 实例。我们在配置文件中切换 spring.cache.type,本质上就是在切换不同的 CacheManager 实现类。

下表展示了 Spring 内置的几种常见管理器及其适用场景:

实现类 (Implementation)底层存储特点适用场景
ConcurrentMapCacheManagerJDK ConcurrentHashMap默认实现。数据存在 JVM 堆内存中,速度最快,但重启即丢失,不支持过期时间。本地开发、单元测试、极小规模应用
RedisCacheManagerRedis Server数据存储在 Redis 服务端。支持持久化、支持跨服务共享数据(分布式)。生产环境首选、微服务架构
CaffeineCacheManagerCaffeine (内存)Google Guava Cache 的高性能升级版。支持设置过期时间、最大容量、淘汰策略(W-TinyLFU)。高性能单体应用、作为二级缓存的本地层
SimpleCacheManager自定义列表最简单的实现,允许手动传入一个 Cache 列表。特殊配置需求

核心组件 2:Cache(具体缓存操作接口)

Cache 接口代表了一个 具体的命名缓存区域(例如 “user_cache” 或 “product_stock”)。它定义了缓存的标准行为(增删改查)。

Spring Cache 的强大之处在于 统一了操作接口。无论底层是操作 Redis 的 TCP 连接,还是操作内存中的 Map,对于业务层来说,调用的方法都是一样的。

下表展示了 Cache 接口方法与底层实现的映射关系:

Cache 接口方法含义对应 Map 实现 (伪代码)对应 Redis 实现 (伪代码)
get(key)map.get(key)redis.get("cacheName::key")
put(key, value)map.put(key, value)redis.set("cacheName::key", serializedValue)
evict(key)map.remove(key)redis.del("cacheName::key")
clear()清空map.clear()redis.del("cacheName::*")

组件协作流程演示(非注解方式)

为了让你彻底明白这两个组件是如何配合工作的,我们来看一段 不使用注解 时的伪代码。这正是 Spring 在幕后通过 AOP 帮我们做的事情:

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 内部的工作逻辑
public User getUserById(Long id) {

// 1. 调用 CacheManager:获取(或创建)一个名为 "users" 的缓存区域
// 如果是 RedisCacheManager,这里仅仅是准备好 Key 前缀
Cache userCache = cacheManager.getCache("users");

// 2. 调用 Cache.get():尝试从缓存中查找
// 底层自动处理了:Key 的拼接 ("users::" + id) 和反序列化
ValueWrapper wrapper = userCache.get(id);

if (wrapper != null) {
// 3. 命中缓存:直接返回
return (User) wrapper.get();
}

// 4. 未命中:查询数据库
User user = userMapper.selectById(id);

// 5. 调用 Cache.put():写入缓存
// 底层自动处理了:序列化对象并写入 Redis 或 Map
userCache.put(id, user);

return user;
}

通过这段代码可以看到,CacheManager 负责 找到地盘Cache 负责 在地盘上干活。Spring 的 @Cacheable 等注解,就是把上述模版代码封装起来,让开发者无感知地使用缓存。

本节小结

  • 核心要点
  • 缓存抽象层的核心是 解耦,业务代码不应直接依赖 Redis API。
  • CacheManager 负责管理缓存容器,Cache 负责具体的数据存取。
  • Spring Boot 通过自动配置(AutoConfiguration)根据 classpath 下的依赖自动切换 CacheManager 的实现。

17.2. 第一阶段:集成 Caffeine 高性能本地缓存

在上一节中,我们建立了“面向抽象编程”的缓存理念。但在实际的高频读取场景(如字典表、系统配置、电商首页类目)中,即使是 Redis 这种高性能远程缓存,其网络 IO 开销(通常在 1-5ms)在高并发下也是不可忽视的瓶颈。

为了追求极致性能(纳秒级响应),我们需要引入 进程内缓存。本节我们将集成业界公认性能最强的本地缓存库 —— Caffeine。Caffeine 使用了独创的 W-TinyLFU 算法,解决了传统 LRU(最近最少使用)算法在面对“稀疏流量”或“扫描式流量”时的缓存污染问题,能显著提高缓存命中率。

17.2.1. 引入依赖与架构分析

要使用 Caffeine,我们需要引入两个核心模块:Spring 的缓存抽象模块和 Caffeine 的具体实现库。

步骤 1:修改 pom.xml
请注意,这里我们并不需要引入 Redis,因为第一阶段我们的目标是构建纯本地的高性能缓存。

pom.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

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

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

17.2.2. W-TinyLFU 策略配置详解

Caffeine 的强大之处在于其灵活的淘汰策略。我们需要在 application.yml 中通过 spec 表达式来定义这些规则。

步骤 2:配置 application.yml
这里有一个非常关键的配置项 spec,它是 Caffeine 的配置描述符。

src/main/resources/application.yml:

1
2
3
4
5
6
7
8
9
10
11
spring:
cache:
type: caffeine # 显式指定使用 caffeine,防止因引入其他包导致冲突
caffeine:
# 核心配置字符串 (Spec) 解析:
# 1. initialCapacity=100: 内部哈希表的初始大小,避免扩容抖动
# 2. maximumSize=500: 最大缓存条数。超过 500 时,Caffeine 会基于 W-TinyLFU 算法异步驱逐
# 3. expireAfterWrite=10m: 写入或更新后 10 分钟过期 (适合数据一致性要求不高的场景)
# 4. recordStats: 开启统计功能 (生产环境排查命中率低时非常有用)
spec: initialCapacity=100,maximumSize=500,expireAfterWrite=10m,recordStats

为什么不使用 expireAfterAccess

  • expireAfterAccess(访问后过期):适合 Session 等需要“保活”的数据。
  • expireAfterWrite(写入后过期):适合配置信息、字典数据。对于大多数业务缓存,我们希望数据在固定时间后失效以刷新最新数据,所以通常首选 expireAfterWrite

非常棒的反馈。对于初学者来说,“完整的链路” 至关重要。如果只给一段 Service 代码,读者往往会因为缺少实体类、忘记开启缓存开关(@EnableCaching)、或者不知道如何写 Controller 进行测试而卡住。

我们需要补充完整的 实体类 (Entity)启动类配置 (Main)、以及 Web 层 (Controller)

以下是重写后的 17.2.3. 编写第一个缓存业务


17.2.3. 编写第一个缓存业务

环境准备就绪后,我们通过一个完整的 CRUD 链路来体验 @Cacheable 的魔力。我们将模拟一个“查询慢、读取频繁”的商品详情场景。

请按顺序创建以下 4 个文件。

第一步:开启缓存开关(重要)

很多新手配置了半天发现缓存不生效,往往是因为忘记在启动类上加 @EnableCaching 注解。

文件src/main/java/com/example/demo/DemoApplication.java

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

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching; // 1. 引入注解

@SpringBootApplication
@EnableCaching // 2. 开启 Spring Cache 缓存功能
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

第二步:定义实体对象

创建一个简单的商品 POJO 类。这里使用 Lombok 简化代码。

文件src/main/java/com/example/demo/domain/Product.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.demo.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@AllArgsConstructor
@NoArgsConstructor
// 建议实现 Serializable 接口,虽然 Caffeine 不需要,但若以后切换 Redis 这是必须的
public class Product implements Serializable {
private Long id;
private String name;
private Double price;
}

第三步:实现业务逻辑(模拟耗时)

这是核心部分。我们在 getProductById 方法上添加 @Cacheable 注解,并故意增加 2 秒延迟来模拟真实的数据库 IO。

文件src/main/java/com/example/demo/service/ProductService.java

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.demo.service;

import com.example.demo.domain.Product;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class ProductService {

/**
* @Cacheable 核心行为逻辑:
* 1. 拦截:方法调用前,Spring 检查名为 "product_cache" 的缓存区域。
* 2. 查找:根据 key = "#id" 查找是否存在数据。
* 3. 命中:若存在,直接返回缓存值,**完全跳过** 下方的方法体(不打印日志,不等待)。
* 4. 未命中:执行方法体(查库),将返回值自动写入缓存,供下次使用。
*/
@Cacheable(cacheNames = "product_cache", key = "#id")
public Product getProductById(Long id) {
// 模拟耗时操作
simulateSlowDb();

log.info("--- [DB查询] 缓存未命中,正在从数据库加载商品 id={} ---", id);

// 模拟返回数据
return new Product(id, "联想笔记本 Pro-" + id, 5999.0);
}

// 模拟 2 秒的数据库 IO 延迟
private void simulateSlowDb() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

第四步:创建 Web 接口进行测试

为了方便在浏览器中验证,我们编写一个简单的 Controller。

文件src/main/java/com/example/demo/controller/ProductController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.demo.controller;

import com.example.demo.domain.Product;
import com.example.demo.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/product")
public class ProductController {

@Autowired
private ProductService productService;

@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
// 调用 Service,Spring Cache 代理层会在此处介入
return productService.getProductById(id);
}
}

第五步:验证效果

启动 Spring Boot 应用,打开浏览器或 Postman 进行测试:

  1. 第一次访问http://localhost: 8080/product/1

    • 现象:浏览器转圈圈等待约 2 秒 才显示 JSON 结果。
    • 控制台:打印出 --- [DB查询] 缓存未命中...
    • 结论:缓存为空,执行了真实方法。
  2. 第二次访问http://localhost:8080/product/1

    • 现象:结果 瞬间出现(< 10ms)。
    • 控制台没有任何日志打印
    • 结论:方法体根本没执行,Spring 直接从内存返回了上次存入的对象。
  3. 访问不同 IDhttp://localhost:8080/product/2

    • 现象:再次等待 2 秒。
    • 结论:缓存是根据 ID(Key)隔离的,ID = 2 还没有缓存。

本节小结

  • 核心要点

  • 本地缓存(Caffeine)没有网络开销,适合“读多写少、数据量可控”的场景(如系统参数、国家代码)。

  • spec 表达式中的 maximumSize 是保护 JVM 内存溢出的最后一道防线,必须配置。

  • @Cacheable 是声明式缓存的核心,它利用 AOP 实现了“查缓存 -> 查库 -> 写缓存”的经典模式。

  • 速查代码

1
2
# Caffeine 黄金配置:初始100,最大1万,写后5分钟过期
spring.cache.caffeine.spec=initialCapacity=100,maximumSize=10000,expireAfterWrite=5m

17.3. 第二阶段:平滑迁移至 Redis 分布式缓存

在上一节,我们利用 Caffeine 实现了极速的本地查询(微秒级响应)。这在单机应用或个人项目中是非常完美的方案。然而,当我们把视线转向现代化的微服务架构或集群部署时,本地缓存(Local Cache) 就显露出了它的局限性。

想象一下,你的服务为了应对双十一大促,部署了 10 个节点(实例)。此时,单纯依赖本地缓存会带来两个致命问题:

  1. 数据孤岛(一致性灾难):假设运营人员后台修改了商品 A 的价格为 99 元。请求刚好打到了 节点 1,节点 1 删除了自己的缓存。但是,节点 2 到 节点 10 的内存里,商品 A 的价格依然是旧的 199 元。这就导致了用户在不同页面刷到的价格不一致,极易引发客诉。

    • 解决方案:我们需要一个 公共的、中心化的 存储,让所有节点都去同一个地方拿数据。
  2. 缓存雪崩(重启即穿透):本地缓存是存储在 JVM 堆内存中的。一旦应用发布重启,或者节点崩溃,所有缓存瞬间清零。重启后的那一瞬间,成千上万的并发请求发现缓存为空,会像洪水一样直接冲击数据库(Database),极可能把数据库打挂。

    • 解决方案:我们需要一个 持久化的、独立于应用之外的 缓存服务(Redis),即使应用重启,Redis 里的数据依然安然无恙。

得益于 Spring Cache 的抽象层设计,我们 不需要修改 ProductService 的任何一行 Java 业务代码,只需要调整底层配置,就能完成从“个人笔记本”到“共享黑板”的切换。

17.3.1. 生产级依赖管理:Lettuce 与连接池

在 Spring Boot 2.x/3.x 中,官方默认御用的 Redis 客户端是 Lettuce,而不是早期的 Jedis。

为什么是 Lettuce?

  • Jedis:直连模式。基于标准 I/O(阻塞式),线程不安全。在多线程环境下,必须配合连接池使用,每个线程独占一个连接,并发高时资源消耗大。
  • Lettuce:基于 Netty 框架(非阻塞 I/O)。它的连接是 线程安全 的,支持复用。多个线程可以共享同一个连接实例来并发发送指令,极其高效。

为什么要加连接池?
虽然 Lettuce 单连接支持复用,但在生产环境(高并发)下,单纯依赖一个 TCP 连接依然存在物理瓶颈(如网络包处理速度、Redis 单线程处理能力)。此外,一旦网络抖动导致连接断开,重建连接需要时间。引入连接池(Connection Pool)可以维持一定数量的“温连接”,不仅能分摊流量,还能在连接异常时快速切换。

步骤 1:追加 pom.xml 依赖

我们需要引入 Redis 启动器,并额外引入 commons-pool2,这是 Lettuce 实现连接池所必须的依赖。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 1. Spring Data Redis (默认包含 Lettuce 客户端) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- 2. 连接池依赖 (生产环境必须!) -->
<!-- Spring Boot 2.0+ 的 Lettuce 只有引入了它,连接池配置才会生效 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

17.3.2. 深度配置:Redis 与 CacheManager

这里的配置是生产环境稳定性的关键。很多线上的“Redis 连接超时”或“获取连接排队”故障,都是因为这里的参数设置过于随意。

步骤 2:修改 application.yml

我们将配置分为两部分:底层连接配置(管连接)和 Spring Cache 逻辑配置(管数据)。

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
spring:
data:
redis:
# --- 基础连接信息 ---
host: 127.0.0.1
port: 6379
database: 0
# password: "your_password" # 如果有密码请取消注释

# --- 超时设置 (Fail Fast 原则) ---
# 建立连接的超时时间。如果 3秒连不上 Redis,直接抛错,不要让线程卡死
timeout: 3000ms

# --- Lettuce 连接池详解 (核心调优点) ---
lettuce:
pool:
# 最大连接数:决定了并发上限
# 设为 0 表示无限制(危险!建议根据机器核心数 x 2~4 设置)
max-active: 32

# 最大等待时间:当连接池空了,新请求最多等多久?
# -1 表示无限等待(危险!容易导致雪崩),建议设置 1-2秒
max-wait: 1000ms

# 最大空闲连接:业务低峰期,池子里最多留多少个连接备用?
max-idle: 8

# 最小空闲连接:哪怕没人用,也要保持多少个连接活着?
# 避免突发流量来袭时,临时创建连接耗时
min-idle: 4

# --- Spring Cache 抽象层配置 ---
cache:
# 【关键动作】这里从 caffeine 改为 redis,一键切换底层实现!
type: redis

redis:
# 全局过期时间:防止内存泄漏
# 所有未通过 @Cacheable(ttl=...) 特殊指定的缓存,默认存活 1小时
time-to-live: 1h

# Key 前缀:为了防止 Key 冲突,建议加上统一前缀
# 最终在 Redis 里的 Key 样子: "APP_CACHE::product_cache::1001"
key-prefix: "APP_CACHE::"

# 是否写入 Key 前缀:必须开启,否则上面的 key-prefix 不生效
use-key-prefix: true

# 是否缓存 null 值:
# true = 防止缓存穿透 (数据库查不到也存个 null 到 Redis)
# false = 节省空间 (但如果被人恶意攻击不存在的 ID,会击穿数据库)
cache-null-values: true

17.3.3. 验证迁移结果与“乱码”初现

配置完成后,我们不需要修改任何 Java 代码,直接启动应用进行验证。

环境准备:
如果你本地没有安装 Redis,推荐使用 Docker 快速启动一个:

1
docker run -d -p 6379:6379 --name my-redis redis

验证步骤:

  1. 启动应用:观察控制台日志,确保没有报错。
  2. 触发缓存:访问接口 GET http://localhost:8080/product/1
    • 此时控制台会打印 [DB查询] ...,说明查了数据库并写入了 Redis。
  3. 再次访问:再次访问同一接口。
    • 控制台 无日志,说明直接从 Redis 读取了数据。
  4. 眼见为实:最重要的一步,打开 Redis 客户端(推荐 Another Redis Desktop Manager 或命令行 redis-cli),查看里面的数据。

问题出现了:

你会惊讶地发现,Redis 里存的 Key 和 Value 是一串看不懂的“乱码”(其实是十六进制数据),如下图所示:

image-20251221193546041

17.3.4. 为什么会有“乱码”?(JDK 序列化的坑)

这并非真正的乱码,而是 Java 标准序列化(Java Serialization) 产生的二进制流。

Spring Boot 的 RedisCacheManager 在默认情况下,因为不知道你要缓存什么对象,所以采取了最保守、最通用的策略:使用 JdkSerializationRedisSerializer

只要你的实体类实现了 Serializable 接口,Java 就能把它转成二进制存进去。但这在现代互联网架构中,有 三大罪状

  1. 可读性差(运维噩梦):运维人员或开发者在 Redis 客户端里查看数据时,完全看不出存的是什么(比如看不出价格是 5999 还是 99),无法进行线上排查或紧急修改。
  2. 空间浪费(烧钱)
    JDK 序列化不仅存储数据本身,还会存储完整的类名、包路径、元数据等。一个简单的 {"id":1} 对象,转换成二进制后可能膨胀几倍,导致 Redis 内存成本飙升。
  3. 跨语言障碍(架构死穴)
    Java 的二进制流只有 Java 能读懂。如果你的系统里还有用 Python 写的数据分析脚本,或者用 Go 写的网关,它们根本无法读取这些缓存数据。

如何解决?
我们需要将缓存格式标准化为全宇宙通用的 JSON 格式。这将是下一节的重点——自定义 CacheManager 与序列化策略。这是 Spring Cache 实战中最复杂但也最核心的配置。

本节小结

  • 平滑迁移:从 Local Cache 切到 Redis Cache,业务代码零修改,仅需改动配置。
  • 连接池至关重要:生产环境务必引入 commons-pool2 并配置 max-active 等参数,防止 Redis 连接成为瓶颈。
  • 序列化陷阱:默认的 JDK 序列化虽然省事,但在可读性、体积和跨语言兼容性上表现极差。不要在生产环境直接使用默认配置!

17.4. [核心] 解决序列化“乱码”问题

17.4.1. 为什么 JDK 序列化是生产禁忌?

回顾:上一节我们在 Redis 中看到的 \xAC\xED\x00\x05 开头的数据,是 Java 原生序列化(ObjectOutputStream)的产物。
引出:这种格式虽然兼容性好(只要是 Java 都能读),但在分布式架构中存在致命缺陷:

  1. 体积膨胀:它不仅存数据,还存了完整的类路径(com.example.demo.domain.Product)和版本号,导致数据体积比 JSON 大 5-10 倍。
  2. 语言隔离:PHP、Go、Python 等其他语言无法读取 Java 的序列化流。
  3. 可读性为零:你无法通过 Redis 客户端直接修改某个字段来快速修复线上数据。

预告:我们将使用 GenericJackson2JsonRedisSerializer 替换默认序列化器,将数据转为标准的 JSON 格式。

17.4.2. 编写生产级 CacheConfig

这是 Spring Cache 中最复杂的一步。我们需要接管 RedisCacheConfiguration 的创建权。

文件路径src/main/java/com/example/demo/config/CacheConfig.java

我们将分步构建这个配置类。

步骤 1:定义配置类骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.demo.config;

import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer;
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.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration
@EnableCaching // 再次确认开启缓存,建议移到这里统一管理
public class CacheConfig {
// 下面将填充 Bean 定义
}

步骤 2:配置 JSON 序列化策略(核心)

我们需要告诉 Spring:“Key 请用 String 格式,Value 请用 JSON 格式”。

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
/**
* 核心配置:自定义 RedisCacheConfiguration
* 目的:覆盖默认的 JDK 序列化,改用 JSON
*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
// 1. 定义序列化器
// 使用 GenericJackson2JsonRedisSerializer,它会在 JSON 中自动添加 @class 属性
// 这样从 Redis 读回来时,才能知道转成 Product 还是 User 对象
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();

// 2. 加载默认配置 (默认 TTL 1 小时等)
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();

// 3. 链式修改配置 (注意:Spring Data Redis 的配置是不可变对象,每次修改都会返回新对象)
return config
// 设置 Key 序列化器:String (如 "product:: 1")
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
// 设置 Value 序列化器:JSON
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
// 全局默认过期时间:1 小时
.entryTtl(Duration.ofHours(1))
// 禁止缓存 null 值 (根据业务决定,通常建议允许 null 以防穿透,这里演示配置项)
.disableCachingNullValues();
}

17.4.3. 验证“乱码”消除

配置完成后,重启应用

  1. 再次访问接口 GET /product/1
  2. 查看 Redis。

此时,你会发现 Key 变成了清晰的字符串,Value 变成了标准的 JSON:

image-20251221194605780

注意 @class 属性:这就是 GenericJackson2JsonRedisSerializer 的功劳。它记录了全类名,确保反序列化时不会报错。这也带来了一个小副作用:如果你重命名了类或移动了包,旧缓存会解析失败(详见本章末尾的避坑指南)。

本节小结

  • 核心要点
  • 生产环境严禁使用默认的 JDK 序列化,必须切换为 JSON 序列化以提升可读性和性能。
  • 配置 RedisCacheConfiguration 时,必须同时设置 Key(String)和 Value(JSON)的序列化器。
  • GenericJackson2JsonRedisSerializer 能够处理泛型和多态,是目前最通用的选择。

17.5. 深度解析:注解三剑客与 SpEL

掌握了底层配置,接下来我们回到业务层。Spring Cache 提供了三个核心注解,覆盖了 CRUD 的全生命周期。熟练使用 SpEL (Spring Expression Language) 是灵活运用这些注解的关键。

17.5.1. @Cacheable(查:若无则查库,若有则返回)

这是最常用的注解,用于读取操作。

SpEL 表达式实战:有时我们需要组合多个参数作为 Key,或者根据条件决定是否缓存。

1
2
3
4
5
6
7
8
9
10
11
12
// 场景:多参数组合 Key
// 假设查询条件是 (String type, int page)
// Redis Key: "product_list::phone:1"
@Cacheable(cacheNames = "product_list", key = "#type + ':' + #page")
public List<Product> listProducts(String type, int page) { ... }

// 场景:条件缓存 (Condition & Unless)
// condition: 事前判断。只有 id > 10 才走缓存逻辑 (id <= 10 每次都查库)
// unless: 事后判断。如果返回结果是 null,则不存入缓存 (防止缓存空对象)
@Cacheable(cacheNames = "user", key = "#id", condition = "#id > 10", unless = "#result == null")
public User getUser(Long id) { ... }

17.5.2. @CachePut(改:双写更新)

语义无论缓存有没有,都执行方法体,并将方法的返回值更新到缓存中。
常用于“保存”或“更新”操作,保证缓存与数据库的 最终一致性

常见错误示范

1
2
3
4
5
// ❌ 错误!
@CachePut(cacheNames = "product_cache", key = "#product.id")
public void updateProduct(Product product) {
db.update(product);
}

原因@CachePut 会把方法的返回值放入缓存。如果返回 void,缓存中就会被置为 null
修正:必须返回更新后的最新数据对象。

1
2
3
4
5
6
// ✅ 正确
@CachePut(cacheNames = "product_cache", key = "#product.id")
public Product updateProduct(Product product) {
db.update(product);
return product; // 将最新对象写入缓存
}

17.5.3. @CacheEvict(删:清理缓存)

语义从缓存中移除数据。
用于删除操作,或者在无法保证增量更新正确性时,直接清空缓存强迫下次查库。

1
2
3
4
5
6
7
8
9
// 场景 1:删除单条数据
@CacheEvict(cacheNames = "product_cache", key = "#id")
public void deleteProduct(Long id) { ... }

// 场景 2:级联删除 / 批量清空
// 例如:后台上架了新商品,可能影响了 "首页列表"、"分类列表" 等多个缓存
// allEntries = true 表示清空 product_list 下的所有 Key
@CacheEvict(cacheNames = "product_list", allEntries = true)
public void refreshProductList() { ... }

本节小结

  • 核心要点
  • @Cacheable 用于读,@CachePut 用于双写更新(必须有返回值),@CacheEvict 用于删除。
  • key 属性支持 SpEL 表达式,可以灵活组合参数;如果不指定,Spring 会使用默认策略(所有参数的 hash),容易产生 Key 冲突。
  • 慎用 @CacheEvict(allEntries = true),在高并发下瞬间清空所有缓存可能导致数据库压力骤增。

17.6. 生产环境避坑指南与高阶配置

在开发环境跑通代码只是第一步,生产环境往往隐藏着更多细节魔鬼。本节我们将解决三个最常见的“线上事故”源头。

17.6.1. 经典大坑:内部调用失效

现象:你在 OrderService 中写了 findById(带缓存)和 createOrder。在 createOrder 内部调用了 this.findById(id),发现缓存注解 完全失效,每次都查数据库。

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

public void createOrder(Long id) {
// ... 业务逻辑
// ❌ 错误:内部调用,绕过了 Spring 的动态代理对象
Order order = this.findById(id);
}

@Cacheable(cacheNames = "order", key = "#id")
public Order findById(Long id) { ... }
}

原理
Spring Cache(以及事务 @Transactional)是基于 AOP 动态代理 实现的。

  • 外部调用 orderService.findById() 时,实际上是调用了 Proxy.findById(),代理类在执行目标方法前先检查了缓存。
  • 内部调用 this.findById() 时,this 指的是目标对象本身,直接执行了原方法代码,根本没有经过代理类的“缓存拦截器”。

解决方案

  1. 拆分 Service(推荐):将缓存方法移到另一个 Service(如 OrderQueryService)中,然后注入调用。
  2. 自我注入(勉强可用):在类中注入自己 @Lazy @Autowired OrderService self;,然后用 self.findById() 调用。

17.6.2. 进阶需求:不同业务,不同 TTL

痛点:全局配置里我们设置了 TTL 为 1 小时。但业务要求:验证码(VerifyCode) 必须 5 分钟过期,而 每日热榜(DailyRank) 需要存 24 小时。
Spring Cache 注解本身不支持 ttl 参数(这是它被诟病最多的点)。

解决方案:RedisCacheManagerBuilderCustomizer
在 Spring Boot 3 中,我们可以在 CacheConfig 中通过 RedisCacheManagerBuilderCustomizer 来实现精细化控制。

追加配置到 CacheConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 高阶配置:针对不同 CacheName 设置不同的 TTL
*/
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
return (builder) -> builder
// 针对 "verify_code" 开头的缓存,覆盖默认配置
.withCacheConfiguration("verify_code",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5)) // 5 分钟过期
// 记得这里也要配 JSON 序列化,否则会回退到 JDK 序列化!
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
)
// 针对 "daily_rank" 开头的缓存,覆盖为 24 小时
.withCacheConfiguration("daily_rank",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(24))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
);
}

用法

1
2
3
4
// 这个缓存会自动遵循 5 分钟过期的规则
@Cacheable(cacheNames = "verify_code", key = "#phone")
public String getCode(String phone) { ... }

17.6.3. 缓存穿透与 Null Object Pattern

现象:黑客恶意查询一个不存在的 ID(如 -999)。

  1. 查询缓存 -> 无。
  2. 查询数据库 -> 无(返回 null)。
  3. Spring Cache 默认不缓存 null。
  4. 下一次请求 -> 再次查库。这就导致请求直接穿透缓存打到数据库。

防御方案:我们需要允许缓存 null 值,但给它一个极短的过期时间(防止未来该 ID 真的产生数据了却读不到)。

  1. 在全局配置中开启 .cacheNullValues()(我们在 17.3.2 中配置了 true,或者在 CacheConfig 中移除 .disableCachingNullValues())。
  2. 利用 unless 排除非空结果(如果非空正常存,如果为空则走特殊逻辑),或者简单地依赖 Redis 的 TTL 自动过期。

最简单的生产实践是:开启缓存 null 值,并设置合理的全局 TTL。Spring 默认是允许缓存 null 的,它会存一个特殊的 NullValue 对象到 Redis 中。


本节小结

  • 核心要点
  • 严禁在类内部通过 this 调用带缓存注解的方法,这会导致 AOP 失效。
  • 使用 RedisCacheManagerBuilderCustomizer 可以灵活地为不同 cacheName 设置差异化的 TTL。
  • 防御缓存穿透的最简单方法是允许缓存 null 值(cache-null-values: true),Spring 会自动处理 Null 值的序列化。

17.7. 本章总结与实战速查

17.7.1. 摘要回顾

本章我们从 Spring Cache 的抽象层出发,首先利用 Caffeine 实现了高性能的进程内缓存,随后平滑迁移至 Redis 分布式缓存。我们重点攻克了 JSON 序列化配置 这一难关,解决了 Redis 数据不可读的问题,并深入探讨了 SpEL 表达式、AOP 失效机制以及多租户 TTL 的配置策略。

17.7.2. 场景化代码模版

遇到以下 3 种场景时,请直接 Copy 下方模版:

1. 场景一:标准对象查询(JSON 序列化 + 1 小时过期)

需求:缓存用户详情,常规过期时间。
前提:已在 CacheConfig 配置好 JSON 序列化。

1
2
3
4
5
@Cacheable(cacheNames = "user_details", key = "#userId")
public UserDetailVO getUser(Long userId) {
// 这里的返回值 UserDetailVO 会被自动转为 JSON 存入 Redis
return userMapper.selectById(userId);
}

2. 场景二:列表数据缓存(组合 Key)

需求:根据 类型页码 缓存商品列表。

1
2
3
4
5
// Redis Key: "product_list::phone:1"
@Cacheable(cacheNames = "product_list", key = "#type + ':' + #page")
public List<ProductVO> listProducts(String type, int page) {
return productMapper.selectByType(type, page);
}

3. 场景三:数据更新联动(双写一致性)

需求:更新用户时,刷新用户详情缓存,并清空该用户的所有关联列表缓存。

1
2
3
4
5
6
7
8
9
10
@Caching(
// 1. 更新详情缓存 (前提:方法必须返回最新的 UserDetailVO)
put = { @CachePut(cacheNames = "user_details", key = "#user.id") },
// 2. 级联删除相关的列表缓存 (模糊清除比较难,建议直接清空整个列表分区)
evict = { @CacheEvict(cacheNames = "user_list", allEntries = true) }
)
public UserDetailVO updateUser(UserUpdateDTO user) {
userMapper.update(user);
return userMapper.findById(user.getId());
}

17.7.3. 核心避坑指南

  1. 序列化兼容性炸弹
  • 现象:重构代码修改了类名或包名后,读取旧缓存报错 ClassNotFoundException
  • 原因:JSON 中存了 {"@class": "com.old.package.User"}
  • 对策:在上线重大重构前,必须评估是否需要 FlushDB 清空旧缓存,或使用 Key 版本号隔离(如 cacheNames="user_v2")。
  1. AOP 内部调用失效
  • 现象this.method() 不走缓存。
  • 对策:自我调用时,请将缓存方法抽取到独立的 Service Bean 中。
  1. 大 Key 瞬间清理
  • 现象@CacheEvict(allEntries=true) 删除了包含 100 万个 Key 的缓存分区。
  • 对策:Redis 处理 DEL 命令是单线程阻塞的。对于超大数据集,应避免使用 allEntries=true,或者改用异步删除(UNLINK 命令,Spring Data Redis 较新版本支持)。