Redis 主从架构下过期键到底谁来删?顺带聊聊大批量过期的雪崩防护
一、先问一个问题:从库上的键过期了吗?
很多人对 Redis 过期的理解停留在"到点就删"。实际上 Redis 从来没有一个精确的定时器去删除某个 key——它用的是惰性删除 + 定期删除的组合,而且这套机制只在主库上运行。
从库(replica)不主动清理过期键。它做的事只有两件:
- 收到读请求时,用本地的 TTL 判断这个键是否逻辑过期,过期就直接返回
nil(Redis 3.2 起的行为),但键在内存里还在; - 等主库的删除命令同步过来,照单执行
DEL/UNLINK。
也就是说,从库上会出现一个反直觉的现象:
# 在从库上
127.0.0.1:6380> TTL session:abc
(integer) -2 # 已经过期
127.0.0.1:6380> GET session:abc
(nil) # 读到的是空
127.0.0.1:6380> DBSIZE
(integer) 1002 # 但键还占着内存,DBSIZE 里还算它
TTL 返回 -2、GET 返回 nil,但 DBSIZE 和 SCAN 还能看到这个键。这不是主从延迟 bug,是设计如此——从库如果自己删,就会和主库的数据版本不一致,因为两者的时钟、复制偏移、写入顺序都可能有差异。
二、主库到底怎么删
主库有两条路径:
惰性删除:每次访问一个 key,先走 expireIfNeeded()。过期了就删掉、返回 nil,同时把这个删除动作写进复制流。
定期删除:serverCron 每 100ms 触发一次 activeExpireCycle(),从"设置了 TTL 的字典"里随机采样一批键,删掉其中已过期的。这里有三个要点:
- 是采样,不是全量遍历,所以未过期键多的实例里,过期键可能存活很久;
- 单轮有时间上限(默认不超过 CPU 时间的 25%),扫不完就下一轮继续;
- 正因为是采样,"过期时间到了"和"内存被释放"之间没有保证。
这就解释了一个常见疑惑:为什么有的 key 设了 60 秒 TTL,两小时后用 SCAN 还能扫到?因为主库既没被访问到它(惰性删除没触发),采样也没抽到它(定期删除没覆盖)。
三、复制流里到底传了什么
主库删除过期键后,会以普通写命令的形式传播:
DEL session:abc
Redis 4.0 之后,如果开了 lazyfree-lazy-expire yes,传播的是:
UNLINK session:abc
UNLINK 只把键从字典里摘掉,实际内存回收交给后台线程,避免大 key 删除阻塞主线程。从库收到 UNLINK 也一样做异步释放。
还有一个细节值得知道:主库不会把"过期时间点"单独同步过去——过期时间是通过 PEXPIREAT 这类命令在写入时就带过去的,从库自己也有这个绝对时间戳,所以从库能自行判断"逻辑上过期了",只是不会主动删。
四、什么时候会读到"过期了但还在"的值
三种典型场景:
| 场景 | 表现 | 原因 |
|---|---|---|
用 RANDOMKEY / SCAN / DBSIZE 做统计 |
数量和实际有效数据不符 | 过期键还在内存里 |
| 主库采样没扫到、也没人访问 | 从库能把旧值读出来(早期版本) | 现在版本读时会判过期返回 nil,但 3.2 之前会返回旧值 |
用 KEYS * 做数据导出 |
导出到已过期的键 | 同上 |
结论很直接:不要把 Redis 的 TTL 当成业务上的强保证。如果业务要求"优惠券到期绝对不能再用",那就必须在应用层用 expireAt 字段做二次校验,Redis 的 TTL 只当"大概率清理"的手段。
五、大量 key 同时过期怎么办
这是压测和线上都常见的坑:早上 9 点整一批活动缓存同时到期,主库瞬间要处理大量 DEL 和回源请求,表现为延迟毛刺、CPU 飙高、数据库被打穿。五个可落地的解法:
1. TTL 加随机抖动
写入时不要给固定过期时间:
import random
ttl = 3600 + random.randint(0, 600) # 1 小时 ±10 分钟
redis.setex(key, ttl, value)
把同一批数据的过期时间打散到 10 分钟窗口里,雪崩就变成了缓坡。
2. 热点 key 永不过期
对 Top N 热点数据,不设 TTL,靠后台任务定时刷新值 + 版本号。读路径永远命中,不会出现"同时失效同时回源"。
3. 逻辑过期 + 异步重建
值里带一个 expireAt 字段,读到时判断:
- 没过期 → 直接返回;
- 逻辑过期 → 先返回旧值,同时投递一个异步刷新任务(或用互斥锁 + 双重检查,只放一个请求去回源)。
这样请求永远不会被"卡住等回源",代价是可能短暂读到旧数据。对资讯、榜单这类场景完全可接受。
4. 开启异步删除
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
replica-lazy-flush yes
大 key(比如几十万元素的 Hash)过期时,DEL 是同步释放内存,能卡住主线程几百毫秒;UNLINK 把释放丢给后台线程,主线程只做摘链。
5. 分批写入 / 分批下线
批量导入 10 万条数据时,别一次性写同 TTL 的键。按 hash 分片或按时间窗分批,每批之间 sleep 一小会儿,让过期时间天然错开。
六、怎么确认是不是过期风暴
别猜,看指标:
redis-cli INFO stats | grep expired_keys # 过期键速率,突增就是有集中过期
redis-cli INFO commandstats | grep -E 'cmdstat_(del|unlink)'
redis-cli SLOWLOG GET 10 # 是不是删除操作变慢了
redis-cli LATENCY MONITOR on # 看主线程有没有被卡
redis-cli INFO replication | grep -E 'offset|lag'
判断顺序建议:先看 expired_keys 速率是否和业务高峰对齐,再看 SLOWLOG 里有没有 del/unlink 的长耗时,最后用 LATENCY MONITOR 确认是否阻塞主线程。如果复制偏移差值同时在扩大,说明删除风暴已经把复制链路拖慢了。
在华为云 DCS 上这套逻辑完全一致(DCS Redis 主从版/集群版都是标准复制模型),额外可以用控制台的慢查询分析、热 key 分析和实例监控里的"过期键"指标来定位。真要清大批量键,用 SCAN 游标 + UNLINK 分批删,别用 KEYS,也别在业务高峰 FLUSHDB。
七、小结
- 过期是主库的职责:惰性删除 + 定期采样,两条腿走路,都不保证及时。
- 从库只做两件事:读时判定过期返回 nil、被动执行主库传来的
DEL/UNLINK。 - TTL 不是业务强一致手段,关键逻辑要有应用层兜底。
- 防雪崩靠设计:TTL 抖散、热点不过期、逻辑过期 + 异步重建、异步删除、分批写入。
- 排查靠指标:
expired_keys速率 →SLOWLOG→LATENCY MONITOR→ 复制偏移。
- 点赞
- 收藏
- 关注作者
评论(0)