赛季前的扩容窗口
《群岛纪元》每个赛季开始前有两周停机维护窗口。赛季三之前,玩家状态约 1.8TB(示例值),分布在 16 个分片上。运营预估新赛季同时在线会涨四成,团队决定扩到 24 个分片。
最直接的做法是把 playerID % 16 改成 playerID % 24。代码只改一行,但数据不是一行:以 100 万个玩家键做示例推演,取模从 16 换到 24 后,约 2/3 的键会换分片,几乎等于全量搬迁。迁移窗口只有 6 小时(设计目标),还要给双写、校验和回滚留时间,这个方案在推演阶段就被否掉了。
扩容的难点不是“新分片起不来”,而是搬多少、按什么单位搬、搬到一半失败怎么办。本节先把三种映射方案的搬迁成本摆出来,再落到迁移单位和可运行的规划代码。所有容量、比例和窗口都是示例值或设计目标,不是线上测量。
三种映射:搬键、搬区间还是搬弧段
| 方案 | 扩容时谁移动 | 搬迁比例(16→24,示例推演) | 元数据 | 适合 |
|---|---|---|---|---|
| 固定分片 + 取模 | 几乎所有键重新取模 | 约 2/3 的键 | 无 | 临时环境、可整体重建的数据 |
| 固定分区 + 目录表 | 只有被改写的区间 | 可控,通常接近新增容量占比 | 目录表,需版本与缓存 | 少量大分片、强共置、需人工覆盖 |
| 一致性哈希 + 虚拟节点 | 新节点弧段上的键 | 约 1/3 的键 | 环视图,需全局一致版本 | 节点多、键均匀、自动再平衡 |
三者不是替代关系。目录表决定“哪个区域、哪个公会放在哪”,哈希环决定“同一区域内玩家键落到哪个存储节点”。《群岛纪元》最终用的是这种两层结构:赛区与公会由目录表固定,赛区内的玩家状态池用一致性哈希分摊。这样既保住了公会共置,又避免每次加存储节点都改业务目录。
《裂隙战场》是 5v5 竞技,战斗房间生命周期只有几十分钟,赛季数据按赛季重置。它的选择是固定分区加整体重建:旧赛季数据归档,新赛季直接起新布局,不需要在线搬迁。《泡泡工坊》是休闲小游戏,数据量小、增长快,用目录表加少量分片,扩容时人工改映射就够了。
迁移单位:玩家、公会还是分片段
| 单位 | 粒度 | 优点 | 风险 |
|---|---|---|---|
| 按玩家 | 单键 | 失败影响小,易限速 | 公会数据被拆散,迁移期跨分片 |
| 按公会包 | 公会 + 成员贡献 + 仓库 | 保住共置,业务语义完整 | 包可能很大,单个失败回滚成本高 |
| 按分片段 | 一段哈希区间或目录区间 | 吞吐最高,任务少 | 爆炸半径大,失败要整段重来 |
SLG 的社交数据必须按公会包迁。一个公会的名册、仓库、公会战报名如果分两次搬,中间窗口里玩家会看到“名册在旧片、仓库在新片”的裂脑状态。包内按固定顺序搬,包外可以并行;包大小设上限,超过上限的公会拆成“核心包 + 仓库子包”,但核心包仍然原子迁移。
个人数据按玩家迁,适合背包、任务这类只属于一个角色的数据。冷数据按分片段迁,适合归档赛季、日志、历史战报,因为这类数据允许更长停机,也不参与在线双写。
计划:先证明只搬该搬的,再谈速度
迁移计划不是“把旧分片的数据拷到新分片”,而是一组带前置条件的断言:
- 覆盖断言:旧布局里的每个键或区间,在新布局里必须有人接手;漏一段就拒绝生成计划。
- 移动断言:只允许从旧主搬迁到新主;如果计划里出现“新主搬到旧主”,说明环视图不一致。
- 容量断言:目标分片在接收数据后仍要保留余量。双写阶段目标要同时承接镜像写,设计目标通常留 30% 以上余量。
- 顺序断言:公会包内按“名册 → 仓库 → 报名”顺序搬;包间可并行,但不能跨包共享事务。
- 可回滚断言:源分片在切换后保留只读窗口,校验和通过前不删除。
前三条可以在生成计划时静态检查,后两条要靠迁移执行器记录检查点。检查点按包持久化,失败后从最后一个完成的包继续,而不是从整段重来。
限速、优先级与回滚
迁移会同时消耗源分片、目标分片和网络。限速要按“业务余量”算,不是按“网络带宽”算:
- 源分片的目标是迁移期间玩家请求 p99 不明显恶化;示例设计目标是把迁移流量控制在源分片容量的 20% 以内。
- 目标分片要同时接迁移写和双写镜像,容量规划按 2 倍写入估算。
- 优先级先迁离线玩家和低活跃公会,在线玩家与正在打公会战的公会最后迁;这不是为了快,而是为了缩短双写窗口。
- 错误率超过阈值时暂停,不自动降级成“跳过失败包”。跳过会让覆盖断言失效。
回滚分两种。切换前回滚:目标还没接管读,直接停迁移、清目标、源继续服务。切换后回滚:目标已经接受写,不能简单切回源,否则目标上的新写会丢;正确做法是回到双写阶段,先双向对账,再决定谁是新主。第 4.3 节会把三态路由展开。
哈希环与虚拟节点
// file: internal/consistenthash/ring.go
// tier: L1
// verify: go test ./internal/consistenthash/
package consistenthash
import (
"errors"
"fmt"
"hash/fnv"
"sort"
"strconv"
)
var (
ErrNoNodes = errors.New("consistenthash: ring needs at least one node")
ErrVnodes = errors.New("consistenthash: virtual nodes must be positive")
ErrEmptyNode = errors.New("consistenthash: empty node name")
ErrDuplicate = errors.New("consistenthash: duplicate node")
ErrUnknown = errors.New("consistenthash: node is not on the ring")
)
// token is one virtual node position. The node name is kept beside the hash so
// a collision is still deterministic: ordering falls back to the name.
type token struct {
hash uint64
node string
}
// Ring maps keys to owners. vnodes is fixed at construction because changing
// it changes every token, which is a full migration even if the node list is
// unchanged.
type Ring struct {
vnodes int
nodes map[string]bool
tokens []token
}
func New(vnodes int, nodes ...string) (*Ring, error) {
if vnodes <= 0 {
return nil, ErrVnodes
}
r := &Ring{vnodes: vnodes, nodes: make(map[string]bool)}
for _, node := range nodes {
if err := r.Add(node); err != nil {
return nil, err
}
}
if len(r.nodes) == 0 {
return nil, ErrNoNodes
}
return r, nil
}
func hashString(s string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(s))
return h.Sum64()
}
// Add inserts a node and all of its virtual nodes. It does not move data; the
// caller must run a plan and copy the pixels, then switch routing.
func (r *Ring) Add(node string) error {
if node == "" {
return ErrEmptyNode
}
if r.nodes[node] {
return fmt.Errorf("%w: %q", ErrDuplicate, node)
}
r.nodes[node] = true
for i := 0; i < r.vnodes; i++ {
r.tokens = append(r.tokens, token{
hash: hashString(node + "#" + strconv.Itoa(i)),
node: node,
})
}
r.sortTokens()
return nil
}
// Remove refuses to empty the ring. A caller that really wants to stop serving
// should remove the last node from routing, not from the safety check.
func (r *Ring) Remove(node string) error {
if !r.nodes[node] {
return fmt.Errorf("%w: %q", ErrUnknown, node)
}
if len(r.nodes) == 1 {
return ErrNoNodes
}
delete(r.nodes, node)
kept := r.tokens[:0]
for _, tok := range r.tokens {
if tok.node != node {
kept = append(kept, tok)
}
}
r.tokens = kept
return nil
}
func (r *Ring) sortTokens() {
sort.Slice(r.tokens, func(i, j int) bool {
if r.tokens[i].hash != r.tokens[j].hash {
return r.tokens[i].hash < r.tokens[j].hash
}
return r.tokens[i].node < r.tokens[j].node
})
}
// Nodes returns the node names in sorted order.
func (r *Ring) Nodes() []string {
out := make([]string, 0, len(r.nodes))
for node := range r.nodes {
out = append(out, node)
}
sort.Strings(out)
return out
}
// Owner finds the first token at or after the key hash, wrapping at the end.
func (r *Ring) Owner(key string) (string, bool) {
if len(r.tokens) == 0 {
return "", false
}
h := hashString(key)
i := sort.Search(len(r.tokens), func(i int) bool { return r.tokens[i].hash >= h })
if i == len(r.tokens) {
i = 0
}
return r.tokens[i].node, true
}
// Move is one sampled key whose owner changed.
type Move struct {
Key string
From string
To string
}
// Plan is a sample-based estimate, not a copy list. Keys outside the sample
// are not proven unchanged; the migration worker still needs a durable scan.
type Plan struct {
Moves []Move
Stay int
}
func (p Plan) MovedTo(node string) int {
n := 0
for _, m := range p.Moves {
if m.To == node {
n++
}
}
return n
}
func (p Plan) Total() int { return len(p.Moves) + p.Stay }
// BuildPlan compares two rings over a sorted key sample. Sorting makes the
// output stable across runs, which matters when two operators diff plans.
func BuildPlan(old, next *Ring, keys []string) Plan {
sorted := append([]string(nil), keys...)
sort.Strings(sorted)
var plan Plan
for _, key := range sorted {
from, ok := old.Owner(key)
if !ok {
continue
}
to, ok := next.Owner(key)
if !ok {
continue
}
if from == to {
plan.Stay++
continue
}
plan.Moves = append(plan.Moves, Move{Key: key, From: from, To: to})
}
return plan
}
这段代码有四个边界:
第一,vnodes 在构造时固定。改变虚拟节点数会改变所有 token,即使用户节点列表没变,也会触发全量重排;它不是一个可以热调的参数。
第二,Owner 找到第一个大于等于键哈希的 token,走到末尾就回绕到开头。回绕是环的本意,不是 bug;测试必须覆盖尾部键。
第三,Add 只改环,不搬数据。环变化后必须先生成计划、复制数据、校验,再切换路由;把 Add 当成“自动再平衡”是危险的。
第四,BuildPlan 是基于采样键的规划工具,不是数据拷贝清单。它证明“样本里哪些键换了主人”,不证明“样本外的键没变”。生产迁移仍要扫描持久层或按目录区间枚举。
虚拟节点的作用是降低方差,不是消灭热点。示例推演中,4 个节点、每节点 200 个虚拟节点、3000 个采样键时,最大节点份额约 46.7%;虚拟节点少的时候,份额可能接近 70%。节点越多、键越均匀,环的分布越稳;但一个头部公会的键仍然集中在同一个节点,环帮不上忙。
迁移计划的生成与校验
一个可执行的计划至少包含这些字段:
| 字段 | 用途 |
|---|---|
bundle_id | 迁移单位,公会包或玩家 |
from / to | 源分片与目标分片 |
keys 或 range | 精确键列表或目录区间 |
bytes | 预估数据量,用于限速和容量 |
checksum | 迁移后校验,逐包比对 |
checkpoint | 已完成的批次,用于断点续传 |
state | pending / copying / verifying / done / failed |
状态机只允许前进或回退到明确状态:pending → copying → verifying → done,失败进入 failed,人工确认后可回到 copying。done 之前目标分片不接受读;done 之后也要等路由版本切换,读才会过去。
校验不能只看行数。至少要比对:键集合、每行版本号、关键字段的校验和。只比行数会漏掉“行数一样但内容错了”的情况,尤其是双写期间目标收到过半截更新。
常见错误与边界
第一,直接改取模数上线。取模扩容的搬迁比例接近全量,没有双写和校验时,迁移窗口内必然出现读旧值、写丢数据。
第二,把平均包大小当容量依据。公会包大小长尾很重,按平均值算容量会在最大的几个包上失败;容量要按 P99 包大小和并发包数估算。
第三,迁移头部公会时不限速。头部公会的报名和仓库操作本来就是热点,迁移流量叠加会直接把源分片打满;这类包应放在低峰并单独限速。
第四,忘记双写阶段的 2 倍写入。目标分片只按最终数据量规划,双写时写放大到两倍,先在目标上过载。
第五,校验通过前删除源数据。切换后源分片要保留只读窗口,确认没有回滚需求、校验和一致、备份可用之后再回收。
第六,把哈希环当成共置方案。环只保证键到节点的映射,不保证同一个公会的多个键落在同一节点;共置必须由上层目录或包迁移保证。
小结
扩容方案的核心不是“用不用一致性哈希”,而是搬迁比例、迁移单位和失败恢复。取模最简单但几乎全量重排;目录表能精确控制区间和共置;一致性哈希把搬迁限制在局部弧段,但需要全局一致的环视图,也不解决热点键。
SLG 的可行组合是“目录表管区域与公会,哈希环管区域内的玩家状态池”,迁移单位按公会包和玩家分开,计划先证明覆盖与容量,再限速执行、逐包校验、保留回滚窗口。下一节讨论迁移窗口内的读写:源可读可写、双写、目标接管三态如何切换,以及重试落到不同分片时如何避免写两次。
阅读导航:上一节:4.1 分片键与数据模型 · 下一节:4.3 再均衡期间的读写 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。