一、现象:凌晨 3 点的告警,比想象中严重
2025-07-12 03:17,监控群弹出第一条告警:Redis 主节点 used_memory 达到 58.7GB,逼近 maxmemory 64GB 上限。20 分钟后,业务侧开始报"接口变慢"。
关键数字先列出来,后面复盘都用得上:
- 实例规格:64GB 主从,
maxmemory-policy allkeys-lru(默认值,没人改过); used_memory从 7 月初的 51.2GB 涨到 58.7GB,周增速约 1.4GB,此前 18 个月增速从 30MB/周 一路涨到 1.4GB/周,但没人看这条曲线;- 03:38 开始
keyspace_hits占比(命中率)从长期稳定的 98.2% 掉到 41%,持续 40 分钟; - 同一窗口 MySQL 主库 QPS 从 1.2k 冲到 9.4k,慢查询(>200ms)从 0 条涨到 214 条;
- 03:58 应急处置完成,命中率回落到 97% 以上,全程业务侧感知 约 25 分钟的 P2 级劣化。
影响面:下单链路 RT P99 从 180ms 涨到 1.9s,客服工单 +37 条。没到 P1,但离熔断只差一个突发流量。
二、根因:不是"内存不够",是一个没设 TTL 的 Hash 吃掉了 53GB
MEMORY USAGE 定位后发现元凶:
127.0.0.1:6379> MEMORY USAGE user_behavior_digest
(integer) 56637087104 # ≈ 52.8GB
127.0.0.1:6379> TYPE user_behavior_digest
hash
127.0.0.1:6379> HLEN user_behavior_digest
(integer) 89314267 # 近 9000 万 field
这是一个"用户行为聚合"Hash:field = userId,value = JSON 行为摘要。2024 年 1 月上线时设计意图是"滚动 7 天",但代码里只有 HSET,没有任何 HDEL / 过期逻辑,也没有 TTL。18 个月只进不出,从 800MB 长到 52.8GB,占实例 58.7GB 的 90%。
机制上有三层叠加,缺一层都不会炸:
- 无界写入:单 Key Hash 没有"大小上限"约束,业务代码
HSET是幂等的,写入方完全无感知; - LRU 淘汰策略反噬:
allkeys-lru意味着这个热 Hash 里最冷的 field 会先被淘汰——淘汰的是"正确数据",于是业务读 miss 后回源 MySQL,回源又SET回缓存,形成"淘汰→回源→再写入"的循环,命中率雪崩不是瞬间跌零,而是在 40%~70% 之间抖了 40 分钟,比直接挂掉更难排查; - 大 Key 删除的隐性代价:应急时如果直接
DEL这个 52.8GB 的 Hash,单线程的 Redis 会卡住数十秒到分钟级(unlink 是 O(N),N 是 field 数,9000 万 field 的释放 + 内存碎片整理),主从同步期间从节点可能触发断连重同步。我们最后没有走DEL,见下节。
三、具体做法:应急 + 根治 + 监控,三段都要能复现
3.1 应急(03:58 完成,约 20 分钟)
# 1. 先给业务热 Key 加一层保护:命中率告警期间临时调大连接池 + 打开本地 Caffeine 二级缓存兜底
# (应用侧配置,略)
# 2. 给元凶 Key 设 TTL,先止血"继续增长"(不影响存量 field)
redis-cli EXPIRE user_behavior_digest 86400
# 3. 分片清理:用 SCAN 按 field 前缀批量 HDEL,每批 2000 个 field、批间 sleep 50ms
# 脚本化(Python + redis-py):
# pipe = r.pipeline(transaction=False)
# for i in range(0, len(fields), 2000):
# batch = fields[i:i+2000]
# pipe.hdel('user_behavior_digest', *batch)
# pipe.execute()
# time.sleep(0.05)
实测:每批 2000 field 的 HDEL 耗时 3~8ms,50ms 间隔下主节点 CPU 波动 < 1%,全程未触发任何慢查询。9000 万 field 清完约 38 分钟,used_memory 降到 6.1GB。
3.2 根治:把"滚动 7 天"真正落地
改造点(Java 侧):
// 错误:无界写入
r.hset("user_behavior_digest", userId, json);
// 正确:按天分片 + 双 Key 轮换,旧分片 TTL 自动过期
String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); // 20250712
String yesterday = today.plusDays(-1);
r.hset("user_behavior_digest:" + today, userId, json);
r.pexpire("user_behavior_digest:" + yesterday, 86_400_000L); // 2 天 TTL 兜底
改造后单 Key 体量上限 ≈ 单日写入量(约 1.2GB),物理上不可能再长到 52.8GB——上限由"分片粒度"决定,而不是靠"记得清理"。
3.3 监控(这次事故真正的教训)
# 1. 内存水位(本次告警 03:17 才响,阈值 80% 太保守)
- alert: RedisMemoryHigh
expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.6
for: 10m
# 2. 大 Key 巡检(每周 cron,阈值 10MB)
redis-cli --scan --pattern '*' | xargs -n 50 -P 4 redis-cli MEMORY USAGE
# 或 RDB 离线分析:rdb-tools 的 keytools --max-mem 10485760 dump.rdb
# 3. 命中率(本次 03:38 才掉,因为告警规则是"低于 50% 且持续 5 分钟",改严)
- alert: RedisHitRateDrop
expr: rate(redis_keyspace_hits[5m]) / (rate(redis_keyspace_hits[5m]) + rate(redis_keyspace_misses[5m])) < 0.9
for: 2m
四、踩过的坑(都是真实代价)
- 别用
redis-cli --bigkeys做巡检主力:它只统计逻辑大小,且遍历慢,58GB 实例上跑了 40 多秒阻塞了业务。生产环境用MEMORY USAGE抽样 + RDB 离线分析组合。 allkeys-lru不是"保护",是"放大器":很多人以为 LRU 淘汰冷数据是合理的,但当内存大头是一个无界 Hash 时,LRU 淘汰的恰恰是它——业务数据被连带误伤。无界 Key 场景下noeviction+ 写失败告警反而更诚实(写不进至少看得见,不会静默丢正确数据)。- 大 Key 别
DEL:9000 万 field 的 Hash,DEL是单线程 O(N) 操作。必须分批HDEL,批大小 1000~5000、批间 20~100ms 是安全区间,我们用 2000/50ms 验证过。 EXPIRE只对新写入生效是错觉:给已存在的 HashEXPIRE是整 Key 过期,field 不会逐个过期——这是"分片 + 每片独立 TTL"方案的底层依据。- 命中率告警别设"低于 50%":雪崩初期命中率在 70%~85% 抖了 15 分钟,50% 的阈值意味着业务已经骂了 15 分钟你才收到告警。90% 起跳、2 分钟确认,是我现在所有 Redis 实例的标配。
五、结论与可复用清单
一句话:缓存雪崩的根因往往不是"流量太大",而是"某个 Key 在无人监督下变成了内存黑洞";LRU 淘汰策略会把这个问题从"内存告警"伪装成"命中率抖动",排查时先看 MEMORY USAGE 找 Top Key,再看 keyspace_hits/misses 的时间序列是否同步劣化。
避坑清单(直接抄):
- [ ] 所有
HSET/SADD/ZADD写入路径,代码 Review 时强制问一句"这个 Key 的上限是什么?谁负责清理?" - [ ] 无界结构(Hash/Set/ZSet)一律改"按天/按租户分片 + 每片独立 TTL",上限由分片粒度决定;
- [ ] 大 Key 清理:分批
HDEL/SREM,批 1000~5000、间隔 20~100ms,禁止直接DEL; - [ ] 告警三件套:内存水位 60%、命中率 <90% 持续 2 分钟、每周大 Key 巡检(>10MB);
- [ ]
maxmemory-policy显式选择并写进文档,不要接受默认allkeys-lru而不自知; - [ ] 命中率掉到 50% 以下再告警 = 事故已经发生 15 分钟以上。
参考了站内关于 MySQL 计划漂移的复盘思路(先削平"悬崖"再做兜底),Redis 侧同理:先消灭无界 Key 这个"悬崖",再谈 LRU / 命中率告警这些"兜底"。