线上缓存雪崩了三次,我们靠自动识别热点 Key 和动态分散才把这事摁住
线上跑着 120 个 Redis 节点,分 10 个分片,每个分片一主两从。那个周二下午两点,我正在工位上改前一天留下的一个慢查询优化,报警群突然炸了——API 网关 502 错误率从 0.03% 飙升到 47%,P99 延迟从 120ms 直接顶到 3000ms 上限。看了眼 Grafana,三个分片的 CPU 同时飙到 95% 以上,另外七个分片负载正常,不到 30%。就是热点 Key。
这不是第一次。三个月里这是我们第三次被热点 Key 打穿缓存层。每次触发条件不一样:第一次是大促秒杀时的商品详情缓存,第二次是某个明星发微博导致用户时间线查询集中刷到同几条动态,第三次是运营推送了一条全量用户的站内信,查询收件箱状态全部打到一个 Key 上。前两次我们做了应急处理——手动找出热点 Key,临时加本地缓存,重启部分节点,但每次都要 20 分钟以上才能恢复,期间用户体验一塌糊涂。
第三次之后我们决定彻底解决这个问题,不再靠人肉救火。
热点 Key 的自动识别:在它炸掉集群之前就发现它
Redis 本身不告诉你哪个 Key 是热点。INFO stats 里只有全局的命中率、命令量,MONITOR 在生产环境跑不了,性能损耗太大。我们的思路是:在客户端侧做采样统计,因为所有请求都要经过客户端,采样点天然存在。
我们用的是自研的 Redis 客户端封装,基于 Jedis 改的,内部加了拦截器。每条命令执行前,根据配置的采样率(我们设了 0.5%,即每 200 次请求采样一次)决定是否记录这条 Key 的访问。采样数据不是全量上报,那样会给监控系统造成压力,客户端内部维护了一个基于 Caffeine 的本地滑动窗口计数器,窗口大小 10 秒,统计每个 Key 在这 10 秒内的采样命中次数。
关键是怎么定义“热点”。简单设一个绝对阈值比如“QPS 超过 5000 就算热点”不靠谱,因为不同业务线的基数差异巨大。商品详情 QPS 日常就 2 万,5000 不算热;但用户收件箱状态日常 QPS 才 200,冲到 1000 就已经是热点了。我们用的算法是相对于该 Key 自身历史基线的偏离度。
具体做法:客户端每隔 10 秒把本地窗口的 Top-N Key(我们取 Top-20)和它们的采样命中次数上报到一个 Kafka Topic。后端有一个 Flink 作业消费这个 Topic,维护每个 Key 的近期基线——用过去 5 分钟的滑动窗口计算该 Key 的平均采样命中次数和标准差。当最新一个窗口的命中次数超过 均值 + 3 * 标准差 时,判定该 Key 为热点候选。为了避免抖动,连续两个窗口都超过阈值才正式标记为热点,标记信息写入一个 Redis 集群的元数据分片(我们叫它 hotkey:registry,Hash 结构,field 是 Key 名,value 是过期时间戳)。
这套机制在第三次事故后一周上线。第二天就自动识别出一个热点:某个商品详情 Key 因为运营在首页改了推荐位,QPS 从日常 18000 跳到 87000。从检测到标记完成,耗时 21 秒(两个 10 秒窗口加上 Flink 处理延迟),比之前人工发现快了不止一个数量级。
动态分散:不是简单加个随机后缀就完事
识别出热点 Key 只是第一步,怎么在它把分片打挂之前分散压力才是核心。
最常见的说法是“热点 Key 加随机后缀,打散到多个分片”。这话没错,但落地到生产环境有一堆细节要处理。我们踩过的坑至少有三个:
第一,分散粒度怎么定。 随机后缀的数量太少,压力还是集中;太多,每个副本的 QPS 是下来了,但带来了两个新问题:一是写操作的一致性维护成本飙升,二是客户端需要维护的副本列表膨胀。我们的做法是根据当前热点 Key 的 QPS 和单分片的承载上限动态计算副本数。单分片我们设定安全水位是 CPU 不超过 60%,对应大约 35000 QPS(我们的节点是 8C16G,纯内存操作)。如果热点 Key 的 QPS 预估是 87000,那至少需要 ceil(87000 / 35000) = 3 个副本,加上 1.5 倍冗余,最终生成 5 个副本 Key:product:detail:12345 变成 product:detail:12345:0 到 product:detail:12345:4。
第二,客户端怎么知道要去读副本。 这里不能每次请求都去查 hotkey:registry,那样这个元数据分片自己就变成热点了。我们的方案是客户端本地维护一个热点 Key 列表的缓存,通过两种方式更新:一是每 5 秒主动拉取一次 hotkey:registry 的全量快照(这个分片压力不大,因为更新频率低),二是有推送通道——Flink 作业标记新热点后,通过 Redis 的 Pub/Sub 通知所有客户端节点立即刷新。客户端拿到热点 Key 列表后,对于标记为热点的读请求,在副本数量范围内随机选一个后缀发请求。写请求仍然走原 Key,由后台的同步机制保证一致性。
第三,副本数据怎么保持一致。 这是我们花时间最多的地方。热点 Key 多数是读多写少,但少不等于没有。商品库存会变,用户收件箱状态会变。我们的方案是:写操作只写原 Key(product:detail:12345),然后通过 Redis 的 Keyspace Notification 监听这个 Key 的写事件,触发一个轻量级的同步器把数据异步写到所有副本 Key,并设置相同的 TTL。同步延迟实测在 50ms 以内(P99 120ms),对于我们的业务场景完全可以接受。如果对一致性要求极高,可以在客户端做双读比对,但我们评估后认为库存这类场景 50ms 延迟不会造成超卖(真正的扣库存走的是 Lua 脚本的原子操作,不经过这个路径)。
整个分散过程对业务代码完全透明。业务方还是用 redis.get("product:detail:12345"),客户端拦截器在底层判断这个 Key 是不是热点,是的话自动走副本逻辑。我们在灰度期间做过压测,模拟一个 10 万 QPS 的热点 Key,分散到 6 个副本后,6 个分片的 CPU 都在 55% 左右,P99 延迟维持在 150ms 以内,没有触发任何限流。
三次雪崩的复盘和额外加固
回头看这三次事故,每次都是因为单点热点把某个分片 CPU 打满,然后该分片上的其他 Key 也受影响,连锁反应导致依赖这个分片的服务集体超时,上游线程池耗尽,最终整个网关层崩溃。第三次之后除了上热点自动识别和动态分散,我们还做了三件事作为兜底:
一是客户端限流。在 Jedis 拦截器层加了针对单个 Key 的本地令牌桶,默认不限流,但热点 Key 标记生效后,自动下发一个限流配置,单 Key 单客户端节点最大 QPS 限制在 5000,超过的直接走降级逻辑(返回缓存的老数据或空值)。这防止了某个副本 Key 也被意外打爆的极端情况。
二是分片级别的熔断。我们给每个 Redis 分片在客户端侧加了熔断器,连续 5 次请求超时或报错就熔断该分片 3 秒,期间所有对这个分片的请求直接快速失败。这个改动在第四次灰度压测时救了我们一次——当时因为同步器的 Bug 导致副本 Key 数据不一致,部分请求走到错误数据上触发了业务层的重试风暴,分片负载瞬间飙升,熔断器在 1.8 秒内生效,隔离了故障分片,其他分片不受影响。
三是本地缓存兜底。对于识别出的热点 Key,客户端会自动在 Caffeine 里缓存一份,TTL 设 3 秒,比 Redis 副本的 TTL 短,作为最后一道防线。这个本地缓存的命中率日常不到 1%,但在 Redis 副本全部不可用的极端场景下,它能顶住几秒钟让系统不至于雪崩。实际效果是,本地缓存的大小控制在每个客户端节点不到 200MB,但能在 Redis 全挂时提供 3 秒的缓冲窗口。
这套方案上线 4 个月,期间自动识别并分散了 17 次热点 Key,平均检测耗时 23 秒,分散生效后的分片 CPU 从未超过 62%。最近一次大促,秒杀商品详情 Key 的瞬时 QPS 冲到 14 万,系统自动生成 8 个副本分散到 8 个分片,网关 P99 延迟最高 210ms,没有触发任何报警。运营和业务方甚至不知道发生过什么。
常见问题
热点 Key 自动检测的采样率设多少合适?
我们设的是 0.5%,在生产环境跑了 4 个月,这个值在精度和开销之间平衡得比较好。采样率太高会增加客户端 CPU 开销(我们实测 1% 采样率时客户端 P99 延迟增加了 8ms),太低会导致小基数 Key 的热点漏检。如果你的集群 QPS 量级更大(比如单分片超过 10 万),可以降到 0.1%;如果 QPS 较小(单分片不到 1 万),建议提高到 1%。关键是要配合偏离度算法而不是绝对阈值,这样采样误差对判断结果影响不大。
副本 Key 的一致性延迟 50ms,库存场景真的不会超卖吗?
不会,因为扣库存的写操作不走副本路径。我们的库存扣减是 DECR 配合 Lua 脚本做原子检查,直接操作原 Key,写完后同步器才把最新库存值刷到副本。读者读到的副本数据最多落后 50ms,但扣库存的决策始终基于原 Key 的实时值。如果业务要求读到的一定是最新库存(比如下单后立即查询订单详情),可以在客户端对写操作后 100ms 内的读请求强制走原 Key,我们没做这么细是因为业务容忍度够。
如果热点 Key 是写多读少怎么办?
这套方案主要针对读热点。写热点是另一个问题,通常需要做写入合并或者队列削峰,不能用副本分散,因为多副本写会带来一致性问题且放大写入压力。我们遇到过一次写热点:一个计数器 Key 在整点被 2000 个客户端同时 INCR,解决方式是客户端做了本地聚合,每 100ms 批量提交一次增量,把 2000 次 INCR 合并成 20 次,Redis 压力直接降了两个数量级。