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%

四个读法:

  1. key 数量完全均匀(1.00),但同一批 key 的请求负载是 3.02 倍——哈希管不了业务热度;
  2. 一个占 40% 请求的热点 key,把单片压到 51.4%(平均值的 8.2 倍),扩容到更多分片也没用,这个 key 还是落在一片;
  3. 加盐拆成 16 个子键后,最大分片降到 13.8%,代价是「按这个 key 的查询要 fan-out 16 片」;
  4. 单调 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 / 文档 / 列族 / 图)。