《游戏服务端架构·平台化与架构治理》11.1 AIOps 与异常检测

从活动结束后的告警洪水出发,区分 AIOps 能辅助的降噪、基线与评分环节和不能替代的根因判断,说明季节性分桶、冷启动与多指标关联的处理方式,用中位数与 MAD 实现可解释的异常评分和三级告警抑制,并给出误报率、漏报率与提前量的评估口径,明确人工兜底的边界。

活动结束后的告警洪水

《裂隙战场》周六 22:00 活动结束。登录失败率、房间创建耗时、支付回调延迟同时抬头,值班同学在十分钟内收到 47 条告警:31 条是同一个分片的重复上报,9 条是活动收尾阶段的正常回落,真正需要处理的是 2 条。以上数字是示例值,但形状很典型——告警数量增加,有效信息没有增加。

AIOps 在这个场景里的价值不是“自动发现线上问题”,而是把 47 条压到 5 条,并说明每一条为什么值得看。它做的是候选筛选和证据整理,根因判断仍然由人完成。把这两件事混在一起,团队就会在第一次误报后失去对系统的信任。

先划边界:能辅助,不能替代根因判断

环节AI 可以做什么不应该做什么人的位置
采集与清洗补点、去噪、对齐时间窗猜测缺失数据确认采集链路是否健康
异常检测给出偏离基线的分数与方向直接宣布“某服务故障”判断是否需要介入
告警降噪去重、冷却、按指纹聚合静默未知类型处理被抑制后的残留风险
关联分析把同窗口的指标、日志、变更放在一起输出确定的因果链结合拓扑与发布记录验证
容量预测给出趋势区间与置信度替代压测结论决定扩容与活动保障方案
根因判断提供排序后的假设自动执行修复最终确认与复盘

这张表决定了架构:检测器只输出“分数 + 依据 + 版本”,不直接调用变更系统。任何自动动作都必须经过一个显式的策略层,而策略层由人配置、可审计、可回滚。

异常的定义随游戏节奏变化

游戏指标没有稳定的“正常值”。工作日晚高峰、周末活动、版本更新、赛季切换都会改变分布。把全周数据混在一起算均值,结果是每个周末都异常、每个工作日都正常。

处理顺序通常是:

  1. 分桶:按“星期几 + 小时”切分,必要时再加“是否活动日”“是否新版本首日”。桶越细,样本越少,冷启动越频繁。
  2. 多指标关联:单指标越界不一定有事。登录失败率上升,同时网关 5xx、房间创建失败、支付回调超时一起上升,才更像系统性问题。
  3. 冷启动:新服、新玩法、新版本没有历史。此时检测器应返回 cold_start 而不是 anomaly,先把数据记下来,等样本够了再参与告警。
  4. 趋势与突变分开:缓慢增长适合用同比/环比,突然跳变适合用稳健基线。两类检测器的阈值不能共用。
信号更适合的检测方式冷启动策略常见误判
登录失败率分桶基线 + 多指标确认先观察 7 个同桶样本(示例值)版本更新导致客户端集中重登
房间创建耗时分位数 + 突变检测用同机型、同分片对照活动开场瞬间的正常排队
支付回调延迟同比 + 绝对值双阈值接入新支付渠道时单独建桶渠道侧维护
在线人数趋势预测 + 区间新服按开服天数分层周末自然增长
单分片 CPU分片内基线新分片继承同角色基线大区合并后的流量迁移

基线学习:为什么用中位数与 MAD

均值与标准差对历史里的一个尖峰很敏感。某次事故留下一个超高值,均值和方差都会被拉大,接下来一周的真实异常反而落在“正常区间”里。中位数和 MAD(中位数绝对偏差)不依赖正态分布,少量离群点不会改变结论,适合作为第一层基线。

下面的实现把“分桶、冷启动、稳健评分”放在同一个文件里。MinSamples 没到就返回冷启动;评分用 |x - median| / (1.4826 * MAD),其中 1.4826 让 MAD 在正态假设下与标准差可比。

// file: internal/aiopsdetect/baseline.go
// tier: L1
// verify: go test ./internal/aiopsdetect/
package aiopsdetect

import (
	"errors"
	"math"
	"sort"
)

// ErrNoSamples means the seasonal bucket has never been observed. Callers
// should treat it as "learn more", not as an incident.
var ErrNoSamples = errors.New("aiopsdetect: no samples for bucket")

// Point is one sampled value. Bucket encodes the season, for example
// "sat-20" for Saturday 20:00 in the game's local time zone.
type Point struct {
	Bucket string
	Value  float64
}

// Baseline keeps one sample window per seasonal bucket. A separate bucket
// for weekday/hour is the simplest way to stop a weekend event from making
// every Tuesday look anomalous.
type Baseline struct {
	MinSamples int
	Samples    map[string][]float64
}

// NewBaseline returns a baseline that will not score a bucket until it has
// MinSamples observations. The default of 7 is one week of the same season.
func NewBaseline(minSamples int) *Baseline {
	if minSamples <= 0 {
		minSamples = 7
	}
	return &Baseline{
		MinSamples: minSamples,
		Samples:    make(map[string][]float64),
	}
}

// Add records one observation. Callers own retention: keeping the last N
// samples per bucket avoids unbounded growth and stale seasonality.
func (b *Baseline) Add(p Point) {
	b.Samples[p.Bucket] = append(b.Samples[p.Bucket], p.Value)
}

// Verdict is the decision the detector returns for one point.
type Verdict string

const (
	// ColdStart means there is not enough history to judge. Do not alert.
	ColdStart Verdict = "cold_start"
	// Normal means the point is inside the learned band.
	Normal Verdict = "normal"
	// Anomaly means the point crossed the configured robust threshold.
	Anomaly Verdict = "anomaly"
)

// Result carries enough context for a human to audit the decision.
type Result struct {
	Verdict Verdict
	Score   float64
	Median  float64
	Scale   float64
	Samples int
}

// Detector scores points against a Baseline using median and MAD. Median and
// MAD are used instead of mean and standard deviation because a single
// outlier in history would otherwise widen the band and hide real incidents.
type Detector struct {
	Baseline  *Baseline
	Threshold float64
	MinScale  float64
}

// NewDetector builds a detector. Threshold is a robust z-score; 3.5 is a
// common starting point and should be tuned against labeled incidents.
func NewDetector(b *Baseline, threshold float64) *Detector {
	if threshold <= 0 {
		threshold = 3.5
	}
	return &Detector{
		Baseline:  b,
		Threshold: threshold,
		MinScale:  1e-9,
	}
}

// Evaluate scores one point. It never returns Anomaly for a cold bucket:
// an empty history is a data gap, not evidence of a fault.
func (d *Detector) Evaluate(p Point) (Result, error) {
	if d.Baseline == nil {
		return Result{}, errors.New("aiopsdetect: nil baseline")
	}
	hist := d.Baseline.Samples[p.Bucket]
	if len(hist) == 0 {
		return Result{}, ErrNoSamples
	}

	res := Result{Samples: len(hist)}
	if len(hist) < d.Baseline.MinSamples {
		res.Verdict = ColdStart
		return res, nil
	}

	med := median(hist)
	scale := 1.4826 * mad(hist, med)
	if scale < d.MinScale {
		scale = d.MinScale
	}

	res.Median = med
	res.Scale = scale
	res.Score = math.Abs(p.Value-med) / scale
	res.Verdict = Normal
	if res.Score >= d.Threshold {
		res.Verdict = Anomaly
	}
	return res, nil
}

// median copies the input so the caller's history order is preserved.
func median(xs []float64) float64 {
	if len(xs) == 0 {
		return 0
	}
	cp := append([]float64(nil), xs...)
	sort.Float64s(cp)

	n := len(cp)
	if n%2 == 1 {
		return cp[n/2]
	}
	return (cp[n/2-1] + cp[n/2]) / 2
}

// mad is the median absolute deviation, a robust stand-in for sigma. The
// 1.4826 factor in Evaluate makes it comparable to a normal distribution.
func mad(xs []float64, med float64) float64 {
	if len(xs) == 0 {
		return 0
	}
	dev := make([]float64, len(xs))
	for i, v := range xs {
		dev[i] = math.Abs(v - med)
	}
	return median(dev)
}

这段代码有三个有意的取舍:

  • 不自动淘汰旧样本。调用方决定保留最近 N 个样本,因为不同指标的漂移速度不同。检测器不替业务做保留策略,避免“悄悄换基线”。
  • MAD 为零时用极小值兜底。历史完全平坦时,任何变化都会得到巨大分数。这不是 bug,而是提醒:平坦序列要么是常量配置,要么是采集粒度太粗,应该先检查数据而不是直接告警。
  • 阈值是外部参数。3.5(示例值)只是起点,必须用带标签的历史事故回测。阈值写死在代码里,等于把调参责任藏起来。

中位数基线也有盲区:它看不见缓慢漂移。连续三个月每天涨 1%,中位数会跟着涨,检测器始终认为正常。这类问题要靠同比、容量预测或业务侧的目标值补上,不能指望一个检测器覆盖全部形态。

告警抑制:冷却、滑动窗口与静默

检测器给出异常,不等于要叫醒人。同一个分片在十分钟内反复越界,第一次告警已经足够;值班同学处理期间,重复告警只会制造噪音。抑制层放在检测和通知之间,规则要少、要可解释。

// file: internal/aiopsdetect/suppress.go
// tier: L1
// verify: go test ./internal/aiopsdetect/
package aiopsdetect

import "time"

// Alert is the minimum input for suppression. Fingerprint should be stable
// across restarts, for example "metric:login-fail:shard-7".
type Alert struct {
	Fingerprint string
	At          time.Time
	Severity    string
}

// Suppressor applies three cheap rules before paging a human: silence
// windows, per-fingerprint cooldown, and a sliding-window rate limit.
// It deliberately does not merge different fingerprints; correlation belongs
// to the grouping layer above it.
type Suppressor struct {
	Cooldown     time.Duration
	Window       time.Duration
	MaxPerWindow int

	last     map[string]time.Time
	recent   map[string][]time.Time
	silenced map[string]time.Time
}

// NewSuppressor builds a suppressor. A zero MaxPerWindow disables the rate
// limit; a zero Cooldown disables duplicate suppression.
func NewSuppressor(cooldown, window time.Duration, maxPerWindow int) *Suppressor {
	if cooldown < 0 {
		cooldown = 0
	}
	if window < 0 {
		window = 0
	}
	if maxPerWindow < 0 {
		maxPerWindow = 0
	}
	return &Suppressor{
		Cooldown:     cooldown,
		Window:       window,
		MaxPerWindow: maxPerWindow,
		last:         make(map[string]time.Time),
		recent:       make(map[string][]time.Time),
		silenced:     make(map[string]time.Time),
	}
}

// Allow returns whether the alert should reach the paging path, plus a
// machine-readable reason. Alerts are expected in non-decreasing time order;
// callers replaying historical data must sort them first.
func (s *Suppressor) Allow(a Alert) (bool, string) {
	if a.Fingerprint == "" {
		return false, "empty_fingerprint"
	}
	if until, ok := s.silenced[a.Fingerprint]; ok && a.At.Before(until) {
		return false, "silenced"
	}
	if last, ok := s.last[a.Fingerprint]; ok && a.At.Sub(last) < s.Cooldown {
		return false, "cooldown"
	}
	if s.MaxPerWindow > 0 && s.Window > 0 {
		if len(s.prune(a.Fingerprint, a.At)) >= s.MaxPerWindow {
			return false, "rate_limited"
		}
	}

	s.last[a.Fingerprint] = a.At
	if s.Window > 0 {
		s.recent[a.Fingerprint] = append(s.recent[a.Fingerprint], a.At)
	}
	return true, "accepted"
}

// Silence suppresses one fingerprint for maintenance or a known migration.
// The window must be explicit and short; an open-ended silence hides the
// next real incident.
func (s *Suppressor) Silence(fingerprint string, until time.Time) {
	if s.silenced == nil {
		s.silenced = make(map[string]time.Time)
	}
	s.silenced[fingerprint] = until
}

// prune drops timestamps older than the sliding window and returns what is
// left. It reuses the backing array, so the map stays allocation-light on the
// hot path.
func (s *Suppressor) prune(fingerprint string, at time.Time) []time.Time {
	cut := at.Add(-s.Window)
	kept := s.recent[fingerprint][:0]
	for _, t := range s.recent[fingerprint] {
		if !t.Before(cut) {
			kept = append(kept, t)
		}
	}
	s.recent[fingerprint] = kept
	return kept
}

三条规则的顺序很重要:

  1. 静默优先级最高,用于已知维护、迁移、压测。静默必须带截止时间,禁止永久静默。
  2. 冷却按指纹去重。指纹要稳定,不能包含请求 ID、时间戳这类每次都变的字段。
  3. 滑动窗口限流防止“不同指纹但同一根因”的告警刷屏。它按指纹计数,不按全局计数;全局限流会把真正的新问题一起挡掉。
规则关键参数示例值失效方式
冷却Cooldown10 分钟指纹不稳定时完全失效
滑动窗口Window、MaxPerWindow30 分钟、3 条窗口太长会掩盖新故障
静默until不超过 2 小时忘记结束会漏掉真实告警
指纹指标 + 分片 + 错误类型不含请求 ID过细导致去重无效,过粗导致误合并

抑制会带来一个必须接受的风险:被压掉的告警里可能藏着第二个问题。缓解办法不是取消抑制,而是把被抑制的数量和原因记录下来,在值班面板上显示“过去 30 分钟抑制了 38 条”,让人知道噪音仍在,只是没有打扰。

评估:三个数字和一条曲线

没有标签的 AIOps 无法评估。标签来自值班确认和复盘结论,前期可以人工标注,后期再考虑半自动。评估至少看三个数字:

  • 误报率 = 误报数 / 所有告警数。太高会让值班忽略告警。
  • 漏报率 = 未检出的事故数 / 所有已确认事故数。需要用历史事故回放,不能只看线上。
  • 平均提前量 = 事故被确认时间 - 首次告警时间。提前量太短,等于没有预警。
指标计算口径示例目标说明
误报率误报 / 总告警低于 30%初期可以放宽,先保证不漏
漏报率未检出事故 / 已确认事故低于 10%用近 3 个月事故回放
平均提前量确认时间 - 首告警时间大于 5 分钟按事故等级分层统计
告警压缩比原始告警 / 通知告警5:1 到 10:1只看数量会掩盖误合并
确认耗时告警到首次人工响应小于 10 分钟与值班流程强相关

这些目标都是示例值,必须按自己的事故基线和值班容量调整。一个常见错误是只优化压缩比:把 47 条压成 1 条很容易,但那条如果经常是错的,团队会更快地失去信任。

人工确认与反馈闭环

每条告警被处理后,至少要打一个标签:true_positive、expected(活动或维护导致)、data_gap(采集缺失)、duplicate(重复)、false_positive。标签写入告警记录,不写进聊天工具。

反馈闭环的节奏建议是每周一次,而不是实时自动调参:

  1. 统计本周误报最多的三个指纹,检查是基线问题、阈值问题还是采集问题。
  2. 统计被抑制但后来确认有事的告警,调整冷却或窗口。
  3. 把新确认的事故加入回放集,下一轮调参必须在这套回放集上不退化。
  4. 任何阈值变更记录版本、变更人、回放结果,和发布一样可回滚。

不要用线上告警流自动训练模型。误报会被当成负样本、漏报没有样本,训练集本身是有偏的;而且自动更新会让“为什么这周告警变多”无法解释。

落地检查表

  • 指标是否按星期、小时、活动日分桶,桶的粒度是否有足够样本?
  • 冷启动是否返回 cold_start,而不是默认正常或默认异常?
  • 基线是否使用稳健统计,是否定期检查漂移?
  • 指纹是否稳定、可读、不含高基数随机字段?
  • 静默是否强制带截止时间,是否有到期提醒?
  • 被抑制的告警是否可查询,数量是否在值班面板可见?
  • 是否有带标签的历史事故回放集,阈值变更是否跑回放?
  • 自动动作是否被禁止,或至少经过独立策略层与人工确认?
  • 每次告警是否记录了检测器版本、基线版本和策略版本?

小结

AIOps 在游戏服务端最稳的落点是“少叫人、叫对人”,而不是“无人值守”。把季节性分桶、稳健基线、可解释抑制和人工标签四件事做扎实,比换一个更复杂的模型更能降低告警噪音。下一节讨论另一类 AI 能力:面对玩家的智能客服与内容审核,它们的失败代价更直接,边界也更硬。

阅读导航:上一节:10.3 架构路线图与技术雷达 · 下一节:11.2 智能客服与内容审核 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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