41.6 热点 key 与分片
一句话
热点 key 的问题不是“缓存没命中”,而是“命中之后所有请求都打向同一个分片”——哈希能把 key 均匀分布,但没法把一个 key 拆开;解法顺序是本地缓存 → 副本分片 → 值分片 → 限流降级,前两步免费,后两步要付读放大或写放大的账。
先分清三种“热”
| 类型 | 表现 | 根因 | 解法 |
|---|---|---|---|
| 热 key | 单个 key 的 QPS 远超其他(占实例 30%+) | 业务集中访问(秒杀商品、首页配置、明星微博) | 本地缓存、副本分片 |
| 大 key | 单个 value 几 MB / 集合几十万成员 | 数据结构设计问题(把列表塞进一个 key) | 拆分数据结构,别用单 key 存集合 |
| 热分片 | 整个分片(不是单 key)CPU 打满 | 分片键设计不均(19.8 热点与数据倾斜) | 换分片键、虚拟节点 |
🔑 热 key 与大 key 经常同时出现,但修法相反:热 key 要复制(多份),大 key 要拆分(多段)。先判断是哪一种,再动手。
四种手段的代价
| 手段 | 原理 | 读放大 | 写放大 | 一致性代价 | 什么时候用 |
|---|---|---|---|---|---|
| 本地缓存前置 | 进程内缓存,命中就不出网 | 0 | 0 | 失效传播有延迟(41.7) | 第一步,几乎总是先做 |
| 副本分片 | 同一个 key 复制 N 份,读随机选一份 | 1 | N(每次写要写 N 份) | 各副本短暂不一致 | 读多写少的极热数据 |
| 值分片 | 一个逻辑值拆成 N 份(如计数器分桶) | N(读要聚合) | 1 | 聚合有误差/延迟 | 可加和的计数、库存分桶 |
| 限流 / 降级 | 保护后端,超出的请求排队或降级 | 0 | 0 | 无(但体验下降) | 兜底,防止打穿 |
代码:同一个 5 万 QPS 热点,四种方案能不能扛住
package main
import "fmt"
const (
hotQPS = 50000.0 // 单个热点 key 的读 QPS
shardCapQPS = 10000.0 // 单个分片能扛的 QPS
replicas = 16 // 副本分片数 / 值分片数
localHitRate = 0.95 // 本地缓存命中率
)
type plan struct {
name string
originQPS float64 // 回源到分布式缓存的 QPS
shards int // 承载这个 key 的分片数
readOpsPerReq float64 // 每次业务读,打向缓存的操作数
writeOpsPerWrite float64 // 每次业务写,打向缓存的操作数
note string
}
func main() {
plans := []plan{
{"A 不拆(单 key 落单分片)", hotQPS, 1, 1, 1, "热点全压一个分片"},
{"B 副本分片 ×16", hotQPS, replicas, 1, replicas, "读随机选一份,写要写全"},
{"C 值分片 ×16(计数器分桶)", hotQPS, replicas, replicas, 1, "读要聚合 16 份"},
{"D 本地缓存 95% + 单分片", hotQPS * (1 - localHitRate), 1, 1, 1, "只回源 5%"},
{"E 本地缓存 95% + 副本 ×4", hotQPS * (1 - localHitRate), 4, 1, 4, "回源再摊薄"},
}
fmt.Printf("=== 热点 key:%.0f QPS,单分片上限 %.0f QPS ===\n\n", hotQPS, shardCapQPS)
fmt.Printf("%-30s %-12s %-12s %-10s %-10s %s\n",
"方案", "回源QPS", "每分片QPS", "每读操作", "每写操作", "判定")
for _, p := range plans {
perShard := p.originQPS / float64(p.shards)
verdict := "✓ 可扛"
if perShard > shardCapQPS {
verdict = fmt.Sprintf("✗ 超容量 %.1f 倍", perShard/shardCapQPS)
}
fmt.Printf("%-30s %-12.0f %-12.0f %-10.0f %-10.0f %s\n",
p.name, p.originQPS, perShard, p.readOpsPerReq, p.writeOpsPerWrite, verdict)
}
// 单分片能容纳所需的本地缓存命中率
need := 1 - shardCapQPS/hotQPS
fmt.Printf("\n单分片要扛住这个热点,本地缓存命中率需 ≥ %.1f%%(当前 %.0f%%)\n",
need*100, localHitRate*100)
// 值分片的聚合延迟:一次 MGET 16 个分片的额外往返
fmt.Printf("值分片 ×%d 的读:一次逻辑读要多取 %d 份,若走 pipeline 仍是 1 个 RTT,但网络包与 CPU 放大 %d 倍\n",
replicas, replicas, replicas)
}
本机实测(macOS,Apple M1,Go 1.27):
=== 热点 key:50000 QPS,单分片上限 10000 QPS ===
方案 回源QPS 每分片QPS 每读操作 每写操作 判定
A 不拆(单 key 落单分片) 50000 50000 1 1 ✗ 超容量 5.0 倍
B 副本分片 ×16 50000 3125 1 16 ✓ 可扛
C 值分片 ×16(计数器分桶) 50000 3125 16 1 ✓ 可扛
D 本地缓存 95% + 单分片 2500 2500 1 1 ✓ 可扛
E 本地缓存 95% + 副本 ×4 2500 625 1 4 ✓ 可扛
单分片要扛住这个热点,本地缓存命中率需 ≥ 80.0%(当前 95%)
值分片 ×16 的读:一次逻辑读要多取 16 份,若走 pipeline 仍是 1 个 RTT,但网络包与 CPU 放大 16 倍
读数:
| 方案 | 结论 |
|---|---|
| A 不拆 | 超容量 5 倍,单分片 CPU 打满,同分片其他 key 一起变慢(故障扩散) |
| B 副本 ×16 | 能扛,但每次写要写 16 份:写 QPS 1,000 就意味着 16,000 次缓存写 |
| C 值分片 ×16 | 能扛,但每次读要聚合 16 份:读放大 16 倍,且聚合结果可能不一致 |
| D 本地缓存 95% | 回源只剩 2,500 QPS,单分片就够,读放大 0——所以它排第一 |
| E 本地缓存 + 副本 ×4 | 冗余最高,但只在“本地缓存也无法收敛”时才需要 |
🔑 顺序不是随便排的:本地缓存把“回源”降一个数量级,副本/值分片才在剩余流量上做文章。先分片后加缓存,等于用 16 倍的写放大去解决一个本地缓存就能解决的问题。
分片算法:解决的是“分布”,不是“热点”
| 算法 | 迁移量 | 热点 key 能不能打散 | 备注 |
|---|---|---|---|
取模 hash % N | 扩容时几乎全量迁移 | 不能 | 简单,慎扩容 |
| 一致性哈希 | 只迁移相邻区间 | 不能 | 见 19.4 一致性哈希 |
| 虚拟节点 | 同上,且更均匀 | 不能 | 见 19.5 虚拟节点 |
| 客户端分片 + 本地表 | 取决于实现 | 不能 | 适合可控的固定规模 |
⚠️ 这是最容易混淆的一点:哈希只能保证“不同 key 分散”,对“同一个 key 的 QPS 过高”完全无能为力。热点 key 必须从业务层拆(加副本/分桶),或者用本地缓存挡住,不能指望换哈希算法解决。
怎么发现热点
| 信号 | 采集方式 | 阈值(经验值) |
|---|---|---|
| 单 key QPS 占比 | 客户端埋点 / 代理采样(每 N 个请求记一次 key) | 单 key > 实例总 QPS 的 10% |
| 单分片 CPU 显著高于其他 | 实例级监控 | 某实例 CPU 是均值的 2 倍 |
| 慢查询 | SLOWLOG(41.9) | 单命令 > 10ms |
| 大 key | redis-cli --bigkeys / MEMORY USAGE | value > 1MB 或集合 > 10 万元素 |
| 网络出口打满 | 实例网卡监控 | 单实例带宽接近上限(大 value 热 key 的典型表现) |
反模式
| 反模式 | 后果 |
|---|---|
| 用一致性哈希“解决”热点 key | 同一个 key 永远落一个分片,换算法没用 |
| 给热 key 加 16 个副本却不改写入路径 | 写只写 1 份 → 16 份数据不一致 |
| 值分片后读做 16 次串行 GET | 读延迟 ×16,比不分片更慢 |
| 本地缓存没有失效通道 | 数据改完后各实例永远读到旧值(41.7) |
| 只加缓存不限流 | 缓存穿透或雪崩时,5 万 QPS 直接打向数据库 |
| 把大 key 当热 key 修(加副本) | 副本让大 key 的复制/网络开销翻倍,问题更严重 |
自检
Q1 一个秒杀商品的详情 key 有 5 万 QPS,单分片上限 1 万。团队决定“加 8 个副本分片”。这个方案够吗?还要改什么?
▸ 答案:8 副本后每分片 50000/8 = 6250 QPS,容量上够;但必须同时改三处:① 写入路径——任何更新要写 8 份(或先删后由读回填,但要接受短暂不一致);② 读路径——随机选一份(不能固定读第一份,否则又回到热点);③ 一致性——8 份的过期/失效要一起处理,否则用户会看到不同实例给出不同价格。更稳的做法是先加本地缓存(把回源降到 2500),再决定要不要副本。
Q2 为什么“值分片”适合计数器,却不适合商品详情?
▸ 答案:因为值分片要求“这个值可以拆开再合并”。计数器可以拆成 16 个桶各自 INCR,读时求和,合并是可交换、可结合的;商品详情是一个 JSON 对象,拆成 16 份后每次读都要取回 16 份再拼装,读放大 16 倍且拼装逻辑复杂。值分片的本质是“用读放大换写分散”,只对可聚合的数据成立。
Q3 本地缓存能挡住热点,但它自己会带来什么问题?
▸ 答案:一致性。本地缓存分布在 N 个进程里,数据更新后要通知所有进程失效;通知丢失、延迟、乱序都会导致某些实例长期返回旧值(41.7 的“陈旧上界”)。缓解手段:① 短 TTL 兜底(接受秒级陈旧);② 广播失效 + 版本号(拒绝旧版本覆盖);③ 只把“可容忍陈旧”的数据放本地(配置、榜单),价格/库存不放。
Q4 一个实例的 CPU 打满,但监控显示没有特别热的单 key。可能是什么原因?
▸ 答案:大概率是热分片而不是热 key:分片键设计导致某个分片承载了过多 key(如按用户 ID 哈希但大客户集中在少数 ID 段),或者某个分片上有多个“中等热度”key 叠加。排查顺序:① 看是“单实例高”还是“全集群高”——单实例高 = 分布问题;② 抽样看该实例上的 key 分布与命令耗时;③ 若是大 key 导致的单次命令阻塞,看慢查询与网络出口。解法是换分片键/加虚拟节点,不是加副本。
把整个城市的 POI 塞进一个 GEO key,是“大 key”最典型的一种来源,拆法与判据见 45.5 地理空间检索 (GeoHash / 空间索引)。