本节目标:把上一节的
CacheManager真正落到 Redis——接好 starter、选对序列化与连接方式、定好键命名,并认清大 key / 热 key 这两类线上事故的成因与对策。
适用版本:Spring Boot 4.1.x(Java 21)
8.2 Redis 缓存实践
上一节把缓存的声明交给了注解,把存储交给了 CacheManager。本节把存储具体化为 Redis:它要跨多个应用实例共享,因此不能再用进程内的 ConcurrentHashMap。这一步的决策点比注解多得多——序列化选错会让缓存不可读、不可升级;连接模型选错会在高并发下打满连接;键命名和容量规划没做,线上就会收到内存告警。
本节所有「运行输出」均为示例输出,用来展示格式与量级;本机只跑通了纯 Spring Boot 应用,未搭建真实 Redis 实例。
8.2.1 接入:starter 与最小配置
依赖只有一行。注意 4.x 的模块化命名:starter 名不变(spring-boot-starter-data-redis),但对应模块改叫 spring-boot-data-redis。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
第二行 commons-pool2 只有用连接池时才需要。Lettuce 默认共享单连接、不池化,所以纯 Lettuce 可以不加;一旦开了 spring.data.redis.lettuce.pool.* 或改用 Jedis,没有它就会在启动时报找不到池实现。
最小配置(4.x 属性前缀是 spring.data.redis.*,不是旧版的 spring.redis.*):
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: ${REDIS_PASSWORD:}
database: 0
timeout: 2s
connect-timeout: 2s
timeout 是命令超时,connect-timeout 是建连超时,两个都要设。默认无超时意味着 Redis 一慢,业务线程就会成片挂起——这是「Redis 抖动放大成应用雪崩」的经典路径。
8.2.2 序列化:默认的 JDK 序列化不能上生产
RedisTemplate 的默认序列化器是 JdkSerializationRedisSerializer。它把对象用 Java 原生序列化写进 Redis,问题很集中:
| 问题 | 具体表现 |
|---|---|
| 不可读 | redis-cli get 出来是一堆 \xac\xed 开头的二进制,排障基本靠猜 |
| 强绑定类结构 | 类加字段、改包名、换版本都可能 InvalidClassException |
| 体积大 | 同一条数据通常比 JSON 大 30% 以上,直接放大内存成本 |
| 安全风险 | 反序列化不可信字节流是已知攻击面 |
生产上应显式配置:key 用字符串、value 用 JSON。
@Configuration
public class RedisConfig {
@Bean
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf,
RedisSerializer<Object> jsonSerializer) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(cf);
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
return template;
}
}
注意 key 和 hashKey 都要设成字符串,只设 key 而漏了 hashKey,用 Hash 结构时仍会写出二进制键。
8.2.3 Jackson 3 与 JSON 序列化方案的关系
Spring Data Redis 长期提供的 GenericJackson2JsonRedisSerializer,顾名思义基于 Jackson 2。而 Spring Boot 4.x 已把首选 JSON 库切到 Jackson 3:包名从 com.fasterxml.jackson 变为 tools.jackson(例外是注解包 com.fasterxml.jackson.annotation 仍在原位),自动配置改用 JsonMapper / XmlMapper,且自定义 ObjectMapper bean 不再能替换自动配置。
这就带来一个必须说清的边界:任何硬编码依赖 com.fasterxml.jackson 的序列化器,在 4.x 里都跑在 Jackson 2 那条线上,与自动配置的 Jackson 3 是两套栈。有两种处理方式:
- 优先:自己提供一个
RedisSerializer<Object>,内部用 Boot 自动配置好的 Jackson 3JsonMapper(tools.jackson.databind.json.JsonMapper)读写 JSON,把它注入上面的RedisTemplate与RedisCacheConfiguration。这样序列化口径和应用其余部分(HTTP 响应体)保持一致。 - 过渡:若依赖的库仍要求 Jackson 2,可引
spring-boot-jackson2模块并使用基于 Jackson 2 的序列化器,代价是同时存在两套 JSON 栈。
@Configuration
public class RedisJsonConfig {
@Bean
RedisSerializer<Object> jsonSerializer(JsonMapper jsonMapper) {
return new RedisSerializer<>() {
@Override
public byte[] serialize(Object value) {
if (value == null) {
return new byte[0];
}
try {
return jsonMapper.writeValueAsBytes(value);
} catch (Exception e) {
throw new IllegalStateException("Redis 序列化失败", e);
}
}
@Override
public Object deserialize(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return null;
}
try {
return jsonMapper.readValue(bytes, Object.class);
} catch (Exception e) {
throw new IllegalStateException("Redis 反序列化失败", e);
}
}
};
}
}
这段代码展示了「用自动配置的 Jackson 3 JsonMapper 做 Redis 序列化」的做法,不依赖某个具体的 4.x 序列化器类名。若你要用 Spring Data Redis 自带的 JSON 序列化器,请以其 4.x 文档为准确认当前类名与 Jackson 版本对应关系——不要照搬 3.x 时代的类名到 4.x 项目。
用 Object.class 反序列化到通用类型有个代价:返回的是 Map / List 而非原始类型,取用时要么强转失败、要么得做类型转换。要精确还原类型,需要在 JSON 里写入类型信息(@class 字段)并在反序列化时据此选择目标类型,这正是 GenericJackson2JsonRedisSerializer 内部做的事。选择自带序列化器还是自实现,本质是「要不要把类型元数据写进缓存值」的取舍:写了更省心但值更大、且与类名强绑定;不写更省空间但要求调用方自己知道类型。
8.2.4 连接池:Lettuce 共享连接 vs Jedis 池
Spring Boot 4.x 默认客户端是 Lettuce,也可切 Jedis:
spring:
data:
redis:
client-type: lettuce # 或 jedis
两者模型完全不同,不是「换个实现」那么简单:
| 维度 | Lettuce | Jedis |
|---|---|---|
| 连接模型 | 基于 Netty,默认共享一条线程安全连接,多路复用 | 每个连接非线程安全,必须靠连接池 |
| 默认是否需要 pool | 不需要(shareNativeConnection=true) | 需要,否则并发下要不断建连 |
| 阻塞命令 | 会占用连接,需谨慎 | 每个操作独占一条池连接 |
| 需要池的场景 | 阻塞命令(BLPOP 等)、事务、想限制并发时 | 基本总是需要 |
关键认知:Lettuce 不加池不是「省配置」,而是它的默认工作方式。一条共享连接靠 Netty 多路复用就能扛住常规的读写;盲目给 Lettuce 加池反而会引入「连接数量与池参数不匹配」的调优负担。只有当你要用阻塞命令、或想给某类操作单独限流时,才给 Lettuce 配池:
spring:
data:
redis:
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 500ms
shutdown-timeout: 200ms
read-from: replica-preferred # 3.5 起支持,读优先走副本
read-from(spring.data.redis.lettuce.read-from)用于读多写少的缓存场景,让读请求优先落到副本,减轻主节点压力——它只在 Lettuce 下有效。
8.2.5 静态主从:4.0 新增的接入方式
除了哨兵(Sentinel)与集群(Cluster),4.0 为 Lettuce 增加了静态主从配置,直接给节点列表:
spring:
data:
redis:
masterreplica:
nodes:
- redis-master:6379
- redis-replica-1:6379
- redis-replica-2:6379
spring.data.redis.masterreplica.nodes 是 4.0 新增属性(见 4.0 Release Notes),仅 Lettuce 支持。它适合主从拓扑固定、不依赖自动故障转移的场景;需要自动选主仍应用 Sentinel。
8.2.6 键命名规范与内存估算
键命名要满足三条:可读、可批量定位、可估算容量。推荐的层级式命名:
cache:book:{id} # 图书详情
cache:bookByIsbn:{isbn} # 按 ISBN 查
cache:loanStats:{memberId} # 会员借阅统计
冒号分层便于 SCAN 匹配(如 SCAN 0 MATCH cache:book:*)和监控按前缀统计。两条硬性建议:给所有缓存键统一前缀(与业务数据隔离,便于按前缀清理),键里不要放无界值(如把整个查询条件 JSON 塞进 key,会造出超长键)。
内存估算按「键大小 + 值大小 + 每键固定开销」逐项累加。Redis 每个键本身有几十字节的对象头与字典开销,键名越长这笔固定成本越大。估算方法不要拍脑袋:
# 示例输出:查看单个键占用的内存(含对象开销)
127.0.0.1:6379> MEMORY USAGE cache:book:1001
(integer) 336
# 示例输出:全局内存与峰值
127.0.0.1:6379> INFO memory
used_memory_human:12.50M
used_memory_peak_human:18.20M
maxmemory_human:256.00M
MEMORY USAGE 返回的是这条键的真实占用,用它乘以条目数就能得到量级估算;INFO memory 看总量与是否逼近 maxmemory。容量上限必须显式设 maxmemory 并配淘汰策略,否则 Redis 会一直吃内存直到触发系统 OOM:
# 属于 Redis 服务端配置(redis.conf 或 CONFIG SET),不是 Spring 属性
maxmemory 256mb
maxmemory-policy allkeys-lru
给纯缓存实例用 allkeys-lru 或 allkeys-lfu;若实例里混存了不能丢的数据,用 volatile-* 系列并确保缓存键都设了 TTL——否则淘汰时可能挑不到可淘汰的键。
8.2.7 大 key 与热 key
线上缓存事故绝大多数可归为两类:
| 类型 | 定义 | 危害 | 对策 |
|---|---|---|---|
| 大 key | 单个值过大(如一个几十 KB 的 JSON、几十万元素的集合) | 读写慢、网络传输大、删除时阻塞主线程、集群迁移卡顿 | 拆分结构、压缩值、改用 Hash 分片、避免整集合读写 |
| 热 key | 单个键的 QPS 远高于其他(如全站首页配置) | 单分片/单节点被打满,成为全局瓶颈 | 本地缓存兜底、键加随机后缀打散、读写分离走副本 |
大 key 的发现靠 redis-cli --bigkeys(扫描各类型最大的键)或 --memkeys(按内存排序):
# 示例输出:扫描大 key(节选)
redis-cli --bigkeys
# Scanning the entire keyspace to find biggest keys.
# Biggest string found 'cache:loanStats:all' has 51200 bytes
热 key 的发现不能靠离线扫描,得靠实时监控:看 INFO commandstats 里单键命令量,或用 Redis 的 MONITOR / 慢查询日志,或在上层用 Micrometer 给每个缓存名的访问量打点。
对策上,大 key 是数据建模问题(不该把一个大对象整体塞进一个键),热 key 是访问分布问题(该把压力摊开或前移)。两者都别指望靠调大 maxmemory 解决。
8.2.8 4.1 新增:@RedisListener 自动配置
4.1 新增了对 Spring Data Redis @RedisListener 端点的自动配置(见 4.1 Release Notes)。要点:
- 应用没有自定义
RedisMessageListenerContainer时,自动配置会注册一个默认容器,于是标注了@RedisListener的方法能被自动发现并调用,无需额外接线。 - 可选参数见
spring.data.redis.listener.*。 - 需要额外容器时,用
RedisMessageListenerContainerConfigurer在自建容器上套用与自动配置一致的默认值。 spring-boot-starter-data-redis现在额外声明了对spring-messaging的依赖,正是这个特性所需。
它解决的是「缓存失效广播」这类需求:某个节点更新了图书数据,通过 Redis 发布订阅把失效事件广播给其他节点,各节点清掉本地缓存。示例结构如下(具体注解属性以 Spring Data Redis 4.x 文档为准):
@Component
public class CacheInvalidationSubscriber {
// 4.1 起由自动配置发现并绑定到默认的 RedisMessageListenerContainer
@RedisListener
public void onBookChanged(BookChangedEvent event) {
// 收到其他节点广播的失效事件后,清理本地缓存
localCache.invalidate(event.bookId());
}
}
注意 @RedisListener 是消息订阅能力,不是缓存抽象的一部分——它和 @Cacheable 解决的是两件事:前者让节点间能互相通知「数据变了」,后者负责本地读写缓存。8.3 讲一致性时会把两者接起来。
小结
- 依赖加
spring-boot-starter-data-redis;用连接池(Lettuce pool 或 Jedis)时还必须加commons-pool2。4.x 属性前缀是spring.data.redis.*。 timeout与connect-timeout必须显式设置,否则 Redis 变慢会直接拖垮业务线程。- 默认的 JDK 序列化不可读、体积大、与类结构强绑定、有安全风险;生产用「字符串键 + JSON 值」,且 key 与 hashKey 都要设字符串序列化器。
- 4.x 首选 JSON 库是 Jackson 3(包名
tools.jackson);GenericJackson2JsonRedisSerializer基于 Jackson 2。优先用自动配置的 Jackson 3JsonMapper自实现序列化器,避免两套 JSON 栈并存。 - Lettuce 默认共享单连接、靠多路复用,默认不需要池;Jedis 非线程安全、基本总需要池。需要池的场景是阻塞命令、事务或主动限流。
- 4.0 新增静态主从
spring.data.redis.masterreplica.nodes,仅 Lettuce 支持。 - 键用层级命名并统一前缀;内存按「键 + 值 + 每键固定开销」估算,用
MEMORY USAGE与INFO memory核实,并显式设maxmemory与淘汰策略。 - 大 key 是建模问题(拆分/压缩),热 key 是访问分布问题(本地缓存/打散/走副本);发现分别靠
--bigkeys与实时监控。 - 4.1 的
@RedisListener自动配置让订阅端点免接线,是节点间失效广播的基础设施。
缓存怎么放、怎么存已经定了。但「数据变了以后缓存怎么办」才是真正难的部分,也是下一节的全部主题。
阅读导航:上一节:8.1 Spring Cache 抽象 · 下一节:8.3 缓存一致性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。