凌晨缩容,早场排队
《裂隙战场》有过两次和缩容有关的事故。
第一次在凌晨三点:利用率降到 12%,值班同学手动缩掉一半节点;早上八点活动开场,暖池来不及补,开房排队四分钟。第二次是自动缩容在排位赛结算前触发,节点被直接关机,上面二十场对局强制迁移,玩家看到的是“网络异常,本局不计分”。
两次事故的共同点是把缩容当成扩容的逆操作。扩容的目标是“尽快有容量”,缩容的目标是“在不伤害玩家的前提下少花钱”,两者约束完全不同。这一节讨论排空的顺序、缩容判定的迟滞、以及缩容省下的钱到底来自哪里。
三种缩容做法
| 做法 | 过程 | 玩家影响 | 适用 |
|---|---|---|---|
| 直接关机 | 摘流量后立即停进程 | 对局中断、可能判负 | 仅限空节点或测试环境 |
| 停止新开 | 从调度池摘除,等现有对局结束 | 无新影响,尾部可能等很久 | 大部分对局节点 |
| 排空 + 超时强制 | 停新开、等到截止时间、剩余强制迁移 | 尾部玩家有损 | 需要按时交付资源的场景 |
三种做法的差别不在技术难度,而在“谁承担尾部成本”。直接关机把成本转给玩家;无限等待把成本转给资源账单;排空加超时是把尾部截断在一个可承诺的时间点,代价是少量强制迁移。
玩家体验分级
| 节点类型 | 缩容影响 | 允许的操作 |
|---|---|---|
| 空对局节点 | 无感 | 立即排空 |
| 有对局的排位节点 | 可接受:等本局结束 | 排空,超时强制 |
| 大厅节点 | 短暂重连 | 快速排空,配合重连退避 |
| 世界服 / 大地图 | 有损:状态迁移复杂 | 不参与日常缩容,单独迁移 |
《群岛纪元》的世界服不在日常缩容范围内。它的状态是大地图与联盟数据,迁移一次的成本远高于省下的机器钱;这类节点只能按赛区做计划内迁移,不能交给自动缩容。
排空状态机
active --StartDrain--> draining --最后一个房间结束--> drained --> removed
└── 截止时间到达且仍有房间 --> forced --> removed
四条规则:
- 先停新开。排空开始时节点必须从调度池摘除,否则一边排一边进,永远排不空。
- 等对局自然结束。每结束一局调用一次
FinishRoom,房间数减到零进入drained。 - 超时强制。截止时间到达仍有房间,标记
forced,记录被强制结束的房间数;这个数字要进入玩家补偿和对局审计。 - 移除前必须为零。
removed只能从drained或forced进入,且房间数为零,避免“节点已下线、会话还挂着”。
宽限期按节点类型配置,不按全局一个值:
| 节点类型 | 宽限期(示例值) | 理由 |
|---|---|---|
| 排位对局 | 10 分钟 | 覆盖绝大多数对局时长 |
| 大厅 | 1 分钟 | 无长状态,重连即可 |
| 世界服 | 不允许 | 需要独立迁移方案 |
缩容判定:两个迟滞一起用
缩容判定要同时满足四个条件:利用率低于阈值、持续足够久、节点类型可回收、缩容后不低于保留节点数。
只看瞬时利用率会抖动。假设阈值 20%,负载在 19% 和 21% 之间波动,每一分钟都会触发一次“可以缩”和“不能缩”。两个迟滞参数解决这个问题:sustain 要求低利用率持续一段时间,cooldown 要求两次缩容之间留出间隔。只用一个不够:只有 sustain,缩容后负载回升又立刻掉下来,仍会连续缩;只有 cooldown,第一次触发仍然可能发生在瞬时低谷。
保留节点数(floor)是给突发留的余量,按第 1 章的容量模型计算,不能设成零。缩容只能缩到 floor,剩下的靠暖池和按需扩容承接。
利用率口径也要选对。CPU 利用率对房间服有意义,但对长连接网关,连接数和内存更接近真实占用;对世界服,要看地图分片的常驻内存而不是 CPU。用错口径会出现“CPU 很低所以缩容,结果连接数已经打满”的情况。
成本:缩容省的是变动部分
游戏服的成本结构里,有一部分是缩不掉的:世界服、数据库、日志与监控、预留带宽、以及为峰值包年包月的基线节点。缩容能省的通常是按需计费的对局节点和突发容量。
| 产品 | 基线(包年包月) | 峰值补充(按需) | 缩容对象 |
|---|---|---|---|
| 《裂隙战场》 | 世界服、匹配、数据库 | 排位对局节点 | 按需对局节点 |
| 《群岛纪元》 | 世界服、大地图分片 | 活动结算节点 | 结算临时节点 |
| 《泡泡工坊》 | 大厅、登录 | 房间节点 | 房间节点与暖池 |
成本估算要把两部分分开:
月成本 = 预留节点数 × 预留单价 + 按需节点数 × 使用小时 × 按需单价
预留比例按 P50 负载而不是峰值定。按峰值预留会把大部分时间浪费在空转上;按 P50 预留、峰值用按需补,总成本通常更低,代价是峰值时刻的单价更高、且需要能快速拿到按需容量。这个取舍要用真实账单验证,不能只看单价表。
代码:排空状态机、缩容判定与成本估算
下面的 internal/scalein/drain.go 把节点阶段、宽限期、强制回收、可回收节点排序、迟滞判定和成本估算放在一个文件里。时间通过 now 注入,测试可以精确控制截止时间。
// file: internal/scalein/drain.go
// tier: L1
// verify: go test ./internal/scalein/
package scalein
import (
"errors"
"fmt"
"sort"
"time"
)
var (
ErrUnknownNode = errors.New("scalein: unknown node")
ErrInvalidNode = errors.New("scalein: invalid node")
ErrInvalidKind = errors.New("scalein: node kind cannot be drained")
ErrInvalidTransition = errors.New("scalein: invalid phase transition")
)
type Kind string
const (
Match Kind = "match" // holds running matches; drain waits for settlement
Lobby Kind = "lobby" // stateless or short-lived; quick drain
World Kind = "world" // long-lived state; never scale in without migration
)
type Phase string
const (
Active Phase = "active"
Draining Phase = "draining"
Drained Phase = "drained"
Forced Phase = "forced" // deadline hit while rooms were still running
Removed Phase = "removed"
)
// Grace returns how long a node of this kind may wait for its rooms to end.
// A world node has no safe grace: scaling it in requires a migration plan,
// which is a separate operation from draining a match host.
func Grace(k Kind) (time.Duration, bool) {
switch k {
case Match:
return 10 * time.Minute, true
case Lobby:
return time.Minute, true
default:
return 0, false
}
}
type Node struct {
ID string
Kind Kind
Phase Phase
Rooms int
DrainStart time.Time
Deadline time.Time
Forced int // rooms terminated by the deadline, not by settlement
}
type Event struct {
NodeID string
Phase Phase
Rooms int
}
type Manager struct {
now func() time.Time
nodes map[string]*Node
}
func New(now func() time.Time) *Manager {
if now == nil {
now = time.Now
}
return &Manager{now: now, nodes: map[string]*Node{}}
}
func (m *Manager) Add(n Node) error {
if n.ID == "" || n.Rooms < 0 {
return ErrInvalidNode
}
if _, ok := Grace(n.Kind); !ok && n.Kind != World {
return ErrInvalidNode
}
if _, exists := m.nodes[n.ID]; exists {
return fmt.Errorf("%w: %s", ErrInvalidNode, n.ID)
}
n.Phase = Active
m.nodes[n.ID] = &n
return nil
}
func (m *Manager) Node(id string) (Node, bool) {
n, ok := m.nodes[id]
if !ok {
return Node{}, false
}
return *n, true
}
// StartDrain stops accepting new rooms and starts the grace timer. A match
// node keeps serving its current matches; the caller removes it from match
// making first, which is why this method does not touch the room count.
func (m *Manager) StartDrain(id string) (Node, error) {
n, ok := m.nodes[id]
if !ok {
return Node{}, ErrUnknownNode
}
if n.Phase != Active {
return Node{}, ErrInvalidTransition
}
grace, ok := Grace(n.Kind)
if !ok {
return Node{}, ErrInvalidKind
}
n.Phase = Draining
n.DrainStart = m.now()
n.Deadline = n.DrainStart.Add(grace)
if n.Rooms == 0 {
n.Phase = Drained
}
return *n, nil
}
// FinishRoom records one room ending normally. When the last room ends before
// the deadline the node becomes drained and can be removed in this release.
func (m *Manager) FinishRoom(id string) (Node, error) {
n, ok := m.nodes[id]
if !ok {
return Node{}, ErrUnknownNode
}
if n.Phase != Draining {
return Node{}, ErrInvalidTransition
}
if n.Rooms > 0 {
n.Rooms--
}
if n.Rooms == 0 {
n.Phase = Drained
}
return *n, nil
}
// Tick forces every draining node whose deadline has passed. Forcing evicts
// the remaining rooms, so it is recorded separately for player-impact review.
func (m *Manager) Tick() []Event {
now := m.now()
var out []Event
for _, n := range m.nodes {
if n.Phase != Draining || now.Before(n.Deadline) {
continue
}
if n.Rooms == 0 {
n.Phase = Drained
out = append(out, Event{NodeID: n.ID, Phase: Drained})
continue
}
n.Forced += n.Rooms
out = append(out, Event{NodeID: n.ID, Phase: Forced, Rooms: n.Rooms})
n.Rooms = 0
n.Phase = Forced
}
sort.Slice(out, func(i, j int) bool { return out[i].NodeID < out[j].NodeID })
return out
}
func (m *Manager) Remove(id string) error {
n, ok := m.nodes[id]
if !ok {
return ErrUnknownNode
}
if n.Phase != Drained && n.Phase != Forced {
return ErrInvalidTransition
}
if n.Rooms != 0 {
return ErrInvalidTransition
}
n.Phase = Removed
return nil
}
// Reclaimable lists nodes that can be drained in this cycle. Idle match hosts
// come first, then idle lobby hosts; world hosts are never candidates.
func (m *Manager) Reclaimable() []string {
type cand struct {
id string
rank int
}
var list []cand
for _, n := range m.nodes {
if n.Phase != Active || n.Rooms != 0 {
continue
}
switch n.Kind {
case Match:
list = append(list, cand{n.ID, 0})
case Lobby:
list = append(list, cand{n.ID, 1})
}
}
sort.Slice(list, func(i, j int) bool {
if list[i].rank != list[j].rank {
return list[i].rank < list[j].rank
}
return list[i].id < list[j].id
})
out := make([]string, len(list))
for i, c := range list {
out[i] = c.id
}
return out
}
type Policy struct {
Threshold float64 // below this utilization a node is a candidate
Sustain time.Duration // how long it must stay low
Cooldown time.Duration // minimum gap between two scale-in actions
MinNodes int // reserved floor for sudden peaks
}
type Decision struct {
ScaleIn bool
Reason string
}
type Decider struct {
policy Policy
low bool
lowSince time.Time
last time.Time
}
func NewDecider(p Policy) *Decider {
return &Decider{policy: p}
}
// Observe answers "may we remove one node now". The sustain window and the
// cooldown are both hysteresis: either alone still allows flapping when the
// utilization signal oscillates around the threshold.
func (d *Decider) Observe(at time.Time, util float64, nodes int) Decision {
if nodes <= d.policy.MinNodes {
d.low = false
return Decision{Reason: "floor"}
}
if util > d.policy.Threshold {
d.low = false
return Decision{Reason: "busy"}
}
if !d.low {
d.low = true
d.lowSince = at
return Decision{Reason: "sustain"}
}
if at.Sub(d.lowSince) < d.policy.Sustain {
return Decision{Reason: "sustain"}
}
if !d.last.IsZero() && at.Sub(d.last) < d.policy.Cooldown {
return Decision{Reason: "cooldown"}
}
d.last = at
d.low = false
return Decision{ScaleIn: true, Reason: "scale-in"}
}
// Price separates the reserved baseline from burst capacity. Reserved nodes
// are paid monthly even when idle; on-demand nodes are paid per hour.
type Price struct {
ReservedPerNodeMonth float64
OnDemandPerNodeHour float64
ReservedNodes int
}
func MonthlyCost(nodes int, hours float64, p Price) float64 {
if nodes <= 0 {
return 0
}
reserved := nodes
if reserved > p.ReservedNodes {
reserved = p.ReservedNodes
}
if reserved < 0 {
reserved = 0
}
onDemand := nodes - reserved
return float64(reserved)*p.ReservedPerNodeMonth + float64(onDemand)*hours*p.OnDemandPerNodeHour
}
Grace按节点类型返回宽限期;世界服返回false,从类型层面拒绝日常缩容。StartDrain只允许从active进入;房间数为零的节点直接进入drained,不占用宽限期。FinishRoom每次只减一,最后一间结束时转drained,重复调用返回ErrInvalidTransition。Tick在截止时间到达时把剩余房间计入Forced并转forced,这个计数是玩家补偿的依据。
失败模式与兜底
- 强制迁移:
forced只是状态,玩家补偿和对局审计要单独做;强制结束的对局不能按失败计分。 - 节点移除后会话泄漏:
Remove要求房间数为零,但网关侧仍可能持有旧连接,需要连接注册表按节点下线事件清理。 - 世界服误缩:世界服不进入
Reclaimable,也不允许StartDrain;如果业务确实要迁移,走独立的地图迁移流程。
常见错误与边界
- 把缩容当成扩容的逆操作,直接关机。
- 缩容到零,突发时没有基线可承接。
- 用瞬时利用率判定,负载在阈值附近抖动。
小结
缩容的关键不是“什么时候关机”,而是“怎么让节点在关机前自然空下来”。排空状态机把停新开、等对局、超时强制和移除串成一条可审计的路径;缩容判定用持续窗口加冷却时间避免抖动,并保留不能突破的 floor;成本上要分清哪些是缩不掉的基线、哪些是按需的变动部分。扩容买速度,缩容买成本,两者都要为玩家体验让路。到这里,调度器、暖池与排空三件事已经连成一条链路,下一章把视角从单个集群拉到全球。
阅读导航:上一节:2.2 暖池、冷启动与扩容速度 · 下一节:3.1 全球延迟预算与路由 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。