一张地图,三个赛区,一次跨洋行军
《群岛纪元》出海第一年,运营把欧洲、北美和亚洲玩家接进同一张大地图,卖点是“全球同服”。上线第三周,一次联盟战暴露了承诺与实现之间的裂缝:欧洲联盟的舰队从西欧海域出发,攻击亚洲联盟的岛礁。舰队状态在法兰克福分片,目标岛礁的归属在新加坡分片,一次攻击要跨两次地域写两份状态。玩家看到的是舰队先到、伤害后到;两个分片对到达时间的判断相差 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. 客户端重新拉取路由 │
五个约束缺一不可:
- 冻结先于发送。 源分片在发消息前停止接受该实体的新命令,否则迁移过程中还会产生旧片裁决。
- 目标分片校验 epoch。 epoch 在分片拆分、合并、布局重算时递增;旧 epoch 的消息一律拒绝。
- ack 先于删除。 源分片收到目标确认后才删除本地副本;没有 ack 就重放,目标端按
(entity, seq)幂等。 - 不双写。 迁移期间两边都写是短期最省事、长期最难查的做法,冲突时无法判断哪边是权威。
- 超时要可见。 迁移超时不能静默重试到天荒地老,应进入“迁移中”状态并告警;超过阈值由人工或补偿任务把实体回滚到源片。
跨分片消息还有一层顺序问题。一个实体在边界附近连续移动,消息可能先到 B 再到 C,也可能重复到达。Inbox 用每个实体的 Seq 做单调检查:B 收到 seq=2 后,seq=1 会被拒绝。发送方必须保留未确认消息并按序重放;接收方不做缓存重排,因为重排会引入额外内存和超时语义。
热区扩容:拆矩形,不搬玩家
王城战是典型热区:地图面积不大,但四万名玩家的主城、行军和战斗集中在同一个矩形里。扩容有三条路:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 纵向扩容 | 给单分片更多 CPU/内存 | 改动最小 | 单进程上限,故障半径不降 |
| 拆分矩形 | 沿低流量缝把热区分成两片 | 线性扩展,故障半径下降 | 边界迁移与 epoch 变更 |
| 只读复制 | 复制地图格给观战/排行榜 | 读扩展 | 写仍然单点,且有一致性窗口 |
《群岛纪元》选择拆分矩形,因为王城战的地图有明确的低流量缝:河流和山脉把战场分成两块,跨缝行军占比低于 5%(示例值,来自设计文档而非线上测量)。拆分步骤是:
- 选取低流量缝,生成新的矩形布局,保证每个旧格仍被覆盖(复用第 3.3 节的覆盖校验)。
- 给新分片分配新的 epoch,旧分片停止接受跨缝实体的新命令。
- 按迁移协议把缝两侧的实体逐个交接;交接期间的跨缝命令进入队列,不直接拒绝。
- 全部交接完成后切换路由表,旧分片只保留历史事件查询。
- 观察一个赛季后回收旧分片。
这里最重要的不是拆分算法,而是路由表切换必须是原子的。如果客户端拿到一半新路由、一半旧路由,跨缝行军会出现“发到旧片被拒、发到新片找不到实体”的窗口。做法是把布局版本写进路由响应,客户端和服务端都按版本拒绝过期请求。
区域延迟与合规分区
“全球同服”最难解释的部分是延迟。物理距离不会因为产品承诺消失:法兰克福到新加坡的往返至少一百多毫秒,跨洋写两份状态只会更慢。可接受的口径要按操作类型分别定义:
| 操作 | 期望延迟(示例值) | 说明 |
|---|---|---|
| 同分片内移动/攻击 | 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 分区分服、跨服与合服 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。