活动结束后的告警洪水
《裂隙战场》周六 22:00 活动结束。登录失败率、房间创建耗时、支付回调延迟同时抬头,值班同学在十分钟内收到 47 条告警:31 条是同一个分片的重复上报,9 条是活动收尾阶段的正常回落,真正需要处理的是 2 条。以上数字是示例值,但形状很典型——告警数量增加,有效信息没有增加。
AIOps 在这个场景里的价值不是“自动发现线上问题”,而是把 47 条压到 5 条,并说明每一条为什么值得看。它做的是候选筛选和证据整理,根因判断仍然由人完成。把这两件事混在一起,团队就会在第一次误报后失去对系统的信任。
先划边界:能辅助,不能替代根因判断
| 环节 | AI 可以做什么 | 不应该做什么 | 人的位置 |
|---|---|---|---|
| 采集与清洗 | 补点、去噪、对齐时间窗 | 猜测缺失数据 | 确认采集链路是否健康 |
| 异常检测 | 给出偏离基线的分数与方向 | 直接宣布“某服务故障” | 判断是否需要介入 |
| 告警降噪 | 去重、冷却、按指纹聚合 | 静默未知类型 | 处理被抑制后的残留风险 |
| 关联分析 | 把同窗口的指标、日志、变更放在一起 | 输出确定的因果链 | 结合拓扑与发布记录验证 |
| 容量预测 | 给出趋势区间与置信度 | 替代压测结论 | 决定扩容与活动保障方案 |
| 根因判断 | 提供排序后的假设 | 自动执行修复 | 最终确认与复盘 |
这张表决定了架构:检测器只输出“分数 + 依据 + 版本”,不直接调用变更系统。任何自动动作都必须经过一个显式的策略层,而策略层由人配置、可审计、可回滚。
异常的定义随游戏节奏变化
游戏指标没有稳定的“正常值”。工作日晚高峰、周末活动、版本更新、赛季切换都会改变分布。把全周数据混在一起算均值,结果是每个周末都异常、每个工作日都正常。
处理顺序通常是:
- 分桶:按“星期几 + 小时”切分,必要时再加“是否活动日”“是否新版本首日”。桶越细,样本越少,冷启动越频繁。
- 多指标关联:单指标越界不一定有事。登录失败率上升,同时网关 5xx、房间创建失败、支付回调超时一起上升,才更像系统性问题。
- 冷启动:新服、新玩法、新版本没有历史。此时检测器应返回
cold_start而不是anomaly,先把数据记下来,等样本够了再参与告警。 - 趋势与突变分开:缓慢增长适合用同比/环比,突然跳变适合用稳健基线。两类检测器的阈值不能共用。
| 信号 | 更适合的检测方式 | 冷启动策略 | 常见误判 |
|---|---|---|---|
| 登录失败率 | 分桶基线 + 多指标确认 | 先观察 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
}
三条规则的顺序很重要:
- 静默优先级最高,用于已知维护、迁移、压测。静默必须带截止时间,禁止永久静默。
- 冷却按指纹去重。指纹要稳定,不能包含请求 ID、时间戳这类每次都变的字段。
- 滑动窗口限流防止“不同指纹但同一根因”的告警刷屏。它按指纹计数,不按全局计数;全局限流会把真正的新问题一起挡掉。
| 规则 | 关键参数 | 示例值 | 失效方式 |
|---|---|---|---|
| 冷却 | Cooldown | 10 分钟 | 指纹不稳定时完全失效 |
| 滑动窗口 | Window、MaxPerWindow | 30 分钟、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。标签写入告警记录,不写进聊天工具。
反馈闭环的节奏建议是每周一次,而不是实时自动调参:
- 统计本周误报最多的三个指纹,检查是基线问题、阈值问题还是采集问题。
- 统计被抑制但后来确认有事的告警,调整冷却或窗口。
- 把新确认的事故加入回放集,下一轮调参必须在这套回放集上不退化。
- 任何阈值变更记录版本、变更人、回放结果,和发布一样可回滚。
不要用线上告警流自动训练模型。误报会被当成负样本、漏报没有样本,训练集本身是有偏的;而且自动更新会让“为什么这周告警变多”无法解释。
落地检查表
- 指标是否按星期、小时、活动日分桶,桶的粒度是否有足够样本?
- 冷启动是否返回
cold_start,而不是默认正常或默认异常? - 基线是否使用稳健统计,是否定期检查漂移?
- 指纹是否稳定、可读、不含高基数随机字段?
- 静默是否强制带截止时间,是否有到期提醒?
- 被抑制的告警是否可查询,数量是否在值班面板可见?
- 是否有带标签的历史事故回放集,阈值变更是否跑回放?
- 自动动作是否被禁止,或至少经过独立策略层与人工确认?
- 每次告警是否记录了检测器版本、基线版本和策略版本?
小结
AIOps 在游戏服务端最稳的落点是“少叫人、叫对人”,而不是“无人值守”。把季节性分桶、稳健基线、可解释抑制和人工标签四件事做扎实,比换一个更复杂的模型更能降低告警噪音。下一节讨论另一类 AI 能力:面对玩家的智能客服与内容审核,它们的失败代价更直接,边界也更硬。
阅读导航:上一节:10.3 架构路线图与技术雷达 · 下一节:11.2 智能客服与内容审核 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。