《游戏服务端架构·规模化、全球化与成本》2.2 暖池、冷启动与扩容速度

从《泡泡工坊》开服洪峰房间创建 P99 从 4 秒涨到 38 秒的现场出发,拆解镜像拉取、进程启动、配置加载与预热四段冷启动成本,说明暖池水位、预热深度、租借超时与归还协议如何共同决定扩容速度,并给出带状态机、可运行、可测试的 Go 暖池实现。

开服九十秒,房间创建慢了十倍

《泡泡工坊》新版本上线,预约玩家在 10:00 同时进入。监控上房间创建 P99 从平日的 4 秒涨到 38 秒,匹配队列一度排到六万人。值班同学先怀疑匹配服务,扩容匹配节点后没有改善;真正慢的是“匹配成功之后到房间可进”这一段。

把一次开房拆开看,时间花在四个地方(示例值,来自压测拆解而不是生产实测):镜像拉取 12 秒,因为新版本镜像不在节点缓存里;进程启动 3 秒;地图与配置加载 9 秒;首次自检与缓存预热 11 秒;注册和健康检查 3 秒。加起来正好 38 秒。

关键认知是:冷启动不是“启动一个进程”,而是一条流水线,任何一段没有预热,整条流水线就慢。暖池的作用也不是“多准备几台机器”,而是把流水线的前几段提前做掉。这一节讨论这条流水线怎么拆、暖池的水位和预热深度怎么定、租借与归还如何保证不把坏实例放给玩家。

冷启动的四段成本

阶段典型量级(示例值)主要成本常见优化
镜像拉取5–30 秒网络带宽、节点磁盘节点预拉、镜像分层、P2P 分发
进程启动1–5 秒二进制大小、动态链接静态编译、裁剪依赖、常驻父进程
配置与地图加载2–20 秒反序列化、寻路网格构建二进制格式、共享只读映射、按房间类型裁剪
预热与自检5–30 秒首次执行路径、连接池启动即跑一遍自检对局、预建连接
注册与健康检查1–3 秒服务发现、探针周期与前三段并行、缩短探针间隔

这张表里最容易照搬错的是“JIT 预热”。Go 是静态编译,没有 JVM 那样的 JIT,预热省下的不是编译时间,而是 page cache、TLS 握手、数据库连接池和第一次地图寻路缓存。把 JVM 的优化清单直接套到 Go 服务上,通常会得到“做了很多预热、延迟没降”的结果。

暖池的三个参数

暖池不是单一开关,它至少有三个参数:水位(保持多少可用实例,同时是延迟与成本参数)、预热深度(进程启动到完成自检对局,越深占用越多)和租借超时(太短误杀长对局,太长让崩溃实例占着租约)。

产品暖池形态目标水位(示例值)预热深度租借超时
《裂隙战场》固定池峰值并发的 15%配置加载 + 自检对局对局上限 + 2 分钟
《泡泡工坊》弹性池20–50 个进程启动 + 配置加载单局上限 + 30 秒
《群岛纪元》无房间暖池0世界服常驻,不按房间预热不适用

《群岛纪元》没有房间暖池,不是因为它不需要快,而是它的负载形态是大地图与异步结算,房间不是短生命周期对象。把竞技游戏的暖池方案照搬到 SLG,只会得到一堆空转进程。

租借与归还:一个四状态机

Reconcile 创建 → warming --Warmup 到期--> ready --Acquire--> leased
                                            ▲                  │
                                            └── Release(健康) ─┤
                                                               └── 超时/崩溃 → dead

四条规则决定这个状态机是否安全。

第一,只租 ready。warming 实例即使进程已经起来,也可能还没加载完配置,借出去等于把冷启动转嫁给玩家。

第二,归还要带健康标记。正常结束回 ready;崩溃、超时、健康检查失败一律进 dead,不复用。复用状态未知的进程,是暖池最危险的优化。

第三,超时租约进 dead 而不是回 ready。租约超时说明房间服务没有按约定归还,进程可能还在跑旧对局,内存里是什么状态没人知道。

第四,缩容只杀 ready 和 warming,永远不杀 leased。池子缩容不能中断正在进行的对局;正在跑的实例要等它自然结束,或者走第 3 节的排空流程。

水位控制需要死区

水位如果按“当前排队数”实时调整,会在阈值附近来回震荡:排队多一个就扩,少一个就缩,实例刚预热好又被杀掉。控制器的做法是加死区: backlog 高于高水位才扩,低于低水位才缩,中间区间保持目标不变。

这和第 1 章容量模型的区别是:容量模型回答“需要多少机器”,水位控制器回答“这一分钟池子里放多少个”,前者按天或按赛季调整,后者按秒到分钟调整。

扩容 SLO 分场景

场景目标(示例值)主要手段扩不出来时
开服洪峰5 分钟内承接 10 倍日常预拉镜像、固定暖池、提前扩容登录队列限流,分批放行
赛季更新更新后 30 分钟内稳定双版本节点池、滚动替换保留旧版本房间,禁止新开
活动开场准点瞬时 3 倍定时预热、活动专属节点池排队 + 预计等待时间提示

扩不出来不是异常,是可以设计的路径。暖池 Acquire 返回 ErrNoReady 时,调用方应该把玩家放进队列并给出预计等待时间,而不是同步等待创建实例。排队和限流要和登录队列共用一套令牌桶,否则匹配层限了、登录层没限,压力只是换了个入口。

代码:暖池状态机与水位控制

下面的 internal/warmpool/pool.go 把 key、配置、控制器、槽位状态机、租借、归还和超时回收放在一个包里。时间通过 now 注入,测试不依赖真实时钟。

// file: internal/warmpool/pool.go
// tier: L1
// verify: go test ./internal/warmpool/

package warmpool

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

var (
	ErrInvalidConfig = errors.New("warmpool: min/max or water levels are invalid")
	ErrUnknownKey    = errors.New("warmpool: key is not registered")
	ErrNoReady       = errors.New("warmpool: no ready instance")
	ErrUnknownSlot   = errors.New("warmpool: unknown slot")
	ErrNotLeased     = errors.New("warmpool: slot is not leased")
)

type Key struct {
	Region   string
	Version  string
	RoomType string
}

type State string

const (
	Warming State = "warming"
	Ready   State = "ready"
	Leased  State = "leased"
	Dead    State = "dead" // crashed or timed out; never reused
)

type Config struct {
	Min       int
	Max       int
	Warmup    time.Duration
	LeaseTTL  time.Duration
	HighWater int
	LowWater  int
}

type Slot struct {
	ID        string
	Key       Key
	State     State
	ReadyAt   time.Time
	LeaseID   string
	ExpiresAt time.Time
}

type Lease struct {
	ID        string
	SlotID    string
	Key       Key
	ExpiresAt time.Time
}

type Stats struct {
	Target  int
	Warming int
	Ready   int
	Leased  int
	Dead    int
}

type Pool struct {
	mu     sync.Mutex
	now    func() time.Time
	cfg    map[Key]Config
	target map[Key]int
	slots  map[string]*Slot
	seq    int
}

func New(now func() time.Time) *Pool {
	if now == nil {
		now = time.Now
	}
	return &Pool{
		now:    now,
		cfg:    map[Key]Config{},
		target: map[Key]int{},
		slots:  map[string]*Slot{},
	}
}

func (p *Pool) Register(k Key, c Config) error {
	if k.Region == "" || k.Version == "" || k.RoomType == "" ||
		c.Min < 0 || c.Max < c.Min || c.Warmup <= 0 || c.LeaseTTL <= 0 ||
		c.HighWater <= c.LowWater {
		return ErrInvalidConfig
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	p.cfg[k] = c
	p.target[k] = c.Min
	return nil
}

func (p *Pool) SetTarget(k Key, n int) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	c, ok := p.cfg[k]
	if !ok {
		return ErrUnknownKey
	}
	p.target[k] = clamp(n, c.Min, c.Max)
	return nil
}

func (p *Pool) promoteLocked(k Key, now time.Time) {
	for _, s := range p.slots {
		if s.Key == k && s.State == Warming && !now.Before(s.ReadyAt) {
			s.State = Ready
		}
	}
}

// Reconcile promotes warming slots, fills up to the target and removes excess
// warm capacity. It never touches a leased slot.
func (p *Pool) Reconcile(k Key) (created, killed int) {
	p.mu.Lock()
	defer p.mu.Unlock()
	if _, ok := p.cfg[k]; !ok {
		return 0, 0
	}
	for id, s := range p.slots {
		if s.State == Dead {
			delete(p.slots, id)
		}
	}
	now := p.now()
	p.reclaimLocked(now)
	p.promoteLocked(k, now)

	target := p.target[k]
	alive := p.countAliveLocked(k)
	for alive < target {
		p.seq++
		id := fmt.Sprintf("warm-%s-%04d", k.Region, p.seq)
		p.slots[id] = &Slot{ID: id, Key: k, State: Warming, ReadyAt: now.Add(p.cfg[k].Warmup)}
		alive++
		created++
	}
	for alive > target {
		id := p.killCandidateLocked(k)
		if id == "" {
			break
		}
		delete(p.slots, id)
		alive--
		killed++
	}
	return created, killed
}

func (p *Pool) countAliveLocked(k Key) int {
	n := 0
	for _, s := range p.slots {
		if s.Key == k && s.State != Dead {
			n++
		}
	}
	return n
}

func (p *Pool) killCandidateLocked(k Key) string {
	var ready, warming []string
	for id, s := range p.slots {
		if s.Key != k {
			continue
		}
		switch s.State {
		case Ready:
			ready = append(ready, id)
		case Warming:
			warming = append(warming, id)
		}
	}
	sort.Strings(ready)
	sort.Strings(warming)
	if len(ready) > 0 {
		return ready[len(ready)-1] // newest ready first, keep the long-warmed
	}
	if len(warming) > 0 {
		return warming[len(warming)-1]
	}
	return ""
}

// Acquire leases one ready instance; an empty pool means "queue the player".
func (p *Pool) Acquire(k Key, holder string) (Lease, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	if _, ok := p.cfg[k]; !ok {
		return Lease{}, ErrUnknownKey
	}
	now := p.now()
	p.reclaimLocked(now)
	p.promoteLocked(k, now)

	var pick *Slot
	for _, s := range p.slots {
		if s.Key != k || s.State != Ready {
			continue
		}
		if pick == nil || s.ReadyAt.Before(pick.ReadyAt) ||
			(s.ReadyAt.Equal(pick.ReadyAt) && s.ID < pick.ID) {
			pick = s
		}
	}
	if pick == nil {
		return Lease{}, ErrNoReady
	}
	p.seq++
	lease := Lease{
		ID:        fmt.Sprintf("lease-%04d", p.seq),
		SlotID:    pick.ID,
		Key:       k,
		ExpiresAt: now.Add(p.cfg[k].LeaseTTL),
	}
	pick.State = Leased
	pick.LeaseID = lease.ID
	pick.ExpiresAt = lease.ExpiresAt
	return lease, nil
}

// Release returns a healthy slot to ready; an unhealthy one becomes dead.
func (p *Pool) Release(slotID string, healthy bool) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	s, ok := p.slots[slotID]
	if !ok {
		return ErrUnknownSlot
	}
	if s.State != Leased {
		return ErrNotLeased
	}
	s.LeaseID = ""
	s.ExpiresAt = time.Time{}
	if healthy {
		s.State = Ready
	} else {
		s.State = Dead
	}
	return nil
}

// ReclaimExpired marks timed-out leases dead; the room is fenced by epoch.
func (p *Pool) ReclaimExpired() []Lease {
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.reclaimLocked(p.now())
}

func (p *Pool) reclaimLocked(now time.Time) []Lease {
	var out []Lease
	for _, s := range p.slots {
		if s.State != Leased || now.Before(s.ExpiresAt) {
			continue
		}
		out = append(out, Lease{ID: s.LeaseID, SlotID: s.ID, Key: s.Key, ExpiresAt: s.ExpiresAt})
		s.State = Dead
		s.LeaseID = ""
		s.ExpiresAt = time.Time{}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].SlotID < out[j].SlotID })
	return out
}

func (p *Pool) Stats(k Key) Stats {
	p.mu.Lock()
	defer p.mu.Unlock()
	st := Stats{Target: p.target[k]}
	for _, s := range p.slots {
		if s.Key != k {
			continue
		}
		switch s.State {
		case Warming:
			st.Warming++
		case Ready:
			st.Ready++
		case Leased:
			st.Leased++
		case Dead:
			st.Dead++
		}
	}
	return st
}
  • 配套的 internal/warmpool/controller.go 用高/低水位死区计算目标值,避免目标随排队数抖动。
  • Reconcile 先清理已标记的 dead 槽位、回收超时租约,再把到期的 warming 提升为 ready,最后按目标补建或删除。
  • Acquire 只从 ready 中挑最早预热完成的实例;池子为空返回 ErrNoReady,这是排队信号而不是故障。
  • Release 用健康标记决定回 ready 还是进 dead;ReclaimExpired 把超时租约直接标记为 dead。

失败模式与兜底

  • 暖池被长对局借空:租借超时按对局上限设置,池子水位按最长对局估算,必要时为长对局单独建池。
  • 归还风暴:大量房间同时结束会让 ready 瞬间超过目标,Reconcile 逐步删除,不一次性杀光,避免下一波开房又冷启动。
  • 预热实例版本错:key 里必须带版本,新版本上线时旧 key 的实例只能用于旧房间,不能借给新客户端。
  • 镜像未预拉:暖池实例创建本身也会卡在镜像拉取,预热深度再深也没用;镜像分发要独立于暖池扩容。

常见错误与边界

  • 只统计进程启动时间,不统计镜像拉取和配置加载。
  • 把 JVM 的 JIT 预热经验直接套到 Go 服务。
  • 超时租约回收到 ready,把状态未知的进程借给下一个玩家。

小结

冷启动是一条由镜像、进程、配置、预热和注册组成的流水线,暖池只是把流水线的前几段提前做掉。水位决定“准备多少”,预热深度决定“准备到哪一步”,租借超时决定“坏实例多久被清掉”;三者要一起调,单独调一个通常只是把瓶颈挪到下一段。扩不出来时的排队与限流不是补丁,而是扩容方案的一部分。

下一节转向相反的方向:机器空下来了,怎么缩、怎么排空、缩容省下的钱和玩家体验怎么平衡。

阅读导航:上一节:2.1 专用服、容器与调度器 · 下一节:2.3 缩容、排空与成本 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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