《游戏服务端架构·品类、状态与拓扑》8.1 全球同服的大地图与分片

《群岛纪元》全球同服后,欧洲舰队跨区行军要穿过三个地图分片,王城战又把单个分片压到过载。本节把“一格一个权威分片”写成可运行的矩形路由,给出边界实体迁移的 epoch 与序列号协议,并对比按玩家、按格子、按区块三种切法的延迟、热区与合规代价。

一张地图,三个赛区,一次跨洋行军

《群岛纪元》出海第一年,运营把欧洲、北美和亚洲玩家接进同一张大地图,卖点是“全球同服”。上线第三周,一次联盟战暴露了承诺与实现之间的裂缝:欧洲联盟的舰队从西欧海域出发,攻击亚洲联盟的岛礁。舰队状态在法兰克福分片,目标岛礁的归属在新加坡分片,一次攻击要跨两次地域写两份状态。玩家看到的是舰队先到、伤害后到;两个分片对到达时间的判断相差 1.8 秒(示例值),社区因此质疑服务器偏袒。

同一周,王城战把另一个问题放大:约四万名玩家(示例值)的主城和行军集中在 200×200 的格子区域,按人数均衡划分的分片全部落在同一个进程上,CPU 在开战 90 秒后打到 95%(示例值),跨分片消息开始堆积。延迟问题与热区问题同时出现,说明“全球同服”不能只靠一句产品承诺,也不能只靠一个一致性哈希环。

本节先给出结论:全球同服是产品承诺,不是所有数据物理同库。 它可以实现为“一个逻辑世界 + 多套地域数据 + 一层事件同步”,但必须把地图格、实体归属和跨片消息写成显式协议。第 3.3 节 已经比较过分区、分服与全球同服三种形态,本节往下走一层,回答“地图怎么切、边界怎么迁、热区怎么扩”。

逻辑一张图,物理三套数据

先把“同服”拆成可验证的承诺,而不是一句宣传语。对《群岛纪元》,团队最终只承诺三件事:

承诺可实现的部分不承诺的部分
同一套世界规则所有赛区跑同一版本、同一数值表不承诺同一毫秒完成裁决
同一份世界事件王城开启、赛季结束、全球公告有单一时间线不承诺事件在所有分片同时可见
跨区可见的社交联盟、排行榜、邮件可以跨区展示不承诺跨区数据在同一数据库

玩家眼里的地图是一张 4096×4096 的连续网格;服务端眼里的地图是若干矩形,每个矩形有一个权威分片。两者之间靠路由函数连接:

玩家视角
  ┌──────────────────────────────────────────┐
  │              一张 4096×4096 大地图        │
  │   ┌─────────┬─────────┬─────────┐        │
  │   │ cn-west │ cn-east │ cn-south│  ...   │
  │   └─────────┴─────────┴─────────┘        │
  └──────────────────────────────────────────┘
服务端视角
  region=cn  ->  [cn-west: x∈[0,2047], y∈[0,2047]]
                 [cn-east: x∈[2048,4095], y∈[0,2047]]
  region=eu  ->  [eu-core: x∈[0,4095], y∈[0,4095]]

关键不在矩形本身,而在下面这张状态归属表。它决定了哪些操作可以本地完成,哪些必须跨片:

状态权威副本切分键跨片操作
地图格归属地图分片赛区 + 格坐标边界变更走两阶段确认
舰队与行军当前格所属分片实体当前格跨边界走 handoff
玩家主城与资源赛区经济服务赛区 + 玩家迁城走迁移命令
联盟与外交赛区联盟服务赛区跨区联盟走异步协议
全球事件时间线全球事件服务无(只存事件)只同步脱敏事件
账号与支付赛区账号服务赛区禁止跨境

这张表里有两个容易被忽略的边界。第一,账号和支付不跟着地图走。欧洲玩家的地图格可以因为合服改变归属,但账号数据不能离开欧盟。第二,全球事件服务只存事件,不存个人状态。它告诉所有分片“王城在 20:00 开启”,但不保存某个玩家的行军队列。

三种切法,代价完全不同

地图分片有三条常见路线,选错一条,后面所有协议都会变形:

切法典型做法AOI 成本迁移成本热区风险适用场景
按玩家玩家 ID 哈希到分片极高,相邻玩家常在不同片低(玩家自己就是单位)高,热门玩家集中纯社交、无共享空间
按格子范围矩形/条带切地图低,片内 AOI 本地完成中,跨边界实体要迁移中,可按热区再切SLG、开放世界
按区块固定大小 chunk 哈希中,AOI 需聚合邻居 chunk低,chunk 可整体搬低到中,chunk 粒度可调体素、沙盒、UGC

按玩家切最不适合 SLG:两支相邻的舰队可能落在不同分片,一次碰撞要跨片查询,AOI 从“本地遍历”退化成“分布式 join”。按区块切适合地图可以无限扩展的场景,但 SLG 的联盟领土、王城、关卡往往是跨 chunk 的连续区域,聚合逻辑会变复杂。最终选择是按矩形格子范围切,矩形内部再按 chunk 做 AOI:归属边界稳定,AOI 仍在片内。

这也是一个 ADR 级别的决定,备选方案与代价要写清楚:

  • 备选 A:按玩家哈希。迁移便宜,但 SLG 的共享地图语义会被破坏。
  • 备选 B:按 chunk 哈希。扩展性好,但联盟领土跨 chunk 时,权限判断要跨片。
  • 选定 C:按矩形范围。边界少、AOI 本地化,代价是热区需要主动拆分。
  • 放弃 D:全球单库。延迟与合规都不可接受。

路由:一格一个权威分片

路由必须同时满足三个约束:格坐标闭区间、赛区隔离、重叠即失败。下面的 worldmap 把这三个约束写进构造函数,查询时不做“找不到就回退到默认分片”这种事——回退意味着一个欧洲玩家可能被路由到亚洲数据,既违反延迟预算,也违反数据驻留。

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

import (
	"errors"
	"fmt"
)

type Region string

const (
	RegionCN Region = "cn"
	RegionEU Region = "eu"
	RegionNA Region = "na"
)

var (
	ErrEmptyID     = errors.New("worldmap: empty shard id")
	ErrDuplicateID = errors.New("worldmap: duplicate shard id")
	ErrBadBounds   = errors.New("worldmap: min must not exceed max")
	ErrOverlap     = errors.New("worldmap: shards overlap in one region")
	ErrOutsideMap  = errors.New("worldmap: point outside every shard")
	ErrNotOwner    = errors.New("worldmap: shard does not own the origin")
	ErrWrongShard  = errors.New("worldmap: message delivered to the wrong shard")
	ErrStaleEpoch  = errors.New("worldmap: ownership epoch does not match")
	ErrOutOfOrder  = errors.New("worldmap: sequence already applied")
)

// Point is an inclusive map cell. Coordinates never cross a region boundary.
type Point struct{ X, Y int }

// Bounds is an inclusive rectangle; both corners belong to the shard.
type Bounds struct{ Min, Max Point }

func (b Bounds) Contains(p Point) bool {
	return p.X >= b.Min.X && p.X <= b.Max.X && p.Y >= b.Min.Y && p.Y <= b.Max.Y
}

func (b Bounds) Valid() bool {
	return b.Min.X <= b.Max.X && b.Min.Y <= b.Max.Y
}

// Shard owns a rectangle inside one region. A cell has at most one owner.
type Shard struct {
	ID     string
	Region Region
	Bounds Bounds
}

type Router struct {
	byRegion map[Region][]Shard
}

// NewRouter validates the layout once and answers ownership without fallback.
// It deliberately has no "global default shard": an unmapped cell is an error,
// not a reason to route into another region.
func NewRouter(layout []Shard) (*Router, error) {
	ids := make(map[string]bool, len(layout))
	for _, s := range layout {
		if s.ID == "" {
			return nil, ErrEmptyID
		}
		if ids[s.ID] {
			return nil, fmt.Errorf("%w: %s", ErrDuplicateID, s.ID)
		}
		ids[s.ID] = true
		if !s.Bounds.Valid() {
			return nil, fmt.Errorf("%w: %s", ErrBadBounds, s.ID)
		}
	}
	for i := range layout {
		for j := i + 1; j < len(layout); j++ {
			a, b := layout[i], layout[j]
			if a.Region != b.Region {
				continue
			}
			if a.Bounds.Min.X <= b.Bounds.Max.X && b.Bounds.Min.X <= a.Bounds.Max.X &&
				a.Bounds.Min.Y <= b.Bounds.Max.Y && b.Bounds.Min.Y <= a.Bounds.Max.Y {
				return nil, fmt.Errorf("%w: %s and %s", ErrOverlap, a.ID, b.ID)
			}
		}
	}
	r := &Router{byRegion: make(map[Region][]Shard)}
	for _, s := range layout {
		r.byRegion[s.Region] = append(r.byRegion[s.Region], s)
	}
	return r, nil
}

// Owner returns the single shard that owns the cell in this region.
func (r *Router) Owner(region Region, p Point) (Shard, bool) {
	for _, s := range r.byRegion[region] {
		if s.Bounds.Contains(p) {
			return s, true
		}
	}
	return Shard{}, false
}

// Handoff is the command that transfers one entity between shards. Epoch is
// the destination ownership epoch; the receiver rejects older epochs so a
// delayed message cannot resurrect a previous owner.
type Handoff struct {
	Entity string
	From   string
	To     string
	Seq    uint64
	Epoch  uint64
}

// PlanMove validates authority before producing a handoff. Moving inside one
// shard returns ok=false and no message, which keeps the common path cheap.
func (r *Router) PlanMove(region Region, entity, fromShard string,
	from, to Point, seq, epoch uint64) (Handoff, bool, error) {
	if entity == "" || fromShard == "" {
		return Handoff{}, false, ErrEmptyID
	}
	origin, ok := r.Owner(region, from)
	if !ok {
		return Handoff{}, false, fmt.Errorf("%w: origin (%d,%d)", ErrOutsideMap, from.X, from.Y)
	}
	if origin.ID != fromShard {
		return Handoff{}, false, fmt.Errorf("%w: %s claims (%d,%d) owned by %s",
			ErrNotOwner, fromShard, from.X, from.Y, origin.ID)
	}
	dest, ok := r.Owner(region, to)
	if !ok {
		return Handoff{}, false, fmt.Errorf("%w: target (%d,%d)", ErrOutsideMap, to.X, to.Y)
	}
	if dest.ID == origin.ID {
		return Handoff{}, false, nil
	}
	return Handoff{
		Entity: entity,
		From:   origin.ID,
		To:     dest.ID,
		Seq:    seq,
		Epoch:  epoch,
	}, true, nil
}

// Message is one cross-shard command delivered to a destination shard.
type Message struct {
	Entity string
	From   string
	To     string
	Seq    uint64
	Epoch  uint64
}

// Inbox applies cross-shard messages in order. It is intentionally not a
// reordering buffer: out-of-order and duplicate messages are rejected, and the
// sender is expected to replay from its last acknowledged sequence.
type Inbox struct {
	shard   string
	epoch   uint64
	applied map[string]uint64
}

func NewInbox(shard string, epoch uint64) *Inbox {
	return &Inbox{shard: shard, epoch: epoch, applied: make(map[string]uint64)}
}

func (in *Inbox) Accept(m Message) error {
	if m.To != in.shard {
		return fmt.Errorf("%w: got %q want %q", ErrWrongShard, m.To, in.shard)
	}
	if m.Epoch != in.epoch {
		return fmt.Errorf("%w: got %d want %d", ErrStaleEpoch, m.Epoch, in.epoch)
	}
	if last, ok := in.applied[m.Entity]; ok && m.Seq <= last {
		return fmt.Errorf("%w: entity %s seq %d <= %d", ErrOutOfOrder, m.Entity, m.Seq, last)
	}
	in.applied[m.Entity] = m.Seq
	return nil
}

几个设计点值得展开:

  • Owner 使用闭区间,Bounds{Min:{0,0}, Max:{99,99}} 覆盖 100×100 个格子。边界格必须属于且只属于一个分片,否则两支舰队会在同一条格线上得到不同答案。
  • 构造函数检查矩形是否重叠。重叠不是“后写覆盖先写”,而是直接失败;地图布局应该在发布前被证明无重叠,而不是在运行时靠顺序决定归属。
  • Owner 对分片列表做线性扫描。分片数量在几十到几百时完全够用;如果增长到几千,应换成按行或按 chunk 的二级索引,但接口语义不变。
  • PlanMove 先验证源分片确实拥有起点格,再判断目标格。它可以拒绝“客户端声称自己在某个分片”的伪造请求,也可以把片内移动识别为无消息。
  • Handoff 带上 Seq 与 Epoch。没有 epoch,分片拆分后一条迟到消息可能把实体交还给已经不存在的前任。
  • Inbox 不做重排序缓冲。跨片消息按实体序列号严格递增应用,乱序或重复直接拒绝,由发送方从最后确认的序号重放。这是“至少一次 + 幂等应用”,不是“恰好一次”。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/worldmap
$ go test -count=1 ./internal/worldmap/
ok  	gsarchlab/internal/worldmap	0.241s

4 个测试全部通过,覆盖闭区间归属、区域隔离、非法布局、跨片迁移和消息乱序/重复。时间为本机一次运行结果,后续运行可能显示 (cached)。

边界迁移:先冻结,再交接

一张地图切得再整齐,实体总会跨边界。行军、迁城、被击退、合服后的坐标重映射,都会让一个实体从 A 片走到 B 片。迁移协议的目标不是“让迁移更快”,而是任何时刻只有一个分片能裁决这个实体。

源分片 A                         目标分片 B
   │                                  │
   │ 1. 标记实体冻结,停止接受新命令   │
   │ 2. 写入 handoff(entity,seq,epoch) │
   │────────────── 消息 ─────────────>│
   │                                  │ 3. 校验 epoch,应用实体
   │<────────────── ack ──────────────│
   │ 4. 收到 ack 后删除本地副本         │
   │ 5. 客户端重新拉取路由              │

五个约束缺一不可:

  1. 冻结先于发送。 源分片在发消息前停止接受该实体的新命令,否则迁移过程中还会产生旧片裁决。
  2. 目标分片校验 epoch。 epoch 在分片拆分、合并、布局重算时递增;旧 epoch 的消息一律拒绝。
  3. ack 先于删除。 源分片收到目标确认后才删除本地副本;没有 ack 就重放,目标端按 (entity, seq) 幂等。
  4. 不双写。 迁移期间两边都写是短期最省事、长期最难查的做法,冲突时无法判断哪边是权威。
  5. 超时要可见。 迁移超时不能静默重试到天荒地老,应进入“迁移中”状态并告警;超过阈值由人工或补偿任务把实体回滚到源片。

跨分片消息还有一层顺序问题。一个实体在边界附近连续移动,消息可能先到 B 再到 C,也可能重复到达。Inbox 用每个实体的 Seq 做单调检查:B 收到 seq=2 后,seq=1 会被拒绝。发送方必须保留未确认消息并按序重放;接收方不做缓存重排,因为重排会引入额外内存和超时语义。

热区扩容:拆矩形,不搬玩家

王城战是典型热区:地图面积不大,但四万名玩家的主城、行军和战斗集中在同一个矩形里。扩容有三条路:

方案做法优点代价
纵向扩容给单分片更多 CPU/内存改动最小单进程上限,故障半径不降
拆分矩形沿低流量缝把热区分成两片线性扩展,故障半径下降边界迁移与 epoch 变更
只读复制复制地图格给观战/排行榜读扩展写仍然单点,且有一致性窗口

《群岛纪元》选择拆分矩形,因为王城战的地图有明确的低流量缝:河流和山脉把战场分成两块,跨缝行军占比低于 5%(示例值,来自设计文档而非线上测量)。拆分步骤是:

  1. 选取低流量缝,生成新的矩形布局,保证每个旧格仍被覆盖(复用第 3.3 节的覆盖校验)。
  2. 给新分片分配新的 epoch,旧分片停止接受跨缝实体的新命令。
  3. 按迁移协议把缝两侧的实体逐个交接;交接期间的跨缝命令进入队列,不直接拒绝。
  4. 全部交接完成后切换路由表,旧分片只保留历史事件查询。
  5. 观察一个赛季后回收旧分片。

这里最重要的不是拆分算法,而是路由表切换必须是原子的。如果客户端拿到一半新路由、一半旧路由,跨缝行军会出现“发到旧片被拒、发到新片找不到实体”的窗口。做法是把布局版本写进路由响应,客户端和服务端都按版本拒绝过期请求。

区域延迟与合规分区

“全球同服”最难解释的部分是延迟。物理距离不会因为产品承诺消失:法兰克福到新加坡的往返至少一百多毫秒,跨洋写两份状态只会更慢。可接受的口径要按操作类型分别定义:

操作期望延迟(示例值)说明
同分片内移动/攻击20ms 以内本地内存裁决
同赛区跨分片30–60ms一次 handoff 或消息往返
跨赛区异步事件150–300ms只同步事件,不同步个人状态
跨赛区实时战斗不承诺应改为异步远征或分区战场

合规是另一条硬边界。欧洲玩家的账号、聊天、支付数据留在欧盟;全球事件服务只接收脱敏后的联盟名、岛屿 ID 和事件类型。跨区联盟可以存在,但它的成员数据仍然留在各自赛区,全球服务只保存“谁和谁结盟”的关系摘要。

这也是为什么第 3.3 节说“全球同服不等于全球一个数据库”。如果架构图上出现一条从欧洲分片直写亚洲数据库的线,延迟和合规会同时出问题。正确画法是:每个赛区一套数据,全球层只放事件和索引。

失败模式与兜底

失败表现兜底
迁移双写两边都认为自己是权威,战斗结果分叉冻结 + 单写 + epoch 校验
消息乱序实体先到 B 后又收到 A 的旧命令按实体 Seq 单调应用,拒绝旧序号
epoch 过期拆分后旧消息把实体交还给已下线分片目标端校验 epoch,旧消息丢弃并告警
迁移中断实体卡在“迁移中”,玩家无法操作超时回滚源片或补偿任务重放
热区抖动拆分后新片又被打满预留缓冲片,按小时级指标再拆
路由缺失某个格没有分片,命令无处可去启动时全覆盖校验,缺失即拒绝发布
全球事件重复同一王城开启事件被消费两次事件 ID 去重,消费端幂等

这些兜底里,最容易被省略的是“启动时全覆盖校验”。地图布局是配置,不是代码;配置错一个格子,线上就会出现无法操作的死区。把覆盖校验放进发布流水线,比事后补一个默认分片安全得多。

常见错误

第一,按玩家哈希切 SLG 地图。它让相邻实体分散,AOI 查询从本地遍历变成跨片聚合,延迟随在线人数上升。

第二,用一致性哈希切地图格。一致性哈希擅长“节点变化时少搬数据”,但相邻格会散落到不同分片,边界数量爆炸;地图分片首先需要空间局部性。

第三,迁移时双写。短期看起来平滑,长期无法解释冲突;一旦两边都产生战斗结果,回滚也没有权威版本。

第四,把“全球同服”理解成“全球一个数据库”。延迟和合规都不允许;正确目标是逻辑一张图、物理多套数据、事件层同步。

第五,没有 epoch。分片拆分后,旧消息可能复活一个已经不存在的前任;epoch 是迁移协议里最便宜、也最容易被漏掉的字段。

第六,只按人数均衡分片。SLG 的热度由地图位置决定,王城、关卡、资源带会把玩家吸到同一个矩形;均衡指标应该是“峰值 tick 成本”,不是“注册玩家数”。

小结

全球同服不是把所有状态塞进一个数据库,而是把“一张地图”的产品承诺拆成三件事:按矩形范围切分地图、用 epoch 与序列号保证边界迁移只有一个权威、把热区扩容和合规分区当成常态运维而不是例外。

本节的可运行产物是 internal/worldmap:它把矩形路由、边界迁移和跨分片消息的拒绝条件写成了 4 个测试。下一节进入运营层:分区分服、跨服活动与合服,讨论当“开新服”和“合并老服”成为增长手段时,ID、重名、排行榜和赛季数据要怎么迁移。

阅读导航:上一节:7.3 观战、回放与赛事架构 · 下一节:8.2 分区分服、跨服与合服 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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