主城满了,野外却是空的
《群岛纪元》开服第一周,主城同时在线四千人(示例值),野外只有六百人。主城的 AOI 把玩家按 50 米格子切开,但格子再小也挡不住“所有人都要看见拍卖行、邮件和公会招募”的产品需求;同一块屏幕里挤着上百个名字,客户端帧率掉到二十多(示例值)。运营提出“开二线”,技术团队第一反应是“加机器”,第二反应才是“分线会不会把玩家拆散”。
《裂隙战场》没有这个问题:一局十人,打完就回收,天然是副本。《泡泡工坊》也没有:一个房间八人,房间本身就是隔离单位。真正需要设计的是《群岛纪元》这种“共享世界 + 局部过载”的产品。分线、副本、地图分片、房间,四种切法经常被混为一谈,但它们的代价完全不同。
四种“把世界切开”的方式
| 方式 | 玩家看到的世界 | 数据归属 | 跨区可见 | 典型用途 |
|---|---|---|---|---|
| 分线 | 同一张地图的多份拷贝 | 每条线一套实体状态 | 默认不可见 | 主城、野外扩容 |
| 副本 | 独立规则的私有场景 | 实例独立,结算回主世界 | 不可见 | 战斗、剧情、活动 |
| 地图分片 | 同一张大地图的不同区域 | 每片负责一块坐标 | 路由决定 | SLG 大地图、开放世界 |
| 房间 | 一局对战的隔离单元 | 房间进程独占 | 不可见 | 竞技、休闲小游戏 |
关键区别在第一行和第三行:分线是“同一块地图复制多份”,地图分片是“一张地图切成几块”。 分线的玩家在二线看不到一线的朋友;地图分片的玩家从 A 区走到 B 区,仍然在同一个逻辑世界里,只是权威进程换了。把 SLG 的地图分片叫成“分线”,会导致两个错误:以为跨片玩家天然不可见,以及以为合线等于合服。
副本则多一层规则隔离:副本里的掉落、进度、胜负由实例自己裁决,结算后写回主世界。它不能简单复用分线的迁移协议,因为副本结束时要处理“实例销毁后玩家去哪”。
分线的代价:容量换可见性
分线不是免费的扩容。每开一条线,就多一份主城实体、多一套 AOI、多一组广播;更麻烦的是社交关系被切开:
| 代价 | 表现 | 缓解 |
|---|---|---|
| 好友看不见 | 组队邀请跨线失败 | 组队、私聊走全局服务 |
| 交易受阻 | 摊位和拍卖不在同一线 | 交易行做成全服共享 |
| 活动分散 | 世界 Boss 在三条线各刷一只 | 活动按线独立结算或强制合线 |
| 迁移成本 | 换线要冻结、交接、重建视野 | 只允许在安全区换线 |
| 运维复杂 | 每条线独立扩缩容 | 线由统一控制器管理 |
北辰工作室给《群岛纪元》定的规则是:社交关系全局化,空间状态分线化。 好友、公会、邮件、拍卖行不跟着线走;位置、AOI、地图事件跟着线走。这样分线只影响“看见谁”,不影响“认识谁”。
跨线迁移:先冻结,再交接
玩家从一线换到二线,最危险的不是慢,而是两边同时认为自己拥有这个玩家。协议必须保证任意时刻只有一个权威:
一线(源) 二线(目标)
│ 1. BeginTransfer:冻结玩家 │
│ 2. 生成 token + seq + epoch │
│──────────── handoff ────────────>│
│ │ 3. 校验 token 与 epoch
│ │ 4. 写入目标线,返回 ack
│<──────────── ack ────────────────│
│ 5. 删除源线副本,解冻 │
│ 6. 客户端重新拉取视野 │
四个约束缺一不可:
- 冻结先于发送。 源线在发 handoff 前拒绝该玩家的写命令,否则迁移过程中还会产生旧线裁决。
- token 绑定一次迁移。 重复的
Complete不能把玩家移动两次;token 不匹配直接拒绝。 - epoch 绑定目标线的生命周期。 目标线被重置或重建后,旧 handoff 必须失效。
- 失败可回滚。 超时或目标拒绝时调用
Abort解冻,玩家仍留在源线,而不是卡在“迁移中”。
这就是 internal/worldline 的核心状态机:BeginTransfer 只冻结、不搬人;Complete 才真正转移归属;Abort 释放冻结。任何一步崩溃,玩家都还有一个明确的归属。
容量阈值:用迟滞而不是单点
最简单的扩容策略是“占用超过 80% 就开线”,但它会在阈值附近来回抖动:开线后占用降到 40%,立刻关线,又回到 80%。北辰工作室用三个参数控制:
| 参数 | 示例值 | 作用 |
|---|---|---|
| 高水位 | 85% | 超过则请求开线 |
| 低水位 | 35% | 低于则请求关线 |
| 冷却 tick | 10 | 两次变更之间的最小间隔 |
还有一条隐含约束:低水位乘二必须小于高水位。关掉一条线会让剩余线的占用翻倍;如果 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 世界事件与全服广播 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。