无缝是体验承诺,分片是部署事实
《长风原》是北辰工作室的开放世界原型,不是已上线产品。它的世界是 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 后成为唯一写者,源片保留只读镜像,窗口到期后删除。五个约束:
- 冻结先于转移。 源片在发出交接后不再接受该实体的命令,否则窗口内仍会产生旧片裁决。
- epoch 每次跨片递增。 布局重算、分片拆分都会产生新 epoch;旧 epoch 的迟到消息直接拒绝。
- seq 按实体单调。 重复或回退的序号不应用,由发送方从最后确认位置重放。
- 镜像只读。 双写窗口里两个分片都有实体副本,但只有目标片能写。这和 8.1 的“不许双写”不矛盾:那里禁止的是双权威,这里允许的是有界只读副本。
- 窗口必须到期。 镜像不是缓存,不能无限续期;到期由
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、跨服活动入口。扩容的做法是沿低流量缝把一个分片组拆成两个:
- 选一条跨缝流量低于 5%(示例目标)的边界,生成覆盖完整的矩形布局;
- 给新分片分配新 epoch,按交接协议迁移缝两侧的实体;
- 原子切换路由表版本,观察一个窗口后回收旧片。
常见错误
第一,把区块大小和分片大小设成同一个值。区块一变,路由、资源 URL、客户端缓存全部要跟着变。
第二,交接时双写权威状态。短期平滑,长期无法解释冲突;只允许只读镜像,不允许两个写者。
第三,没有 epoch。分片拆分后,迟到消息可能把实体交还给已经下线的旧片。
第四,窗口长度拍脑袋。窗口由流式加载的 P99 决定,不由 RTT 决定;太短会弹入,太长会长期占用双份内存。
小结
无缝世界不是“没有分片”,而是把分片藏在一层稳定的区块抽象后面:区块负责流式加载,分片负责权威裁决;跨片移动用冻结、epoch、序号和只读镜像完成交接;越界请求在入口就被拒绝,而不是在错误的分片上产生副作用。
本节的可运行产物是 internal/seamless:255 行、6 个测试函数,覆盖布局、归属、交接、超时和交互规则。下一节进入玩家创作内容:当地图本身由玩家上传时,版本、依赖和审核流程会成为比分片更棘手的边界。
阅读导航:上一节:9.3 小游戏后端的轻量化与快速上线 · 下一节:10.2 UGC 地图的隔离、审核与发布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。