一次联盟战的名单查询
《群岛纪元》赛季三开赛前,北辰工作室的 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 一致性哈希与迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。