《游戏服务端编程实践》12.4 数据分片与多租户模型

本篇比较玩家 ID、公会 ID、区域三种分片键,拆解取模、一致性哈希与范围分片的扩容迁移、双写切换和多租户隔离,并给出可运行的 Go 路由实现,以及迁移比例、分布倾斜、路由确定性、跨片聚合和构造校验的完整测试边界、常见错误与扩容事故复盘说明。

一、分片键是第一决策

分片不是“把表拆开”这么简单,先要回答一个问题:哪些数据必须一起读写?分片键决定了这个边界。选对了,绝大多数请求只落一个分片;选错了,每次操作都要跨片,延迟和一致性成本会持续放大。

分片键适合的数据单请求成本主要风险
玩家 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,发布瞬间新请求按新规则路由,旧数据还留在旧分片,玩家会看到资产消失、公会成员不全。正确流程要把路由变更和数据迁移分开:

  1. 上线新分片,但读仍然走旧路由,新分片只做只读校验。
  2. 开启双写:旧分片和新分片都写,旧分片仍是权威。
  3. 双读比对:抽样读取新旧结果,记录差异,不直接返回新结果。
  4. 回填历史数据:按分片扫描,写入新分片,任务可重入、可断点续跑。
  5. 校验计数和摘要:按业务键抽样,确认新旧一致。
  6. 切换读流量到新路由,观察错误率和延迟。
  7. 停止双写,保留旧分片一段时间用于回滚。

迁移任务必须幂等。回填失败重跑时,不能把已经写过的数据再加一次;推荐按主键覆盖写,而不是累加。任务要有 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,跨租户读到别人的数据。
  • 跨片事务用两阶段提交硬扛高频写入,故障时长期阻塞。
  • 跨片分页每个分片取全量再内存排序,数据量一大就拖垮服务。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist