《游戏服务端架构·品类、状态与拓扑》3.3 分区、分服与全球同服

《群岛纪元》出海后,欧洲玩家的一支舰队跨到亚洲赛区,280ms 延迟让联盟战结果失去说服力;合规审计又要求欧洲玩家数据不得离开本地。本节对比分区、分服与全球同服三种形态,用按赛区与地图范围路由的 Go 实现和再均衡计划,说明拓扑为什么会被数据驻留反向约束。

一张地图,三个赛区,一条跨洋舰队

《群岛纪元》是北辰工作室的 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 内存态、持久态与快照 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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