19.8 热点与数据倾斜
一句话
分片解决的是 “总量”,解决不了 “集中”:一个热点 key、一段最新时间、一个大 V,都能把单个分片打满——而扩容对热点无效,只会让 “平均” 更好看、热点更难看。
四种成因
| 成因 | 例子 | 特征 |
|---|---|---|
| 低基数分区键 | 按 status(只有 5 个值)分片 | 天然 5 个分片,且分布极不均 |
| Zipf 分布 | 商品详情、用户主页 | 头部 key 占大部分请求 |
| 时间序列 | 按日期分区,只查 “今天” | 最新分区承受几乎全部写入与查询 |
| 大 V / 热 key | 明星发一条动态 | 单 key 的 QPS 远超其他 |
怎么识别
| 信号 | 观测 |
|---|---|
| 分片间 QPS 方差 | 单分片 QPS / 平均值 > 2 |
| 分片间存储方差 | 最大/最小 > 1.5 |
| 单 key 热点 | 抓 top-N key 的访问计数(采样) |
| 延迟长尾 | P99 集中在少数分片 |
注意:平均值会掩盖问题。分片平均利用率 30%、但某一分片 95%,扩容后仍然是那一片最差。
六种解法
| 解法 | 做法 | 代价 |
|---|---|---|
| 加盐(salting) | key + 随机后缀,打散到 N 个分片 | 读要合并 N 份结果 |
| 拆分热 key | 把一个大 key 拆成 key#0..k 的子 key | 业务要改读写逻辑 |
| 本地聚合 + 全局合并 | 各分片先局部计数,再汇总 | 聚合延迟,实现复杂 |
| 缓存前置 | 热 key 上本地缓存/CDN | 一致性下降(见 41.6 热点 key 与分片) |
| 请求合并(single-flight) | 同一 key 的并发请求只打一次后端 | 需要锁与超时 |
| 按时间滚动分区 | 最新分区写、历史分区只读 | 查询要跨分区合并 |
数字:Zipf 有多集中
Zipf 分布(α≈1)下,前 1% 的 key 大约承担 20%–40% 的请求(具体取决于 α 与基数)。这意味着 “按 key 哈希分到 100 片”,仍有分片会因为落到几个头部 key 而超载——所以热 key 必须单独治理,不能只靠分片。
实测:Zipf 到底有多集中(修正一个常见估计)
本机 Python 3.14 实测(20 万次采样,按 1/r^α 的 CDF 采样):
| α | top 1% key 承担的请求 | 不同 key 数 |
|---|---|---|
| 0.8 | 27.8% | 79,217 |
| 1.1 | 65.2% | 31,997 |
| 1.4 | 86.1% | 7,369 |
很多文章说 “前 1% 的 key 占 20%–40%”,那对应的是 α≈0.8 的弱倾斜;真实业务(商品详情、用户主页、热搜)测得 α 常在 1.1 以上——前 1% 的 key 就能吃掉 2/3 的流量。这直接解释了 “100 个分片、平均负载 30%、却有一个分片 95%”。
# 复现:按 CDF 采样 Zipf,统计 top1% 占比
w = [1.0 / r**a for r in range(1, M+1)]; tot = sum(w)
acc, s = [], 0.0
for x in w: s += x; acc.append(s/tot)
rank = bisect.bisect_left(acc, random.random()) + 1 # 抽到的 key 排名
反模式
| 反模式 | 后果 |
|---|---|
| 用低基数字段做分区键 | 分片数被基数和死死限制 |
| 只扩容,不拆热 key | 热点分片依旧过载 |
| 用随机后缀打散,破坏范围查询 | 查询退化为全分片扫描 |
| 时间分区只按天切,查询只看今天 | 最新分区成为唯一热点 |
| 没有 top key 监控 | 热点到来时只能事后归因 |
三条铁律
🔑 分片治 “总量”,热 key 要单独治;扩容不能解决集中。
🔑 看方差,不看均值;平均值会掩盖倾斜。
🔑 打散要付代价:加盐/拆 key 都会把 “一次读写” 变成 “多次合并”。
自检
Q1 100 个分片,平均 CPU 30%,但有一个分片 95%,扩容能解决吗?
▸ 答案:不能。扩容只增加分片数,热点 key 仍映射到某一个分片;新分片接的是 “长尾流量”,热点分片负载不变。必须做 “热 key 治理”:拆 key、加盐、或在该 key 前加缓存。
Q2 加盐(key#rand)为什么会让读变复杂?
▸ 答案:因为同一个逻辑 key 被拆到 N 个物理分片,读要并行查 N 份再合并(或维护映射表);写也要写 N 份。若写失败部分成功,还要处理不一致。所以加盐只适合 “可合并的计数/统计” 场景。
Q3 为什么按时间分区容易出现 “最新分区热点”?
▸ 答案:因为写入几乎总落在最新分区(按时间追加),而查询也常看 “最近”。于是最新分区的写入与读放大都最高,历史分区则几乎空闲。解法:按时间 + 哈希二级分片,或把 “热窗口” 再细分。
Q4 单 key 热点用什么手段最有效?
▸ 答案:前置缓存 + 请求合并最直接:把 “N 个并发请求打同一个 key” 变成 “1 次后端 + N 次缓存命中”。如果是 “大 key 本身很大”(如大 V 粉丝列表),还要拆 key 或按页缓存。治本仍是 “让热点数据离用户更近”。
Q5 低基数分区键为什么是设计错误?
▸ 答案:因为分片数被基数限制:status 只有 5 个值 → 最多 5 个分片,且分布极不均(大部分订单是 “已完成”)。分区的第一要求是高基数(见 19.1 分区键设计),低基数字段只能作为二级索引或过滤条件。
Q7 α=1.4 时 top1% 占 86%,分片还有意义吗?
▸ 答案:有,但分片解决的是 “长尾的 14%” 与写放大,不是 “头部 86%”。此时正确策略是:热 key 单独治理(前置缓存、请求合并、把大 key 拆成子 key),分片只作为 “兜住剩余流量” 的基础设施。指望靠加 100 个分片摊薄 86%,数学上不成立。