Redis 深度研究:从数据结构到生产实践的完整拆解
Redis 深度研究:从数据结构到生产实践的完整拆解
环境基线:Redis 8.0(社区版,AGPLv3)· Java 17 · Spring Boot 3.4.x · Spring Data Redis 3.4 · Lettuce 6.4 · Linux x86_64(生产建议 4C8G 起步)
全文示例统一使用上述版本,配置文件、命令与 Java 代码不混用 6.x / 7.x 的旧写法。涉及 7.x 行为的地方会单独标注。
Redis 是最容易被「用对了但没理解」的中间件。多数人第一次接触它,学的是 SET / GET / EXPIRE 三件套;真正出事故时,倒下的地方却往往是编码方式、淘汰策略、持久化 fsync 时机、集群 slot 归属这些没人教的部分。
这篇文章不做命令字典,而是沿着「一个 key 从写入到被淘汰」的完整生命周期,把 Redis 的内部机制、边界条件和工程约束逐个拆开,最后落到 Spring Boot 3.4 的可运行代码。
一、先看全景:Redis 到底是什么
1.1 常见误解与实际情况
| 常见说法 | 实际情况 |
|---|---|
| Redis 是单线程的 | 命令执行主线程是单线程的;网络 I/O、持久化、异步删除、部分模块有多线程/子进程 |
| Redis 就是缓存 | 缓存只是最常见用法,它同时是数据结构服务器、消息中间件(Stream)、计数器、分布式锁载体 |
| Redis 数据都在内存,重启就没了 | RDB / AOF 都是持久化方案,配置得当可以做到秒级丢失甚至不丢 |
| Redis 快是因为「内存比磁盘快」 | 更关键的是避免锁、避免上下文切换、纯内存操作、极简协议 |
| key 越多越占内存 | 空 key 不存在;真正吃内存的是 value 体积与 key 数量带来的 dict 开销 |
1.2 组件全景
| 层面 | 组成 | 关键点 |
|---|---|---|
| 网络层 | 单 Reactor + 多 I/O 线程(6.0+) | io-threads 只处理读写与协议解析,不执行命令 |
| 协议层 | RESP2 / RESP3 | RESP3 支持推送、Map/Set 类型返回 |
| 数据结构层 | SDS、dict、listpack、quicklist、intset、skiplist、rax | 同一逻辑类型有多种物理编码,按阈值切换 |
| 内存层 | jemalloc + 过期字典 + 淘汰策略 | maxmemory 与 maxmemory-policy 是两条独立开关 |
| 持久化层 | RDB 快照 / AOF 日志 / 混合持久化 | 7.0 起 AOF 为多文件(base + incr) |
| 高可用层 | 主从复制 / Sentinel / Cluster | Cluster 用 16384 个 slot 分片 |
| 扩展层 | Functions、Lua、模块(JSON/Search/TimeSeries/Bloom/Vector Set) | 8.0 把原 Redis Stack 能力并入发行版 |
Redis 8.0 最值得关注的两个变化:
- 原 Redis Stack 模块进入主发行包:JSON、Search(查询引擎)、TimeSeries、Bloom 不再需要单独装模块,
docker run redis:8即可用。 - 新增 Vector Set 类型:
VADD/VSIM/VCARD等命令,把向量检索内置到键空间,适合做 RAG 的召回层。这是 8.0 与 7.x 最实质的能力差异。
二、数据结构与底层编码:决定性能的真正变量
2.1 逻辑类型与物理编码对照
Redis 对外只有 String / Hash / List / Set / ZSet / Stream / Bitmap / HyperLogLog / GEO / Vector Set 这些逻辑类型,但每个类型在内部有多种物理编码。OBJECT ENCODING key 是排查内存与延迟问题的第一把钥匙。
| 逻辑类型 | 小对象编码 | 大对象编码 | 切换阈值(默认配置) |
|---|---|---|---|
| String | int / embstr |
raw |
纯数字且 ≤ long 范围用 int;≤ 44 字节用 embstr;否则 raw |
| Hash | listpack |
hashtable |
hash-max-listpack-entries 128、hash-max-listpack-value 64 |
| List | listpack(quicklist 单节点) |
quicklist |
list-max-listpack-size 128 |
| Set | intset / listpack |
hashtable |
全整数且 ≤ 512 用 intset;set-max-listpack-entries 128 |
| ZSet | listpack |
skiplist |
zset-max-listpack-entries 128、zset-max-listpack-value 64 |
| Stream | listpack(每个 radix 节点) |
同左 | stream-node-max-entries 100 |
两条必须记住的规则:
- 阈值是「与」关系:Hash 只要有一个 field 的 value 超过 64 字节,整体立刻从
listpack升级为hashtable,而且不会降级。 - 升级是永久的:删除元素让长度回到阈值以下,编码也不会退回。想缩回只能重建 key(
DUMP/RESTORE或业务侧迁移)。
验证方式:
redis-cli -h 127.0.0.1 -p 6379
127.0.0.1:6379> HSET user:1 name "张三" age 28
127.0.0.1:6379> OBJECT ENCODING user:1
"listpack"
127.0.0.1:6379> HSET user:1 bio "<一段超过 64 字节的自我介绍……>"
127.0.0.1:6379> OBJECT ENCODING user:1
"hashtable"
2.2 SDS:String 为什么不是 C 字符串
Redis 自己实现了 SDS(Simple Dynamic String),结构大致为 len / alloc / flags / buf[],带来三个直接收益:
| 能力 | C 字符串 | SDS |
|---|---|---|
| 取长度 | O(n) | O(1)(读 len) |
| 二进制安全 | 遇 \0 截断 |
安全,可存图片、序列化对象 |
| 追加扩容 | 每次 realloc | 预分配 + 惰性释放,减少拷贝 |
embstr 与 raw 的区别在于:embstr 把 redisObject 与 SDS 一次性连续分配,适合短字符串;超过 44 字节后需要两次分配,改为 raw。
2.3 dict:哈希表的渐进式 rehash
Redis 的 dict 使用两张哈希表做渐进式 rehash:扩容时保留旧表,每次操作顺带迁移一个桶,把一次性 O(n) 的停顿摊到多次请求上。这也是 Redis 单线程还能保持低延迟的原因之一。
扩容触发条件与负载因子:
| 场景 | 触发阈值 | 说明 |
|---|---|---|
| 未做 BGSAVE | 负载因子 ≥ 1 | 正常扩容 |
| 正在 BGSAVE/BGREWRITEAOF | 负载因子 ≥ 5 | 避免 fork 期间写入触发大量内存拷贝 |
| 缩容 | 负载因子 < 0.1 | 且表大小超过 4,渐进缩容 |
在 hashtable 编码下,Hash 与 Set 的每次读写都要经过 dict 查找,所以「小对象用 listpack、大对象用 hashtable」的分界点会直接反映在 P99 延迟上。
2.4 listpack / quicklist / skiplist
- listpack:紧凑的连续内存块,替代了旧的 ziplist(7.0 起全面替换),解决了 ziplist 的连锁更新问题。
- quicklist:List 的默认实现,本质是「双向链表 + 每节点一个 listpack」,兼顾插入效率与内存局部性。
- skiplist:ZSet 大对象编码,配合 dict 实现「按分数范围查」与「按成员查」两条路径都是 O(log n)。
实践结论:凡是「小集合」场景(配置项、对象字段、标签集合),把元素控制在上表阈值内,内存和延迟都会明显更好;一旦越过阈值,性能会呈台阶式变化,而不是线性渐变。
三、单线程模型与 I/O 多线程
3.1 为什么单线程还这么快
| 原因 | 展开 |
|---|---|
| 无锁 | 单线程执行命令,不需要加锁、不需要处理竞态 |
| 无上下文切换 | 不会因线程调度产生 CPU 抖动 |
| 内存操作 | 绝大多数命令是纯内存读写 |
| 高效协议 | RESP 解析极简,且支持 pipeline 摊薄 RTT |
| I/O 多路复用 | epoll 单线程管理海量连接 |
单线程的代价是一条慢命令会阻塞所有请求。KEYS *、FLUSHALL、大 key 的 DEL、SORT、复杂度 O(n) 的 Lua 脚本,都会让整实例停摆。这是 Redis 最重要的性能纪律。
3.2 6.0 之后的多线程边界
# redis.conf
io-threads 4
io-threads-do-reads yes
| 线程 | 负责什么 | 不负责什么 |
|---|---|---|
| 主线程 | 执行命令、操作数据结构、处理过期 | —— |
| I/O 线程 | socket 读写、协议解析、结果回写 | 不执行任何命令逻辑 |
| 后台线程 | UNLINK 异步删除、lazyfree、AOF fsync |
不参与命令处理 |
| 子进程 | RDB / AOF rewrite 的 fork 子进程 | 不处理请求 |
所以「Redis 6 变多线程了」并不意味着命令可以并行。命令层面的串行语义没有变,变的只是网络收发。
3.3 延迟敏感场景的配置建议
| 参数 | 建议值 | 理由 |
|---|---|---|
io-threads |
4(不超过 CPU 核数) | 超过 8 收益递减 |
io-threads-do-reads |
yes |
读多场景收益明显 |
maxmemory-policy |
见第五节 | 缓存实例必须设置 |
slowlog-log-slower-than |
10000(微秒) | 超过 10ms 的命令值得复盘 |
slowlog-max-len |
1024 | 保留足够现场 |
latency-monitor-threshold |
100 | 开启延迟监控基线 |
别把
io-threads当成「加线程就变快」。它在高并发、小 value、多连接的场景收益最大;value 很大时收益有限,因为瓶颈在内存带宽与协议序列化。
四、持久化:RDB、AOF 与混合模式
4.1 三种方案对比
| 维度 | RDB | AOF | 混合(RDB 前言 + AOF 增量) |
|---|---|---|---|
| 数据形态 | 二进制快照 | 命令日志 | 头部 RDB + 尾部增量命令 |
| 恢复速度 | 快 | 慢(重放全部命令) | 快(先加载 RDB) |
| 数据丢失 | 两次快照之间的数据 | 取决于 appendfsync |
同 AOF |
| 文件体积 | 小(压缩) | 大 | 中等 |
| 对主线程影响 | fork 时有内存拷贝开销 | fsync 策略决定 | 两者兼有 |
| 适用场景 | 允许分钟级丢失 | 要求秒级/不丢 | 生产默认推荐 |
4.2 RDB 的关键参数
save 900 1
save 300 10
save 60 10000
dbfilename dump.rdb
dir /var/lib/redis
rdbcompression yes
rdbchecksum yes
stop-writes-on-bgsave-error yes
stop-writes-on-bgsave-error yes 是一个容易被忽略的保护:快照失败时拒绝写入,避免「以为有持久化、其实早就断了」。但它在磁盘满时会直接让实例只读,需要配合监控。
4.3 AOF:7.0 起是多文件结构
Redis 7.0 把单文件 AOF 改成多部分 AOF,目录结构如下:
/var/lib/redis/
└── appendonlydir/
├── appendonly.aof.1.base.rdb # 基线(混合模式下是 RDB)
├── appendonly.aof.1.incr.aof # 增量命令
└── appendonly.aof.manifest # 清单文件
对应配置:
appendonly yes
appendfilename "appendonly.aof"
appenddirname "appendonlydir"
aof-use-rdb-preamble yes
aof-timestamp-enabled yes
appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
appendfsync 的三种取值直接决定可靠性与吞吐:
| 取值 | 行为 | 最坏丢失 | 吞吐影响 |
|---|---|---|---|
always |
每个命令都 fsync | 1 条命令 | 最大,通常只有金融类场景接受 |
everysec |
每秒 fsync 一次 | 约 1 秒 | 生产默认,平衡点 |
no |
交给操作系统 | 由内核刷盘决定,可能数十秒 | 最小,风险最高 |
7.0 之前的单文件 AOF 在 rewrite 期间可能产生「base 与 incr 混合」的重放歧义;多文件结构 + manifest 正是为解决这个问题。如果你的运维脚本还在按
appendonly.aof单文件路径做备份,升级后必须改成备份appendonlydir整个目录。
4.4 fork 与内存放大
BGSAVE 与 AOF rewrite 都依赖 fork + COW(写时复制)。经验关系:
| 指标 | 经验值 |
|---|---|
| 峰值内存 | 约为数据量的 1.5~2 倍(写入越频繁,COW 页越多) |
| fork 耗时 | 与页表大小相关,几十 GB 实例可能达数百毫秒 |
建议 vm.overcommit_memory |
1,否则 fork 可能失败 |
线上必须显式设置:
sysctl -w vm.overcommit_memory=1
echo 'never' > /sys/kernel/mm/transparent_hugepage/enabled
透明大页(THP)会让 fork 与 COW 的延迟抖动放大,Redis 官方长期建议关闭。
五、内存管理与淘汰策略
5.1 两层机制要分清
| 机制 | 作用对象 | 触发条件 | 关键参数 |
|---|---|---|---|
| 过期删除 | 设置了 TTL 的 key | 惰性删除 + 定期抽样删除 | hz、active-expire-effort |
| 内存淘汰 | 全部或部分 key | 已用内存超过 maxmemory |
maxmemory、maxmemory-policy |
没有设置 maxmemory 时,淘汰策略永远不会触发,实例会一直吃到被 OOM Killer 干掉或触发 swap。这是 Redis 最常见的生产配置缺失。
5.2 八种淘汰策略
| 策略 | 候选集合 | 淘汰依据 | 适用场景 |
|---|---|---|---|
noeviction |
无 | 写入直接报错 OOM command not allowed |
数据不可丢、DB 型用途 |
allkeys-lru |
全部 key | 近似 LRU | 纯缓存,最常见 |
allkeys-lfu |
全部 key | 访问频率 | 热点明显、长尾访问的缓存 |
allkeys-random |
全部 key | 随机 | 访问分布均匀时省 CPU |
volatile-lru |
有 TTL 的 key | 近似 LRU | 混合用途:缓存与持久数据共存 |
volatile-lfu |
有 TTL 的 key | 访问频率 | 同上,热点更集中 |
volatile-random |
有 TTL 的 key | 随机 | 少用 |
volatile-ttl |
有 TTL 的 key | 剩余 TTL 最短优先 | 短期会话类数据 |
选型决策链:
- 这个实例是否允许丢数据?不允许 →
noeviction,并配置告警。 - 是否全部 key 都可淘汰?是 → 在
allkeys-lru与allkeys-lfu中二选一。 - 只有部分 key 可淘汰 → 用
volatile-*,并确保可淘汰的 key 都设置了 TTL(否则候选集为空,行为退化为noeviction)。
5.3 LRU 与 LFU 的真实实现
Redis 的 LRU 不是严格 LRU,而是采样近似:
maxmemory 4gb
maxmemory-policy allkeys-lru
maxmemory-samples 5
每次需要淘汰时随机采样 maxmemory-samples 个 key,挑最久未使用的淘汰。采样数越大越接近真实 LRU,CPU 开销也越高,5 是官方默认平衡点。
LFU 则维护一个 8 位的计数器(对数计数,lfu-log-factor 控制增长速度)与一个 16 位的衰减时间(lfu-decay-time 分钟)。LFU 更适合「热点长期稳定」的场景,LRU 更适合「访问模式随时间漂移」的场景。
5.4 内存碎片
mem_fragmentation_ratio = used_memory_rss / used_memory:
| 比值 | 判断 | 处理 |
|---|---|---|
| 1.0 ~ 1.5 | 正常 | 无需处理 |
| > 1.5 | 碎片偏多 | 开启 activedefrag yes |
| < 1 | 已发生 swap | 严重问题,立即扩容或排查内存 |
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
active-defrag-threshold-upper 100
active-defrag-cycle-min 1
active-defrag-cycle-max 25
碎片治理的根因通常是「频繁增删不同大小的 value」与「大量过期 key 未及时释放」。前者靠对象复用与批量重建,后者靠 hz 与异步删除(lazyfree-lazy-expire yes、lazyfree-lazy-user-del yes)。
六、高可用与集群
6.1 三种部署形态
| 形态 | 节点数 | 故障转移 | 数据分片 | 适用规模 |
|---|---|---|---|---|
| 主从复制 | 2+ | 手动 | 无(全量副本) | 读扩展、备份 |
| Sentinel | 3+ Sentinel + 主从 | 自动选主 | 无 | 单机数据量可容纳 |
| Cluster | ≥ 3 主 3 从 | 自动选主 | 16384 slot | 数据量或吞吐超出单机 |
6.2 主从复制的关键参数
replicaof 10.0.0.11 6379
masterauth YourPassword
masteruser default
repl-backlog-size 64mb
repl-diskless-sync yes
repl-diskless-sync-delay 5
repl-diskless-load disabled
min-replicas-to-write 1
min-replicas-max-lag 10
min-replicas-to-write 1 + min-replicas-max-lag 10 是防脑裂的核心配置:当没有健康副本、或副本延迟超过 10 秒时,主节点拒绝写入,避免旧主继续接受写请求造成数据分叉。
repl-backlog-size 决定「短暂断连后能否增量同步」。默认 1MB 在高写入场景下很容易被冲掉,导致全量同步(fork + 传输),进而引发雪崩式延迟。写入量大的实例建议 64MB 以上。
6.3 Cluster 的 slot 与重定向
Cluster 把键空间切成 16384 个 slot,slot = CRC16(key) mod 16384。
redis-cli --cluster create \
10.0.0.1:6379 10.0.0.2:6379 10.0.0.3:6379 \
10.0.0.4:6379 10.0.0.5:6379 10.0.0.6:6379 \
--cluster-replicas 1
两种重定向必须区分:
| 响应 | 含义 | 客户端行为 |
|---|---|---|
MOVED 3999 10.0.0.2:6379 |
slot 永久归属另一个节点 | 更新本地 slot 映射后重试 |
ASK 3999 10.0.0.2:6379 |
slot 正在迁移,本次先去目标节点 | 只本次重试,不更新映射 |
把 ASK 当成 MOVED 处理、或反之,都会导致迁移期间的大量失败。成熟的客户端(Lettuce、Jedis)已经实现,自研客户端必须自己处理。
多 key 命令的约束:
# 同一个 slot(用 hash tag 强制同槽)
SET {order:1001}:amount 99
SET {order:1001}:status paid
# 跨 slot 会直接报错
MGET user:1 user:2
# (error) CROSSSLOT Keys in request don't hash to the same slot
所以 Cluster 下的批量操作要么用 hash tag 让相关 key 同槽(代价是热点集中),要么拆成多次单 key 请求 + pipeline,要么改用 RedisTemplate 的 executePipelined 逐 key 发送。
七、缓存三大问题与一致性
7.1 问题全景与解法
| 问题 | 现象 | 根因 | 解法 |
|---|---|---|---|
| 缓存穿透 | 大量请求打穿到 DB | 查询不存在的数据,缓存永远不命中 | 空值缓存(短 TTL)、布隆过滤器、参数校验 |
| 缓存击穿 | 单个热点 key 失效瞬间 DB 被打爆 | 热点 key 过期,重建并发 | 互斥锁重建、逻辑过期、永不过期 + 后台刷新 |
| 缓存雪崩 | 大面积 key 同时失效或实例宕机 | TTL 集中、实例故障 | TTL 加随机抖动、多级缓存、熔断降级、集群 |
7.2 空值缓存与布隆过滤器的取舍
| 方案 | 优点 | 缺点 |
|---|---|---|
| 空值缓存 | 实现简单,命中即返回 | 占用内存;恶意随机 key 会撑爆缓存 |
| 布隆过滤器 | 内存极省,可拦截绝大多数无效 key | 有假阳性;不支持删除(需 Counting Bloom 或重建) |
布隆过滤器适合「ID 空间可枚举」的场景(如商品 ID、用户 ID);空值缓存适合「无效请求量可控」的场景。两者通常组合使用:布隆过滤挡第一层,空值缓存兜住假阳性。
7.3 Cache Aside 的正确顺序
| 操作 | 推荐顺序 | 原因 |
|---|---|---|
| 读 | 读缓存 → 未命中读 DB → 回写缓存 | 标准路径 |
| 写 | 先更新 DB,再删除缓存 | 删除比更新缓存更安全,避免并发写造成脏数据 |
为什么是「删除」而不是「更新」:两个并发写请求,若都去更新缓存,可能因执行顺序错乱导致缓存保留旧值;删除则让下一次读自然回源重建。
即便如此,仍有极小概率的不一致窗口(读请求在写请求删缓存之后、DB 更新之前读到旧值并回写)。常见兜底是延迟双删:
1. 删除缓存
2. 更新数据库
3. 延迟 500ms~1s 再删除一次缓存
延迟时间要大于「一次读请求的回源 + 回写」耗时,具体值需按业务压测确定,没有通用数字。
八、分布式锁:从 SET NX 到 Redisson
8.1 最小可用实现
# 加锁:原子地 SET NX + 过期时间,绝不要分成两条命令
SET lock:order:1001 "8f14e45f-ce" NX PX 30000
两个必须遵守的纪律:
SET key value NX PX ms必须一条命令完成。先SETNX再EXPIRE,中间进程崩溃就会产生永久锁。- value 必须是唯一标识(UUID / 请求 ID),释放时校验,避免误删别人的锁。
8.2 释放锁的 Lua 脚本
-- scripts/unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
Redis 保证单条 Lua 脚本原子执行,因此「比较 + 删除」不会被打断。
8.3 三种实现对比
| 方案 | 自动续期 | 可重入 | 公平锁 | 适用场景 |
|---|---|---|---|---|
手写 SET NX PX + Lua |
无,需自己实现 | 无 | 无 | 简单短任务 |
Redisson RLock |
有(看门狗) | 有 | 有 FairLock |
生产通用 |
| RedLock(多实例) | 需自行处理 | 无 | 无 | 对一致性要求极高且能接受复杂度 |
Redisson 看门狗机制:默认锁租约 30 秒,后台任务每 10 秒(租约的 1/3)续期一次,业务执行完主动 unlock 后停止续期。注意:只有「不显式指定 leaseTime」时看门狗才生效,一旦传入 leaseTime,Redisson 认为你自行管理超时,不再续期。
8.4 RedLock 的争议要点
RedLock 要求在 N 个独立 Redis 实例上多数派加锁成功才算成功。它的争议集中在:
- 依赖各实例的时钟速率假设,无法完全抵御进程暂停(GC 停顿、虚拟机迁移)。
- 运维成本高:需要维护 N 套互不相关的实例。
- 对绝大多数业务,单实例 + 合理超时 + 幂等设计已经足够。
结论:分布式锁的正确性最终应该由「幂等 + 唯一约束」兜底,而不是由锁本身保证。 锁是性能优化,不是正确性保证。
九、Spring Boot 3.4 集成实战
9.1 依赖
<dependencies>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
Spring Boot 3.x 的 Redis starter 默认使用 Lettuce(基于 Netty,连接共享、线程安全)。commons-pool2 只有启用连接池时才需要,但不引入它而配置了 lettuce.pool,配置会静默失效。
9.2 配置文件
单机:
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: "YourStrong@Passw0rd"
database: 0
timeout: 2s
connect-timeout: 2s
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 1s
shutdown-timeout: 200ms
Cluster:
spring:
data:
redis:
password: "YourStrong@Passw0rd"
timeout: 2s
cluster:
nodes:
- 10.0.0.1:6379
- 10.0.0.2:6379
- 10.0.0.3:6379
max-redirects: 3
lettuce:
cluster:
refresh:
adaptive: true
period: 30s
adaptive: true 让 Lettuce 在收到 MOVED 后自动刷新拓扑,比固定周期刷新更及时。
9.3 序列化配置(最容易踩坑的一环)
package com.example.redis.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
StringRedisSerializer keySerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer valueSerializer =
new GenericJackson2JsonRedisSerializer(mapper);
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}
关键点:
| 问题 | 说明 |
|---|---|
| 默认 JDK 序列化 | 可读性差、体积大、跨语言不可用,必须替换 |
activateDefaultTyping |
让 JSON 里带 @class,反序列化才能还原具体类型;代价是不能信任外部输入 |
LaissezFaireSubTypeValidator |
放行所有子类型,存在反序列化风险,仅适用于内部可信数据 |
JavaTimeModule |
否则 LocalDateTime 序列化直接抛异常 |
| key 用 String 序列化 | 否则 redis-cli 里看到的是乱码,排查成本极高 |
如果缓存内容可能来自外部或不可信来源,应改用 Jackson2JsonRedisSerializer<T> 绑定具体类型,或干脆只用 StringRedisTemplate 存 JSON 字符串,由业务层显式转换。
9.4 业务服务:缓存 + 防击穿
package com.example.redis.service;
import com.example.redis.config.RedisKeys;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
@Service
public class ProductService {
private final StringRedisTemplate redis;
private final ProductRepository repository;
public ProductService(StringRedisTemplate redis, ProductRepository repository) {
this.redis = redis;
this.repository = repository;
}
public Product getProduct(long id) {
String key = RedisKeys.product(id);
String cached = redis.opsForValue().get(key);
if (cached != null) {
return cached.isEmpty() ? null : JsonUtils.fromJson(cached, Product.class);
}
String lockKey = RedisKeys.productLock(id);
Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
try {
if (Boolean.TRUE.equals(locked)) {
Product product = repository.findById(id).orElse(null);
if (product == null) {
redis.opsForValue().set(key, "", randomTtl(60, 120));
} else {
redis.opsForValue().set(key, JsonUtils.toJson(product), randomTtl(1800, 2400));
}
return product;
}
sleepQuietly(50);
String retry = redis.opsForValue().get(key);
if (retry != null) {
return retry.isEmpty() ? null : JsonUtils.fromJson(retry, Product.class);
}
return repository.findById(id).orElse(null);
} finally {
if (Boolean.TRUE.equals(locked)) {
redis.delete(lockKey);
}
}
}
private Duration randomTtl(int minSeconds, int maxSeconds) {
int seconds = ThreadLocalRandom.current().nextInt(minSeconds, maxSeconds);
return Duration.ofSeconds(seconds);
}
private void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
三个设计意图:
- 空值缓存用空字符串而不是
null:opsForValue().get对不存在的 key 返回null,缓存空对象必须用可区分的非空值。 - TTL 加随机抖动:避免同一批次 key 同时失效造成雪崩。
- 互斥锁失败后短暂等待再重试读缓存:把并发重建降为一次,其余请求要么命中新缓存,要么直接回源一次。
9.5 分布式锁的正确写法(StringRedisTemplate + Lua)
package com.example.redis.lock;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
@Component
public class RedisLock {
private static final RedisScript<Long> UNLOCK_SCRIPT =
new DefaultRedisScript<>(
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) else return 0 end",
Long.class);
private final StringRedisTemplate redis;
public RedisLock(StringRedisTemplate redis) {
this.redis = redis;
}
public String tryLock(String key, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue().setIfAbsent(key, token, ttl);
return Boolean.TRUE.equals(ok) ? token : null;
}
public boolean unlock(String key, String token) {
if (token == null) {
return false;
}
Long result = redis.execute(UNLOCK_SCRIPT, List.of(key), token);
return result != null && result == 1L;
}
}
使用 StringRedisTemplate 而不是自定义 RedisTemplate 的原因很实际:Lua 脚本的 KEYS / ARGV 会被模板的序列化器处理。若 value 序列化器是 JSON,ARGV[1] 会变成带引号的 "uuid",而 GET 返回的原始值不一定带引号,比较必然失败,锁永远删不掉。这是一个非常典型的线上事故。
9.6 注解式缓存
package com.example.redis.service;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class UserQueryService {
private final UserRepository repository;
public UserQueryService(UserRepository repository) {
this.repository = repository;
}
@Cacheable(cacheNames = "user", key = "#id", unless = "#result == null")
public User findById(long id) {
return repository.findById(id).orElse(null);
}
@CacheEvict(cacheNames = "user", key = "#user.id")
public void update(User user) {
repository.save(user);
}
}
启用注解需要 @EnableCaching,并注册 CacheManager:
package com.example.redis.config;
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.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
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
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.prefixCacheNameWith("cache:")
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
@Cacheable 有几个必须知道的行为边界:
| 行为 | 说明 |
|---|---|
| 同类内部调用不生效 | 代理模式,this.findById() 绕过代理,缓存注解失效 |
| 异常不缓存 | 方法抛异常时不写入缓存 |
unless 条件 |
用于排除 null 等值,但要与 disableCachingNullValues 配合 |
| 默认 key 生成 | 无参方法用 SimpleKey.EMPTY,多参用参数组合,建议显式写 key |
十、常见坑与排查手册
10.1 生产事故高发清单
| 坑 | 后果 | 正确做法 |
|---|---|---|
线上执行 KEYS * |
全实例阻塞,P99 直接爆表 | 用 SCAN 游标遍历,且限速 |
大 key 直接 DEL |
删除大集合阻塞主线程数秒 | 用 UNLINK 异步删除,或分批 HDEL/SREM |
未设 maxmemory |
内存打满被 OOM Killer 杀掉 | 必设上限 + 淘汰策略 + 内存告警 |
未设 maxmemory-policy |
默认 noeviction,写满后所有写请求报错 |
缓存实例显式设为 allkeys-lru 或 allkeys-lfu |
volatile-* 策略但 key 没 TTL |
候选集为空,退化为拒写 | 使用前校验 TTL 覆盖率 |
| AOF 备份仍拷单文件 | 7.0+ 恢复丢数据 | 备份整个 appendonlydir |
单实例 repl-backlog-size 太小 |
网络抖动触发全量同步 | 高写入实例调到 64MB+ |
| Cluster 下跨 slot 批量操作 | CROSSSLOT 报错 |
hash tag 或拆请求 |
| Lua 脚本里遍历大集合 | 阻塞时间不可控 | 脚本内操作元素数设上限 |
用 RedisTemplate 执行锁脚本 |
序列化不一致,锁删不掉 | 锁统一用 StringRedisTemplate |
连接池不引入 commons-pool2 |
池配置静默失效,连接数失控 | 显式加依赖并核对 pool 生效 |
10.2 排查命令对照表
| 现象 | 首先看什么 | 命令 |
|---|---|---|
| 延迟抖动 | 慢查询、延迟事件 | SLOWLOG GET 10、LATENCY LATEST |
| 内存异常 | 内存分布、碎片率、大 key | INFO memory、MEMORY DOCTOR、--bigkeys |
| 淘汰频繁 | 淘汰计数、内存水位 | INFO stats 中的 evicted_keys |
| 命中率下降 | 命中/未命中计数 | INFO stats 中的 keyspace_hits / keyspace_misses |
| 主从延迟 | 复制偏移与 lag | INFO replication |
| 集群异常 | slot 归属、节点状态 | CLUSTER INFO、CLUSTER NODES |
| 连接数暴涨 | 客户端列表 | CLIENT LIST、CLIENT KILL |
| key 编码异常 | 对象编码与内存占用 | OBJECT ENCODING、MEMORY USAGE |
常用扫描方式(替代 KEYS):
redis-cli --scan --pattern 'user:*' --count 1000
redis-cli --bigkeys
redis-cli --memkeys
redis-cli --hotkeys # 需要 maxmemory-policy 为 lfu 系列
10.3 监控指标的最小集合
| 类别 | 指标 | 告警建议 |
|---|---|---|
| 内存 | used_memory、used_memory_rss、mem_fragmentation_ratio |
使用率 > 80% 告警 |
| 淘汰 | evicted_keys 增速 |
持续 > 0 即需关注 |
| 命中率 | keyspace_hits / (hits + misses) |
低于基线 10% 告警 |
| 延迟 | latency_percentiles_usec 的 p99 |
超过 SLA 告警 |
| 连接 | connected_clients、blocked_clients |
接近 maxclients 告警 |
| 持久化 | rdb_last_bgsave_status、aof_last_write_status |
非 ok 立即告警 |
| 复制 | master_link_status、master_repl_offset 差值 |
断连或 lag 超阈值告警 |
十一、实践准则清单
把全文压缩成可以直接贴进团队规范的条目:
- 每个缓存实例必须设
maxmemory与maxmemory-policy,两者缺一不可。 - 禁止在生产使用
KEYS,统一走SCAN;批量删除用UNLINK。 - 单 value 控制在 10KB 以内,集合元素控制在 5000 以内;超出即拆 key。
- 相关性强的 key 用同一个 hash tag,兼顾 Cluster 多 key 操作与批量读取。
- TTL 必须带随机抖动,避免整批同时失效。
- 持久化优先混合模式(
aof-use-rdb-preamble yes+appendfsync everysec)。 - 主从必须配
min-replicas-to-write,防止脑裂写入。 - 锁的 value 必带唯一 token,释放必须用 Lua 原子比较删除,且统一用
StringRedisTemplate。 - 序列化只用 String 与 JSON,禁用 JDK 序列化;不可信数据不要开 default typing。
- 所有 Redis 操作必须有超时,客户端超时要小于上游服务的超时,避免线程池被拖垮。
- 缓存只是优化,正确性由 DB 唯一约束与幂等逻辑兜底。
- 上线前跑一遍
--bigkeys与SLOWLOG,把问题留在压测阶段。
十二、可选的后续方向
这篇文章覆盖了机制与工程实践,但每个方向都还能再往下挖一层,按需可以继续:
| 方向 | 内容 |
|---|---|
| 拆成系列 | 数据结构篇 / 持久化篇 / 集群篇 / Spring 集成篇,各自独立成文 |
| 换版本基线 | Redis 8.0 的 Vector Set 与 Search 单独成篇(RAG 召回实战) |
| 补架构图 | 用 Mermaid 画主从复制、Cluster slot 迁移、Cache Aside 时序图 |
| 压测数据 | 用 redis-benchmark + 自建压测脚本给出不同 value 大小下的 QPS/延迟曲线 |
| 运维视角 | 补齐 Prometheus + Grafana 的 Redis 大盘与告警规则文件 |
| 客户端对比 | Lettuce 与 Jedis 在连接模型、线程安全、Cluster 拓扑刷新上的差异实测 |
如果后续要把这套内容落到具体业务,建议的推进顺序是:先定数据结构与 key 规范 → 再定淘汰与持久化策略 → 最后写缓存与锁的代码。顺序反了,代码写得再漂亮也会被配置问题打回去。
- 点赞
- 收藏
- 关注作者
评论(0)