《游戏服务端架构·规模化、全球化与成本》4.1 分片键与数据模型

《群岛纪元》的联盟名单按公会切、联盟仓库却按玩家切,一次跨服报名要扫 16 个分片。本节比较玩家、账号、区服、公会四种分片键的热点与共置后果,给出读扩散与写扩散的选择,并用可运行的 Go 目录表校验共置承诺、估算分片偏斜,最后给出热点分片的兜底手段。

一次联盟战的名单查询

《群岛纪元》赛季三开赛前,北辰工作室的 SLG 数据层已经有 16 个分片。玩家个人数据按玩家 ID 切,联盟名册按公会 ID 切,联盟仓库又按玩家 ID 切。平时看不出问题,直到一场 200 人联盟战报名:服务端要读取 200 名成员的战力、职位和仓库配额,代码按玩家 ID 逐个查,200 次请求散到 16 个分片。示例压测里,这个接口的 p99 从 45ms 涨到 900ms。

这不是数据库慢,而是分片键把一次业务操作拆成了扇出。更麻烦的是,联盟仓库的扣减和名册的职位校验不在同一个分片,报名失败时两边都可能留下半成品。团队最初想“加缓存”解决,缓存只能掩盖读放大,解决不了写操作的跨分片原子性。

本节讨论四个候选键,以及一个比“选哪个键”更重要的问题:一套游戏里,不同数据集是否必须使用同一个键。文中的流量、分片数和延迟都是示例值或设计目标,不是线上实测。

分片键选的是业务边界

哈希函数决定数据是否均匀,分片键决定哪些业务操作能本地完成。两个键可以有同样漂亮的哈希分布,却带来完全不同的尾延迟。

候选键基数热点风险共置后果适合的数据
玩家 ID最高单个大 R 或脚本号会形成热点个人数据本地;账号、公会、好友全部扇出背包、任务、个人进度
账号 ID高同账号多角色集中,可能被工作室刷登录、支付本地;角色与公会仍扇出账号、钱包、支付订单
区服很低几乎没有键热点,但分片粒度太粗同服数据全部本地;无法按玩家分摊世界地图、赛季、公告
公会 ID中头部公会天然热点名册、仓库、公会战本地;个人数据扇出公会、联盟、集体仓库

四个键都不是“更好”,而是各自锁定一段业务边界:

  • 玩家 ID 让个人读写本地化,是背包、任务、个人货币的默认选择。它的代价是任何“多人一起看”的功能都要扇出。
  • 账号 ID 让登录、支付、账号级封禁本地化。若一个账号下有多个角色,按账号切会让单个角色的高频读写争抢同一分片,所以它不适合承载角色背包。
  • 区服 粒度最粗,适合本来就只有一份的数据:世界地图、赛季配置、全服公告。把玩家数据按区服切,等于把整个服压进一个分片,扩容时无路可走。
  • 公会 ID 让联盟名册、公会仓库、公会战报名本地化。SLG 的社交玩法密度高,这个收益通常大于“个人数据扇出”的成本。

《群岛纪元》最后没有选“一个主键”,而是按数据集分别选键:

数据集分片键同步要求
角色背包、任务、个人资源玩家 ID单角色内强一致
账号、钱包、支付订单账号 ID支付强一致,角色进度异步
公会、名册、公会仓库公会 ID公会内强一致
世界地图、赛季、公告区服全服强一致

这张表的价值不在于“分了四类”,而在于它暴露了三条跨键路径:账号到角色、角色到公会、公会到区服。每条路径都必须明确是同步调用、异步事件,还是读扩散。

共置不是一句“放在一起”

“共置”常被说成“相关数据放同一个分片”,但相关数据往往有两个主人。好友关系是最典型的例子:A 和 B 是好友,按玩家 ID 切,A 的分片存一份、B 的分片也存一份;按关系 ID 切,查 A 的好友列表又要扇出。没有一种键能让双向关系在两边都本地。

所以共置要先回答:读扩散和写扩散,哪个代价可以接受。

场景共置对象选择理由
公会名册、公会仓库公会与成员贡献同键共置读写都频繁,扇出不可接受
好友列表两个玩家的关系边写扩散读远多于写,关系边很小
拍卖行商品、卖家托管物商品键 + 托管成交要跨卖家,用托管把写变成两次本地操作
跨服战报名报名快照异步快照允许秒级延迟,不能阻塞本服战斗

写扩散的规则是:接受好友时,在双方分片各写一条关系边;读取时只读本分片。它把一致性成本放在低频写路径上,代价是解除好友、封号、删号时要清理两份。读扩散则把成本放在每次读上,适合关系边很大、写又无法原子完成的场景。

拍卖行是另一种思路:商品挂单时,把商品从卖家背包移到“拍卖托管”,托管数据按商品 ID 分片;成交时拍卖分片产生结算事件,资金通过幂等消费者进入卖家账号。这样买家浏览只查商品分片,卖家不必参与成交事务。它不是“把数据放一起”,而是把跨分片事务改造成本地状态迁移加异步结算。

元数据:少量大分片用目录表

分片键确定后,还要决定键到物理分片的映射怎么存。三种常见形态:

形态扩容时搬什么优点缺点
固定分片数 + 取模键重新取模,大部分键要搬实现最简单,无元数据服务扩容等于全量重排,回滚困难
目录表只搬改写的区间可精确控制、可人工覆盖、易回滚目录是关键路径,必须缓存和版本化
一致性哈希环相邻弧段上的键节点增删只影响局部热点键仍热点,共置要靠上层保证

《群岛纪元》选目录表,原因不是它“更先进”,而是数据形态:分片数量少(16 个),单个分片大,公会共置需要人工覆盖,迁移窗口必须可暂停、可回滚。目录表可以把“哪个公会放在哪个分片”写成显式记录,出问题能改一行配置,而不是重算整张哈希环。

一致性哈希更适合节点多、键均匀、允许自动再平衡的场景,比如无状态服务的会话粘滞或大量小存储节点。它解决的是“节点变化时少搬”,不解决“一个头部公会占 6% 流量”这种业务热点。第 4.2 节会把两种方案的迁移成本算清楚。

把共置承诺写成可执行检查

下面的 Catalog 把数据集、键类型和共置关系写成一张表。它做三件事:注册数据集、校验共置承诺、估算分片热点;Rank 则把候选键按业务后果排序。

// file: internal/shardkey/shardkey.go
// tier: L1
// verify: go test ./internal/shardkey/
package shardkey

import (
	"errors"
	"fmt"
	"sort"
)

// Kind is the business identity a row is partitioned by. It matters more than
// the hash function: it decides which reads stay local and which become
// scatter-gather.
type Kind string

const (
	KindPlayer  Kind = "player"
	KindAccount Kind = "account"
	KindZone    Kind = "zone"
	KindGuild   Kind = "guild"
)

var (
	ErrEmptyName    = errors.New("shardkey: empty dataset name")
	ErrUnknownKind  = errors.New("shardkey: unknown key kind")
	ErrDuplicate    = errors.New("shardkey: duplicate dataset")
	ErrSelf         = errors.New("shardkey: dataset colocates with itself")
	ErrUnknown      = errors.New("shardkey: unknown colocation target")
	ErrKindMismatch = errors.New("shardkey: colocated datasets need the same key kind")
	ErrZoneMismatch = errors.New("shardkey: colocated datasets need the same zone scope")
	ErrEmptyLoad    = errors.New("shardkey: no partition load")
	ErrCapacity     = errors.New("shardkey: capacity must be positive")
	ErrNegativeLoad = errors.New("shardkey: load must not be negative")
	ErrNoCandidate  = errors.New("shardkey: no candidate key")
	ErrShare        = errors.New("shardkey: max share must be within 0..1")
)

func validKind(k Kind) bool {
	switch k {
	case KindPlayer, KindAccount, KindZone, KindGuild:
		return true
	}
	return false
}

// Dataset is one table or aggregate whose rows must be routed together.
// Colocate names datasets read in the same operation. The promise is checked
// as a whole catalog, because a target may be registered after its reader.
type Dataset struct {
	Name      string
	Kind      Kind
	ZoneBound bool
	Colocate  []string
}

type Catalog struct {
	datasets map[string]Dataset
}

func NewCatalog() *Catalog {
	return &Catalog{datasets: make(map[string]Dataset)}
}

// Add registers one dataset. Colocation targets are not resolved here so a
// catalog can be loaded in any order; Validate is the gate before use.
func (c *Catalog) Add(d Dataset) error {
	if d.Name == "" {
		return ErrEmptyName
	}
	if !validKind(d.Kind) {
		return fmt.Errorf("%w: %q", ErrUnknownKind, d.Kind)
	}
	if _, ok := c.datasets[d.Name]; ok {
		return fmt.Errorf("%w: %q", ErrDuplicate, d.Name)
	}
	for _, target := range d.Colocate {
		if target == d.Name {
			return fmt.Errorf("%w: %q", ErrSelf, d.Name)
		}
	}
	copied := d
	copied.Colocate = append([]string(nil), d.Colocate...)
	c.datasets[d.Name] = copied
	return nil
}

// Names returns registered dataset names in sorted order.
func (c *Catalog) Names() []string {
	out := make([]string, 0, len(c.datasets))
	for name := range c.datasets {
		out = append(out, name)
	}
	sort.Strings(out)
	return out
}

// Validate checks every colocation promise. A missing target is an error, not
// a warning: a promise that exists only in one dataset's comment is not a
// constraint, and the missing side is exactly the side that will fan out.
func (c *Catalog) Validate() []error {
	var errs []error
	for _, name := range c.Names() {
		d := c.datasets[name]
		for _, target := range d.Colocate {
			other, ok := c.datasets[target]
			if !ok {
				errs = append(errs, fmt.Errorf("%w: %s -> %s", ErrUnknown, name, target))
				continue
			}
			if other.Kind != d.Kind {
				errs = append(errs, fmt.Errorf("%w: %s(%s) -> %s(%s)",
					ErrKindMismatch, name, d.Kind, target, other.Kind))
			}
			if other.ZoneBound != d.ZoneBound {
				errs = append(errs, fmt.Errorf("%w: %s -> %s", ErrZoneMismatch, name, target))
			}
		}
	}
	return errs
}

// Hotspot summarises partition load. Skew is Max/Mean, not max minus mean, so
// it stays comparable as traffic grows. Overloaded is a hard capacity check,
// never an average: one hot shard can fail while the fleet looks idle.
type Hotspot struct {
	Total      int64
	Mean       float64
	Max        int64
	Hottest    int
	Skew       float64
	Overloaded bool
}

func Estimate(loads []int64, capacity int64) (Hotspot, error) {
	if len(loads) == 0 {
		return Hotspot{}, ErrEmptyLoad
	}
	if capacity <= 0 {
		return Hotspot{}, ErrCapacity
	}
	var total int64
	hottest := 0
	for i, v := range loads {
		if v < 0 {
			return Hotspot{}, fmt.Errorf("%w: partition %d has %d", ErrNegativeLoad, i, v)
		}
		total += v
		if v > loads[hottest] {
			hottest = i
		}
	}
	h := Hotspot{
		Total:   total,
		Mean:    float64(total) / float64(len(loads)),
		Max:     loads[hottest],
		Hottest: hottest,
	}
	if h.Mean > 0 {
		h.Skew = float64(h.Max) / h.Mean
	}
	h.Overloaded = h.Max > capacity
	return h, nil
}

// Candidate is one possible partition key with its observed business costs.
// MaxShare is the busiest key value's share of all traffic, in 0..1.
type Candidate struct {
	Kind        Kind
	Cardinality int64
	MaxShare    float64
	CrossShard  int
	Colocated   bool
}

// Rank orders candidates by business consequence, not hash quality:
// co-location first, then unavoidable scatter-gather, then busiest-key share,
// then cardinality. Callers take Rank[0]; there is no hidden weighted score.
func Rank(candidates []Candidate) ([]Candidate, error) {
	if len(candidates) == 0 {
		return nil, ErrNoCandidate
	}
	out := append([]Candidate(nil), candidates...)
	for _, c := range out {
		if c.MaxShare < 0 || c.MaxShare > 1 {
			return nil, fmt.Errorf("%w: %s=%v", ErrShare, c.Kind, c.MaxShare)
		}
		if c.CrossShard < 0 {
			return nil, fmt.Errorf("shardkey: negative cross-shard count for %s", c.Kind)
		}
	}
	sort.SliceStable(out, func(i, j int) bool {
		a, b := out[i], out[j]
		if a.Colocated != b.Colocated {
			return a.Colocated
		}
		if a.CrossShard != b.CrossShard {
			return a.CrossShard < b.CrossShard
		}
		if a.MaxShare != b.MaxShare {
			return a.MaxShare < b.MaxShare
		}
		if a.Cardinality != b.Cardinality {
			return a.Cardinality > b.Cardinality
		}
		return a.Kind < b.Kind
	})
	return out, nil
}

三个设计点值得说明:

第一,Add 不要求共置目标已经存在。目录表可能从多个配置文件加载,顺序不该成为约束;真正的门禁是 Validate。它会把“目标不存在”“键类型不一致”“区服范围不一致”都报出来,而不是只在启动日志里留一行警告。

第二,Estimate 用 Max > capacity 判断过载,而不是看平均值。16 个分片平均 6.25% 流量,不代表每个分片都能接住;头部公会的分片可能已经超过容量。Skew 用最大值除以均值,便于跨流量规模比较。

第三,Rank 不计算加权总分。它先看共置,再看跨分片次数,再看最热键占比,最后看基数。这个顺序是业务判断,不是可以调参的魔法数字:一个需要六次跨分片读的键,不该因为基数大就排第一。

热点不是平均值

《群岛纪元》做容量规划时,假设头部公会的联盟战报名占全部联盟流量的 6%(设计目标)。16 个分片平均每个 6.25%,看起来还有余量;但联盟数据按公会 ID 切,这个公会的所有报名都落在同一个分片,峰值时该分片承载的请求可能是均值的数倍。

分片键无法消灭单实体热点,只能让热点可见。可选手段有三类:

  • 目录表覆盖:把头部公会单独放到一个分片,或给它更高的配额。这是目录表相对哈希环的主要优势。
  • 二级拆分:公会仓库按 公会 ID + 桶号 拆成多个子片,名册仍按公会 ID 共置。代价是仓库读取要做一次小范围聚合。
  • 入口排队:对报名、拍卖竞价这类突发操作做令牌桶和公平队列,把过载挡在存储层之外。排队不提升容量,但能把雪崩变成可控的等待。

如果三者都不做,只靠“加机器”,热点会跟着公会 ID 一起搬到新分片,平均值仍然好看。

失败模式与兜底

失败表现兜底
新增数据集忘记声明共置上线后读放大,p99 抖动CI 加载目录表并执行 Validate,失败即阻断
共置键类型写错编译能过,运行期跨分片键类型是枚举,校验器拒绝混合类型
头部公会打满分片单分片超时,其他分片空闲目录覆盖 + 入口限流 + 二级拆分
账号合并两个账号 ID 指向同一角色别名表双读,后台重写后再单读
跨键事务报名扣仓库成功、名册失败本地状态机 + outbox + 幂等消费者

最后一项要展开一点:跨键事务不要用分布式事务硬扛。更稳的做法是让每个分片先完成本地状态迁移,再发一个带业务键的事件;下游用幂等消费者推进。这样失败时留下的是“可重放的中间态”,不是“两个分片各写一半”。

常见错误与边界

第一,全游戏只选一个分片键。账号、角色、公会、区服的数据访问模式不同,强行统一会把某类操作永久变成扇出。

第二,先选哈希函数,再补共置。哈希只影响均匀性,共置决定一次业务操作要跨几个分片;顺序反了,后面只能靠缓存和补偿打补丁。

第三,用平均负载判断扩容。平均值会掩盖单分片过载,容量判断必须看最大值和热点键。

第四,把区服当成万能键。区服适合全服唯一的数据,不适合玩家级数据;它的粒度决定了你无法靠增加分片分摊单服压力。

第五,忽略删除与封号。写扩散的好友关系、账号别名、公会成员索引都需要清理路径,否则数据会越迁越多。

小结

分片键不是技术参数,而是业务边界:玩家 ID 保住个人读写,账号 ID 保住支付,公会 ID 保住集体玩法,区服保住全服唯一数据。真正的难点是跨键路径,以及共置冲突时选读扩散还是写扩散。

Catalog 把共置承诺变成可执行检查,Estimate 让热点在容量规划阶段暴露,Rank 把候选键的取舍写成固定顺序而不是隐藏权重。下一节讨论这些分片在扩容时怎么搬:哈希环、目录表和迁移计划各自的代价。

阅读导航:上一节:3.3 全球同服的状态归属 · 下一节:4.2 一致性哈希与迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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