《游戏服务端架构·核心系统与契约》4.2 分线、副本与主城/野外

主城装不下就开分线,副本要独立规则,SLG 的大地图分片却不是分线。本节区分四种“世界切开”的方式,给出跨线迁移的冻结、令牌与 epoch 协议,讨论容量阈值、迟滞与回收策略如何避免频繁抖动,并用可运行的 Go 包演示状态归属与旧线拒绝写入。

主城满了,野外却是空的

《群岛纪元》开服第一周,主城同时在线四千人(示例值),野外只有六百人。主城的 AOI 把玩家按 50 米格子切开,但格子再小也挡不住“所有人都要看见拍卖行、邮件和公会招募”的产品需求;同一块屏幕里挤着上百个名字,客户端帧率掉到二十多(示例值)。运营提出“开二线”,技术团队第一反应是“加机器”,第二反应才是“分线会不会把玩家拆散”。

《裂隙战场》没有这个问题:一局十人,打完就回收,天然是副本。《泡泡工坊》也没有:一个房间八人,房间本身就是隔离单位。真正需要设计的是《群岛纪元》这种“共享世界 + 局部过载”的产品。分线、副本、地图分片、房间,四种切法经常被混为一谈,但它们的代价完全不同。

四种“把世界切开”的方式

方式玩家看到的世界数据归属跨区可见典型用途
分线同一张地图的多份拷贝每条线一套实体状态默认不可见主城、野外扩容
副本独立规则的私有场景实例独立,结算回主世界不可见战斗、剧情、活动
地图分片同一张大地图的不同区域每片负责一块坐标路由决定SLG 大地图、开放世界
房间一局对战的隔离单元房间进程独占不可见竞技、休闲小游戏

关键区别在第一行和第三行:分线是“同一块地图复制多份”,地图分片是“一张地图切成几块”。 分线的玩家在二线看不到一线的朋友;地图分片的玩家从 A 区走到 B 区,仍然在同一个逻辑世界里,只是权威进程换了。把 SLG 的地图分片叫成“分线”,会导致两个错误:以为跨片玩家天然不可见,以及以为合线等于合服。

副本则多一层规则隔离:副本里的掉落、进度、胜负由实例自己裁决,结算后写回主世界。它不能简单复用分线的迁移协议,因为副本结束时要处理“实例销毁后玩家去哪”。

分线的代价:容量换可见性

分线不是免费的扩容。每开一条线,就多一份主城实体、多一套 AOI、多一组广播;更麻烦的是社交关系被切开:

代价表现缓解
好友看不见组队邀请跨线失败组队、私聊走全局服务
交易受阻摊位和拍卖不在同一线交易行做成全服共享
活动分散世界 Boss 在三条线各刷一只活动按线独立结算或强制合线
迁移成本换线要冻结、交接、重建视野只允许在安全区换线
运维复杂每条线独立扩缩容线由统一控制器管理

北辰工作室给《群岛纪元》定的规则是:社交关系全局化,空间状态分线化。 好友、公会、邮件、拍卖行不跟着线走;位置、AOI、地图事件跟着线走。这样分线只影响“看见谁”,不影响“认识谁”。

跨线迁移:先冻结,再交接

玩家从一线换到二线,最危险的不是慢,而是两边同时认为自己拥有这个玩家。协议必须保证任意时刻只有一个权威:

一线(源)                          二线(目标)
   │ 1. BeginTransfer:冻结玩家       │
   │ 2. 生成 token + seq + epoch      │
   │──────────── handoff ────────────>│
   │                                  │ 3. 校验 token 与 epoch
   │                                  │ 4. 写入目标线,返回 ack
   │<──────────── ack ────────────────│
   │ 5. 删除源线副本,解冻             │
   │ 6. 客户端重新拉取视野             │

四个约束缺一不可:

  1. 冻结先于发送。 源线在发 handoff 前拒绝该玩家的写命令,否则迁移过程中还会产生旧线裁决。
  2. token 绑定一次迁移。 重复的 Complete 不能把玩家移动两次;token 不匹配直接拒绝。
  3. epoch 绑定目标线的生命周期。 目标线被重置或重建后,旧 handoff 必须失效。
  4. 失败可回滚。 超时或目标拒绝时调用 Abort 解冻,玩家仍留在源线,而不是卡在“迁移中”。

这就是 internal/worldline 的核心状态机:BeginTransfer 只冻结、不搬人;Complete 才真正转移归属;Abort 释放冻结。任何一步崩溃,玩家都还有一个明确的归属。

容量阈值:用迟滞而不是单点

最简单的扩容策略是“占用超过 80% 就开线”,但它会在阈值附近来回抖动:开线后占用降到 40%,立刻关线,又回到 80%。北辰工作室用三个参数控制:

参数示例值作用
高水位85%超过则请求开线
低水位35%低于则请求关线
冷却 tick10两次变更之间的最小间隔

还有一条隐含约束:低水位乘二必须小于高水位。关掉一条线会让剩余线的占用翻倍;如果 35% 翻倍后仍高于 85%,关线会立刻触发开线。Policy.Validate 把这条约束写进构造函数,配置错误在启动时失败,而不是在凌晨三点抖动。

关线还有第二个条件:剩余容量必须装得下当前在线人数。即使占用很低,只要关掉后装不下,就不能关。控制器因此是一个纯函数:输入 tick、线数、在线数和单线容量,输出开、关或不动作;它不直接操作进程,便于用表驱动测试覆盖边界。

可运行片段:冻结、令牌与 epoch

下面的 internal/worldline 把分配、迁移、旧线拒绝写入和 epoch 失效写成可测试的状态机。容量控制器在同包的 capacity.go 里,接口保持纯函数,文章不重复贴出。

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

import (
	"errors"
	"fmt"
)

var (
	ErrBadCapacity       = errors.New("worldline: capacity must be positive")
	ErrNoLines           = errors.New("worldline: at least one line is required")
	ErrEmptyLine         = errors.New("worldline: empty line id")
	ErrDuplicateLine     = errors.New("worldline: duplicate line id")
	ErrUnknownLine       = errors.New("worldline: unknown line")
	ErrEmptyPlayer       = errors.New("worldline: empty player id")
	ErrUnknownPlayer     = errors.New("worldline: unknown player")
	ErrWorldFull         = errors.New("worldline: every line is at capacity")
	ErrLineFull          = errors.New("worldline: target line is at capacity")
	ErrSameLine          = errors.New("worldline: source and target are the same line")
	ErrTransferPending   = errors.New("worldline: transfer already pending")
	ErrNoTransfer        = errors.New("worldline: no pending transfer")
	ErrTokenMismatch     = errors.New("worldline: handoff token does not match")
	ErrFrozen            = errors.New("worldline: player is frozen during transfer")
	ErrWrongLine         = errors.New("worldline: write delivered to a non-owning line")
	ErrStaleEpoch        = errors.New("worldline: target line epoch changed")
	ErrDuplicateTransfer = errors.New("worldline: transfer already completed")
	ErrLineNotEmpty      = errors.New("worldline: line still has players")
)

// Line is one copy of the same scene. Epoch changes when the line is reset, so
// a handoff prepared against the old incarnation cannot commit.
type Line struct {
	ID       string
	Epoch    uint64
	Capacity int
	members  map[string]struct{}
}

func (l *Line) Load() int { return len(l.members) }

func (l *Line) Has(player string) bool {
	_, ok := l.members[player]
	return ok
}

// Handoff binds one player move to a sequence number and a target epoch.
type Handoff struct {
	Player string
	From   string
	To     string
	Token  string
	Seq    uint64
	Epoch  uint64
}

// World owns line membership. A player has one home line, and that line is
// frozen for the player between BeginTransfer and Complete or Abort.
type World struct {
	capacity int
	lines    map[string]*Line
	order    []string
	home     map[string]string
	pending  map[string]Handoff
	done     map[string]bool
	seq      uint64
}

// NewWorld keeps the given order as the least-loaded tie-breaker.
func NewWorld(capacity int, lineIDs ...string) (*World, error) {
	if capacity <= 0 {
		return nil, ErrBadCapacity
	}
	if len(lineIDs) == 0 {
		return nil, ErrNoLines
	}
	w := &World{
		capacity: capacity,
		lines:    make(map[string]*Line, len(lineIDs)),
		home:     make(map[string]string),
		pending:  make(map[string]Handoff),
		done:     make(map[string]bool),
	}
	for _, id := range lineIDs {
		if id == "" {
			return nil, ErrEmptyLine
		}
		if _, ok := w.lines[id]; ok {
			return nil, fmt.Errorf("%w: %s", ErrDuplicateLine, id)
		}
		w.lines[id] = &Line{ID: id, Epoch: 1, Capacity: capacity, members: make(map[string]struct{})}
		w.order = append(w.order, id)
	}
	return w, nil
}

// Assign is idempotent for a player that already has a home.
func (w *World) Assign(player string) (string, error) {
	if player == "" {
		return "", ErrEmptyPlayer
	}
	if line, ok := w.home[player]; ok {
		return line, nil
	}
	best := ""
	for _, id := range w.order {
		line := w.lines[id]
		if line.Load() >= line.Capacity {
			continue
		}
		if best == "" || line.Load() < w.lines[best].Load() {
			best = id
		}
	}
	if best == "" {
		return "", ErrWorldFull
	}
	w.lines[best].members[player] = struct{}{}
	w.home[player] = best
	return best, nil
}

func (w *World) Locate(player string) (string, bool) {
	line, ok := w.home[player]
	return line, ok
}

func (w *World) Load(line string) (int, error) {
	l, ok := w.lines[line]
	if !ok {
		return 0, fmt.Errorf("%w: %s", ErrUnknownLine, line)
	}
	return l.Load(), nil
}

// BeginTransfer freezes the player but does not move ownership yet, so a
// crash leaves exactly one owner: the source.
func (w *World) BeginTransfer(player, to string) (Handoff, error) {
	from, ok := w.home[player]
	if !ok {
		return Handoff{}, fmt.Errorf("%w: %s", ErrUnknownPlayer, player)
	}
	if _, ok := w.pending[player]; ok {
		return Handoff{}, fmt.Errorf("%w: %s", ErrTransferPending, player)
	}
	if from == to {
		return Handoff{}, ErrSameLine
	}
	target, ok := w.lines[to]
	if !ok {
		return Handoff{}, fmt.Errorf("%w: %s", ErrUnknownLine, to)
	}
	if target.Load() >= target.Capacity {
		return Handoff{}, fmt.Errorf("%w: %s", ErrLineFull, to)
	}
	w.seq++
	h := Handoff{
		Player: player,
		From:   from,
		To:     to,
		Token:  fmt.Sprintf("%s:%s:%d", player, to, w.seq),
		Seq:    w.seq,
		Epoch:  target.Epoch,
	}
	w.pending[player] = h
	return h, nil
}

// Write is the guard every line calls before applying a command. It rejects a
// frozen player even on the owning line, closing the migration window.
func (w *World) Write(player, line string) error {
	home, ok := w.home[player]
	if !ok {
		return fmt.Errorf("%w: %s", ErrUnknownPlayer, player)
	}
	if _, frozen := w.pending[player]; frozen {
		return fmt.Errorf("%w: %s", ErrFrozen, player)
	}
	if home != line {
		return fmt.Errorf("%w: %s owns %s, not %s", ErrWrongLine, home, player, line)
	}
	return nil
}

// Complete commits a handoff. A repeated token is rejected, not applied twice.
func (w *World) Complete(h Handoff) error {
	pending, ok := w.pending[h.Player]
	if !ok {
		if w.done[h.Token] {
			return fmt.Errorf("%w: %s", ErrDuplicateTransfer, h.Token)
		}
		return fmt.Errorf("%w: %s", ErrNoTransfer, h.Player)
	}
	if pending.Token != h.Token {
		return fmt.Errorf("%w: got %q want %q", ErrTokenMismatch, h.Token, pending.Token)
	}
	target, ok := w.lines[h.To]
	if !ok {
		return fmt.Errorf("%w: %s", ErrUnknownLine, h.To)
	}
	if target.Epoch != h.Epoch {
		return fmt.Errorf("%w: got %d want %d", ErrStaleEpoch, h.Epoch, target.Epoch)
	}
	if target.Load() >= target.Capacity {
		return fmt.Errorf("%w: %s", ErrLineFull, h.To)
	}
	if source, ok := w.lines[h.From]; ok {
		delete(source.members, h.Player)
	}
	target.members[h.Player] = struct{}{}
	w.home[h.Player] = h.To
	delete(w.pending, h.Player)
	w.done[h.Token] = true
	return nil
}

// Abort releases the freeze without moving ownership.
func (w *World) Abort(h Handoff) error {
	pending, ok := w.pending[h.Player]
	if !ok {
		return fmt.Errorf("%w: %s", ErrNoTransfer, h.Player)
	}
	if pending.Token != h.Token {
		return fmt.Errorf("%w: got %q want %q", ErrTokenMismatch, h.Token, pending.Token)
	}
	delete(w.pending, h.Player)
	return nil
}

// ResetLine advances a drained line's epoch, invalidating old handoffs.
func (w *World) ResetLine(line string) (uint64, error) {
	l, ok := w.lines[line]
	if !ok {
		return 0, fmt.Errorf("%w: %s", ErrUnknownLine, line)
	}
	if l.Load() != 0 {
		return 0, fmt.Errorf("%w: %s has %d players", ErrLineNotEmpty, line, l.Load())
	}
	l.Epoch++
	return l.Epoch, nil
}

三个设计点值得强调:

  • Assign 选最小负载线,平手时按构造顺序,保证同一批玩家在重放时落到同一条线,便于复现问题。
  • BeginTransfer 只冻结、不删除源线成员;Complete 才删除并写入目标。这样任何一步失败,源线仍然是唯一权威。
  • done 记录已完成的 token,重复提交返回 ErrDuplicateTransfer,而不是静默成功。幂等不等于假装没发生,调用方需要知道重试是否真的生效。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/worldline
$ go vet ./internal/worldline/...
$ go test -count=1 ./internal/worldline/
ok  	gsarchlab/internal/worldline

6 个表驱动测试全部通过,覆盖最小负载分配、冻结期间旧线拒绝写入、重复提交、epoch 失效、容量迟滞和非法策略。gofmt -l 与 go vet 均无输出。

失败模式与兜底

失败表现兜底
迁移双写两边都裁决同一玩家冻结 + 单写 + token 校验
重复提交玩家被移动两次或计数翻倍token 幂等,重复返回明确错误
目标线重置旧 handoff 写进新实例校验目标 epoch,失效即拒绝
迁移超时玩家卡在“迁移中”超时 Abort,保留源线归属
容量抖动开线关线反复触发高低水位 + 冷却 + 剩余容量校验
社交被切断好友、公会跨线不可见社交数据全局化,只分空间状态
副本未回收实例进程和内存持续增长结算后延迟销毁,空闲超时兜底

最危险的是“迁移超时后自动重试到成功”。重试本身没错,错在没有上限和可见状态;玩家在客户端看到的是“换线中”转圈,服务端却在无限重放。正确做法是超时进入可观测的失败状态,由补偿任务或玩家重新发起。

常见错误

第一,把分线当成分片。分线是同一地图的拷贝,分片是同一地图的切块;前者跨线不可见,后者跨片仍同世界。

第二,迁移时双写。短期最省事,长期无法判断哪边是权威;一旦两边都产生战斗结果,回滚也没有正确版本。

第三,换线不冻结。玩家在迁移窗口内还能向旧线发命令,旧线会基于过期位置裁决。

第四,只按在线人数开线。主城、活动、世界 Boss 的局部热点和总人数无关;应该看单线峰值占用和 AOI 密度。

第五,副本结束直接销毁实例。玩家的结算、邮件、掉落还没写完就销毁,会丢数据;销毁必须晚于结算确认。

小结

分线用“多份地图拷贝”换容量,代价是空间可见性被切开;副本用“独立规则实例”换隔离,代价是结算和回收要单独设计;地图分片切的是同一张地图,不是拷贝。跨线迁移的核心不是速度,而是任意时刻只有一个权威:冻结、token、epoch、可回滚,四者缺一不可。

场景切开之后,还有一个问题没回答:那些不属于某一条线、却要让所有人知道的事件——世界 Boss 刷新、赛季结束、全服公告——该怎么广播。下一节进入全服事件的扇出模型、分级通道和去重顺序。

阅读导航:上一节:4.1 AOI 选型:九宫格、十字链表与空间索引 · 下一节:4.3 世界事件与全服广播 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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