19.1 分区键设计
一句话
分区键同时决定「一条数据去哪一片」和「一次查询要打几片」;好的分区键要同时满足高基数、负载均匀、查询收敛、热点可拆,这四条经常互相冲突,必须按业务排优先级。
四个判据,缺一个就会出事
| 判据 | 问什么 | 不满足的后果 |
|---|---|---|
| 高基数 | 可选值够不够多? | 分区数上不去,一片顶到天 |
| 负载均匀 | key 数量、请求量、存储量是否都均匀? | 单片过热,扩容也救不了 |
| 查询收敛 | 主查询能否只打一片? | 每次查询 fan-out 到所有分片,尾延迟爆炸 |
| 热点可拆 | 超级热点能否拆成多个子键? | 一个大客户/爆款商品拖垮一片 |
🔑 第一课:哈希只保证 key 数量均匀,不保证负载均匀。请求分布由业务决定,跟哈希函数无关。
算一笔账:四种分区键的负载分布
模拟:16 个分片,10 万用户,请求按 Zipf(α=1.1)分布;另设一个「占 40% 请求的超级用户」,以及一个单调递增 ID 的范围分区。
package main
import (
"fmt"
"math"
"sort"
)
const shards = 16
func hash(s string) uint64 { // FNV-1a,仅用于演示确定性分布
var h uint64 = 1469598103934665603
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= 1099511628211
}
return h
}
func shardOf(key string) int { return int(hash(key) % shards) }
func report(name string, load [shards]float64, total float64) {
max, min := 0.0, math.MaxFloat64
for _, v := range load {
if v > max {
max = v
}
if v < min {
min = v
}
}
fmt.Printf("%-28s 最大/平均 %.2f 最小/平均 %.2f 最大分片占比 %.1f%%\n",
name, max/(total/shards), min/(total/shards), max/total*100)
}
func main() {
// 0) key 数量:100 万个 user_id 均匀哈希到 16 片
var keys [shards]float64
for i := 0; i < 1000000; i++ {
keys[shardOf(fmt.Sprintf("user-%d", i))]++
}
report("哈希分片(key 数量)", keys, 1000000)
// 1) 请求负载:Zipf 长尾,抽样 20 万次
const users = 10000
requests := 200000
cum := make([]float64, users)
sum := 0.0
for i := 0; i < users; i++ {
sum += 1 / math.Pow(float64(i+1), 1.1)
cum[i] = sum
}
seed := uint64(12345)
next := func() float64 {
seed = seed*6364136223846793005 + 1442695040888963407
return float64(seed>>11) / float64(1<<53)
}
var load [shards]float64
for i := 0; i < requests; i++ {
u := sort.SearchFloat64s(cum, next()*sum)
load[shardOf(fmt.Sprintf("user-%d", u))]++
}
report("按 user_id 哈希(长尾请求)", load, float64(requests))
// 2) 超级热点:40% 请求集中到一个用户
var hot [shards]float64
for i := 0; i < requests; i++ {
var key string
if next() < 0.4 {
key = "user-0"
} else {
u := sort.SearchFloat64s(cum, next()*sum)
key = fmt.Sprintf("user-%d", u)
}
hot[shardOf(key)]++
}
report("热点用户占 40%", hot, float64(requests))
// 3) 打散热点:把热点 key 拆成 16 个带盐的子键
var salted [shards]float64
for i := 0; i < requests; i++ {
if next() < 0.4 {
salted[shardOf(fmt.Sprintf("user-0#%d", int(next()*16)))]++
} else {
u := sort.SearchFloat64s(cum, next()*sum)
salted[shardOf(fmt.Sprintf("user-%d", u))]++
}
}
report("热点拆 16 份(加盐)", salted, float64(requests))
// 4) 单调 ID 按范围分区:1 万次连续插入全落在当前范围
var window [shards]float64
for id := 999000; id < 1000000; id++ {
window[(id/62500)%shards]++
}
report("单调 ID 范围分区(写窗口)", window, 1000)
}
输出(本机运行):
哈希分片(key 数量) 最大/平均 1.00 最小/平均 1.00 最大分片占比 6.3%
按 user_id 哈希(长尾请求) 最大/平均 3.02 最小/平均 0.55 最大分片占比 18.9%
热点用户占 40% 最大/平均 8.22 最小/平均 0.33 最大分片占比 51.4%
热点拆 16 份(加盐) 最大/平均 2.20 最小/平均 0.47 最大分片占比 13.8%
单调 ID 范围分区(写窗口) 最大/平均 16.00 最小/平均 0.00 最大分片占比 100.0%
四个读法:
- key 数量完全均匀(1.00),但同一批 key 的请求负载是 3.02 倍——哈希管不了业务热度;
- 一个占 40% 请求的热点 key,把单片压到 51.4%(平均值的 8.2 倍),扩容到更多分片也没用,这个 key 还是落在一片;
- 加盐拆成 16 个子键后,最大分片降到 13.8%,代价是「按这个 key 的查询要 fan-out 16 片」;
- 单调 ID 的范围分区,写窗口 100% 落在最后一个分片——存储最终均匀,但写永远只打一片。
选分区键的五个维度
| 维度 | 典型键 | 优点 | 风险 |
|---|---|---|---|
| 用户/租户 | user_id、tenant_id | 高基数、单用户查询收敛 | 超级租户热点;租户内数据倾斜 |
| 时间 | created_at、日期 | 范围查询、冷热分层、按时间删除 | 写永远打最新分片(见 19.3) |
| 地理 | region、city_id | 就近访问、合规隔离 | 地区人口/业务量不均 |
| 业务实体 | order_id、product_id | 与主查询对齐 | 爆款商品/大商户热点 |
| 复合键 | (tenant_id, user_id)、(user_id, created_at) | 兼顾隔离与局部性 | 键变长;前缀决定分布 |
🔑 判据的顺序应该是:先看主查询能不能收敛到一片,再看负载是否均匀,最后看热点能不能拆。只追求「哈希均匀」而忽略查询模式,会得到一堆均匀但每次都要 fan-out 的分片。
单调键:最容易被忽视的写热点
自增 ID、时间戳、雪花 ID(高位含时间)都近似单调。按它们做范围分区,写入永远落在「当前区间」:
| 方案 | 写分布 | 范围查询 | 说明 |
|---|---|---|---|
| 原始时间戳/自增 ID | 100% 打一个分片 | ✅ 连续 | 单写者也能打满一片的磁盘/CPU |
反转时间戳(MAX-ts) | 仍然 100% 打一片,只是换了个方向 | ✅ 逆序 | 不解决热点,只改变方向 |
前缀加盐(hash(id)%16 + id) | 均匀到 16 片 | ❌ 需 fan-out | 用查询换写入均匀 |
复合键(tenant_id, ts) | 按租户分散,大租户内仍热点 | ✅ 租户内连续 | 多租户系统常用 |
雪花 ID 常被误认为「已经分散」:它的低位是序列号,高位是时间,整体仍随时间单调递增;按 ID 范围分区照样热点。要分散,必须把非单调的维度(用户、租户、随机盐)放到分区键前缀。
分区键一旦写入,就不能改
分区键是「数据在哪」的坐标,改键意味着删旧行 + 插新行 + 跨分片迁移,不是一条 UPDATE 能完成的:
| 需求 | 正确做法 | 错误做法 |
|---|---|---|
| 按非分区键查询 | 建二级索引表/全局索引(见 19.9 二级索引与分区) | 全分片扫描 |
| 修改分区键 | 双写迁移 + 校验 + 删旧(见 19.7 再平衡与数据迁移) | 直接 UPDATE 分区键 |
| 跨分区唯一约束 | 单独的唯一性服务/协调 | 假设分片内唯一就是全局唯一 |
| 跨分区事务 | 尽量把相关数据放同一分区;否则 2PC/Saga(24.9 事务模型选型) | 在一个本地事务里改两个分片 |
决策表
| 业务 | 推荐分区键 | 理由 |
|---|---|---|
| 用户中心、订单(按用户查) | user_id | 单用户查询收敛;用户量高基数 |
| 多租户 SaaS | tenant_id 前缀,必要时 (tenant_id, user_id) | 隔离 + 单租户查询收敛 |
| 聊天消息 | (conversation_id, seq) | 会话内有序,会话间分散 |
| 时序/日志 | (series_id, time_bucket) | 按序列分散,桶内有序 |
| 商品详情 | product_id | 单商品查询收敛;爆款需缓存/副本 |
| 账单/流水 | (account_id, month) | 账户内有序;大账户加盐 |
| 全局配置 | 不分区或小表广播 | 需要全局一致(16.2 线性一致性) |
反模式
| 反模式 | 后果 |
|---|---|
| 只看「哈希均匀」选键 | 请求/热点仍倾斜,扩容无效 |
| 用单调递增 ID 做范围分区键 | 写永远打最后一个分片 |
| 用低基数字段(状态、性别、省份)做键 | 分片数受限于枚举值,严重倾斜 |
| 用可变字段做分区键 | 更新即迁移,事务与一致性都变复杂 |
| 热点 key 不做拆分/缓存 | 单片打满,其他片空闲 |
| 按查询维度之外随便选键 | 每次查询 fan-out 全分片,P99 失控 |
| 忽略跨分区事务 | 本地事务写两个分片,一半成功一半失败 |
自检
Q1 为什么 100 万个 user_id 哈希到 16 片后「最大/平均 = 1.00」,而同样是 user_id 的请求负载却是 3.02?
▸ 答案:哈希均匀的是key 的数量:每个 user 只占一行,100 万个 key 分到 16 片自然接近 1/16。请求负载取决于用户活跃度,Zipf 分布下少数用户贡献大部分请求;这些用户落到哪片,哪片就热。要平衡请求,要么按请求特征拆热点(加盐、副本、缓存),要么换键,哈希函数本身无能为力。
Q2 一个占 40% 请求的超级用户,加盐拆成 16 份后最大分片从 51.4% 降到 13.8%。代价是什么?什么时候不该拆?
▸ 答案:代价是查询 fan-out:按这个用户的查询要合并 16 个子键的结果(或维护聚合),写入也要写 16 份/或按子键路由,跨子键的事务与排序都变复杂。不该拆的场景:需要该用户的强一致读/写(拆开后跨子键原子性难保证)、子键数量远小于分片数导致「拆了还是集中」、以及热点只是短时突发(缓存/排队更划算)。
Q3 为什么「反转时间戳」不能解决范围分区的写热点?
▸ 答案:反转只是把「最大时间」映射成「最小键」,写入仍然只落在一个端点(原来是最后一个分片,现在是第一个分片)。单调性没变,只是方向变了;要打散必须引入非单调维度(哈希前缀、随机盐、租户 ID),代价是范围查询要 fan-out。
Q4 分区键为什么不能随便改?如果业务确实要改,怎么做?
▸ 答案:分区键决定数据在哪个分片,UPDATE 分区键等于「从 A 片删除、在 B 片插入」,跨分片操作无法用一个本地事务保证原子性,还会触发数据搬迁与索引重建。正确做法是双写 + 回填 + 校验 + 切读 + 删旧:新键写入新位置,后台把旧数据迁过去,校验一致后切读,最后清理旧数据;期间要处理并发更新(用版本号/幂等,见 25.4 幂等设计与去重)。
Q5 一个按 order_id 分区的订单库,运营要「查某用户最近 10 单」,怎么办?
▸ 答案:order_id 分区让「按订单查」收敛,但「按用户查」必须 fan-out。方案:① 建 (user_id, created_at) → order_id 的二级索引表,按 user_id 分区,先查索引再按订单取详情(见 19.9 二级索引与分区);② 双写一份按 user_id 分区的宽表/搜索索引(CQRS,见 32.1 CQRS,待补);③ 如果查询量小,接受 fan-out 到全部分片并设超时。选哪种取决于查询 QPS 与一致性要求,不能既想单分区又想任意维度查询。
跨篇相关:哈希分区与取模扩容见 19.2 哈希分区;范围分区与写热点见 19.3 范围分区;一致性哈希与虚拟节点见 19.4 一致性哈希、19.5 虚拟节点;分库分表的工程落地见 44.14 分库分表;水平扩展的整体视角见 37.1 垂直扩展与水平扩展、37.3 分片与分区;热点与数据倾斜见 19.8 热点与数据倾斜;二级索引见 19.9 二级索引与分区;再平衡与迁移见 19.7 再平衡与数据迁移;跨分区事务见 24.9 事务模型选型;NoSQL 的分区模型见 44.15 NoSQL 四类 (KV / 文档 / 列族 / 图)。