结论先行:热 key 是「单点」问题,不是「集群」问题——加分片、扩容集群对它完全无效,解药只有 localcache、key 分片、异步失效这三招。 下面是一次真实的秒杀前爬虫扫描事故,从止血到治本 40 分钟走完,P99 从 8s 回到 220ms。
一、现象与背景:单 key 48 万 QPS 打穿一个 8C16G 分片
场景:商品详情页服务,Redis Cluster 8 分片(每分片 8C16G,Redis 6.2.14),前置 12 台应用机(JDK 17 + Lettuce 6.2)。基线:单 key item:detail:6666 读 QPS 约 2k,整集群 P99 30ms。
出事当天 20:30,一个爬虫脚本开始高频扫这个 key(疑似抢库存前置探测),单 key 读 QPS 从 2k 一路涨到 48 万。
后果(持续 40 分钟):
- 详情页 P99 从 30ms 飙到 8s,503 率从 0.02% 涨到 12%;
- 落 slot 的那个分片
info stats里instantaneous_ops_per_sec贴到 48 万,其他 7 个分片 3 万~5 万,CPU 单核 92%、内存带宽打满; - 上游网关 read timeout 2s 触发重试,网关 3 次 × 应用层 2 次,下游实际 QPS 放大 6 倍,进一步把单分片打满——这条放大路径和我之前写超时重试那篇里「入口 3×网关 2×下游 1=6 倍」完全同构,只是入口从 HTTP 换成了 Redis 命令队列;
- 加 2 个分片扩容集群(
CLUSTER ADDSLOTS迁走 2000 个 slot)后 P99 只从 8s 降到 6s——因为热 key 的 hash slot 没变,请求还是全打到原来那台。
二、原理:为什么 Redis 集群扛不住热 key
三个机制叠在一起:
- Redis 6 命令执行仍是单线程(I/O 用 epoll 多路复用,但命令在单核上顺序执行)。一条
GET简单命令 100ns~1µs 量级,48 万 QPS × 0.5µs = 240ms/s 纯执行时间,加上网络拷贝、序列化、CLIENT队列调度,单核 80%~90% 就打满。 - Cluster 模式下 key 的归属由 hash slot 决定,
CRC16(key) % 16384,slot 固定落在某个分片。热 key 的所有请求都路由到同一实例,其他分片再空闲也帮不上——集群的横向扩展对「单 key 热点」无效,这是和 MySQL 分库分表最大的区别。 - 命令队列堆积 → 客户端 read timeout → 上游重试 → 放大:Letthuse 默认
commandTimeout=60s,但网关 2s 超时先触发,重试流量又全落到同一个 slot,形成正反馈。CLIENT LIST里能看到大量cmd=get排队,connected_clients从 800 涨到 3000+,全是同一种命令。
一个容易被忽略的点:Redis 的 CLIENT PAUSE 对热 key 几乎无效——它只是暂停新命令进入,已经排队的命令照样按顺序执行,单核执行瓶颈不变。MONITOR 只能用于采样定位,绝不能开在生产,每条命令都走一次日志输出会直接把单核打穿。
三、三级解法:localcache 止血 → key 分片治本 → 异步失效兜底
按落地顺序,每级都给出我们最终用的参数。
第 1 级:应用侧 localcache(止血,5 分钟上线)
Caffeine 2.9.3 配置:
Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofSeconds(2))
.recordStats()
.build(key -> redis.get(key));
效果:单分片 QPS 从 48 万降到 12 万(命中率 ~75%),P99 从 8s 回落到 220ms。
一致性的关键:不能只靠 TTL。我们让 Redis 里同时存两个 key:
item:detail:6666= 详情 JSONitem:detail:6666:ver= 单调递增版本号(商品变更时INCR)
localcache 命中时先比对 ver,不等则重取。商品改价后 ver 立刻变,localcache 内所有命中条目下一次读就失效,最坏延迟 1 个 TTL(2s)。
第 2 级:key 分片(治本,30 分钟改造)
把 item:detail:6666 拆成 item:detail:6666:shard:0 ~ :shard:7:
- 写路径:商品变更时 8 个 shard 都写(
MSET一条命令搞定),写 QPS 基线只有 200,放大 8 倍 = 1600,可忽略; - 读路径:客户端随机取一个 shard 读(
shard = random(0, 8)),单分片 QPS 从 48 万降到 6 万,彻底解除单核瓶颈; - 删除路径:商品下架时 8 个 shard 全删。
分片数怎么定?经验公式:shard 数 = 目标单 key QPS / 单分片安全 QPS。单分片安全 QPS 我们压测出来是 8 万(Redis 6 + 8C,GET 简单命令),48 万 / 8 万 = 6,取 8 留余量。
第 3 级:binlog 异步失效(兜底一致性)
商品变更走 MySQL → binlog → Canals → 广播到 12 台应用机 → 各机 localcache 主动 invalidate 对应 key。更新延迟从「等 2s TTL」降到 200ms 以内,且不再依赖读路径触发失效。
四、踩过的坑(按代价从大到小)
- localcache TTL 不能设太长。我们一开始图省事设了 5s,结果有一次商品改价 3s 才在所有机器生效,客服工单爆了 4 条。TTL 是「读 QPS 放大」和「一致性」的折中,1~3s 是甜蜜区,超过 3s 必须靠 ver 或广播失效兜底。
- key 分片的写入顺序必须是「先写新 shard,再删旧 key」。反过来(先删旧 key 再写新 shard)会有一个窗口期读到空,秒杀场景下等于「商品消失」。我们踩过一次,线上 200ms 内详情页 500 率飙到 30%。
- 热 key 探测不要等 P99 报警。P99 报警时单分片已经打满,止血窗口只剩几秒。正确做法:每 10s 用
MONITOR在一台低流量分片上采样(不是热分片,否则自杀),按 key 聚合 top10,阈值 5 万 QPS 就告警,早于 P99 30s 以上。 - 加集群分片对热 key 无效,这条最反直觉。
CLUSTER ADDSLOTS只改变 slot 归属,不改变 key 的 hash。要迁热 key 必须改 key 名(加 shard 后缀)或改 hash tag({item:6666}这种显式 tag 能强制落同一 slot,但热 key 场景下恰好是反模式)。 CLIENT PAUSE是心理安慰剂。它暂停新命令,但已排队命令照样跑,单核执行瓶颈不变。真正的限流要在应用侧(令牌桶 / 信号量)做,把 48 万 QPS 压到 8 万再进 Redis。
五、结论与可复用清单
热 key 的治理顺序是固定的:先限流(应用侧)→ 再 localcache(读放大)→ 最后 key 分片(写放大)。跳级做都会翻车——直接 key 分片会放大写路径,直接限流会伤正常用户。
可复用清单(可直接抄进团队的 SLO 检查单):
- 上线前压测必须包含「单 key QPS」维度,不能只看集群总 QPS;
- 监控
INFO stats的instantaneous_ops_per_sec按分片看,不要看集群总和——集群总和正常时单分片可能已经 90%; - localcache TTL 建议 1~3s,配版本号或广播失效兜底,不要裸 TTL;
- key 分片数 = 目标单 key QPS / 单分片安全 QPS(Redis 6 + 8C 简单命令经验值 5~10 万);
- 热 key 告警阈值 5 万 QPS(采样聚合),早于 P99 报警 30s 以上;
- 上游重试策略对 Redis 命令同样适用——网关 3 次 × 应用 2 次 = 6 倍放大在 Redis 命令队列上一样成立,热 key 场景下重试是雪上加霜,Redis 读命令默认不重试,失败直接降级到 localcache 或兜底值。
一句话:集群横向扩展解决「总容量」问题,热 key 是「单点」问题,解药永远在应用侧和 key 设计上,不在 Redis 集群参数里。