《游戏服务端架构·品类、状态与拓扑》10.1 无缝世界的分片与边界

《长风原》开放世界原型把 6144×6144 的地图切成 64×64 区块,再把区块组分配给权威分片。本节讨论玩家可感知的无缝边界:哪些交互必须同片、哪些可以跨片,跨片移动如何用只读镜像掩盖接缝,越界传送为什么要在入口就拒绝,而不是让错误在分片之间扩散。

无缝是体验承诺,分片是部署事实

《长风原》是北辰工作室的开放世界原型,不是已上线产品。它的世界是 6144×6144 个格子,按 64×64 切成 96×96 个区块;玩家从森林走到河谷时,客户端不应该出现读条。第一次内部联调,三个问题同时出现:

  • 两名玩家在区块边界互砍,双方各自收到一份伤害结算,血量出现两个版本;
  • 一名玩家跨过边界后,对面的队友有 0.8 秒看不见他(示例值,来自客户端日志);
  • GM 传送指令把角色写到目标区块,但目标区块属于另一个分片,角色落在了没有权威副本的位置。

第一个是权威边界问题,第二个是流式边界问题,第三个是请求边界问题。它们都发生在“边界”,修复方式却完全不同。本节先把三者拆开,再给出边界规则表和可运行的交接模型。

第 8.1 节 讨论的是 SLG 的全球大地图:矩形分片、跨洋行军、热区拆分。那一节的边界由地图坐标决定,玩家能感知到“赛区”。开放世界的边界更隐蔽:地图是一整块,区块是流式的,分片是部署的,玩家不应该感知到任何一条线。

三个边界,三套失败模式

边界单位谁感知典型故障处理方式
流式边界64×64 区块客户端与 AOI实体弹入弹出、视野缺一块邻接预取、源片镜像
权威边界分片(一组区块)服务端双写、伤害分叉单写者、epoch、序号
请求边界单条命令网关与分片越界传送、伪造归属入口校验、一步限制

把三者混在一起,常见的错误是“用一致性哈希解决所有边界”:相邻区块会散开,AOI 从本地遍历退化成跨片聚合。

两级切分:区块负责流式,分片负责权威

《长风原》选择两级结构:区块固定 64×64 格,只由坐标决定;分片拥有一组连续区块,默认 16×16 个,可以在热区拆分,但区块 ID 不变;客户端只认区块,路由表把区块映射到分片,版本号随布局变化。

代价是路由表成为一份必须原子切换的配置。收益是流式加载的粒度稳定:即使后台把一个分片拆成两个,客户端看到的区块编号、预取半径和资源 URL 都不变。

切法空间局部性负载均衡扩容代价适用
按玩家哈希差好低纯社交
按区块哈希差好低体素沙盒
区块 + 矩形分片组好中,需要热区拆分中,切路由表开放世界

边界规则表:哪些交互允许跨片

交互同区块相邻区块同分片跨分片
移动本地本地交接(handoff)
战斗本地本地先合片,再结算
交易本地本地拒绝,直到同片
拾取本地本地拒绝
聊天/表情本地本地消息总线
AOI 可见性本地本地源片只读镜像,限时

规则不是拍脑袋:移动是状态转移,可以用 epoch 和序号保证只有一个写者;聊天是幂等事件,走消息总线最多重复一次;战斗和交易有不可合并的副作用,两份结算无法事后判定谁对。合片的动作就是一次交接,玩家看到的是短暂的“正在接近”,而不是两个血量版本。

交接协议:冻结、转移、镜像、过期

交接分四步:源片冻结实体并发起 transfer,目标片校验 epoch 后成为唯一写者,源片保留只读镜像,窗口到期后删除。五个约束:

  1. 冻结先于转移。 源片在发出交接后不再接受该实体的命令,否则窗口内仍会产生旧片裁决。
  2. epoch 每次跨片递增。 布局重算、分片拆分都会产生新 epoch;旧 epoch 的迟到消息直接拒绝。
  3. seq 按实体单调。 重复或回退的序号不应用,由发送方从最后确认位置重放。
  4. 镜像只读。 双写窗口里两个分片都有实体副本,但只有目标片能写。这和 8.1 的“不许双写”不矛盾:那里禁止的是双权威,这里允许的是有界只读副本。
  5. 窗口必须到期。 镜像不是缓存,不能无限续期;到期由 Sweep 回收,回收失败要告警。

窗口长度取客户端一个预取周期,示例值 1.5 秒。它应该由流式加载的 P99 决定,而不是由网络 RTT 决定;两者混用会让窗口要么太短、要么长期占用双份内存。

代码:区块归属、越界拒绝与交接窗口

internal/seamless 把上面的规则写成一个不依赖外部存储的世界模型。Layout 在构造时要求每个区块恰好有一个 owner;Submit 同时校验写者、序号、一步距离和目标区块;跨片时返回一个 Transfer,目标片成为唯一写者,源片保留只读镜像直到窗口到期。

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

import (
	"errors"
	"fmt"
	"time"
)

var (
	ErrBadChunkSize = errors.New("seamless: chunk size must be positive")
	ErrBadWorldSize = errors.New("seamless: world needs at least one chunk")
	ErrBadWindow    = errors.New("seamless: mirror window must be positive")
	ErrEmptyShard   = errors.New("seamless: empty shard id")
	ErrEmptyEntity  = errors.New("seamless: empty entity id")
	ErrEntityExists = errors.New("seamless: entity already spawned")
	ErrNoEntity     = errors.New("seamless: unknown entity")
	ErrChunkOutside = errors.New("seamless: chunk outside world")
	ErrCoverageGap  = errors.New("seamless: chunk has no owner")
	ErrOutOfBounds  = errors.New("seamless: outside world or one-chunk step")
	ErrNotAuthority = errors.New("seamless: shard is not the writer")
	ErrOutOfOrder   = errors.New("seamless: sequence not increasing")
	ErrFrozen       = errors.New("seamless: entity frozen during handoff")
	ErrNoTransfer   = errors.New("seamless: no open handoff")
	ErrStaleEpoch   = errors.New("seamless: stale handoff epoch")
)

type Point struct{ X, Y int }

type Chunk struct{ X, Y int }

// Layout assigns every chunk to one shard; a hole is rejected at construction.
type Layout struct {
	size  int
	cols  int
	rows  int
	owner map[Chunk]string
}

func floorDiv(a, b int) int {
	q := a / b
	if a%b != 0 && (a < 0) != (b < 0) {
		q--
	}
	return q
}

func NewLayout(chunkSize, cols, rows int, assign map[Chunk]string) (*Layout, error) {
	if chunkSize <= 0 {
		return nil, fmt.Errorf("%w: %d", ErrBadChunkSize, chunkSize)
	}
	if cols <= 0 || rows <= 0 {
		return nil, fmt.Errorf("%w: %dx%d", ErrBadWorldSize, cols, rows)
	}
	l := &Layout{size: chunkSize, cols: cols, rows: rows,
		owner: make(map[Chunk]string, cols*rows)}
	for c, shard := range assign {
		if shard == "" {
			return nil, fmt.Errorf("%w: %v", ErrEmptyShard, c)
		}
		if c.X < 0 || c.X >= cols || c.Y < 0 || c.Y >= rows {
			return nil, fmt.Errorf("%w: %v", ErrChunkOutside, c)
		}
		l.owner[c] = shard
	}
	for y := 0; y < rows; y++ {
		for x := 0; x < cols; x++ {
			if _, ok := l.owner[Chunk{x, y}]; !ok {
				return nil, fmt.Errorf("%w: (%d,%d)", ErrCoverageGap, x, y)
			}
		}
	}
	return l, nil
}

func (l *Layout) ChunkOf(p Point) (Chunk, bool) {
	c := Chunk{floorDiv(p.X, l.size), floorDiv(p.Y, l.size)}
	if c.X < 0 || c.X >= l.cols || c.Y < 0 || c.Y >= l.rows {
		return Chunk{}, false
	}
	return c, true
}

func (l *Layout) Owner(p Point) (string, Chunk, bool) {
	c, ok := l.ChunkOf(p)
	if !ok {
		return "", Chunk{}, false
	}
	shard, ok := l.owner[c]
	return shard, c, ok
}

// Transfer records one handoff. FrozenUntil bounds command rejection;
// VisibleUntil bounds the read-only mirror kept by the source shard.
type Transfer struct {
	Entity, From, To          string
	Epoch, Seq                uint64
	FrozenUntil, VisibleUntil time.Time
}

type Placement struct {
	Entity, Shard string
	Chunk         Chunk
	Seq, Epoch    uint64
	Transfer      *Transfer
}

type entityState struct {
	pos           Point
	shard, mirror string
	epoch, seq    uint64
	frozenUntil   time.Time
	mirrorUntil   time.Time
}

// World keeps one writer per entity; the source mirror is never writable.
type World struct {
	layout   *Layout
	window   time.Duration
	now      func() time.Time
	entities map[string]*entityState
}

func NewWorld(layout *Layout, window time.Duration, now func() time.Time) (*World, error) {
	if layout == nil {
		return nil, errors.New("seamless: nil layout")
	}
	if window <= 0 {
		return nil, fmt.Errorf("%w: %s", ErrBadWindow, window)
	}
	if now == nil {
		now = time.Now
	}
	return &World{layout: layout, window: window, now: now,
		entities: make(map[string]*entityState)}, nil
}

func (w *World) Spawn(id string, p Point) (Placement, error) {
	if id == "" {
		return Placement{}, ErrEmptyEntity
	}
	if _, ok := w.entities[id]; ok {
		return Placement{}, ErrEntityExists
	}
	shard, chunk, ok := w.layout.Owner(p)
	if !ok {
		return Placement{}, fmt.Errorf("%w: (%d,%d)", ErrOutOfBounds, p.X, p.Y)
	}
	e := &entityState{pos: p, shard: shard, epoch: 1}
	w.entities[id] = e
	return Placement{Entity: id, Shard: shard, Chunk: chunk, Epoch: e.epoch}, nil
}

// Submit rejects a stale, foreign or teleporting request and opens a handoff
// when the target chunk belongs to another shard.
func (w *World) Submit(id, shard string, to Point, seq uint64) (Placement, error) {
	e, ok := w.entities[id]
	if !ok {
		return Placement{}, ErrNoEntity
	}
	if shard != e.shard {
		return Placement{}, fmt.Errorf("%w: %s owns %s", ErrNotAuthority, e.shard, id)
	}
	if w.now().Before(e.frozenUntil) {
		return Placement{}, fmt.Errorf("%w: until %s", ErrFrozen, e.frozenUntil)
	}
	if seq <= e.seq {
		return Placement{}, fmt.Errorf("%w: %d <= %d", ErrOutOfOrder, seq, e.seq)
	}
	from, ok := w.layout.ChunkOf(e.pos)
	if !ok {
		return Placement{}, fmt.Errorf("%w: from (%d,%d)", ErrOutOfBounds, e.pos.X, e.pos.Y)
	}
	target, ok := w.layout.ChunkOf(to)
	if !ok {
		return Placement{}, fmt.Errorf("%w: to (%d,%d)", ErrOutOfBounds, to.X, to.Y)
	}
	if dx, dy := target.X-from.X, target.Y-from.Y; dx < -1 || dx > 1 || dy < -1 || dy > 1 {
		return Placement{}, fmt.Errorf("%w: chunk %v -> %v", ErrOutOfBounds, from, target)
	}
	dest, _, _ := w.layout.Owner(to)
	e.seq = seq
	e.pos = to
	out := Placement{Entity: id, Shard: dest, Chunk: target, Seq: seq, Epoch: e.epoch}
	if dest == e.shard {
		return out, nil
	}
	now := w.now()
	e.mirror = e.shard
	e.shard = dest
	e.epoch++
	e.frozenUntil = now.Add(w.window)
	e.mirrorUntil = now.Add(w.window)
	out.Epoch = e.epoch
	out.Transfer = &Transfer{Entity: id, From: e.mirror, To: dest, Epoch: e.epoch,
		Seq: seq, FrozenUntil: e.frozenUntil, VisibleUntil: e.mirrorUntil}
	return out, nil
}

type Sweep struct {
	Unfrozen []string
	Released []string
}

// Sweep expires the command freeze and the source mirror.
func (w *World) Sweep() Sweep {
	now := w.now()
	var out Sweep
	for id, e := range w.entities {
		if !e.frozenUntil.IsZero() && !now.Before(e.frozenUntil) {
			e.frozenUntil = time.Time{}
			out.Unfrozen = append(out.Unfrozen, id)
		}
		if e.mirror != "" && !now.Before(e.mirrorUntil) {
			e.mirror = ""
			out.Released = append(out.Released, id)
		}
	}
	return out
}

func (w *World) Authoritative(id, shard string) bool {
	e, ok := w.entities[id]
	return ok && e.shard == shard
}

func (w *World) Visible(id, shard string) bool {
	e, ok := w.entities[id]
	if !ok {
		return false
	}
	return e.shard == shard || (e.mirror == shard && w.now().Before(e.mirrorUntil))
}

type Interaction int

const (
	Move Interaction = iota
	Combat
	Trade
	Loot
	Chat
)

// Move has a handoff and Chat a message bus; combat, trade and loot must be
// co-located because they have no safe split-brain version.
func (k Interaction) MayCrossShard() bool {
	return k == Move || k == Chat
}

func CoLocated(a, b string, k Interaction) bool {
	return a == b || k.MayCrossShard()
}

几个设计点:

  • floorDiv 让负坐标也落在正确的区块里;直接用截断除法会把 -1 映射到区块 0,越界请求会被误判为合法。
  • Submit 只允许目标区块与当前区块最多相差一格。这个限制不是为了防作弊的全部,而是把“传送”从移动命令里剥离出去,传送必须走单独的、带权限校验的入口。
  • Sweep 是超时兜底。目标片没有在窗口内完成应用时,实体不会永久冻结;但被解冻这件事必须可观测,调用方应把 Unfrozen 列表接入告警。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/seamless
$ go test -count=1 ./internal/seamless/
ok  	gsarchlab/internal/seamless	0.267s

6 个测试函数覆盖布局、归属、越界重放、交接、超时和交互规则;耗时是本机一次运行结果,不应当作性能指标。

失败模式与兜底

失败表现兜底
双写者两边都产生伤害,血量分叉目标片单写 + epoch 校验
镜像永不过期源片内存持续增长窗口硬上限 + Sweep 告警
冻结丢失实体永久不可操作超时解冻并记录
布局有洞某些格子无分片发布前全覆盖校验
路由表半更新一半请求发到旧片路由版本号,过期请求拒绝
边界战斗两份结算先合片再接受战斗命令
越界传送实体落到无权威位置入口一步限制 + 独立传送入口

热区扩容:切分片组,不改区块

开放世界的热区通常不是“玩家最多的格子”,而是“交互最密的格子”:主城广场、世界 Boss、跨服活动入口。扩容的做法是沿低流量缝把一个分片组拆成两个:

  1. 选一条跨缝流量低于 5%(示例目标)的边界,生成覆盖完整的矩形布局;
  2. 给新分片分配新 epoch,按交接协议迁移缝两侧的实体;
  3. 原子切换路由表版本,观察一个窗口后回收旧片。

常见错误

第一,把区块大小和分片大小设成同一个值。区块一变,路由、资源 URL、客户端缓存全部要跟着变。

第二,交接时双写权威状态。短期平滑,长期无法解释冲突;只允许只读镜像,不允许两个写者。

第三,没有 epoch。分片拆分后,迟到消息可能把实体交还给已经下线的旧片。

第四,窗口长度拍脑袋。窗口由流式加载的 P99 决定,不由 RTT 决定;太短会弹入,太长会长期占用双份内存。

小结

无缝世界不是“没有分片”,而是把分片藏在一层稳定的区块抽象后面:区块负责流式加载,分片负责权威裁决;跨片移动用冻结、epoch、序号和只读镜像完成交接;越界请求在入口就被拒绝,而不是在错误的分片上产生副作用。

本节的可运行产物是 internal/seamless:255 行、6 个测试函数,覆盖布局、归属、交接、超时和交互规则。下一节进入玩家创作内容:当地图本身由玩家上传时,版本、依赖和审核流程会成为比分片更棘手的边界。

阅读导航:上一节:9.3 小游戏后端的轻量化与快速上线 · 下一节:10.2 UGC 地图的隔离、审核与发布 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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