一、分片键是第一决策
分片不是“把表拆开”这么简单,先要回答一个问题:哪些数据必须一起读写?分片键决定了这个边界。选对了,绝大多数请求只落一个分片;选错了,每次操作都要跨片,延迟和一致性成本会持续放大。
| 分片键 | 适合的数据 | 单请求成本 | 主要风险 |
|---|---|---|---|
| 玩家 ID | 背包、资产、角色、个人邮件 | 单分片 | 公会、全服排行需要跨片 |
| 公会 ID | 公会成员、公会仓库、公会战报名 | 单分片 | 玩家个人数据与公会数据分离 |
| 区域 / 区服 | 开服隔离、赛事分区、租户隔离 | 单分片 | 大区内部仍可能倾斜 |
| 订单 ID | 订单、支付流水 | 单分片 | 按玩家查订单要二级索引 |
| 时间 | 日志、流水、审计 | 单分片 | 当前时间分区成为写热点 |
同一个业务不要同时存在两个“主分片键”。如果背包按玩家 ID 分片,公会仓库按公会 ID 分片,那么“把道具从背包捐给公会”天然跨片。解决方式不是加分布式事务,而是把捐赠设计成两步:先扣个人背包并写一条待确认流水,再由公会分片消费事件入账,失败走补偿。
分片键还要考虑增长方式。按自增 ID 做范围分片,新注册玩家全部落在最后一个区间,开服和买量高峰会把最后一个分片打成热点;按哈希取模则分布均匀,但扩容时迁移成本高。没有一种键同时满足均衡、局部性和易扩容,必须按业务优先级取舍。
二、取模、一致性哈希与范围分片
| 方案 | 均衡性 | 扩容迁移 | 范围查询 | 实现复杂度 | 典型问题 |
|---|---|---|---|---|---|
| 哈希取模 | 好 | 差,改模数几乎全迁 | 不支持 | 低 | 扩容必须停机迁移 |
| 一致性哈希 | 较好 | 好,只迁约 1/(N+1) | 不支持 | 中 | 虚拟节点少时倾斜 |
| 范围分片 | 取决于切分 | 需要分裂与合并 | 支持 | 高 | 新数据集中在一个区间 |
取模的公式是 hash(key) % N。它的优点是简单、可预测,任意客户端都能独立算出分片;缺点是 N 一变,绝大多数键的映射都会改变。把 3 个分片扩到 4 个,只有约四分之一的键保持原位,其余都要迁移,缓存也会大面积失效。
一致性哈希把分片和键都映射到一个环上,键顺时针找到第一个分片节点。增加一个节点时,只有落在新节点逆时针区间的键需要迁移,其他键不受影响。为了让环上的区间更均匀,每个物理分片会放多个虚拟节点,例如 128 个。
范围分片按键的区间切分,例如玩家 ID 1 到 100 万一个分片。它天然支持范围扫描,适合按时间或 ID 段查询;代价是区间边界需要维护,新数据可能集中在一个区间,必须能自动分裂和迁移。
三、取模与一致性哈希的细节
先哈希再取模,不要直接拿有结构的字符串取模。玩家 ID 如果带区域前缀,直接对字符串做简单运算很容易把同一区域的玩家压到少数分片。哈希函数要稳定:同一个键在网关、战斗服和离线任务里必须得到同一结果,不能使用带随机种子的哈希。
一致性哈希的查找依赖哈希值的高位分布。只用 FNV-1a 这类增量哈希时,长公共前缀、仅末尾不同的键,输出值可能聚在很窄的区间,环上看起来就是“所有键都落进同一个分片”。实验代码在 FNV-1a 之后加了一次 splitmix64 finalizer,把高位打散;如果换成其他哈希,也要先验证长前缀键和后缀键的分布。
虚拟节点数量是均衡性和内存的折中。节点太少,环上区间大小差异明显;节点太多,查找表和重建成本上升。128 是一个常见起点,但必须用真实键分布验证,不能只看平均值。
环的查找用二分搜索,找到第一个哈希值不小于键哈希的节点,越界则回到环首。重建环时要对哈希值排序,并对哈希冲突做稳定的次级排序,否则同一组分片在不同进程中可能得到不同顺序。
四、扩容:迁移、双写与切换
扩容最危险的动作是“直接改模数”。代码里把 % 4 改成 % 5,发布瞬间新请求按新规则路由,旧数据还留在旧分片,玩家会看到资产消失、公会成员不全。正确流程要把路由变更和数据迁移分开:
- 上线新分片,但读仍然走旧路由,新分片只做只读校验。
- 开启双写:旧分片和新分片都写,旧分片仍是权威。
- 双读比对:抽样读取新旧结果,记录差异,不直接返回新结果。
- 回填历史数据:按分片扫描,写入新分片,任务可重入、可断点续跑。
- 校验计数和摘要:按业务键抽样,确认新旧一致。
- 切换读流量到新路由,观察错误率和延迟。
- 停止双写,保留旧分片一段时间用于回滚。
迁移任务必须幂等。回填失败重跑时,不能把已经写过的数据再加一次;推荐按主键覆盖写,而不是累加。任务要有 checkpoint,记录每个旧分片处理到哪个键区间,重启后从断点继续。
一致性哈希扩容的迁移比例约为 1/(N+1),这是理论值,不是保证值。虚拟节点少、键分布偏斜时,实际迁移比例可能明显偏离。测试应断言“只迁往新分片”和“迁移比例落在合理区间”,而不是断言一个精确数字。
五、多租户模型
游戏里的“租户”可能是发行商、渠道、大区或独立赛事。多租户不是只有一种形态:
| 模型 | 隔离性 | 成本 | 迁移难度 | 适用场景 |
|---|---|---|---|---|
| 独立数据库 | 最强 | 最高 | 中 | 大客户、强合规 |
| 共享库独立 schema | 较强 | 中 | 中 | 中大型渠道 |
| 共享表带 tenant_id | 弱 | 最低 | 低 | 大量小租户 |
共享表模型必须在每一张表、每一个查询、每一个索引里都带 tenant_id。只靠应用层“记得加条件”迟早会漏,比较稳妥的做法是复合索引以 tenant_id 开头,并在数据访问层强制注入租户条件。数据库支持行级安全时,可以再加一道兜底。
噪声邻居是共享模型的核心风险。一个租户做全服排行或批量导出,可能占满连接池和 IO,拖慢其他租户。需要按租户做限流、连接池配额和慢查询隔离;对超过阈值的大租户,迁移到独立 schema 或独立库。
租户 ID 不能只放在会话里。异步任务、补偿脚本、离线批处理都必须显式携带租户上下文,否则跨租户读写会在后台悄悄发生。日志和指标也要带租户维度,否则故障时无法判断影响范围。
六、跨片操作与聚合
跨片事务应尽量避免。两阶段提交在游戏高频写入下成本高、故障恢复复杂,而且很多业务并不需要原子性。更常见的做法是把跨片操作拆成“本地事务 + 事件 + 幂等消费”,或者把需要原子性的数据放到同一个分片键下。
跨片查询可以用 scatter-gather:按分片并行查询,再在服务层合并。成本约等于触及的分片数,而不是键的数量。全服排行榜如果每次都扫所有分片,分片越多越慢;更合适的是异步汇总到一张全局榜,或按排名段做缓存。
分页是跨片聚合的难点。每个分片各取前 N 条再合并,只能保证局部有序,无法直接得到全局第 100 页。可行方案包括:全局索引、按时间或分数做范围查询、或者把榜单物化成独立服务。不要用“每个分片取全量再内存排序”应付,数据量一大就会拖垮服务。
七、失败场景:自增 ID 范围分片的热点
某项目按玩家 ID 做范围分片:1 到 100 万一个分片,100 万到 200 万一个分片,以此类推。上线初期数据少,每个分片都很轻。买量活动开始后,新注册玩家 ID 持续增长,全部落进最后一个分片。
结果是最后一个分片的写入、索引和备份压力持续上升,其他分片几乎空闲。更麻烦的是,玩家 ID 是数据库全局自增的,无法通过“预分配 ID 段”把新玩家分散到不同分片;等发现时,最后一个分片已经接近容量上限。
修复方案不是临时加机器,而是改分片键:对玩家 ID 做哈希后再分片,或者使用一致性哈希按玩家 ID 路由。范围分片仍然可以保留给日志、流水这类按时间查询的数据,但玩家主数据不能再按自增 ID 做范围切分。
第二个变种是直接改模数。从 4 个分片扩到 6 个,代码发布后约三分之二的键被重映射,缓存全部失效,部分公会数据在新分片查不到。正确做法是新增分片、双写、回填、校验、切读,而不是改一个常量。
第三个变种是跨片事务。玩家在 A 分片扣货币,公会在 B 分片加资金,两个本地事务各自成功,中间进程崩溃后一边成功一边失败。没有对账和补偿时,这类差异会一直留在库里。
八、Go 实现:两种路由与跨片分组
下面的 Modulo 和 Consistent 实现两种路由。Consistent 的 AddShard 返回新环,旧环不变,便于扩容期间双读;HashKey 在 FNV-1a 后加了 64 位混合,避免长公共前缀键在环上聚集;Group 演示跨片分组,返回触及的分片数。
// file: ch12/shard/shard.go
// tier: L1
// verify: go test ./ch12/shard/
// Package shard 演示取模与一致性哈希两种分片路由,以及跨片分组聚合。
package shard
import (
"errors"
"fmt"
"hash/fnv"
"sort"
)
var (
ErrNoShards = errors.New("shard: no shards")
ErrDuplicateShard = errors.New("shard: duplicate shard")
)
// Router 把业务键映射到分片编号。实现必须稳定:同一个键在任何进程都得到同一结果。
type Router interface {
Shard(key string) int
}
// HashKey 先做 FNV-1a,再做一次 64 位 avalanche 混合。
// 只用 FNV-1a 时,长公共前缀、仅后缀不同的键在高位上仍然聚得很近,
// 一致性哈希按高位切环时会全部落进同一个区间。
func HashKey(key string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(key))
return mix64(h.Sum64())
}
// mix64 是 splitmix64 的 finalizer,用来打散 FNV 输出的高位。
func mix64(x uint64) uint64 {
x ^= x >> 30
x *= 0xbf58476d1ce4e5b9
x ^= x >> 27
x *= 0x94d049bb133111eb
x ^= x >> 31
return x
}
// Modulo 是哈希取模路由。扩容时模数变化,绝大多数键会重新映射。
type Modulo struct {
n int
}
func NewModulo(n int) (*Modulo, error) {
if n <= 0 {
return nil, ErrNoShards
}
return &Modulo{n: n}, nil
}
func (m *Modulo) Shard(key string) int {
return int(HashKey(key) % uint64(m.n))
}
// Count 返回分片数量。
func (m *Modulo) Count() int {
return m.n
}
// DefaultReplicas 是每个物理分片的虚拟节点数。
// 虚拟节点越多,环上的分布越均匀,但环的查找表也越大。
const DefaultReplicas = 128
type point struct {
hash uint64
shard int
}
// Consistent 是一致性哈希环。AddShard 返回新环而不是修改旧环,
// 这样扩容期间旧环仍然可以服务读请求,便于双读和灰度切换。
type Consistent struct {
replicas int
ring []point
shards map[int]struct{}
}
func NewConsistent(replicas int, shards ...int) (*Consistent, error) {
if replicas <= 0 {
replicas = DefaultReplicas
}
c := &Consistent{
replicas: replicas,
shards: make(map[int]struct{}, len(shards)),
}
for _, s := range shards {
if _, ok := c.shards[s]; ok {
return nil, fmt.Errorf("%w: %d", ErrDuplicateShard, s)
}
c.shards[s] = struct{}{}
}
if len(c.shards) == 0 {
return nil, ErrNoShards
}
c.rebuild()
return c, nil
}
// AddShard 返回加入新分片后的新环;旧环内容不变。
func (c *Consistent) AddShard(id int) (*Consistent, error) {
if _, ok := c.shards[id]; ok {
return nil, fmt.Errorf("%w: %d", ErrDuplicateShard, id)
}
next := &Consistent{
replicas: c.replicas,
shards: make(map[int]struct{}, len(c.shards)+1),
}
for s := range c.shards {
next.shards[s] = struct{}{}
}
next.shards[id] = struct{}{}
next.rebuild()
return next, nil
}
func (c *Consistent) Shard(key string) int {
h := HashKey(key)
idx := sort.Search(len(c.ring), func(i int) bool {
return c.ring[i].hash >= h
})
if idx == len(c.ring) {
idx = 0
}
return c.ring[idx].shard
}
// Shards 返回排好序的分片编号。
func (c *Consistent) Shards() []int {
out := make([]int, 0, len(c.shards))
for s := range c.shards {
out = append(out, s)
}
sort.Ints(out)
return out
}
func (c *Consistent) rebuild() {
ring := make([]point, 0, len(c.shards)*c.replicas)
for shard := range c.shards {
for i := 0; i < c.replicas; i++ {
key := fmt.Sprintf("%d#%d", shard, i)
ring = append(ring, point{hash: HashKey(key), shard: shard})
}
}
sort.Slice(ring, func(i, j int) bool {
if ring[i].hash == ring[j].hash {
return ring[i].shard < ring[j].shard
}
return ring[i].hash < ring[j].hash
})
c.ring = ring
}
// Group 按分片聚合键,返回分组以及触及的分片数。
// 跨片查询的成本约等于触及的分片数,而不是键的数量。
func Group(r Router, keys []string) (map[int][]string, int) {
groups := make(map[int][]string)
for _, key := range keys {
s := r.Shard(key)
groups[s] = append(groups[s], key)
}
return groups, len(groups)
}
NewConsistent 接受分片列表和虚拟节点数,分片顺序不影响结果,因为重建时会排序。AddShard 采用写时复制:旧环继续服务读请求,新环用于灰度,调用方可以按比例切换。
Shard 用二分搜索定位环上的第一个节点,越界回绕到环首。空环在构造阶段就被拒绝,因此读路径不需要再判空。
Group 不执行查询,只做路由分组。它返回的 touched 就是这次聚合需要访问的分片数,调度器可以据此估算扇出成本,必要时并行或降级。
九、测试覆盖哪些边界
实验包的 shard_test.go 覆盖了六类行为:
- 一致性哈希从 3 个分片扩到 4 个,10000 个键只迁往新分片,迁移比例落在
[0.15, 0.40]。 - 取模从 3 改到 4,超过 60% 的键被重映射,说明不能直接改模数。
- 4 个分片、20000 个键时没有空分片,最大与最小分片比值不超过 1.6。
- 分片列表顺序不同,重建出的环对同一批键给出相同映射。
- 40 个公会成员跨 2 到 4 个分片,分组后合并总分与原始总和一致。
- 0 分片、空分片列表和重复分片都在构造阶段被拒绝。
这些断言分别对应分布倾斜、扩容迁移、路由确定性和跨片聚合四类线上问题。
十、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch12/shard && go vet ./ch12/shard/ && go test ./ch12/shard/ -count=1
ok gspractice/ch12/shard 0.298s
gofmt -l 没有输出表示格式一致,go vet 通过,测试全部通过。测试只使用标准库,不依赖外部存储。
小结
分片的第一决策是分片键,而不是分片算法。玩家主数据通常按玩家 ID 路由,公会数据按公会 ID 路由,跨片操作要用事件、补偿或异步汇总拆开。取模简单但改模数代价高,一致性哈希扩容只迁移约 1/(N+1) 的键,范围分片适合有序查询但要防写热点。多租户共享表必须把 tenant_id 注入每一层,并用限流隔离噪声邻居。扩容要坚持新增、双写、回填、校验、切读的顺序,迁移任务必须幂等。
常见错误与边界
- 按自增 ID 做范围分片,新注册玩家全部压到最后一个分片。
- 扩容时直接修改模数,旧数据留在旧分片,玩家看到资产丢失。
- 分片键同时选玩家和公会,捐赠、结算天然跨片。
- 一致性哈希虚拟节点过少,环上区间倾斜,单分片过热。
- 哈希函数不稳定或带随机种子,不同服务算出不同分片。
- 共享表查询漏加
tenant_id,跨租户读到别人的数据。 - 跨片事务用两阶段提交硬扛高频写入,故障时长期阻塞。
- 跨片分页每个分片取全量再内存排序,数据量一大就拖垮服务。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。