《游戏服务端架构·核心系统与契约》3.3 实例分配、暖池与回收

从《裂隙战场》节点“还有空闲内存却开不出房”的现象出发,说明实例分配器的赛区、版本、资源规格硬约束,比较冷启动、固定暖池与弹性暖池的成本,拆解正常结束、异常退出、节点故障三条回收路径与装箱碎片的具体成因和应对办法,并给出可运行 Go 实现。

节点还有空闲内存,却开不出一间房

《裂隙战场》的一次晚高峰扩容后,监控显示集群总空闲内存还有 12GB,但新房间创建持续失败。逐台检查发现:每台节点只剩 2GB 连续可用,而一个标准房间需要 4GB。总量足够,单机不够,这就是资源碎片。

同一周还出现了两个相关问题:凌晨低峰期暖池里躺着 80 个空闲实例,白天空闲实例又不够用;一台节点故障后,上面的租约没有失效,调度器继续往这台机器分配房间。三个问题指向同一个组件:实例分配器。

这一节讨论分配器如何按赛区、版本和资源规格选节点,暖池如何影响冷启动与成本,以及正常结束、异常退出、节点故障三条回收路径的差别。具体的房间状态机已经在 3.2 讲过,这里只关心“房间落在哪台机器上”。

备选方案:冷启动、暖池与专用节点

方案做法开房延迟(示例值)资源浪费适用场景
纯冷启动需要时再拉起进程30–90 秒最低低频、可接受排队
固定暖池常驻一批空闲实例1–3 秒高,低峰期空转竞技排位、开赛洪峰
弹性暖池按预测水位扩缩3–15 秒中大多数在线产品
专用节点每个房间独占整机取决于调度最高高规格、强隔离需求

《裂隙战场》用固定暖池保排位赛,《泡泡工坊》用很小的弹性暖池,《群岛纪元》没有按房间预热的暖池,因为它的主要负载是大地图和异步结算,不是短对局。暖池不是“越大越好”,它本质上是拿钱换开房延迟。

分配器的三条硬约束

分配器只做一件事:在满足硬约束的节点里选一台,发出带 epoch 的租约。硬约束有三条:

  1. 赛区匹配。玩家在哪个赛区,房间就落在哪个赛区的节点,跨区只作为兜底。
  2. 版本匹配。房间进程的构建版本必须与当前对局版本一致,不能把新版本房间分配给旧客户端。
  3. 资源规格匹配。CPU 和内存同时满足,且节点健康。

在这三条之上才是策略:best-fit 选择“放完之后剩余资源最少”的节点,把大块空闲留给后续的大房间。这不是为了当前这一间房最快,而是为了降低整体碎片。

暖池大小:三款产品的不同答案

产品暖池策略目标水位(示例值)补充时机回收策略
《裂隙战场》固定暖池峰值并发的 15%每 30 秒检查一次正常结束回池,异常退出丢弃
《泡泡工坊》弹性暖池20–50 间开房失败率超过 1% 时扩低峰期缩到下限
《群岛纪元》不按对局预热0不适用世界服常驻,不参与房间回收

暖池里的实例已经占着节点资源,所以“池子大小”同时是成本和延迟参数。目标水位应该按历史峰值和开房失败率调,而不是按节点总数拍一个百分比。

回收的三条路径

  • 正常结束:房间结算完成,释放租约;如果暖池未满,实例回到池子,资源继续保留。
  • 异常退出:进程崩溃或心跳超时,租约回收,资源立即释放,实例不回到暖池,因为内存状态可能已损坏。
  • 节点故障:节点标记不健康,批量失效该节点上的所有租约和暖池实例,受影响会话重新入队;恢复后的节点重新注册。

为什么空闲内存不等于可开房

装箱问题不能用总量判断。假设两台节点各有 8 CPU、8GB,每台已经被一个 6 CPU、6GB 的房间占用,总空闲是 4 CPU、4GB;但任何一台都放不下新的 4 CPU、4GB 房间。分配器返回 ErrNoCapacity 是正确的,即使监控上的“总空闲”看起来足够。

减少碎片的办法有三个:best-fit 装箱、限制单节点上的规格种类、在低峰期主动迁移或重启节点。前两个是分配器能做的,第三个需要运维配合,不能指望分配算法单独解决。

代码:租约分配、暖池补充与故障回收

下面的 internal/instancepool/pool.go 把节点容量、暖池、租约和三条回收路径放在一个包里。now 由外部注入;暖池实例在创建时就占用节点资源,所以“池子里的空闲”不是免费容量。

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

import (
	"errors"
	"fmt"
	"sort"
	"sync"
	"time"
)

type Spec struct {
	Region  string
	Version string
	CPU     int
	MemMB   int
}

type Node struct {
	ID       string
	Region   string
	Version  string
	CPUTotal int
	MemTotal int
	CPUUsed  int
	MemUsed  int
	Healthy  bool
}

func (n *Node) fits(s Spec) bool {
	return n.Healthy && n.Region == s.Region && n.Version == s.Version &&
		n.CPUTotal-n.CPUUsed >= s.CPU && n.MemTotal-n.MemUsed >= s.MemMB
}

type Instance struct {
	ID     string
	NodeID string
	Spec   Spec
}

type Lease struct {
	InstanceID string
	SessionID  string
	NodeID     string
	Spec       Spec
	Epoch      uint64
	ExpiresAt  time.Time
}

type Stats struct {
	Healthy   int
	Warm      int
	Active    int
	FreeCPU   int
	FreeMemMB int
}

var (
	ErrEmptySession = errors.New("instancepool: empty session id")
	ErrInvalidSpec  = errors.New("instancepool: invalid spec")
	ErrInvalidNode  = errors.New("instancepool: invalid node")
	ErrNodeExists   = errors.New("instancepool: healthy node already registered")
	ErrDuplicate    = errors.New("instancepool: session already holds a lease")
	ErrNoCapacity   = errors.New("instancepool: no node fits the spec")
	ErrUnknownLease = errors.New("instancepool: unknown lease")
	ErrStaleLease   = errors.New("instancepool: stale lease epoch")
)

type Pool struct {
	mu        sync.Mutex
	now       func() time.Time
	ttl       time.Duration
	nodes     map[string]*Node
	warm      map[Spec][]Instance
	active    map[string]Lease
	bySession map[string]string
	seq       int
	epoch     uint64
}

func New(ttl time.Duration, now func() time.Time) *Pool {
	if now == nil {
		now = time.Now
	}
	return &Pool{now: now, ttl: ttl, nodes: map[string]*Node{},
		warm: map[Spec][]Instance{}, active: map[string]Lease{}, bySession: map[string]string{}}
}

func validateSpec(s Spec) error {
	if s.Region == "" || s.Version == "" || s.CPU <= 0 || s.MemMB <= 0 {
		return ErrInvalidSpec
	}
	return nil
}

func (p *Pool) AddNode(n Node) error {
	if n.ID == "" || n.Region == "" || n.Version == "" || n.CPUTotal <= 0 || n.MemTotal <= 0 {
		return ErrInvalidNode
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	if old, ok := p.nodes[n.ID]; ok && old.Healthy {
		return ErrNodeExists
	}
	n.CPUUsed, n.MemUsed, n.Healthy = 0, 0, true
	p.nodes[n.ID] = &n
	return nil
}

func (p *Pool) bestNodeLocked(s Spec) *Node {
	var best *Node
	bestCPU, bestMem := 0, 0
	for _, n := range p.nodes {
		if !n.fits(s) {
			continue
		}
		leftCPU, leftMem := n.CPUTotal-n.CPUUsed-s.CPU, n.MemTotal-n.MemUsed-s.MemMB
		if best == nil || leftCPU < bestCPU || (leftCPU == bestCPU &&
			(leftMem < bestMem || (leftMem == bestMem && n.ID < best.ID))) {
			best, bestCPU, bestMem = n, leftCPU, leftMem
		}
	}
	return best
}
func (p *Pool) newInstanceLocked(n *Node, s Spec) Instance {
	n.CPUUsed += s.CPU
	n.MemUsed += s.MemMB
	p.seq++
	return Instance{ID: fmt.Sprintf("inst-%04d", p.seq), NodeID: n.ID, Spec: s}
}
func (p *Pool) warmLocked(s Spec, n int) int {
	created := 0
	for i := 0; i < n; i++ {
		node := p.bestNodeLocked(s)
		if node == nil {
			break
		}
		p.warm[s] = append(p.warm[s], p.newInstanceLocked(node, s))
		created++
	}
	return created
}
func (p *Pool) Warm(s Spec, n int) int {
	if n <= 0 || validateSpec(s) != nil {
		return 0
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.warmLocked(s, n)
}
func (p *Pool) Refill(s Spec, target int) int {
	if target <= 0 || validateSpec(s) != nil {
		return 0
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	if have := len(p.warm[s]); have >= target {
		return 0
	}
	return p.warmLocked(s, target-len(p.warm[s]))
}
func (p *Pool) Acquire(session string, s Spec) (Lease, error) {
	if session == "" {
		return Lease{}, ErrEmptySession
	}
	if err := validateSpec(s); err != nil {
		return Lease{}, err
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	p.reclaimExpiredLocked()
	if _, ok := p.bySession[session]; ok {
		return Lease{}, ErrDuplicate
	}
	var inst Instance
	if list := p.warm[s]; len(list) > 0 {
		inst = list[len(list)-1]
		p.warm[s] = list[:len(list)-1]
	} else {
		node := p.bestNodeLocked(s)
		if node == nil {
			return Lease{}, ErrNoCapacity
		}
		inst = p.newInstanceLocked(node, s)
	}
	p.epoch++
	lease := Lease{InstanceID: inst.ID, SessionID: session, NodeID: inst.NodeID,
		Spec: s, Epoch: p.epoch, ExpiresAt: p.now().Add(p.ttl)}
	p.active[inst.ID] = lease
	p.bySession[session] = inst.ID
	return lease, nil
}

func (p *Pool) Release(instanceID string, epoch uint64) error {
	return p.release(instanceID, epoch, true)
}

func (p *Pool) Evict(instanceID string, epoch uint64) error {
	return p.release(instanceID, epoch, false)
}
func (p *Pool) release(instanceID string, epoch uint64, reuse bool) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	lease, ok := p.active[instanceID]
	if !ok {
		return ErrUnknownLease
	}
	if lease.Epoch != epoch {
		return ErrStaleLease
	}
	delete(p.active, instanceID)
	delete(p.bySession, lease.SessionID)
	p.freeLocked(lease, reuse)
	return nil
}
func (p *Pool) freeLocked(lease Lease, reuse bool) {
	node := p.nodes[lease.NodeID]
	if node == nil || !node.Healthy {
		return // FailNode already zeroed every reservation on this node.
	}
	if reuse {
		p.warm[lease.Spec] = append(p.warm[lease.Spec],
			Instance{ID: lease.InstanceID, NodeID: lease.NodeID, Spec: lease.Spec})
		return
	}
	node.CPUUsed -= lease.Spec.CPU
	node.MemUsed -= lease.Spec.MemMB
}

func (p *Pool) ReclaimExpired() []Lease {
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.reclaimExpiredLocked()
}
func (p *Pool) reclaimExpiredLocked() []Lease {
	now := p.now()
	var out []Lease
	for id, lease := range p.active {
		if now.Before(lease.ExpiresAt) {
			continue
		}
		out = append(out, lease)
		delete(p.active, id)
		delete(p.bySession, lease.SessionID)
		p.freeLocked(lease, false)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
	return out
}
func (p *Pool) FailNode(nodeID string) []Lease {
	p.mu.Lock()
	defer p.mu.Unlock()
	n, ok := p.nodes[nodeID]
	if !ok {
		return nil
	}
	n.Healthy = false
	var out []Lease
	for id, lease := range p.active {
		if lease.NodeID == nodeID {
			out = append(out, lease)
			delete(p.active, id)
			delete(p.bySession, lease.SessionID)
		}
	}
	for spec, list := range p.warm {
		kept := list[:0]
		for _, inst := range list {
			if inst.NodeID != nodeID {
				kept = append(kept, inst)
			}
		}
		p.warm[spec] = kept
	}
	n.CPUUsed, n.MemUsed = 0, 0
	sort.Slice(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
	return out
}
func (p *Pool) Stats() Stats {
	p.mu.Lock()
	defer p.mu.Unlock()
	st := Stats{Active: len(p.active)}
	for _, n := range p.nodes {
		if n.Healthy {
			st.Healthy++
			st.FreeCPU += n.CPUTotal - n.CPUUsed
			st.FreeMemMB += n.MemTotal - n.MemUsed
		}
	}
	for _, list := range p.warm {
		st.Warm += len(list)
	}
	return st
}
  • Acquire 先回收过期租约,再检查同一 session 是否已有租约,最后才消耗暖池或冷分配。
  • Release 是正常路径,可能把实例放回暖池;Evict 是异常路径,只释放资源,不复用实例。
  • FailNode 一次性失效该节点上的全部租约和暖池实例,返回需要重新入队的会话。
  • freeLocked 对不健康节点直接返回:FailNode 已经把该节点的占用清零,重复扣减会产生负数。

失败模式与兜底

  • 重复分配:同一 session 连续请求两次,兜底是 bySession 幂等检查和 ErrDuplicate。
  • 过期释放:旧客户端释放已被重新分配的实例,兜底是 epoch 栅栏和 ErrStaleLease。
  • 暖池污染:崩溃实例被放回池子,下一次开房直接失败。兜底是异常路径不回收。

常见错误与边界

  • 用集群总空闲判断能否开房,忽略单节点碎片。
  • 暖池实例不占资源,导致“池子越大、实际可开房越少”。

小结

实例分配器的职责是把“房间需求”映射到“满足赛区、版本、规格的节点”,并用 best-fit 降低碎片;暖池用资源换延迟,大小要按产品和时段配置;回收分正常、异常、节点故障三条路径,异常路径不复用实例,所有释放都必须带 epoch 栅栏。空闲内存不等于可开房,这是装箱问题,不是容量问题。

到这里,匹配、房间生命周期和实例分配连成了一条完整的链路:先找到对手,再创建并推进房间,最后把房间放到合适的机器上并在结束时回收。下一章转向房间内部的空间问题:AOI 如何决定谁能看见谁。

阅读导航:上一节:3.2 房间生命周期与幽灵房间 · 下一节:4.1 AOI 选型:九宫格、十字链表与空间索引 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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