一、AIOps 不是无人运维
AIOps 在游戏服务端的现实目标,不是“机器替人值班”,而是把重复判断变成可回放的策略:什么时候扩容、什么时候回滚、异常时先看哪几个指标。异常检测可以提示“这个分片的 tick 延迟偏离基线”,但要不要切流量、要不要回滚版本,仍然需要人设定规则和边界。
一个可落地的 AIOps 系统至少包含四层:指标采集、告警与异常检测、决策策略、执行与审计。前三层经常被混在一起,结果是告警直接触发动作。正确做法是告警只产生候选动作,决策层检查冷却、边界和变更窗口,执行层留下版本号、操作者和结果。没有审计的自动回滚,比不自动回滚更危险,因为它会让系统在错误方向上快速重复。
自动化部署也一样。流水线能编译、打包、推送、滚动更新,不代表可以无人值守。真正的门槛在于失败时能不能停下来:新版本健康检查不过,是否停止继续放量;回滚目标不存在,是否拒绝执行;连续回滚两次,是否冻结自动发布并通知值班。把这些安全阀写进流程,比多加一个告警渠道更有价值。
二、扩缩容的四个输入
一个可测试的扩缩容策略,输入可以归纳为四类:指标、目标、边界、节奏。指标决定“现在忙不忙”,目标决定“希望忙到什么程度”,边界决定“最少和最多几个实例”,节奏决定“多久允许动一次”。缺任何一项,策略都会在某个场景下失控。
| 参数 | 含义 | 示例值 | 失配后的典型后果 |
|---|---|---|---|
| 目标利用率 | 期望的单实例负载水位 | 0.6 | 太高频繁扩容,太低浪费资源 |
| 最小副本 | 保底容量 | 2 | 缩到 1 后再无冗余 |
| 最大副本 | 成本与配额上限 | 20 | 故障时无限扩容拖垮依赖 |
| 单步扩容 | 一次增加多少实例 | 2 | 步长过大造成过冲 |
| 单步缩容 | 一次减少多少实例 | 1 | 步长过大造成容量缺口 |
| 冷却时间 | 两次动作的最小间隔 | 30 秒 | 指标抖动引发反复扩缩 |
| 滞后区间 | 目标两侧的死区 | ±10% | 阈值附近来回切换 |
示例值只是说明量纲,真实参数必须用压测和线上数据校准。尤其要注意“目标利用率”不是 CPU 使用率,而是业务定义的容量水位,例如每实例承载的房间数、在线人数或 tick 耗时占比。拿 CPU 直接当目标,会把 I/O 等待和锁竞争误判成空闲。
三、冷却与滞后:防止抖动
没有冷却的扩缩容,遇到锯齿状指标会变成振荡器:扩容后利用率下降,策略立刻缩容;缩容后利用率上升,策略又扩容。每一轮都伴随实例启动、玩家迁移和缓存重建,成本远高于多留几个实例。冷却时间的作用不是“延迟响应”,而是给系统留出观察窗口,让上一次动作的效果先体现出来。
滞后区间解决的是阈值附近的抖动。目标利用率 60%、滞后 10%,意味着利用率低于 54% 才缩容,高于 66% 才扩容,中间区间保持不动。滞后区间太窄,等于没有;太宽,则响应迟钝。经验上先从目标值的 ±10% 开始,再根据业务波动调整。
冷却和滞后要一起用。只有滞后没有冷却,指标持续偏高时仍会每个评估周期都扩一次;只有冷却没有滞后,冷却结束后仍会在阈值边缘反复动作。两者叠加,才能把“持续高负载”与“瞬时抖动”区分开。
四、只看 CPU 的盲区
CPU 使用率是最容易采集、也最容易误导的指标。游戏服的大量时间花在等待网络、锁竞争和定时器上,CPU 可能只有 30%,但 tick 已经严重超时。反过来,某些后台任务会让 CPU 短暂冲高,实际并不影响玩家。只用 CPU 扩缩容,会出现“机器很忙但玩家不卡”和“机器很闲但玩家掉线”两种误判。
更可靠的组合是:容量型指标(在线人数、房间数、连接数)决定是否扩容,质量型指标(tick 延迟、消息积压、错误率)决定是否告警和回滚,CPU 与内存作为兜底。三类指标的时间窗口也要分开:容量型看 1 到 5 分钟趋势,质量型看 10 秒级突刺,资源型看 5 分钟均值。窗口混用会让策略对噪声过度反应。
指标本身还要防污染。比如在线人数包含挂机角色,房间数包含空房间,连接数包含半开连接。扩容前先做口径清洗,否则策略会为“虚假的繁忙”买单。口径定义应该和运营报表一致,避免同一指标在监控和计费里是两个数。
五、自动回滚的安全阀
自动回滚的第一步不是“发现异常就回滚”,而是确认回滚目标可用。回滚版本必须存在、镜像可拉取、配置兼容、数据库迁移可逆。缺少任何一项,自动回滚只是把故障从新版本搬到旧版本。发布系统应该在发布前就记录“当前稳定版本”和它的健康基线,而不是出事时再去猜。
第二步是限制回滚次数。连续回滚说明问题可能不在版本,而在依赖、配置或流量。此时继续自动回滚会反复触发迁移和缓存重建。常见做法是:同一服务一小时内最多自动回滚两次,超过后冻结自动发布,转人工接管,同时保留现场快照和最近一次变更记录。
第三步是回滚后的观察期。回滚成功不等于故障结束,旧版本可能只是因为流量已经下降而看起来正常。观察期要覆盖至少一个业务高峰或一个完整的定时任务周期,确认错误率、延迟和在线人数都回到基线,才允许重新发布。否则很容易出现“回滚、再发布、再回滚”的循环。
六、变更审批、灰度与人工接管
自动化程度越高,审批点越要少而明确。不是每个提交都需要人工审批,但涉及数据库 schema、付费、封禁和跨服路由的变更必须有。审批的价值不在于多一道签字,而在于强制记录“谁在什么时间因为什么批准了这次变更”,出事时可以追溯。
灰度要按影响面设计,而不是按实例数平均分。先内部账号,再小比例真实玩家,再按区服或渠道放量。每一阶段都要有明确的通过条件和自动停止条件,例如错误率超过基线两倍、p99 延迟超过预算、关键玩法成功率下降。条件不满足时自动停止放量,但不要自动扩大放量。
人工接管必须随时可用。系统要提供三个开关:暂停自动扩缩容、暂停自动回滚、暂停发布流水线。暂停后仍保留监控和告警,只是不再执行动作。值班同学需要能在不登录每台机器的情况下完成这些操作,否则“人工接管”只存在于文档里。
七、失败场景:没有冷却的扩缩容循环
某 SLG 项目用 CPU 70% 作为扩容阈值,评估周期 15 秒,没有冷却和滞后。一次跨服战开启后,世界服 CPU 冲到 85%,策略连续扩容三次,实例从 6 个涨到 12 个。新实例启动需要加载地图,加载过程本身占用 CPU,指标继续偏高,策略又扩了两个。
十分钟后跨服战结束,负载骤降,策略开始缩容。缩容触发玩家迁移,迁移又带来一轮 CPU 峰值,策略误判为仍需扩容,立刻扩回去。整个晚上实例数在 8 到 14 之间来回跳,玩家反复经历迁移和重连,客诉量比跨服战本身还高。
复盘时发现三个问题:只看 CPU,没有看在线人数和房间数;没有冷却,指标一抖就动作;缩容没有迁移预算,把迁移成本算进了业务负载。修复方案是把扩容信号换成在线人数与 tick 延迟的组合,加入 2 分钟扩容冷却、10 分钟缩容冷却和 ±10% 滞后区间,并给缩容设置每日上限。下面的 Go 实现覆盖了冷却、边界、滞后和越界钳制这几个核心判断。
八、Go 实现:带冷却、钳制与滞后的决策器
autoscale.go 把策略参数和决策逻辑分开。Decide 是纯函数,输入当前副本数、利用率、当前时间和上一次动作时间,输出目标副本数与原因;Controller 保存最近一次动作时间,供控制循环反复调用。越界钳制优先于冷却,因为副本数已经不在合法区间时,纠正是安全动作。
// file: ch21/autoscale/autoscale.go
// tier: L1
// verify: go test ./ch21/autoscale/
package autoscale
import (
"errors"
"fmt"
"math"
"time"
)
var (
ErrInvalidPolicy = errors.New("autoscale: invalid policy")
ErrInvalidMetric = errors.New("autoscale: invalid metric")
)
// Policy 描述扩缩容的边界和节奏。TargetUtilization 用 0~1 表示,
// Hysteresis 是目标值两侧的死区比例,避免在阈值附近来回抖动。
type Policy struct {
Min int
Max int
TargetUtilization float64
Hysteresis float64
StepUp int
StepDown int
Cooldown time.Duration
}
func (p Policy) Validate() error {
if p.Min < 0 {
return fmt.Errorf("%w: min must be >= 0", ErrInvalidPolicy)
}
if p.Max < 1 || p.Max < p.Min {
return fmt.Errorf("%w: max must be >= 1 and >= min", ErrInvalidPolicy)
}
if p.TargetUtilization <= 0 || p.TargetUtilization > 1 {
return fmt.Errorf("%w: target utilization must be in (0,1]", ErrInvalidPolicy)
}
if p.Hysteresis < 0 || p.Hysteresis >= 0.5 {
return fmt.Errorf("%w: hysteresis must be in [0,0.5)", ErrInvalidPolicy)
}
if p.TargetUtilization*(1+p.Hysteresis) > 1 {
return fmt.Errorf("%w: up threshold must not exceed 1", ErrInvalidPolicy)
}
if p.StepUp < 1 || p.StepDown < 1 {
return fmt.Errorf("%w: step must be >= 1", ErrInvalidPolicy)
}
if p.Cooldown < 0 {
return fmt.Errorf("%w: cooldown must be >= 0", ErrInvalidPolicy)
}
return nil
}
func (p Policy) upThreshold() float64 {
return p.TargetUtilization * (1 + p.Hysteresis)
}
func (p Policy) downThreshold() float64 {
return p.TargetUtilization * (1 - p.Hysteresis)
}
type Action string
const (
ActionNone Action = "none"
ActionScaleUp Action = "scale_up"
ActionScaleDown Action = "scale_down"
)
// Result 同时给出当前值和决策后的副本数,调用方即使不动作也能记录原因。
type Result struct {
From int
To int
Action Action
Reason string
}
func (r Result) Changed() bool {
return r.Action != ActionNone && r.To != r.From
}
// Decide 是纯函数:同样输入永远得到同样输出,便于回放和单元测试。
// 越界钳制优先于冷却,副本数已经不在合法区间时纠正是安全动作。
func Decide(p Policy, current int, utilization float64, now, lastAction time.Time) (Result, error) {
if err := p.Validate(); err != nil {
return Result{}, err
}
if current < 0 {
return Result{}, fmt.Errorf("%w: replicas must be >= 0", ErrInvalidPolicy)
}
if math.IsNaN(utilization) || math.IsInf(utilization, 0) || utilization < 0 {
return Result{}, fmt.Errorf("%w: utilization = %v", ErrInvalidMetric, utilization)
}
res := Result{From: current, To: current, Action: ActionNone}
if current < p.Min {
res.To = p.Min
res.Action = ActionScaleUp
res.Reason = "clamp to min"
return res, nil
}
if current > p.Max {
res.To = p.Max
res.Action = ActionScaleDown
res.Reason = "clamp to max"
return res, nil
}
switch {
case utilization >= p.upThreshold():
res.To = min(current+p.StepUp, p.Max)
if res.To > current {
res.Action = ActionScaleUp
res.Reason = fmt.Sprintf("utilization %.3f >= up threshold %.3f",
utilization, p.upThreshold())
} else {
res.Reason = "already at max"
}
case utilization <= p.downThreshold():
res.To = max(current-p.StepDown, p.Min)
if res.To < current {
res.Action = ActionScaleDown
res.Reason = fmt.Sprintf("utilization %.3f <= down threshold %.3f",
utilization, p.downThreshold())
} else {
res.Reason = "already at min"
}
default:
res.Reason = "within hysteresis band"
}
if res.Action != ActionNone && !lastAction.IsZero() && now.Sub(lastAction) < p.Cooldown {
res.To = current
res.Action = ActionNone
res.Reason = fmt.Sprintf("cooldown %s not elapsed", p.Cooldown)
}
return res, nil
}
// Controller 保存最近一次动作的时间,供控制循环反复调用 Observe。
// 它不是并发安全的:一个副本只应由一个控制循环驱动。
type Controller struct {
policy Policy
current int
lastAction time.Time
}
func NewController(p Policy, initial int) (*Controller, error) {
if err := p.Validate(); err != nil {
return nil, err
}
if initial < p.Min {
initial = p.Min
}
if initial > p.Max {
initial = p.Max
}
return &Controller{policy: p, current: initial}, nil
}
func (c *Controller) Current() int {
return c.current
}
func (c *Controller) Observe(utilization float64, now time.Time) (Result, error) {
res, err := Decide(c.policy, c.current, utilization, now, c.lastAction)
if err != nil {
return Result{}, err
}
if res.Action != ActionNone && res.To != c.current {
c.current = res.To
c.lastAction = now
}
return res, nil
}
实现里有两个容易忽略的细节。第一,Decide 返回 Result 而不是只返回副本数,原因字符串让审计日志可以回答“为什么这次没扩”。第二,冷却只拦截扩缩容动作,不拦截越界钳制;如果副本数被外部改到了上限之外,策略应该立即纠正,而不是等冷却结束。
Controller 不做并发保护,是刻意的:扩缩容决策必须单写者,多个评估器同时写会互相覆盖最后动作时间,冷却就失效了。如果确实需要多副本选主,应该在选主层面解决,而不是给控制器加锁。
九、测试覆盖与运行
测试覆盖九类行为:高利用率触发扩容;低利用率触发缩容;利用率落在滞后区间时保持不动;冷却期内不重复动作;冷却结束后允许再次动作;副本数低于下限或高于上限时被钳制;已经在上限或下限时不再产生动作;非法策略与非法指标被拒绝;控制器初始值越界时被钳制。
cd .local/checks/gs-practice-lab && gofmt -l ch21/autoscale && go vet ./ch21/autoscale/ && go test ./ch21/autoscale/ -count=1
ok gspractice/ch21/autoscale 0.387s
gofmt -l 没有输出说明格式一致;go vet 通过;15 个测试用例全部通过。需要强调,这个包只做决策,不执行扩缩容,也不负责迁移、冷却持久化和多副本选主。生产环境还要把最近动作时间写入共享存储,否则控制器重启后冷却窗口会丢失。
小结
AIOps 的价值在于把重复判断固化成可回放的策略,而不是取消人。扩缩容策略要同时有指标、目标、边界和节奏;冷却与滞后负责防抖,业务指标负责纠偏,CPU 只能兜底。自动回滚必须先校验目标版本、限制次数、保留观察期。审批、灰度和人工接管是自动化的安全阀,缺一个都不应该放开无人值守。示例代码只覆盖单控制器的决策逻辑,执行层和迁移层仍需单独设计。
常见错误与边界
- 没有冷却时间,指标一抖动就扩缩,实例数和玩家迁移来回震荡。
- 只设置扩容阈值,不设置缩容阈值和滞后区间,策略在阈值附近反复横跳。
- 把 CPU 当唯一信号,忽略在线人数、tick 延迟和消息积压,扩容总是慢半拍。
- 缩容步长等于扩容步长,一次砍掉太多容量,迁移期间直接过载。
- 自动回滚不校验目标版本是否存在、配置是否兼容,回滚本身变成新故障。
- 自动回滚没有次数上限,连续失败时反复重建实例,放大故障影响。
- 灰度只看实例比例,不看区服、渠道和玩法影响面,问题版本扩散到全量。
- 没有暂停开关,值班同学只能改配置或停流水线,人工接管无法及时生效。
- 决策器多副本并发写同一份状态,冷却时间被互相覆盖,等于没有冷却。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。