开服九十秒,房间创建慢了十倍
《泡泡工坊》新版本上线,预约玩家在 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 缩容、排空与成本 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。