《游戏服务端架构·规模化、全球化与成本》4.2 一致性哈希与迁移

《群岛纪元》赛季前要把 16 个分片扩到 24 个,按玩家 ID 取模会让大部分数据重排。本节比较取模、目录表与一致性哈希的搬迁成本,讨论按玩家、按公会、按分片段的迁移单位,给出可运行的哈希环、虚拟节点与迁移计划生成代码,并附限速、容量预留与回滚清单。

赛季前的扩容窗口

《群岛纪元》每个赛季开始前有两周停机维护窗口。赛季三之前,玩家状态约 1.8TB(示例值),分布在 16 个分片上。运营预估新赛季同时在线会涨四成,团队决定扩到 24 个分片。

最直接的做法是把 playerID % 16 改成 playerID % 24。代码只改一行,但数据不是一行:以 100 万个玩家键做示例推演,取模从 16 换到 24 后,约 2/3 的键会换分片,几乎等于全量搬迁。迁移窗口只有 6 小时(设计目标),还要给双写、校验和回滚留时间,这个方案在推演阶段就被否掉了。

扩容的难点不是“新分片起不来”,而是搬多少、按什么单位搬、搬到一半失败怎么办。本节先把三种映射方案的搬迁成本摆出来,再落到迁移单位和可运行的规划代码。所有容量、比例和窗口都是示例值或设计目标,不是线上测量。

三种映射:搬键、搬区间还是搬弧段

方案扩容时谁移动搬迁比例(16→24,示例推演)元数据适合
固定分片 + 取模几乎所有键重新取模约 2/3 的键无临时环境、可整体重建的数据
固定分区 + 目录表只有被改写的区间可控,通常接近新增容量占比目录表,需版本与缓存少量大分片、强共置、需人工覆盖
一致性哈希 + 虚拟节点新节点弧段上的键约 1/3 的键环视图,需全局一致版本节点多、键均匀、自动再平衡

三者不是替代关系。目录表决定“哪个区域、哪个公会放在哪”,哈希环决定“同一区域内玩家键落到哪个存储节点”。《群岛纪元》最终用的是这种两层结构:赛区与公会由目录表固定,赛区内的玩家状态池用一致性哈希分摊。这样既保住了公会共置,又避免每次加存储节点都改业务目录。

《裂隙战场》是 5v5 竞技,战斗房间生命周期只有几十分钟,赛季数据按赛季重置。它的选择是固定分区加整体重建:旧赛季数据归档,新赛季直接起新布局,不需要在线搬迁。《泡泡工坊》是休闲小游戏,数据量小、增长快,用目录表加少量分片,扩容时人工改映射就够了。

迁移单位:玩家、公会还是分片段

单位粒度优点风险
按玩家单键失败影响小,易限速公会数据被拆散,迁移期跨分片
按公会包公会 + 成员贡献 + 仓库保住共置,业务语义完整包可能很大,单个失败回滚成本高
按分片段一段哈希区间或目录区间吞吐最高,任务少爆炸半径大,失败要整段重来

SLG 的社交数据必须按公会包迁。一个公会的名册、仓库、公会战报名如果分两次搬,中间窗口里玩家会看到“名册在旧片、仓库在新片”的裂脑状态。包内按固定顺序搬,包外可以并行;包大小设上限,超过上限的公会拆成“核心包 + 仓库子包”,但核心包仍然原子迁移。

个人数据按玩家迁,适合背包、任务这类只属于一个角色的数据。冷数据按分片段迁,适合归档赛季、日志、历史战报,因为这类数据允许更长停机,也不参与在线双写。

计划:先证明只搬该搬的,再谈速度

迁移计划不是“把旧分片的数据拷到新分片”,而是一组带前置条件的断言:

  1. 覆盖断言:旧布局里的每个键或区间,在新布局里必须有人接手;漏一段就拒绝生成计划。
  2. 移动断言:只允许从旧主搬迁到新主;如果计划里出现“新主搬到旧主”,说明环视图不一致。
  3. 容量断言:目标分片在接收数据后仍要保留余量。双写阶段目标要同时承接镜像写,设计目标通常留 30% 以上余量。
  4. 顺序断言:公会包内按“名册 → 仓库 → 报名”顺序搬;包间可并行,但不能跨包共享事务。
  5. 可回滚断言:源分片在切换后保留只读窗口,校验和通过前不删除。

前三条可以在生成计划时静态检查,后两条要靠迁移执行器记录检查点。检查点按包持久化,失败后从最后一个完成的包继续,而不是从整段重来。

限速、优先级与回滚

迁移会同时消耗源分片、目标分片和网络。限速要按“业务余量”算,不是按“网络带宽”算:

  • 源分片的目标是迁移期间玩家请求 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已完成的批次,用于断点续传
statepending / copying / verifying / done / failed

状态机只允许前进或回退到明确状态:pending → copying → verifying → done,失败进入 failed,人工确认后可回到 copying。done 之前目标分片不接受读;done 之后也要等路由版本切换,读才会过去。

校验不能只看行数。至少要比对:键集合、每行版本号、关键字段的校验和。只比行数会漏掉“行数一样但内容错了”的情况,尤其是双写期间目标收到过半截更新。

常见错误与边界

第一,直接改取模数上线。取模扩容的搬迁比例接近全量,没有双写和校验时,迁移窗口内必然出现读旧值、写丢数据。

第二,把平均包大小当容量依据。公会包大小长尾很重,按平均值算容量会在最大的几个包上失败;容量要按 P99 包大小和并发包数估算。

第三,迁移头部公会时不限速。头部公会的报名和仓库操作本来就是热点,迁移流量叠加会直接把源分片打满;这类包应放在低峰并单独限速。

第四,忘记双写阶段的 2 倍写入。目标分片只按最终数据量规划,双写时写放大到两倍,先在目标上过载。

第五,校验通过前删除源数据。切换后源分片要保留只读窗口,确认没有回滚需求、校验和一致、备份可用之后再回收。

第六,把哈希环当成共置方案。环只保证键到节点的映射,不保证同一个公会的多个键落在同一节点;共置必须由上层目录或包迁移保证。

小结

扩容方案的核心不是“用不用一致性哈希”,而是搬迁比例、迁移单位和失败恢复。取模最简单但几乎全量重排;目录表能精确控制区间和共置;一致性哈希把搬迁限制在局部弧段,但需要全局一致的环视图,也不解决热点键。

SLG 的可行组合是“目录表管区域与公会,哈希环管区域内的玩家状态池”,迁移单位按公会包和玩家分开,计划先证明覆盖与容量,再限速执行、逐包校验、保留回滚窗口。下一节讨论迁移窗口内的读写:源可读可写、双写、目标接管三态如何切换,以及重试落到不同分片时如何避免写两次。

阅读导航:上一节:4.1 分片键与数据模型 · 下一节:4.3 再均衡期间的读写 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查