一张地图,三个赛区,一条跨洋舰队
《群岛纪元》是北辰工作室的 SLG,核心体验是“所有玩家在同一张大地图上争夺岛屿”。国服上线时它按地图坐标切成若干分片,运营良好。出海第一年,团队把欧洲和北美玩家接进同一个逻辑世界,希望打出“全球同服”的卖点。
问题出现在一次联盟战。欧洲联盟的一支舰队从欧洲海域出发,攻击亚洲联盟的岛礁。服务端把舰队状态放在欧洲分片,目标岛礁的归属却在亚洲分片。一次攻击要跨两次地域、写两份状态,端到端 p99 达到 280ms;更糟的是,两个分片对“到达时间”的判断相差 1.8 秒,战争结果在社区里被质疑“服务器偏袒”。
同一周,合规团队带来第二个约束:欧洲玩家的账号、聊天和支付数据必须留在欧盟境内。技术团队原本以为这是延迟问题,审计之后才发现,它首先是一个数据驻留问题。本节比较三种形态,然后落两个可执行件:按赛区与地图范围路由的 Router,以及保证不丢分片的再均衡计划。
三种形态:切的是世界,不是进程
“分区”“分服”“全球同服”经常被混用。它们切分的东西不同:
| 形态 | 切分对象 | 世界数量 | 跨区交互 | 典型品类 |
|---|---|---|---|---|
| 分区 | 同一世界内的地图范围 | 1 个逻辑世界 | 地图边界处可跨区移动 | SLG、开放世界 |
| 分服 | 玩家群体与完整世界 | N 个独立世界 | 默认不互通,靠跨服活动 | MMO、卡牌、早期 SLG |
| 全球同服 | 逻辑一个世界,物理按地域/地图分片 | 1 个逻辑世界 | 显式跨区命令与异步结算 | 出海 SLG、全球竞技 |
代价完全不同:分区难在边界,玩家跨区时单位、视野、联盟关系都要迁移;分服难在人口与社交,开服、合服、鬼服是运营常态;全球同服难在一致性与合规,逻辑上必须是一张地图,物理上却必须按地域切。
对《群岛纪元》,最终选择是“逻辑全球同服、物理按赛区与地图范围分片”。它不是把全球塞进一个进程,而是让玩家看到一张地图,同时接受跨区操作是异步的、有延迟的、可能失败的。
状态切在哪里
| 状态 | 权威副本 | 切分键 | 跨区操作 |
|---|---|---|---|
| 玩家账号与支付 | 赛区账号服务 | 赛区 | 禁止跨境,按本地法规处理 |
| 玩家主城与资源 | 赛区世界分片 | 赛区 + 地图范围 | 跨区迁城走迁移命令 |
| 地图格归属 | 地图分片 | 地图坐标范围 | 边界变更走两阶段确认 |
| 联盟与外交 | 赛区联盟服务 | 赛区 | 跨区联盟走异步协议 |
| 全球战争时间线 | 全球结算服务 | 无(只存事件) | 只同步事件,不同步个人数据 |
两个关键决定:玩家个人数据按赛区封闭,欧洲玩家的账号、聊天、支付不进入亚洲数据库,全球服务只接收脱敏事件;地图格按坐标范围切分,而不是按玩家切分,一格只能有一个权威分片,玩家跨区时迁移的是玩家状态。所以全球同服不等于全球一个数据库,它可以是“一个逻辑世界 + 多套地域数据 + 一层事件同步”。
路由:赛区优先,范围精确
下面的 Router 把“赛区 + 地图范围”写成显式约束。它拒绝跨赛区回退,也拒绝重叠范围:
// file: internal/shard/router.go
// tier: L1
// verify: go test ./internal/shard/
package shard
import (
"errors"
"fmt"
"sort"
)
type Region string
const (
RegionCN Region = "cn"
RegionEU Region = "eu"
RegionNA Region = "na"
)
var (
ErrOverlap = errors.New("shard: overlapping ranges")
ErrBadRange = errors.New("shard: start must not exceed end")
ErrDuplicateID = errors.New("shard: duplicate shard id")
ErrEmptyID = errors.New("shard: empty shard id")
ErrCoverageLost = errors.New("shard: rebalance plan loses map cells")
)
// Shard owns an inclusive map-cell range inside one region; no cell has two owners.
type Shard struct {
ID string
Region Region
Start int
End int
}
func (s Shard) Size() int { return s.End - s.Start + 1 }
type Router struct {
byRegion map[Region][]Shard
}
func NewRouter(layout []Shard) (*Router, error) {
if err := validate(layout); err != nil {
return nil, err
}
r := &Router{byRegion: make(map[Region][]Shard)}
for _, s := range layout {
r.byRegion[s.Region] = append(r.byRegion[s.Region], s)
}
for region := range r.byRegion {
list := r.byRegion[region]
sort.Slice(list, func(i, j int) bool { return list[i].Start < list[j].Start })
}
return r, nil
}
// Route resolves an inclusive cell inside one region; it never falls back to
// another region, because that would cross a data-residency boundary.
func (r *Router) Route(region Region, cell int) (Shard, bool) {
list := r.byRegion[region]
i := sort.Search(len(list), func(i int) bool { return list[i].End >= cell })
if i < len(list) && list[i].Start <= cell {
return list[i], true
}
return Shard{}, false
}
func validate(layout []Shard) error {
ids := make(map[string]bool, len(layout))
byRegion := make(map[Region][]Shard)
for _, s := range layout {
if s.ID == "" {
return ErrEmptyID
}
if ids[s.ID] {
return fmt.Errorf("%w: %s", ErrDuplicateID, s.ID)
}
ids[s.ID] = true
if s.Start > s.End {
return fmt.Errorf("%w: %s [%d,%d]", ErrBadRange, s.ID, s.Start, s.End)
}
byRegion[s.Region] = append(byRegion[s.Region], s)
}
for region, list := range byRegion {
sort.Slice(list, func(i, j int) bool { return list[i].Start < list[j].Start })
for i := 1; i < len(list); i++ {
if list[i].Start <= list[i-1].End {
return fmt.Errorf("%w: %s and %s in %s",
ErrOverlap, list[i-1].ID, list[i].ID, region)
}
}
}
return nil
}
// Migration copies an inclusive overlap from one shard to another.
type Migration struct {
From, To string
Start, End int
}
func (m Migration) Size() int { return m.End - m.Start + 1 }
type Plan struct {
Migrations []Migration
Retained []Shard
}
func (p Plan) Cells() int {
total := 0
for _, m := range p.Migrations {
total += m.Size()
}
for _, s := range p.Retained {
total += s.Size()
}
return total
}
// BuildPlan proves every old cell is retained or migrated; a gap returns
// ErrCoverageLost instead of a partial plan.
func BuildPlan(from, to []Shard) (Plan, error) {
if err := validate(from); err != nil {
return Plan{}, fmt.Errorf("from layout: %w", err)
}
if err := validate(to); err != nil {
return Plan{}, fmt.Errorf("to layout: %w", err)
}
byRegion := make(map[Region][]Shard)
for _, s := range to {
byRegion[s.Region] = append(byRegion[s.Region], s)
}
for region := range byRegion {
list := byRegion[region]
sort.Slice(list, func(i, j int) bool { return list[i].Start < list[j].Start })
}
var plan Plan
for _, old := range from {
covered := 0
for _, next := range byRegion[old.Region] {
lo, hi := max(old.Start, next.Start), min(old.End, next.End)
if lo > hi {
continue
}
covered += hi - lo + 1
if next.ID == old.ID {
plan.Retained = append(plan.Retained, Shard{
ID: old.ID, Region: old.Region, Start: lo, End: hi})
continue
}
plan.Migrations = append(plan.Migrations, Migration{
From: old.ID, To: next.ID, Start: lo, End: hi})
}
if covered != old.Size() {
return Plan{}, fmt.Errorf("%w: old shard %s has %d cells, new layout covers %d",
ErrCoverageLost, old.ID, old.Size(), covered)
}
}
sort.Slice(plan.Migrations, func(i, j int) bool {
if plan.Migrations[i].From != plan.Migrations[j].From {
return plan.Migrations[i].From < plan.Migrations[j].From
}
return plan.Migrations[i].Start < plan.Migrations[j].Start
})
sort.Slice(plan.Retained, func(i, j int) bool { return plan.Retained[i].ID < plan.Retained[j].ID })
return plan, nil
}
三个约束:范围两端都包含,[0,99] 与 [100,199] 相邻但不重叠;赛区是路由键的一部分,查不到时返回 false,绝不回退,否则会把欧盟数据写进亚洲库;允许表不接受重叠,两个分片声明同一格时启动直接失败。
再均衡:先证明不丢格,再谈迁移
分片不是一次画完的。BuildPlan 的职责不是“生成一堆迁移任务”,而是先证明旧布局的每一格都有人在新区布局里接手:对每个旧分片,与新布局中同赛区的分片求交集并累加大小,若小于旧分片大小就返回 ErrCoverageLost。只有覆盖证明通过,交集才会变成迁移项。
真实迁移还需要三步:冻结与双写,按格加租约,旧分片继续接受写并把变更写入迁移日志;追平与校验,新分片回放日志,按格校验版本号与校验和;切换与回滚,路由表原子切换,旧分片保留只读窗口。没有双写和校验,计划再完整也会在迁移窗口内丢数据。
四类测试:命中、边界、丢格、重叠
路由和计划都需要边界测试,因为它们的错误通常只在边界上出现:
// file: internal/shard/router_test.go
// tier: L1
// verify: go test ./internal/shard/
package shard
import (
"errors"
"testing"
)
func testLayout() []Shard {
return []Shard{
{ID: "cn-1", Region: RegionCN, Start: 0, End: 99},
{ID: "cn-2", Region: RegionCN, Start: 100, End: 199},
{ID: "eu-1", Region: RegionEU, Start: 0, End: 149},
}
}
func TestRouteHitsInclusiveBoundaries(t *testing.T) {
r, err := NewRouter(testLayout())
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
cases := []struct {
region Region
cell int
want string
ok bool
}{
{RegionCN, 0, "cn-1", true},
{RegionCN, 99, "cn-1", true},
{RegionCN, 100, "cn-2", true},
{RegionCN, 199, "cn-2", true},
{RegionCN, 200, "", false},
{RegionCN, -1, "", false},
{RegionEU, 149, "eu-1", true},
{RegionEU, 150, "", false},
{RegionNA, 0, "", false},
}
for _, c := range cases {
got, ok := r.Route(c.region, c.cell)
if ok != c.ok || got.ID != c.want {
t.Fatalf("Route(%s,%d)=(%q,%v), want (%q,%v)",
c.region, c.cell, got.ID, ok, c.want, c.ok)
}
}
}
func TestRouterRejectsOverlapAndBadRanges(t *testing.T) {
_, err := NewRouter([]Shard{
{ID: "a", Region: RegionCN, Start: 0, End: 50},
{ID: "b", Region: RegionCN, Start: 50, End: 99},
})
if !errors.Is(err, ErrOverlap) {
t.Fatalf("overlap error = %v", err)
}
_, err = NewRouter([]Shard{{ID: "bad", Region: RegionCN, Start: 10, End: 9}})
if !errors.Is(err, ErrBadRange) {
t.Fatalf("bad-range error = %v", err)
}
}
func TestPlanSplitKeepsEveryCell(t *testing.T) {
from := []Shard{{ID: "cn-1", Region: RegionCN, Start: 0, End: 99}}
to := []Shard{
{ID: "cn-1", Region: RegionCN, Start: 0, End: 49},
{ID: "cn-2", Region: RegionCN, Start: 50, End: 99},
}
plan, err := BuildPlan(from, to)
if err != nil {
t.Fatalf("BuildPlan: %v", err)
}
if len(plan.Migrations) != 1 || len(plan.Retained) != 1 {
t.Fatalf("plan = %+v", plan)
}
m := plan.Migrations[0]
if m.From != "cn-1" || m.To != "cn-2" || m.Start != 50 || m.End != 99 {
t.Fatalf("migration = %+v", m)
}
if plan.Cells() != 100 {
t.Fatalf("plan cells = %d, want 100", plan.Cells())
}
}
func TestPlanRejectsCoverageLoss(t *testing.T) {
from := []Shard{{ID: "cn-1", Region: RegionCN, Start: 0, End: 99}}
to := []Shard{{ID: "cn-1", Region: RegionCN, Start: 0, End: 49}}
if _, err := BuildPlan(from, to); !errors.Is(err, ErrCoverageLost) {
t.Fatalf("error = %v, want ErrCoverageLost", err)
}
from = append(from, Shard{ID: "eu-1", Region: RegionEU, Start: 0, End: 99})
if _, err := BuildPlan(from, to); !errors.Is(err, ErrCoverageLost) {
t.Fatalf("lost region error = %v", err)
}
}
本机实际运行结果:
$ cd .local/checks/gsarch-lab
$ gofmt -l .
$ go vet ./...
$ go test -count=1 -v ./internal/shard/
=== RUN TestRouteHitsInclusiveBoundaries
--- PASS: TestRouteHitsInclusiveBoundaries (0.00s)
=== RUN TestRouterRejectsOverlapAndBadRanges
--- PASS: TestRouterRejectsOverlapAndBadRanges (0.00s)
=== RUN TestPlanSplitKeepsEveryCell
--- PASS: TestPlanSplitKeepsEveryCell (0.00s)
=== RUN TestPlanRejectsCoverageLoss
--- PASS: TestPlanRejectsCoverageLoss (0.00s)
PASS
ok gsarchlab/internal/shard 0.615s
gofmt -l 无输出,go vet 无告警。四个测试覆盖了路由命中、边界值、重叠拒绝,以及“新布局漏掉一段或整个赛区”时的丢格拒绝。
数据驻留如何反向约束拓扑
回到《群岛纪元》的合规问题。技术团队最初想用“全球一个数据库 + 地域只读副本”实现全球同服,审计直接否掉了这个方案。原因不是性能,而是数据分类:
| 数据类别 | 是否含个人数据 | 驻留要求 | 对拓扑的约束 |
|---|---|---|---|
| 账号、支付、聊天 | 是 | 通常必须本地存储 | 不能有全球单库 |
| 玩家主城、资源 | 可关联到个人 | 按解释可能视为个人数据 | 按赛区封闭更安全 |
| 地图格归属 | 否(联盟级) | 可跨境 | 可作为全球事件同步 |
| 战斗结果事件 | 脱敏后可匿名 | 可跨境 | 可进入全球结算 |
这张表把“全球同服”拆成了两种数据流:
- 个人数据流留在赛区,路由键必须包含赛区,跨区回退被禁止;
- 世界事件流可以跨境,但只同步脱敏事件,不同步原始账号与聊天。
如果合规要求更严——比如所有玩家数据都不得出境——那么“全球同服”在技术上仍然可以实现一张逻辑地图,但跨区联盟、跨区战争这些玩法必须改成异步的、无个人数据的事件交互;如果连事件都不能跨境,就只能退回分服或按赛区独立运营。拓扑不是先选技术再补合规,而是合规先划出可行域。 这也改变了容量规划:不能因为亚洲赛区空闲就把欧洲请求路由过去,每个赛区必须有独立容量上限和降级策略。
常见错误与边界
第一,把“全球同服”理解成一个进程或一个数据库。它通常是一个逻辑世界、多套地域布局和一层事件同步。
第二,路由失败时回退到其他赛区。这会把数据驻留约束变成一句口号;正确行为是拒绝并返回明确的区域不可用。
第三,范围用左闭右开却按闭区间测试。[0,99] 与 [100,199] 在文档里相邻,在代码里差一。边界值必须进入单元测试。
第四,再均衡只生成迁移项,不证明覆盖。漏掉一段范围时,计划看起来仍然“有任务”,执行完却少了地图格。
第五,迁移时只搬快照,不追增量。玩家在迁移窗口内的操作会丢失;必须先冻结/双写,再追平校验,最后切换。跨区战斗也不能同步执行,跨洋调用 p99 不可控,正确做法是把攻击建模为带到达时间的命令,由目标分片在本地时间线上结算。
小结
分区、分服、全球同服切分的是不同对象:地图范围、完整世界、以及“逻辑一个世界、物理多套布局”。全球同服的难点不在把进程连起来,而在状态归属、跨区命令和数据驻留。
Router 用“赛区 + 闭区间”把路由变成可验证的约束,拒绝跨区回退和范围重叠;BuildPlan 先证明旧布局每一格都有接手方,再生成迁移项。下一节进入第 4 章,讨论这些状态在内存、持久化和快照之间如何取舍。
阅读导航:上一节:3.2 大厅、房间与战斗:竞技品类的标准形态 · 下一节:4.1 内存态、持久态与快照 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。