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 / 空间索引)。

关联