三个世界,三种归属
《群岛纪元》上线全球服时,策划提出的目标是“所有玩家在同一个世界里”:联盟可以跨洲、王座战全服可见、排行榜只有一个。工程团队很快发现,这个目标不能直接翻译成“所有数据放在一个数据库”。世界地图的格子状态需要高频写入,联盟关系需要跨区查询,玩家主数据又必须留在账号所属区域。三者的写频率、一致性和驻留要求都不一样。
《裂隙战场》的情况相反。它不需要全球世界,只需要保证同一局五名玩家在同一个房间;跨区组队可以存在,但对局状态必须单区域。《泡泡工坊》更轻:大厅无状态,房间是临时实例,玩家下线后几乎没有需要长期保存的全局关系。
三款产品给出了三种不同的答案,但它们共享同一个问题:每一份状态,谁有权写,其他区域可以旧多久。 回答不了这个问题,“全球同服”就会退化成“全球共用一个库”,然后在第一次跨区高峰时分裂成互相覆盖的副本。
三种形态与代价
| 形态 | 做法 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
| 全球单世界 | 世界状态单点写入,全球玩家读写同一份 | 规则简单、社交完整 | 跨区 RTT 高;写容量受单区域限制 | 小世界、低频 SLG |
| 分区世界 | 每个区域独立世界,玩家不跨区 | 延迟低、容量线性扩展 | 玩家不能跨区互动;合服复杂 | 区域运营、竞技 |
| 混合 | 全球社交 + 区域战斗/世界 | 兼顾社交与延迟 | 归属与可见性规则复杂 | 大多数全球产品 |
北辰的选择是混合,但三款产品的混合比例不同。《群岛纪元》的世界状态按大区切分,联盟、好友和全服排行榜保持全球;《裂隙战场》的对局状态完全区域化,只有账号、好友和跨区观战元数据是全球的;《泡泡工坊》只保留全球账号与好友,房间状态不跨区。
混合形态的代价不是多写几行代码,而是必须维护一张权威的归属表。哪个域归哪个区域、哪个分片、允许哪些区域读、读可以旧多久,都要显式声明。没有这张表,跨区请求只能靠“看起来应该在这”来猜,最终会出现两个区域同时认为自己拥有写入权。
四个状态域与写归属
把全球同服拆成四个域,比按服务拆更稳定,因为域对应的是“谁拥有这份状态”,而不是“哪个进程在处理请求”。
| 域 | 内容 | 写归属 | 读窗口 | 典型冲突 |
|---|---|---|---|---|
| player_master 玩家主数据 | 账号、角色、等级、背包 | 账号主区域 | 跨区读需回源或短窗口 | 跨区登录后等级不一致 |
| world_state 世界状态 | 地图格子、资源点、城池 | 世界所属区域 | 同区强读;跨区只读 | 两个区域同时改同一块地 |
| social_graph 社交关系 | 好友、联盟、黑名单 | 全球单点或按联盟分片 | 可接受数十秒 | 跨区加好友重复 |
| ranking 排行榜 | 战力、赛季、活动榜 | 按榜单分片 | 可接受分钟级 | 跨区提交覆盖 |
这张表的关键是第三列:每个域只有一个写归属。玩家主数据归账号区域,世界状态归世界区域,社交关系归联盟或全球分片,排行榜归榜单分片。跨区请求可以读,可以提交“变更意图”,但不能直接写对方的主库。
第四列同样重要。不是所有域都需要强一致:排行榜旧一分钟通常无感,好友列表旧几十秒也能接受,但世界资源点的归属不能旧,否则两个联盟会同时认为自己占领了同一块地。可见性窗口是产品属性,不是数据库配置。
一致性窗口与玩家可见性
“允许旧多久”需要按玩家能看到的东西来定,而不是按内部表来定。
| 玩家可见信息 | 来源域 | 示例窗口 | 理由 |
|---|---|---|---|
| 自己的等级与背包 | player_master | 0(回源) | 玩家会立刻发现不一致 |
| 好友在线状态 | social_graph | 30 秒 | 状态变化频繁,短暂过期可接受 |
| 联盟成员列表 | social_graph | 60 秒 | 成员变动低频,缓存收益高 |
| 全服排行榜 | ranking | 5 分钟 | 榜单本身按周期结算 |
| 世界资源点归属 | world_state | 0(同区强读) | 争抢同一资源点会直接冲突 |
| 跨区观战画面 | world_state 的只读流 | 2 秒 | 观战允许轻微延迟,但不能改写 |
这张表可以直接变成代码里的 Windows 配置。跨区读请求带上本地副本的年龄,超过窗口就拒绝,让调用方回源;同区读不走窗口,直接读主数据。这样“旧多久”不再靠口头约定,而是每次请求都能检查。
有一个容易忽略的边界:窗口只约束读,不约束写。写永远回主区域,哪怕玩家在本地。把写也做成“本地先写、异步同步”,等于引入第二个写者,最终必须做冲突合并;对等级、背包、资源点这类状态,冲突合并没有业务上可接受的结果。
迁移状态机:写冻结是代价
状态归属不是永久的。世界合服、区域调整、容量搬迁都会触发迁移。迁移的核心约束只有一句:任何时刻最多一个区域可写。
ACTIVE ──BEGIN──▶ DRAINING ──DRAINED──▶ COPYING ──COPIED──▶ VERIFYING
│ │ │ │
│ └────ABORT───────────┴────────ABORT───────┤
│ ▼
│ CUTOVER
│ │
│ COMMIT ────────┤
▼ ▼
(source writes) DONE
(target writes)
状态机的边界如下:
ACTIVE:源区域可写可读,目标区域不提供服务。DRAINING:停止接受新写,等待在途请求完成;源区域仍可读。COPYING:复制存量数据;源区域只读,目标区域不对外。VERIFYING:校验行数、版本和关键业务不变量;仍不切流。CUTOVER:切换路由,目标区域准备接受写,但尚未提交。DONE:目标区域可写可读,源区域停止服务。ABORTED:在提交前放弃,源区域恢复可写;提交后不能回滚。
迁移期间写冻结是必然代价。区别只在于冻结多久:全量复制越久,冻结越长;可以先做增量同步,把冻结压缩到最后一段。但无论怎么优化,都不能在复制过程中让源和目标同时接受写,否则迁移结束后无法判断哪一份是最新。
另一个边界是读。源区域在 DRAINING 到 CUTOVER 期间仍可读,保证玩家不会突然看不到世界;目标区域只在 DONE 后服务,避免玩家看到复制到一半的地图。观战和回放可以延长源区域的只读时间,但不能延长写。
选定方案与边界
北辰的混合方案落成五条规则。
第一,每个域在配置里声明唯一归属:区域、分片和版本。归属表是路由的唯一来源,服务启动时校验,缺失或重复直接失败。
第二,写请求必须到达归属区域。网关可以转发,但不能在本地代写;如果归属区域不可用,返回明确错误,而不是降级到“最近的区域”。
第三,跨区读带年龄。调用方传入本地副本的年龄,服务端按域窗口判断;超窗返回归属区域地址,由调用方回源。这样缓存策略可以分散在各服务,但裁决标准统一。
第四,分片不匹配直接拒绝。请求带着期望分片到达错误节点时,即使数据看起来存在,也不能放行;错误分片上的副本可能落后于主分片。
第五,迁移按状态机执行,提交前可回滚,提交后不可回滚。迁移期间的写冻结由路由层强制执行,不依赖各业务服务自觉检查。
代码:internal/globalstate
下面的 ownership.go 定义四个域、归属表、可见性窗口和读写裁决。migration.go 实现迁移状态机,测试覆盖了正常路径、回滚边界和非法跃迁。
// file: internal/globalstate/ownership.go
// tier: L1
// verify: go test ./internal/globalstate/
package globalstate
import (
"errors"
"fmt"
"time"
)
// Domain is a kind of state, not a service. One domain has exactly one writer
// region at a time; everything else is a reader with a declared staleness
// window. Mixing domains in one rule is how split-brain starts.
type Domain string
const (
DomainPlayerMaster Domain = "player_master"
DomainWorldState Domain = "world_state"
DomainSocialGraph Domain = "social_graph"
DomainRanking Domain = "ranking"
)
// Region is an operational unit that owns storage for a domain.
type Region string
// Owner names the single writer for one domain. Shard is empty for state that
// is not sharded, such as a global social graph.
type Owner struct {
Domain Domain
Region Region
Shard string
}
// Placement is the authoritative map from domain to owner. Unknown domains
// fail closed: a request does not fall back to a default region.
type Placement map[Domain]Owner
// Windows is the maximum age of a cross-region read that the product accepts
// for each domain. A zero window means the reader must go to the owner.
type Windows map[Domain]time.Duration
var (
ErrNoOwner = errors.New("globalstate: domain has no owner")
ErrBadPlacement = errors.New("globalstate: invalid placement")
ErrBadRequest = errors.New("globalstate: invalid request")
)
// Validate requires every owner to name a region and to agree with its map
// key. A silent mismatch between key and Domain is a routing bug that only
// appears under load.
func (p Placement) Validate() error {
if len(p) == 0 {
return fmt.Errorf("%w: empty placement", ErrBadPlacement)
}
for domain, owner := range p {
if domain == "" {
return fmt.Errorf("%w: empty domain", ErrBadPlacement)
}
if owner.Domain != domain {
return fmt.Errorf("%w: %q maps to owner for %q", ErrBadPlacement, domain, owner.Domain)
}
if owner.Region == "" {
return fmt.Errorf("%w: %q has no region", ErrBadPlacement, domain)
}
}
return nil
}
// Operation is read or write. There is no "read your writes" mode: a write
// must go to the owner, otherwise the caller would be guessing.
type Operation string
const (
OpRead Operation = "READ"
OpWrite Operation = "WRITE"
)
// Request is one access with the caller's local view. Staleness is how old
// the caller's cached copy is; a zero value means no cache was consulted.
type Request struct {
Domain Domain
Region Region
Operation Operation
Shard string
Staleness time.Duration
}
// Decision returns the owner even when the request is denied, so the caller
// can redirect instead of retrying the same wrong region.
type Decision struct {
Owner Owner
Allowed bool
CrossRegion bool
MaxStaleness time.Duration
Reason string
}
// Resolve applies ownership and staleness rules. Writes are owner-only.
// Reads are local or, when the domain has a window, served from a stale copy
// no older than that window. A shard mismatch is denied before any of this:
// the request was routed to the wrong place, and freshness cannot fix it.
func Resolve(p Placement, windows Windows, req Request) (Decision, error) {
if err := p.Validate(); err != nil {
return Decision{}, err
}
owner, ok := p[req.Domain]
if !ok {
return Decision{}, fmt.Errorf("%w: %q", ErrNoOwner, req.Domain)
}
if req.Region == "" {
return Decision{}, fmt.Errorf("%w: empty region", ErrBadRequest)
}
if req.Operation != OpRead && req.Operation != OpWrite {
return Decision{}, fmt.Errorf("%w: unknown operation %q", ErrBadRequest, req.Operation)
}
if req.Staleness < 0 {
return Decision{}, fmt.Errorf("%w: negative staleness", ErrBadRequest)
}
window := windows[req.Domain]
if window < 0 {
return Decision{}, fmt.Errorf("%w: negative window for %q", ErrBadRequest, req.Domain)
}
d := Decision{Owner: owner, CrossRegion: req.Region != owner.Region, MaxStaleness: window}
if req.Shard != "" && req.Shard != owner.Shard {
d.Reason = fmt.Sprintf("wrong shard: want %q", owner.Shard)
return d, nil
}
if req.Operation == OpWrite {
if req.Region == owner.Region {
d.Allowed = true
d.Reason = "owner region write"
} else {
d.Reason = "writes are owner-only"
}
return d, nil
}
if req.Region == owner.Region {
d.Allowed = true
d.Reason = "owner region read"
return d, nil
}
if req.Staleness <= window {
d.Allowed = true
d.Reason = "cross-region read within window"
} else {
d.Reason = fmt.Sprintf("snapshot age %s exceeds window %s", req.Staleness, window)
}
return d, nil
}
Resolve 的一个设计选择是:即使拒绝,也返回归属信息。调用方拿到的是“你应该去哪个区域”,而不是一个没有上下文的错误;网关可以据此重定向,客服工具也能解释为什么这次请求被拒。Migration 则把可写区域压缩成两个端点:ACTIVE 时只有源区域,DONE 时只有目标区域,中间所有阶段都不可写。
失败模式与兜底
| 失败 | 现象 | 兜底 |
|---|---|---|
| 归属表缺失 | 请求落到默认区域 | 启动校验;未知域失败关闭 |
| 双写 | 两个区域版本分叉 | 写只允许归属区域;副本拒绝写 |
| 跨区读过期 | 玩家看到旧等级或旧联盟 | 带年龄读;超窗回源;关键页面强读 |
| 迁移卡在复制 | 写冻结时间超预期 | 增量同步;超时告警;提交前可回滚 |
| 提交后回滚 | 两个区域都认为自己拥有 | 提交后禁止回滚;用新迁移纠正 |
| 分片路由错误 | 读写落在错误分片 | 请求带分片断言;不匹配即拒绝 |
前两项是归属设计问题,后四项是运行问题。共同原则是:归属错误在路由层拒绝,迁移错误在状态机里拒绝,读新鲜度错误由调用方回源。不要让业务服务各自实现一套“差不多的判断”,否则同一份状态会有多种归属解释。
常见错误与边界
第一,把“全球同服”理解成“全球单库”。单库能简化规则,但会把写容量和跨区 RTT 绑死;大多数产品需要的是混合,而不是单点。
第二,允许本地先写再同步。这等于引入第二个写者,冲突合并对等级、背包和资源点没有可接受的结果。
第三,用缓存 TTL 代替可见性窗口。缓存组件的默认过期时间和产品能接受的旧数据时间不是一回事。
第四,迁移时源和目标同时可写。复制期间的双写会让迁移结束后无法判断权威版本。
第五,提交后还想回滚。提交意味着目标区域已经接受写,回滚会制造两个写者;正确做法是发起一次新的反向迁移。
第六,忽略分片断言。请求到了错误分片,读到的可能是旧副本;看起来成功,实际已经偏离归属。
小结
全球同服不是一种部署方式,而是一组归属决定。玩家主数据、世界状态、社交关系和排行榜各有自己的写区域与可见性窗口;混合形态把全球社交和区域战斗拆开,用一张权威归属表把两者连起来。跨区读可以旧,但要有窗口;写永远回主区域;迁移期间最多一个写者,提交前可回滚,提交后只能靠新迁移纠正。
延迟预算回答“路怎么走”,数据驻留回答“数据能不能过去”,状态归属回答“过去之后谁有权写”。这三件事合在一起,才构成全球同服可运行的边界。下一章会把视角从区域收回到单区域内部,讨论分片键与数据模型如何承接这些归属规则。
阅读导航:上一节:3.2 数据驻留与跨境合规 · 下一节:4.1 分片键与数据模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。