《游戏服务端架构·规模化、全球化与成本》3.3 全球同服的状态归属

《群岛纪元》要全球同服,《裂隙战场》只能区域对局,《泡泡工坊》连房间都不需要全局状态。本文比较全球单世界、分区世界与混合三种形态,拆开玩家主数据、世界状态、联盟关系与排行榜的写归属,给出跨区可见性窗口和迁移状态机,并附 internal/globalstate 的 Go 实现与六组表驱动测试。

三个世界,三种归属

《群岛纪元》上线全球服时,策划提出的目标是“所有玩家在同一个世界里”:联盟可以跨洲、王座战全服可见、排行榜只有一个。工程团队很快发现,这个目标不能直接翻译成“所有数据放在一个数据库”。世界地图的格子状态需要高频写入,联盟关系需要跨区查询,玩家主数据又必须留在账号所属区域。三者的写频率、一致性和驻留要求都不一样。

《裂隙战场》的情况相反。它不需要全球世界,只需要保证同一局五名玩家在同一个房间;跨区组队可以存在,但对局状态必须单区域。《泡泡工坊》更轻:大厅无状态,房间是临时实例,玩家下线后几乎没有需要长期保存的全局关系。

三款产品给出了三种不同的答案,但它们共享同一个问题:每一份状态,谁有权写,其他区域可以旧多久。 回答不了这个问题,“全球同服”就会退化成“全球共用一个库”,然后在第一次跨区高峰时分裂成互相覆盖的副本。

三种形态与代价

形态做法优点代价适用
全球单世界世界状态单点写入,全球玩家读写同一份规则简单、社交完整跨区 RTT 高;写容量受单区域限制小世界、低频 SLG
分区世界每个区域独立世界,玩家不跨区延迟低、容量线性扩展玩家不能跨区互动;合服复杂区域运营、竞技
混合全球社交 + 区域战斗/世界兼顾社交与延迟归属与可见性规则复杂大多数全球产品

北辰的选择是混合,但三款产品的混合比例不同。《群岛纪元》的世界状态按大区切分,联盟、好友和全服排行榜保持全球;《裂隙战场》的对局状态完全区域化,只有账号、好友和跨区观战元数据是全球的;《泡泡工坊》只保留全球账号与好友,房间状态不跨区。

混合形态的代价不是多写几行代码,而是必须维护一张权威的归属表。哪个域归哪个区域、哪个分片、允许哪些区域读、读可以旧多久,都要显式声明。没有这张表,跨区请求只能靠“看起来应该在这”来猜,最终会出现两个区域同时认为自己拥有写入权。

四个状态域与写归属

把全球同服拆成四个域,比按服务拆更稳定,因为域对应的是“谁拥有这份状态”,而不是“哪个进程在处理请求”。

域内容写归属读窗口典型冲突
player_master 玩家主数据账号、角色、等级、背包账号主区域跨区读需回源或短窗口跨区登录后等级不一致
world_state 世界状态地图格子、资源点、城池世界所属区域同区强读;跨区只读两个区域同时改同一块地
social_graph 社交关系好友、联盟、黑名单全球单点或按联盟分片可接受数十秒跨区加好友重复
ranking 排行榜战力、赛季、活动榜按榜单分片可接受分钟级跨区提交覆盖

这张表的关键是第三列:每个域只有一个写归属。玩家主数据归账号区域,世界状态归世界区域,社交关系归联盟或全球分片,排行榜归榜单分片。跨区请求可以读,可以提交“变更意图”,但不能直接写对方的主库。

第四列同样重要。不是所有域都需要强一致:排行榜旧一分钟通常无感,好友列表旧几十秒也能接受,但世界资源点的归属不能旧,否则两个联盟会同时认为自己占领了同一块地。可见性窗口是产品属性,不是数据库配置。

一致性窗口与玩家可见性

“允许旧多久”需要按玩家能看到的东西来定,而不是按内部表来定。

玩家可见信息来源域示例窗口理由
自己的等级与背包player_master0(回源)玩家会立刻发现不一致
好友在线状态social_graph30 秒状态变化频繁,短暂过期可接受
联盟成员列表social_graph60 秒成员变动低频,缓存收益高
全服排行榜ranking5 分钟榜单本身按周期结算
世界资源点归属world_state0(同区强读)争抢同一资源点会直接冲突
跨区观战画面world_state 的只读流2 秒观战允许轻微延迟,但不能改写

这张表可以直接变成代码里的 Windows 配置。跨区读请求带上本地副本的年龄,超过窗口就拒绝,让调用方回源;同区读不走窗口,直接读主数据。这样“旧多久”不再靠口头约定,而是每次请求都能检查。

有一个容易忽略的边界:窗口只约束读,不约束写。写永远回主区域,哪怕玩家在本地。把写也做成“本地先写、异步同步”,等于引入第二个写者,最终必须做冲突合并;对等级、背包、资源点这类状态,冲突合并没有业务上可接受的结果。

迁移状态机:写冻结是代价

状态归属不是永久的。世界合服、区域调整、容量搬迁都会触发迁移。迁移的核心约束只有一句:任何时刻最多一个区域可写。

ACTIVE ──BEGIN──▶ DRAINING ──DRAINED──▶ COPYING ──COPIED──▶ VERIFYING
   │                  │                    │                    │
   │                  └────ABORT───────────┴────────ABORT───────┤
   │                                                           ▼
   │                                                       CUTOVER
   │                                                           │
   │                                            COMMIT ────────┤
   ▼                                                           ▼
 (source writes)                                            DONE
                                                        (target writes)

状态机的边界如下:

  1. ACTIVE:源区域可写可读,目标区域不提供服务。
  2. DRAINING:停止接受新写,等待在途请求完成;源区域仍可读。
  3. COPYING:复制存量数据;源区域只读,目标区域不对外。
  4. VERIFYING:校验行数、版本和关键业务不变量;仍不切流。
  5. CUTOVER:切换路由,目标区域准备接受写,但尚未提交。
  6. DONE:目标区域可写可读,源区域停止服务。
  7. 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 分片键与数据模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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