这不是一篇 Redis 教程——教程网上已经够多了。这是四场真实发生过的线上事故复盘,时间和公司做了模糊处理,但数字是真的,锅也是真的。如果你也在用缓存,希望这些坑你一个都不用踩。
事故一:预热做对了,却输给了 TTL
前年双十一,我在一家电商公司负责商品详情页。
0 点大促开场,我们是有备而来的:提前一晚跑预热脚本,把 20 万个热点商品灌进了 Redis,自认为万无一失。
23 点整,脚本执行完毕,20 万个 key 全部写入成功,TTL 统一设成 3600 秒——问题就出在这。
0 点整,大促开场,流量洪峰涌进来。也是在这一分钟,预热的那批 key 集体到期。DB 的 QPS 从平时的三四百直接冲到 8600,连接池瞬间打满,接口 RT 从 80ms 涨到 4 秒以上。用户看到的是转圈,转圈就重试,重试让流量更大——教科书级的雪崩。
那晚的应急很狼狈:打开降级开关让详情页返回静态兜底数据,同时用改过的脚本重新灌缓存。15 分钟后恢复,但那 15 分钟损失的 GMV 已经没法算了。
复盘改了三件事:
// 1. TTL 永远加随机抖动,把过期时间摊开
int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.setex(key, ttl, json);
- 预热脚本本身要限速、分批。”一次性灌 20 万个相同过期时间的 key”,这个动作本身就是给雪崩装引信;
- DB 前面挂上熔断限流——缓存全挂的时候,宁可让用户看到降级页面,也不能让 DB 死。
还有一条不痛不痒但很重要:降级开关不是加上就完了。我们那个开关半年没人按过,真按下去的那一刻,谁也不敢确定它还好使。后来每次大促前强制演练一次。
那晚的教训我反复讲:所有缓存的最后一道防线都是 DB,而 DB 比大多数人想象的脆弱得多。
事故二:付款成功了,订单还显示”待支付”
客服转来的反馈:极个别用户支付成功后,订单页还是”待支付”,刷新几次才恢复。一天几单,量不大,但涉及支付,性质严重。
排查走了两天弯路。先怀疑支付回调丢了——查日志,回调全正常;又怀疑前端没刷新状态——也不是。最后才锁定缓存不一致,当时的写法很”直觉”:先删缓存,再更新数据库。
并发场景下,这个顺序会出大问题:
1. 写请求:删除缓存
2. 读请求:缓存 miss,查库,拿到【旧值】
3. 写请求:更新数据库为【新值】
4. 读请求:把【旧值】写回缓存
—— 从此缓存里一直是旧值,直到 TTL 过期
关键在第 2 步和第 4 步之间:隔了一次 DB 查询加一次网络往返。只要读请求慢于写请求(这太常见了),旧值回填就可能发生。我们后来在测试环境用两个终端手工复现了:一个终端发查询,另一个终端赶在查询返回前完成支付。
修复分了两版。
第一版,延迟双删——删缓存、更新库、sleep 500ms 后再删一次。不体面,但有效,跑了大半年。丑点有两个:500ms 是拍的;第二次删除失败还得靠本地重试表兜着。
第二版,也是现在的方案:先更新 DB,再删缓存,且”删缓存”这个动作从业务代码里彻底拿掉——订阅 MySQL binlog(Canal → MQ → 消费者删除),删除失败进重试队列。业务代码从此对缓存无感知,一致性从”靠人肉小心”变成”靠机制重试”。
较真的人会问:先更新库再删缓存,不也有理论上的不一致窗口吗?有——读请求 miss 后查到旧值,写请求趁机完成更新并删缓存,读请求再回填旧值。但这个时序要求”读比写还慢”且恰好错开,概率极低,加上 TTL 兜底,可以接受。
现在回头看,第一版其实不算错——当时团队连 MQ 基础设施都不完整,硬上 binlog 方案才是过度设计。方案没有对错,只有和团队现状匹不匹配。
事故三:Redis 平均 RT 从 0.3ms 涨到 12ms
告警响的时候第一反应是不信——Redis 能慢到哪去。
排查按套路走:机器 CPU、内存、网络都正常。打开 SLOWLOG GET,满屏都是同一个命令:KEYS h5:activity:*。
查了才知道,一个活动统计任务每分钟全量 KEYS 一次,而那个实例里有 800 万个 key。KEYS 要遍历完才能返回,Redis 命令执行是单线程的,这期间所有命令都在后面排队。每分钟一次,RT 图上就是规律的毛刺。
改成 SCAN:
cursor = 0
while True:
cursor, keys = r.scan(cursor, match='h5:activity:*', count=500)
handle(keys) # 注意:SCAN 可能返回重复 key,业务侧要能容忍
if cursor == 0:
break
改完好了大半,但每天还有几次不明毛刺。继续查,发现一个抽奖活动的 hash 有 90 万个 field、300 多 MB,每次过期删除时都在阻塞主线程——DEL 大 key 是同步的,删一次卡几百毫秒。
redis-cli --bigkeys 扫了一遍(建议在从库跑),揪出一批历史遗留大 key。处理方案:
- 大 hash 按”活动 ID + 日期”拆桶,单个 key 控制在 1 万个 field 以内;
- 删大 key 一律用 UNLINK(4.0+)异步释放,顺手把
lazyfree-lazy-expire打开; - 实例配置里
rename-command KEYS "",物理禁用,从此不靠自觉。
那之后团队多了三条铁律:禁 KEYS;单个 value 不超过 10KB;集合类 key 不超过 1 万个元素。 数字不重要,重要的是有一道硬栏杆,而不是一句口头约定。
事故四:活动还没开始,DB 先被打挂了
一次大促前两天,活动页还在内测,运营资源位都没上,DB 的 CPU 却爬到了 85%。
翻 access log,大量请求在查商品详情,ID 全是乱码和负数——有人拿脚本在扫接口。这些 ID 在库里根本不存在,缓存永远 miss,每个请求都直奔 DB。这就是缓存穿透。
应急两板斧:网关限流 + 风控封 IP,先止血。
长期方案讨论时,在布隆过滤器上产生了分歧。我的观点是:大多数业务,”缓存空值”就够了,别上来就布隆过滤器。
Product p = db.query(id);
if (p == null) {
// 空值也要缓存,TTL 给短一点
redis.setex(key, 60, "");
return null;
}
redis.setex(key, ttl, JSON.toJSONString(p));
空值缓存两个注意点:TTL 要短(比如 60 秒),避免这个 ID 之后真被创建时长期不一致;如果恶意请求的 key 无限随机,空值缓存会让内存膨胀,这时候再上布隆过滤器——它需要预估容量、不支持删除、有误判率,扩容还得重建,维护成本不低,只值得用在大流量且 ID 总量可预估的场景。
当时我们的场景,空值缓存 + 限流,一小时的活儿,至今没复发。
写在最后:几条不太愿意写进 wiki 的经验
1. 连接数不是越大越好。 出过 Could not get a resource from the pool,照网上的公式算出要 200 个连接,压测发现 50 就打满吞吐了。Redis 命令执行是单线程的,连接再多只是换个地方排队。连接池参数永远靠压测定,不靠公式定。
2. 不是所有数据都值得上缓存。 大 value、低频读、每次都要绝对准确的数据,上缓存是负收益。上之前先问一句:这个数据允许多”旧”?答案超过一秒,才值得进缓存。
3. 命中率是缓存的心电图。 我们后来把”命中率环比下降 10%”做成告警,好几次都是它比业务先发现问题。命中率突降,要么 key 策略被人改了,要么来了异常流量,没有第三种可能。
4. TTL 是产品决策,不是技术参数。 “这个数据能容忍多久是旧的”,答案应该让产品确认,工程师只是把答案翻译成数字。拍脑袋定的 TTL,早晚要还。
5. 缓存代码要保持愚蠢。 业务代码里散落几百处 if 缓存的判断,最后没人敢动。把缓存收进一层薄薄的抽象,业务无感知,哪天要换实现或者降级直连,改一个地方就行。
缓存是个好东西——它放大你的吞吐,也放大你的失误。用得好的前提,是先把”它会出事”当成默认假设来做设计。











暂无评论内容