《MMO 服务器开发》17.3 实例分配与回收

房间建好了,还得有地方装。本节实现单服内的实例池:预热一批容量不同的实例、按 best-fit 挑刚好够用的那个、用完置为 draining 并冷却一段时间再复用。测试覆盖分配、容量不足、池耗尽、冷却期不可复用与复用后状态复位,并说明跨服调度为何留到卷三。

17.3 实例分配与回收

大厅把房间管住了,但房间只是一个逻辑概念——真正承载一局副本、一个战场的,是地图实例:一块加载好的地图、一份独立的世界状态、一段可以被单独回收的内存。匹配成局之后,服务端必须从某个地方「拿出」一个实例,把玩家放进去;对局结束,再把它还回去。

这件事有两个容易踩的坑。第一个是分配策略:如果无脑挑第一个空闲实例,一个 20 人的小副本可能吃掉一个能装 200 人的世界实例,等到真需要世界实例时反而没得用了。第二个是回收时机:对局一结束就立刻把实例标成空闲是错的——上一局的 AI 状态、掉落缓存、临时对象还没清干净,下一个玩家进来可能撞见上一局的残留。

本节实现单服内的实例池 internal/match/pool.go:预热一批容量不同的实例,按 best-fit 分配,用完置为 draining 并冷却一段时间再复用。注意范围:卷二只做单服内的实例池——「从这台服的池子里拿一个」;真正的跨服调度(哪个大区、哪台物理机、跨服战场怎么撮合)涉及多服的资源视图与一致性,留到卷三。全部代码是 L1,go test ./internal/match/ 可以直接跑。

实例池:预热与 best-fit 分配

池子在启动时预热一批实例,每个实例有一个容量上限(能装多少人)。分配时的策略是 best-fit:在所有「空闲且容量 ≥ 需求」的实例里,挑容量最小的那个。这样大容量实例会被留给真正需要它的场景,小需求不会造成浪费。

下面这段是 L1,go test ./internal/match/ 可以直接跑:

// file: internal/match/pool.go
// tier: L1
// verify: go test ./internal/match/
package match

import (
	"errors"
	"sync"
)

// InstanceID 是地图实例 ID(卷二共享类型,uint64)。
type InstanceID uint64

var (
	// ErrNoInstance 表示池内没有可用的空闲实例。
	ErrNoInstance = errors.New("match: 实例池已耗尽,无可用实例")
	// ErrUnknownInstance 表示分配/回收了一个池内不存在的实例。
	ErrUnknownInstance = errors.New("match: 未知实例 ID")
)

// Instance 是一个单服内的地图实例。卷二只做单服实例池,
// 真正的跨服调度(哪个大区、哪台物理机)留到卷三。
type Instance struct {
	ID            InstanceID
	Capacity      int
	State         string // idle | allocated | draining
	CooldownUntil int64  // draining 期间不可复用的截止时刻(ms)
	Uses          int    // 累计被分配次数,用于灰度与磨损观测
}

// Pool 是单服内的实例池,带预热、best-fit 分配与冷却回收。
type Pool struct {
	mu        sync.Mutex
	instances []*Instance
	nextID    InstanceID
	cooldown  int64
	allocated int
	recycled  int
}

// NewPool 预热 n 个容量为 capacity 的实例,回收冷却 cooldown 毫秒。
func NewPool(n, capacity int, cooldown int64) *Pool {
	p := &Pool{cooldown: cooldown}
	for i := 0; i < n; i++ {
		p.nextID++
		p.instances = append(p.instances, &Instance{ID: p.nextID, Capacity: capacity, State: "idle"})
	}
	return p
}

// Allocate 按 best-fit 挑一个容量 ≥ need 的空闲实例:优先刚好够用的,
// 免得一个 50 人副本吃掉能装 200 人的世界实例。
func (p *Pool) Allocate(need int, now int64) (*Instance, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	var best *Instance
	for _, in := range p.instances {
		if in.State != "idle" || now < in.CooldownUntil || in.Capacity < need {
			continue
		}
		if best == nil || in.Capacity < best.Capacity {
			best = in
		}
	}
	if best == nil {
		return nil, ErrNoInstance
	}
	best.State = "allocated"
	best.Uses++
	p.allocated++
	return best, nil
}

// Release 把实例置为 draining 并进入冷却;冷却结束前 Allocate 不会碰它。
// 冷却的意义是:上一局的残留状态(AI、掉落、缓存)需要时间被清干净。
func (p *Pool) Release(id InstanceID, now int64) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	in := p.find(id)
	if in == nil {
		return ErrUnknownInstance
	}
	in.State = "draining"
	in.CooldownUntil = now + p.cooldown
	p.recycled++
	return nil
}

// Tick 把冷却到期的 draining 实例重新标为 idle,使其回到可分配集合。
func (p *Pool) Tick(now int64) int {
	p.mu.Lock()
	defer p.mu.Unlock()
	n := 0
	for _, in := range p.instances {
		if in.State == "draining" && now >= in.CooldownUntil {
			in.State = "idle"
			in.CooldownUntil = 0
			n++
		}
	}
	return n
}

func (p *Pool) find(id InstanceID) *Instance {
	for _, in := range p.instances {
		if in.ID == id {
			return in
		}
	}
	return nil
}

// Stats 返回 (总实例数, 空闲数, 累计分配数, 累计回收数)。
func (p *Pool) Stats() (total, idle, allocated, recycled int) {
	p.mu.Lock()
	defer p.mu.Unlock()
	for _, in := range p.instances {
		if in.State == "idle" {
			idle++
		}
	}
	return len(p.instances), idle, p.allocated, p.recycled
}

Allocate 里有三个条件缺一不可:State == "idle"(不能是已分配或冷却中)、now >= CooldownUntil(冷却已过)、Capacity >= need(装得下)。任一条不满足就跳过,全部跳完还没有候选,就返回 ErrNoInstance 交给上层——上层可以据此把请求排队、或者临时新建实例,这个决策不该塞进池子里。

池子内部用一把 sync.Mutex 保护,因为实例池会被多个匹配协程并发访问。与大厅刻意「单线程 tick、不加锁」不同,这里是有状态的共享资源,必须自己兜住并发。

回收与冷却:为什么不能立刻复用

对局结束时调 Release,实例不是立刻变空闲,而是先进入 draining 状态并带上一个冷却截止时刻。冷却期内 Allocate 会跳过它;只有 Tick 发现冷却到期,才把它转回 idle。

为什么非要冷却?因为「对局结束」和「实例干净」不是同一时刻。战斗系统刚把最后一个玩家送走时,AI 还在跑收尾逻辑、掉落物对象还挂在场上、临时分配的缓存还没释放。如果这时立刻把实例给下一局,新玩家进来可能看到上一局的尸体、捡到没清掉的掉落。冷却期就是给清理留出的时间窗,代价是这段时间该实例不可用——所以冷却时长要按「清理最长耗时」来定,而不是拍脑袋给个大数。

另一个细节是 Uses:复用实例时状态复位(State 回到 idle、CooldownUntil 清零),但累计使用次数不清零。它是观测实例「磨损」的指标——如果一个实例的 Uses 远高于同批实例,说明 best-fit 总是选中它,可能需要调整容量分布。

还有一点必须写进注释:Release 传入的 now 与 Tick 传入的 now 必须来自同一个时钟源。如果 Release 用墙钟、Tick 用单调钟,冷却判定就会在时钟回拨时错乱——要么提前复用(脏数据),要么永远不复活(实例泄漏)。池子本身不读时钟,全部时间点由调用方注入,就是为了把这件事变成显式约定,而不是散落在各处的隐式假设。

用测试验证分配、耗尽与复用

实例池的每条分支都得有测试兜底:best-fit 选对了没、池耗尽报错没、冷却期能不能挡住复用、复用后状态复位没。下面是 internal/match/pool_test.go 里三段的摘录,属于同一个已跑通的 L1 包:

// snippet: internal/match/pool_test.go(摘录)
// best-fit:需求 20 人时,200 人的世界实例不该被吃掉,应优先给 20 人实例。
func TestAllocateBestFit(t *testing.T) {
	p := NewPool(0, 0, 0)
	p.instances = []*Instance{
		{ID: 1, Capacity: 200, State: "idle"},
		{ID: 2, Capacity: 50, State: "idle"},
		{ID: 3, Capacity: 20, State: "idle"},
	}
	in, err := p.Allocate(20, 0)
	if err != nil || in.ID != 3 {
		t.Fatalf("best-fit 选中 %v (err=%v),期望 ID=3", in, err)
	}
}

// 冷却:释放后到冷却结束前不可复用,Tick 到期后才回到空闲集合。
func TestRecycleCooldown(t *testing.T) {
	const cd = 5000
	p := NewPool(1, 10, cd)
	in, _ := p.Allocate(1, 0)
	p.Release(in.ID, 0)
	if in.State != "draining" {
		t.Fatalf("释放后状态 %q,期望 draining", in.State)
	}
	if _, err := p.Allocate(1, cd-1); !errors.Is(err, ErrNoInstance) {
		t.Fatalf("冷却期内不该可分配,实际 %v", err)
	}
	if n := p.Tick(cd); n != 1 {
		t.Fatalf("冷却到期应转回 1 个实例,实际 %d", n)
	}
	again, err := p.Allocate(1, cd)
	if err != nil || again.Uses != 2 {
		t.Fatalf("复用失败或 Uses=%d,期望 2", again.Uses)
	}
}

// 池耗尽:全部占用后 Allocate 必须返回 ErrNoInstance。
func TestPoolExhausted(t *testing.T) {
	p := NewPool(2, 10, 0)
	p.Allocate(5, 0)
	p.Allocate(5, 0)
	if _, err := p.Allocate(5, 0); !errors.Is(err, ErrNoInstance) {
		t.Fatalf("耗尽时应返回 ErrNoInstance,实际 %v", err)
	}
}

TestAllocateBestFit 用三个容量悬殊的实例验证 best-fit:需求 20 时选中的必须是容量 20 的 ID=3,而不是容量 200 的 ID=1。TestRecycleCooldown 是最关键的一条:cd-1 时刻必须分配失败(冷却挡住),Tick(cd) 之后必须能复用,且 Uses 从 1 变成 2。TestPoolExhausted 覆盖了最坏情况——池子空了就老老实实报错,绝不能返回一个已分配的实例造成两个对局撞进同一块地图。

其余用例覆盖 Release 未知实例报 ErrUnknownInstance、需求超过所有实例容量时报 ErrNoInstance、以及 Stats 的四个计数口径。

本节小结 / 实测结论

本机(Go 1.26.0,darwin/arm64)实跑结果:

$ go test ./internal/match/
ok  	aegis/internal/match	1.170s     (19 个 test 全绿,含 7 个实例池用例)

实例池的七条用例覆盖了分配、耗尽、容量不足、冷却、未知实例与统计口径。整套代码只用标准库(errors、sync),没有任何中间件依赖。绝对数值不可移植:冷却时长、容量分布、预热数量都该按《晨曦大陆》的实际负载来配,本节的默认值只保证语义正确。

到这里,单服的匹配链路就完整了:17.1 产出「谁和谁配」,17.2 保证房间不变成幽灵,17.3 把实例从池子里拿出、用完冷却再还回去。这三块拼起来,单服的「凑局」能力就成立了。而「这台服」之外的世界——多服、跨服、分区——正是卷三要展开的战场;本章刻意把边界停在单服,就是为了让每一层的责任都清楚。下一章,我们把这些玩法系统整体接进卷一的 aegis 网络层,跑出一个真正可玩的《晨曦大陆》单服。

阅读导航:上一节:17.2 大厅生命周期:防幽灵房间 · 下一节:18.1 把玩法系统接进 aegis 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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