《游戏服务端架构·规模化、全球化与成本》2.3 缩容、排空与成本

从《裂隙战场》凌晨缩容导致早场排队、结算前强制迁移的两次事故出发,比较直接关机、停止新开与排空后超时强制三种做法,给出对局、大厅与世界服的体验分级、缩容迟滞判定和预留加按需的分层成本估算,并附一个可运行、可测试的 Go 排空状态机与成本模型。

凌晨缩容,早场排队

《裂隙战场》有过两次和缩容有关的事故。

第一次在凌晨三点:利用率降到 12%,值班同学手动缩掉一半节点;早上八点活动开场,暖池来不及补,开房排队四分钟。第二次是自动缩容在排位赛结算前触发,节点被直接关机,上面二十场对局强制迁移,玩家看到的是“网络异常,本局不计分”。

两次事故的共同点是把缩容当成扩容的逆操作。扩容的目标是“尽快有容量”,缩容的目标是“在不伤害玩家的前提下少花钱”,两者约束完全不同。这一节讨论排空的顺序、缩容判定的迟滞、以及缩容省下的钱到底来自哪里。

三种缩容做法

做法过程玩家影响适用
直接关机摘流量后立即停进程对局中断、可能判负仅限空节点或测试环境
停止新开从调度池摘除,等现有对局结束无新影响,尾部可能等很久大部分对局节点
排空 + 超时强制停新开、等到截止时间、剩余强制迁移尾部玩家有损需要按时交付资源的场景

三种做法的差别不在技术难度,而在“谁承担尾部成本”。直接关机把成本转给玩家;无限等待把成本转给资源账单;排空加超时是把尾部截断在一个可承诺的时间点,代价是少量强制迁移。

玩家体验分级

节点类型缩容影响允许的操作
空对局节点无感立即排空
有对局的排位节点可接受:等本局结束排空,超时强制
大厅节点短暂重连快速排空,配合重连退避
世界服 / 大地图有损:状态迁移复杂不参与日常缩容,单独迁移

《群岛纪元》的世界服不在日常缩容范围内。它的状态是大地图与联盟数据,迁移一次的成本远高于省下的机器钱;这类节点只能按赛区做计划内迁移,不能交给自动缩容。

排空状态机

active --StartDrain--> draining --最后一个房间结束--> drained --> removed
                          └── 截止时间到达且仍有房间 --> forced --> removed

四条规则:

  1. 先停新开。排空开始时节点必须从调度池摘除,否则一边排一边进,永远排不空。
  2. 等对局自然结束。每结束一局调用一次 FinishRoom,房间数减到零进入 drained。
  3. 超时强制。截止时间到达仍有房间,标记 forced,记录被强制结束的房间数;这个数字要进入玩家补偿和对局审计。
  4. 移除前必须为零。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 全球延迟预算与路由 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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