节点还有空闲内存,却开不出一间房
《裂隙战场》的一次晚高峰扩容后,监控显示集群总空闲内存还有 12GB,但新房间创建持续失败。逐台检查发现:每台节点只剩 2GB 连续可用,而一个标准房间需要 4GB。总量足够,单机不够,这就是资源碎片。
同一周还出现了两个相关问题:凌晨低峰期暖池里躺着 80 个空闲实例,白天空闲实例又不够用;一台节点故障后,上面的租约没有失效,调度器继续往这台机器分配房间。三个问题指向同一个组件:实例分配器。
这一节讨论分配器如何按赛区、版本和资源规格选节点,暖池如何影响冷启动与成本,以及正常结束、异常退出、节点故障三条回收路径的差别。具体的房间状态机已经在 3.2 讲过,这里只关心“房间落在哪台机器上”。
备选方案:冷启动、暖池与专用节点
| 方案 | 做法 | 开房延迟(示例值) | 资源浪费 | 适用场景 |
|---|---|---|---|---|
| 纯冷启动 | 需要时再拉起进程 | 30–90 秒 | 最低 | 低频、可接受排队 |
| 固定暖池 | 常驻一批空闲实例 | 1–3 秒 | 高,低峰期空转 | 竞技排位、开赛洪峰 |
| 弹性暖池 | 按预测水位扩缩 | 3–15 秒 | 中 | 大多数在线产品 |
| 专用节点 | 每个房间独占整机 | 取决于调度 | 最高 | 高规格、强隔离需求 |
《裂隙战场》用固定暖池保排位赛,《泡泡工坊》用很小的弹性暖池,《群岛纪元》没有按房间预热的暖池,因为它的主要负载是大地图和异步结算,不是短对局。暖池不是“越大越好”,它本质上是拿钱换开房延迟。
分配器的三条硬约束
分配器只做一件事:在满足硬约束的节点里选一台,发出带 epoch 的租约。硬约束有三条:
- 赛区匹配。玩家在哪个赛区,房间就落在哪个赛区的节点,跨区只作为兜底。
- 版本匹配。房间进程的构建版本必须与当前对局版本一致,不能把新版本房间分配给旧客户端。
- 资源规格匹配。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 选型:九宫格、十字链表与空间索引 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。