《MMO 服务器开发》2.3 运行时所有权与迁移

实体散落在多个逻辑线程与场景里,核心问题是「谁有权写它」。本节实现单写者模型下的归属表(正向 + 反向双索引),再把跨运行时搬迁拆成冻结—传输—接管三段,用缓冲重放解决冻结期间在飞的消息,最后用一个 Router 固化分流规则,并用 go test 验证迁移幂等与消息不丢不重。

2.1 和 2.2 讲的都是「一个运行时内部」的事:句柄怎么校验、状态怎么流转。但一个单服不会只有一个运行时——它按场景或逻辑线程切成多个 worker,每个 worker 跑自己的 Runtime,实体散落在其中。这就冒出一个前面没回答的问题:谁有权写这个实体?

如果两个 worker 都能改同一个玩家的血量,你就得给血量加锁,而锁一旦跨 worker 蔓延,单线程 actor 的全部好处就没了。解法是单写者:一个实体在任意时刻只被一个 owner 写,别的 worker 想改只能发消息给它。而玩家从主城走进副本时,实体要从场景 A 的运行时搬到场景 B 的运行时——这次搬迁必须做到「任何时刻都不会有两个 owner 同时写它」,否则同一帧里两边各扣一次血,玩家就凭空少了一管。

本节先定下归属表,再把搬迁拆成冻结—传输—接管三段,最后处理最容易被忽略的一环:冻结期间「在飞」的消息怎么办。

2.3.1 谁拥有这个实体

单写者模型只需要回答一个问题:这个实体归谁。归属表就是 EntityID → Owner 的映射,Owner 用一个小整数标识(worker 号或场景号都行)。下面这段是 L1,go test ./internal/entity/ 可以直接跑:

// file: internal/entity/owner.go
// tier: L1
// verify: go test ./internal/entity/
package entity

import "sync"

// Owner 标识一个「运行时的所有者」——通常是一个逻辑线程/worker,
// 或一个场景实例。核心不变式:实体在任意时刻只被一个 Owner 独占,
// 只有 Owner 能写它的状态,别的 Owner 想改只能发消息。
type Owner uint32

// Ownership 记录「每个实体归谁」。它只回答两个问题:谁在写这个实体、
// 一条消息该路由给谁。它不持有实体本身。
type Ownership struct {
	mu     sync.RWMutex
	home   map[EntityID]Owner
	worker map[Owner][]EntityID // 反向索引,便于按 Owner 批量枚举
}

// NewOwnership 创建空的归属表。
func NewOwnership() *Ownership {
	return &Ownership{
		home:   make(map[EntityID]Owner),
		worker: make(map[Owner][]EntityID),
	}
}

// Assign 把实体 id 的归属设为 ow。重复 Assign 会先摘掉旧归属。
func (o *Ownership) Assign(id EntityID, ow Owner) {
	o.mu.Lock()
	defer o.mu.Unlock()
	if old, ok := o.home[id]; ok {
		o.detach(old, id)
	}
	o.home[id] = ow
	o.worker[ow] = append(o.worker[ow], id)
}

// OwnerOf 返回实体当前的归属。
func (o *Ownership) OwnerOf(id EntityID) (Owner, bool) {
	o.mu.RLock()
	defer o.mu.RUnlock()
	ow, ok := o.home[id]
	return ow, ok
}

// Retire 从归属表里摘除实体(销毁时调用)。
func (o *Ownership) Retire(id EntityID) {
	o.mu.Lock()
	defer o.mu.Unlock()
	if old, ok := o.home[id]; ok {
		o.detach(old, id)
		delete(o.home, id)
	}
}

// Count 返回某个 Owner 名下当前持有的实体数。
func (o *Ownership) Count(ow Owner) int {
	o.mu.RLock()
	defer o.mu.RUnlock()
	return len(o.worker[ow])
}

// detach 从 ow 的反向列表里摘掉 id(调用方必须已持写锁)。
func (o *Ownership) detach(ow Owner, id EntityID) {
	list := o.worker[ow]
	for i, v := range list {
		if v == id {
			list[i] = list[len(list)-1]
			o.worker[ow] = list[:len(list)-1]
			break
		}
	}
	if len(o.worker[ow]) == 0 {
		delete(o.worker, ow)
	}
}

这里有个容易被忽略的设计:除了正向的 home 表,还维护了一张 worker 反向索引。正向表回答「这个实体归谁」,反向表回答「这个 owner 名下有哪些实体」。反向表在两种场景下是刚需:一是 owner 下线时要把名下实体批量转移或销毁,二是做负载统计和调优时要看每个 worker 扛了多少实体。detach 用「和末尾交换再截断」删除,避免 append 造成的内存搬移——归属表改动频繁,这个小优化值得。

注意 Assign 是幂等安全的:重复赋值会先摘旧归属再写新归属,不会在反向索引里留下重复项。这一条在迁移时会被反复用到。

2.3.2 三段式迁移:冻结、传输、接管

玩家从主城走到副本,实体要从 owner 1 搬到 owner 2。最朴素的做法是「停一下、把数据拷过去、改归属」——但「停一下」期间来的消息就丢了。所以拆成三段:

① 冻结   源侧停止处理该实体的新消息,状态定格
② 传输   把实体状态序列化、搬到目标侧
③ 接管   目标侧加载完成,开始处理消息

关键决策在归属什么时候切。直觉是「传输完成后才改归属」,但这会留下一个空窗:冻结到接管之间到达的消息,路由时归属还在源侧,于是被投给源侧——而源侧已经冻结了,只能丢弃或报错。正确做法是在 Begin 时就把归属切到目标侧:此后所有新消息直接路由到目标侧,源侧不再收到任何该实体的消息。下面这段是 L1:

// file: internal/entity/migrate.go
// tier: L1
// verify: go test ./internal/entity/
package entity

import (
	"errors"
	"sync"
)

var (
	// ErrNotOwner 表示发起迁移的 from 并不是实体当前的归属方。
	ErrNotOwner = errors.New("entity: not owned by source")
	// ErrMigrating 表示该实体已有一笔迁移在进行中。
	ErrMigrating = errors.New("entity: migration already in progress")
	// ErrNoMigration 表示提交了一个不存在的迁移。
	ErrNoMigration = errors.New("entity: no migration in progress")
)

// MigrationPhase 描述一次迁移走到哪一步。
type MigrationPhase uint8

const (
	MigrateFrozen MigrationPhase = iota // 源侧已冻结,不再接受新写
	MigrateDone                         // 目标侧已接管
)

// Message 是迁移期间被暂存的玩法消息。
type Message struct {
	Cmd     uint16
	Payload []byte
}

// Migration 是一次实体迁移的账本。
type Migration struct {
	ID    EntityID
	From  Owner
	To    Owner
	Phase MigrationPhase

	buffer []Message // 冻结期间到达的消息,落地后按序重放
}

// Migrator 实现「冻结—传输—接管」三段式迁移,全程不让两个 Owner
// 同时写同一个实体。它是 2.3 里跨场景迁移的服务端骨架。
type Migrator struct {
	own    *Ownership
	mu     sync.Mutex
	active map[EntityID]*Migration
}

// NewMigrator 基于一张归属表创建迁移器。
func NewMigrator(own *Ownership) *Migrator {
	return &Migrator{own: own, active: make(map[EntityID]*Migration)}
}

// Begin 从 from 冻结实体 id 并向 to 发起迁移。它先把归属切到 to
// (让新消息直接路由到目标侧),再登记一笔冻结中的迁移。
func (m *Migrator) Begin(id EntityID, from, to Owner) (*Migration, error) {
	cur, ok := m.own.OwnerOf(id)
	if !ok || cur != from {
		return nil, ErrNotOwner
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	if _, busy := m.active[id]; busy {
		return nil, ErrMigrating
	}
	mig := &Migration{ID: id, From: from, To: to, Phase: MigrateFrozen}
	m.active[id] = mig
	m.own.Assign(id, to) // 归属即刻切到目标侧
	return mig, nil
}

// Deliver 是迁移期间的消息入口:迁移中暂存并返回 true;没有迁移
// 在跑则返回 false,调用方应直接投递。暂存保证「冻结那一刻在飞的
// 消息」不会丢,也不会被源侧处理两遍。
func (m *Migrator) Deliver(id EntityID, msg Message) bool {
	m.mu.Lock()
	defer m.mu.Unlock()
	mig, ok := m.active[id]
	if !ok {
		return false
	}
	mig.buffer = append(mig.buffer, msg)
	return true
}

// Commit 标记传输完成、目标侧接管,并返回需要重放的缓冲消息。
func (m *Migrator) Commit(id EntityID) ([]Message, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	mig, ok := m.active[id]
	if !ok {
		return nil, ErrNoMigration
	}
	mig.Phase = MigrateDone
	delete(m.active, id)
	replay := mig.buffer
	mig.buffer = nil
	return replay, nil
}

// Active 返回当前进行中的迁移笔数。
func (m *Migrator) Active() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return len(m.active)
}

Begin 里那道 cur != from 的检查是防止「幽灵迁移」:如果一个实体已经被别人搬走了,再用过期的 from 去搬就会把状态搞乱,所以直接拒绝。同理,同一实体已有迁移在跑时返回 ErrMigrating,而不是排队——迁移应该由发起方显式重试,而不是悄悄堆积。

三段式的不变式可以用一句话概括:冻结之后,源侧再收不到这个实体的任何消息;接管之前,目标侧收到的消息先攒着。 下面看最后这一点怎么落地。

2.3.3 迁移期间的消息:不丢、不重、不乱序

冻结那一刻,可能有一批消息已经在网络栈里「在飞」——客户端刚发了个技能,包还没被任何 worker 处理。这批消息有两个约束:不能丢(丢了技能就没了),不能被处理两遍(重复施法、重复扣血)。

Deliver 的返回值就是分流开关:迁移中返回 true,调用方把消息交给它暂存;没有迁移返回 false,调用方按正常路径直接投递。暂存的消息在 Commit 时按到达顺序返回,由目标侧重放——顺序必须保持,否则「先移动后施法」会被重放成「先施法后移动」。

把这条分流规则固化成一个函数,消息入口就再也不用自己判断「目标是不是在迁移」。下面这段是 L1:

// file: internal/entity/route.go
// tier: L1
// verify: go test ./internal/entity/
package entity

// Router 是消息入口处的分流器:迁移中的实体把消息交给 Migrator 暂存,
// 其余消息按归属路由到对应 Owner。它把「迁移期间消息不丢不重」这条
// 规则固化成一个可测试的函数,是 2.3.3 的落地形态。
type Router struct {
	own *Ownership
	mig *Migrator

	delivered map[Owner][]Message // 已直投出去的消息,便于测试断言
}

// NewRouter 绑定归属表与迁移器。
func NewRouter(own *Ownership, mig *Migrator) *Router {
	return &Router{own: own, mig: mig, delivered: make(map[Owner][]Message)}
}

// Route 把一条消息投给实体 id 的当前归属方。若该实体正在迁移,消息
// 被暂存并返回 (0, true);否则按归属直投并返回 (owner, false)。实体
// 已不在归属表里时返回 (0, false) 且不投递。
func (rt *Router) Route(id EntityID, msg Message) (Owner, bool) {
	if rt.mig.Deliver(id, msg) {
		return 0, true
	}
	ow, ok := rt.own.OwnerOf(id)
	if !ok {
		return 0, false
	}
	rt.delivered[ow] = append(rt.delivered[ow], msg)
	return ow, false
}

// Delivered 返回直接路由给 ow 的消息(测试用)。
func (rt *Router) Delivered(ow Owner) []Message { return rt.delivered[ow] }

把测试跑出来,重点看迁移幂等与消息暂存:

=== RUN   TestOwnershipAssignRetire
--- PASS: TestOwnershipAssignRetire (0.00s)
=== RUN   TestMigrationHandshake
--- PASS: TestMigrationHandshake (0.00s)
=== RUN   TestBeginRequiresOwnership
--- PASS: TestBeginRequiresOwnership (0.00s)
=== RUN   TestBeginTwiceFails
--- PASS: TestBeginTwiceFails (0.00s)
=== RUN   TestRouterDuringMigration
--- PASS: TestRouterDuringMigration (0.00s)
=== RUN   TestRouterUnknownEntity
--- PASS: TestRouterUnknownEntity (0.00s)
PASS
ok  	aegis/internal/entity	0.220s

TestMigrationHandshake 覆盖了完整时序:Begin 后归属立刻切到目标侧(断言 OwnerOf == 2)、冻结期间两条消息被暂存、Commit 按原序返回 [0x0401, 0x0403]、迁移结束后 Deliver 不再暂存。TestBeginTwiceFails 验证幂等边界:同一实体第二次 Begin 报 ErrMigrating,重复 Commit 报 ErrNoMigration——这两个错误让重试逻辑能安全地写成「失败就重试」,而不必担心搬两次。TestRouterDuringMigration 则验证分流:迁移前直投 owner 1、迁移中消息被暂存(owner 2 一条都收不到)、接管后直投 owner 2。

有个坑要提前说:Commit 返回的缓冲消息里可能包含已经被目标侧加载的状态覆盖的旧消息。比如一条「扣血 100」在冻结前发出、冻结后到达,而传输过去的状态里血量已经是扣过的。处理办法是在消息里带 Seq,重放时跳过 Seq 小于已加载状态的——这属于第 6 章指令队列的范畴,本节先把「不丢不乱序」这条底线打牢。

本节小结

  1. 单写者是跨 worker 一致性的地基。 一个实体同时只被一个 owner 写,跨 owner 只能发消息;归属表用正向 + 反向两张索引,既支持路由也支持批量操作。
  2. 迁移要在 Begin 时就切归属。 冻结到接管之间的消息必须路由到目标侧,否则源侧已冻结、只能丢弃。切早了顶多让目标侧先攒着,切晚了就会丢消息。
  3. 冻结期间的消息靠缓冲重放。 Deliver 返回 true 就暂存、false 就直投,Commit 按序吐出。配合 ErrMigrating / ErrNoMigration 两个错误,重试逻辑才能写成幂等。

一句话收束:所有权回答「谁能写」,迁移回答「写权怎么交接」;两者合起来,才让实体能在多个运行时之间安全地移动。 到这里,第 2 章把实体的「引用、生命周期、归属」三块地基打完了。下一章进入场景与地图实例:实体总要站在某张地图上,而地图实例本身也有创建与回收的生命周期——那正是 3.1 要展开的东西。

阅读导航:上一节:2.2 生命周期与对象池 · 下一节:3.1 地图数据结构与逻辑坐标 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标