一个订单按什么键分片
arch-lab 的订单表准备从单库拆成 4 个分片。团队里最常见的两种意见是:按 order_id 分,写入一定均匀;按 user_id 分,“我的订单”列表只查一个分片。两边都能说出理由,但真正要先回答的不是“哪个更均匀”,而是“哪些操作必须只靠请求参数就能定位分片”。
下单时,订单号由 ID 生成器先分配,user_id、order_id、商品和金额同时已知。支付回调只带 order_id;客服按订单号查详情;用户按 user_id 查历史订单;运营按 merchant_id 拉当日成交。四个入口里,只有 order_id 和 user_id 是稳定的业务主键,merchant_id 的列表天然跨分片。
错误直觉是“哈希函数均匀,所以分片键随便选”。哈希只能保证键值到分片的映射均匀,不能保证请求分布均匀,也不能把跨键查询变成单分片查询。分片键一旦写进数据,变更成本接近一次全量迁移;这个决定要在建表前用查询清单验证,而不是上线后靠加机器补救。站内 一致性哈希与数据分片 已经介绍过 Range 与 Hash 的算法对比,本节只讨论订单场景下的取舍与可验证的判据。
分片键的三条业务约束
第一条是可路由。分片键必须能从请求里直接得到,或者能由一张稳定的小表反查。order_id 满足前者;user_id 对“我的订单”满足,但支付回调要先按 order_id 找到订单,再得到 user_id,这就多了一次全局索引查询。索引表本身也要分片,故障面反而扩大。
第二条是事务边界。订单、库存预占、支付是三个聚合,库存按 SKU 与仓库归属,订单按用户或订单号归属,它们不可能总是落在同一分片。把 Order 和 InventoryReservation 强行放进同一分片,会让库存分片随订单分片数量一起膨胀;拆开则必须接受跨分片事务,后续用 Saga 或 outbox 处理。分片键的选择不能假装这个边界不存在。
第三条是分布与局部性的取舍。按 order_id 哈希,写入均匀,但用户订单列表要扇出到全部分片;按 user_id 哈希,列表单分片,但大商户、直播促销账号会把请求压到单个分片。两种方案都不是免费的,区别只是把成本放在读放大还是热点上。
| 候选键 | 写入分布 | 典型查询 | 主要风险 | 适用前提 |
|---|---|---|---|---|
order_id | 均匀 | 按订单号查、支付回调 | 用户列表扇出 | 订单号全局唯一且先分配 |
user_id | 依赖用户活跃度 | 我的订单、用户维度统计 | 大用户热点 | 用户量足够大且无超级账号 |
merchant_id | 高度倾斜 | 商户对账、结算 | 头部商户打爆分片 | 仅适合作为二级分区 |
(tenant_id, order_id) | 租户内均匀 | 租户隔离查询 | 大租户仍需二次拆分 | 多租户 SaaS |
如果业务同时要求“按订单号直达”和“按用户单分片”,常见做法是以 user_id 为主分片键,另建 order_id → user_id 的全局路由索引。索引可以放在独立的 KV 或路由服务里,但必须接受它与订单数据不一致的窗口,并让查询在索引缺失时回源扫描。这个方案的成本要提前算进延迟预算,而不是等到上线后才发现支付回调多了一次跨服务调用。
热点是请求维度的问题,不是哈希维度
即使 4 个分片各拿到 25% 的订单行,也不代表各拿到 25% 的请求。一个直播间在 10 分钟内产生大量订单,这些订单的 order_id 仍然均匀散在 4 个分片,但库存扣减、优惠券校验、风控查询都围绕同一个 user_id 或 promotion_id。哈希看不见这种集中,因为热点不在分片键上。
因此监控要同时看两层:分片层的 QPS、CPU、慢查询,以及逻辑键层的请求计数。下面的 HotspotWindow 按逻辑键累计窗口内访问量,超过阈值就进入热点清单。它能回答“哪个用户在打爆系统”,而 Distribution 只能回答“每个分片存了多少行”。两者不是替代关系。
识别热点之后的处置顺序通常是:先做请求合并与缓存,避免同一商品的库存查询打到存储;再把超热键单独路由到专用分片或副本;最后才考虑给热点键加后缀拆分。拆后缀会改变键的语义,必须同步修改路由表和历史数据,属于有状态变更,不能当开关用。
一致性哈希解决的是扩容迁移量
取模分片 hash(key) % N 的问题是扩容时几乎全部键都要搬家。4 个分片扩到 5 个,只有约 20% 的键恰好保持 hash % 4 == hash % 5,其余约 80% 需要迁移。一致性哈希把节点和键都映射到同一个环上,扩容一个节点只接管环上相邻的一段,理论上只移动 1/(N+1) 的键;4→5 时约 20%。
虚拟节点解决的是均匀性。每个物理分片只放一个环上位置时,4 个点把环切成 4 段,段长由哈希值决定,可能极度不均;给每个分片放 256 个虚拟节点后,段长被切得更碎,单个分片的份额更接近 1/N。虚拟节点越多,分布越稳,但环上点数、内存和 sort.Search 的比较次数也线性增加。256 是一个常见起点,不是固定答案。
还有一个常被忽略的边界:一致性哈希只保证“搬得少”,不保证“搬得对”。如果分片键本身会更新,例如订单从个人用户转给企业账户,哈希环无法处理归属变化,仍然要双写和迁移。下一节会专门处理这种有状态迁移。
可运行实现
下面的包只依赖标准库,包含环构建、路由、分布统计、迁移计数和热点窗口。Ring 构建后不可变,Route 可以并发调用;每个物理节点放置 replicas 个虚拟节点,环上位置按哈希排序,查找用二分。
// file: internal/platform/sharding/ring.go
// tier: L1
// verify: go test ./internal/platform/sharding/ -count=1
package sharding
import (
"fmt"
"hash/crc32"
"sort"
)
// Node identifies one shard.
type Node string
type point struct {
hash uint32
node Node
}
// Ring is an immutable consistent-hash ring. Build it once and route
// concurrently; Route has no mutable state.
type Ring struct {
points []point
nodes []Node
replicas int
}
// NewRing places replicas virtual nodes for every shard on a CRC32 ring.
func NewRing(nodes []Node, replicas int) (*Ring, error) {
if len(nodes) == 0 {
return nil, fmt.Errorf("sharding: no nodes")
}
if replicas <= 0 {
return nil, fmt.Errorf("sharding: replicas must be positive, got %d", replicas)
}
r := &Ring{replicas: replicas}
seen := make(map[Node]bool, len(nodes))
for _, n := range nodes {
if n == "" {
return nil, fmt.Errorf("sharding: empty node name")
}
if seen[n] {
return nil, fmt.Errorf("sharding: duplicate node %q", n)
}
seen[n] = true
r.nodes = append(r.nodes, n)
for i := 0; i < replicas; i++ {
r.points = append(r.points, point{
hash: hashKey(fmt.Sprintf("%s#%d", n, i)),
node: n,
})
}
}
sort.Slice(r.points, func(i, j int) bool {
if r.points[i].hash != r.points[j].hash {
return r.points[i].hash < r.points[j].hash
}
return r.points[i].node < r.points[j].node
})
return r, nil
}
func hashKey(key string) uint32 {
return crc32.ChecksumIEEE([]byte(key))
}
// Route returns the shard that owns key.
func (r *Ring) Route(key string) Node {
h := hashKey(key)
i := sort.Search(len(r.points), func(i int) bool {
return r.points[i].hash >= h
})
if i == len(r.points) {
i = 0
}
return r.points[i].node
}
// Distribution counts keys per shard.
func Distribution(r *Ring, keys []string) map[Node]int {
out := make(map[Node]int, len(r.nodes))
for _, k := range keys {
out[r.Route(k)]++
}
return out
}
// MaxDeviation returns the largest distance between a shard's key share and
// the ideal 1/len(nodes).
func MaxDeviation(r *Ring, keys []string) float64 {
if len(keys) == 0 {
return 0
}
ideal := 1.0 / float64(len(r.nodes))
dist := Distribution(r, keys)
var worst float64
for _, n := range r.nodes {
d := float64(dist[n])/float64(len(keys)) - ideal
if d < 0 {
d = -d
}
if d > worst {
worst = d
}
}
return worst
}
// Moved counts keys whose owner changes between two rings.
func Moved(oldRing, newRing *Ring, keys []string) int {
moved := 0
for _, k := range keys {
if oldRing.Route(k) != newRing.Route(k) {
moved++
}
}
return moved
}
// HotKey is one logical key observed above the hotspot threshold.
type HotKey struct {
Key string
Count int
}
// HotspotWindow counts accesses per logical key. It catches business hotspots
// that a uniform physical shard key cannot see.
type HotspotWindow struct {
minCount int
counts map[string]int
}
// NewHotspotWindow flags keys with at least minCount observations.
func NewHotspotWindow(minCount int) *HotspotWindow {
if minCount < 1 {
minCount = 1
}
return &HotspotWindow{minCount: minCount, counts: make(map[string]int)}
}
// Observe adds n accesses for key.
func (w *HotspotWindow) Observe(key string, n int) {
if n > 0 {
w.counts[key] += n
}
}
// HotKeys returns flagged keys by count descending, then key ascending.
func (w *HotspotWindow) HotKeys() []HotKey {
var hot []HotKey
for k, c := range w.counts {
if c >= w.minCount {
hot = append(hot, HotKey{Key: k, Count: c})
}
}
sort.Slice(hot, func(i, j int) bool {
if hot[i].Count != hot[j].Count {
return hot[i].Count > hot[j].Count
}
return hot[i].Key < hot[j].Key
})
return hot
}
这个实现有三个刻意选择。第一,哈希用 crc32.ChecksumIEEE,只用标准库,确定性好,适合复现实验;它不是抗碰撞哈希,面对恶意构造的键会被引导到同一段环,生产上应换成带随机种子的哈希或加盐。第二,环上的点允许哈希碰撞,排序时用节点名打破平局,保证构建结果确定。第三,NewRing 拒绝空节点、重复节点和非正虚拟节点数,避免一个配置错误悄悄退化成单点。
本机实测:分布、偏斜与迁移比例
测试用 10 万个合成订单键(order-%07d),4 个分片,每片 256 个虚拟节点,然后比较 4→5 扩容前后的归属。命令和原始输出如下:
$ go test ./internal/platform/sharding/ -v -count=1
=== RUN TestRingDistribution
ring_test.go:26: shard=shard-0 keys=28139 share=0.2814
ring_test.go:26: shard=shard-1 keys=22112 share=0.2211
ring_test.go:26: shard=shard-2 keys=23075 share=0.2308
ring_test.go:26: shard=shard-3 keys=26674 share=0.2667
--- PASS: TestRingDistribution (0.02s)
=== RUN TestVirtualNodesReduceSkew
ring_test.go:46: replicas=1 max_deviation=0.7359
ring_test.go:47: replicas=256 max_deviation=0.0331
--- PASS: TestVirtualNodesReduceSkew (0.00s)
=== RUN TestMigrationRatio
ring_test.go:67: moved=21981/100000 share=0.2198
--- PASS: TestMigrationRatio (0.02s)
=== RUN TestHotspotWindow
--- PASS: TestHotspotWindow (0.00s)
=== RUN TestNewRingRejectsInvalidInput
--- PASS: TestNewRingRejectsInvalidInput (0.00s)
PASS
ok archlab/internal/platform/sharding 0.430s
读数要分三层看。分布层,4 个分片的份额是 28.14%、22.11%、23.08%、26.67%,最大份额比理想值高约 3.1 个百分点。偏斜层,每个物理节点只放 1 个虚拟节点时,最大偏差是 73.59%;放到 256 个虚拟节点后降到 3.31%,说明虚拟节点确实在切碎环上的长段,而不是只改善平均值。迁移层,4→5 扩容移动了 21981 个键,占 21.98%,与理论值 20% 同量级。
这些数字的边界要说清楚:这是单机、单进程、CRC32、顺序合成键的结果,不是生产集群的分布证明。真实订单号常带时间前缀或由 Snowflake 生成,低比特有规律,分布可能不同;上线前应取一天的真实键样本重跑同样的三个统计量,而不是直接引用本节数字。测试耗时 0.325 秒只说明本机这组数据规模下的运行时间,不代表生产迁移耗时。
扩容判据
分片不是一次性的容量动作,而是一组需要退出条件的决策。下表把本节可测的量转成动作,避免“感觉不均匀就加机器”。
| 观测 | 判据 | 动作 | 退出条件 |
|---|---|---|---|
| 分片份额 | 256 虚拟节点下最大偏差 > 10% | 先查键生成与哈希,不要加节点 | 连续两次采样偏差 < 5% |
| 逻辑键请求 | 单键窗口占比 > 5% | 缓存、合并、专用分片 | 热点键回落到 2% 以下 |
| 扩容迁移量 | 实测移动比例 > 1/(N+1) + 5pp | 检查环构建、节点名与键集是否变化 | 固定键集复测回到理论区间 |
| 查询模式 | 出现按非分片键的范围查询 | 评估全局索引或改 Range 分区 | 查询能收敛到单分片或有限扇出 |
| 分片键可变 | 订单归属会从个人转企业 | 停止扩容,先设计双写迁移 | 路由与历史数据同时可回滚 |
最容易被跳过的是最后一行。哈希环假设键不变;只要分片键会更新,扩容和归属变更就会纠缠在一起。下一节把迁移状态机、双写和路由版本拆开处理,先保证“搬的过程中不丢更新”,再谈“搬得多快”。
常见错误与边界
第一,用可变字段做分片键。用户 ID、商户 ID、订单归属都可能变化;一旦变化,旧数据留在旧分片,新数据写进新分片,查询要么扇出要么读到旧值。分片键应当是创建后不可变的主键。
第二,只看行数均匀,不看请求均匀。分片各 25% 的行不代表各 25% 的 QPS;热点键、慢查询和批量任务都会让某个分片先饱和。监控必须同时覆盖存储分布和逻辑键访问分布。
第三,虚拟节点越多越好。虚拟节点改善均匀性,但增加环上点数、内存和查找比较次数;当副本数从 256 提到 4096,收益通常远小于成本。先用固定键集测最大偏差,再决定副本数。
第四,把一致性哈希当成迁移方案。它只决定“扩一个节点该搬哪些键”,不负责搬的过程;双写、校验、切换、回滚仍然要单独设计。缺少这些步骤时,扩容期间的写入会丢在旧分片。
小结
分片键首先要满足可路由和不可变,其次才是均匀;均匀是键空间的属性,热点是请求空间的属性,两者要分开监控。一致性哈希用虚拟节点把扩容迁移量压到约 1/(N+1),本机 10 万键实测 4→5 迁移 21.98%,256 个虚拟节点把最大偏差从 73.59% 降到 3.31%。这些数字只对本机合成键成立,真实键样本必须重跑。选型时先写查询清单和事务边界,再决定哈希还是 Range;只要分片键可能变化,就必须进入下一节的迁移与双写流程。
阅读导航:上一节:成员变更、快照与线性一致读 · 下一节:再平衡:迁移、双写与客户端路由 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。