《游戏服务端编程实践》21.2 AIOps 与自动化部署

从指标驱动扩缩容、异常检测与自动回滚的边界讲起,说明目标利用率、上下限、冷却和滞后如何共同防止抖动,并给出带越界钳制与冷却判断的 Go 扩缩容决策器实现、测试覆盖和无人值守失败场景分析,帮助团队在自动化前先补齐安全阀,避免把自动化做成新的故障源。

一、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 延迟和消息积压,扩容总是慢半拍。
  • 缩容步长等于扩容步长,一次砍掉太多容量,迁移期间直接过载。
  • 自动回滚不校验目标版本是否存在、配置是否兼容,回滚本身变成新故障。
  • 自动回滚没有次数上限,连续失败时反复重建实例,放大故障影响。
  • 灰度只看实例比例,不看区服、渠道和玩法影响面,问题版本扩散到全量。
  • 没有暂停开关,值班同学只能改配置或停流水线,人工接管无法及时生效。
  • 决策器多副本并发写同一份状态,冷却时间被互相覆盖,等于没有冷却。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist