大促前夜,告警群里三百条消息
某应用在大促前夜接入了新的监控。零点刚过,值班群被三百多条消息刷屏:同一条 CDN 5xx 被 12 条规则重复触发,每条规则又按地区、按版本各发一次;告警里写着 owner 是“全体”,runbook 链接指向 wiki 首页;真正需要处理的支付成功率下跌,被淹没在重复消息里。
事后复盘,问题不在监控没报警,而在告警没有闭环:
- 没有去重。 同一个故障产生几十条告警,值班同学先花二十分钟分辨哪些是同一件事。
- 没有聚合。 每条告警单独发送,没有“首次发生、最后发生、次数”的上下文。
- 没有 owner。 路由规则写的是团队名,不是具体值班人,消息发出去没人认领。
- 没有 runbook。 链接存在,但打开是首页,排查步骤要靠记忆。
- 没有未闭环标记。 告警发出去就算完成,没人跟踪它是否被确认、是否恢复。
告警是一台状态机
把告警当成一条消息,就会只关心“发没发出去”。把告警当成一台状态机,才会关心“谁确认、何时恢复、是否超时”。
| 状态 | 含义 | 合法迁移 |
|---|---|---|
firing | 规则命中,等待处理 | acked、resolved |
acked | 已确认,仍在处理 | resolved |
resolved | 已恢复或已关闭 | 无 |
状态机带来三个直接约束:不能重复确认、不能重复关闭、未关闭的告警要能按 SLA 标出来。这三条看起来琐碎,但它们决定了告警能不能被统计。
规则本身也需要固定字段,否则每条规则各写各的:
| 字段 | 作用 | 例子 |
|---|---|---|
id | 规则唯一标识 | r-ttfb |
metric | 观测指标 | ttfb.p95 |
comparison | 比较方向 | > |
threshold | 阈值 | 2000 |
windowMs | 评估窗口 | 60000 |
aggregate | 窗口内聚合方式 | avg、max、min |
severity | 严重度 | warning、critical |
owner | 默认负责人 | client-core |
runbookId | 处置手册 | rb-ttfb |
指纹、聚合与路由
去重的关键是指纹:同一规则、同一组标签,无论触发多少次,都应该是同一条告警。指纹不能包含告警 id 或触发时间,否则每次都是新的。
// file: src/observability/alert-loop.ts
// tier: L1
// verify: node --test test/observability/alert-loop.test.ts
export type Severity = "warning" | "critical";
export type Comparison = ">" | ">=" | "<" | "<=";
export type Aggregate = "avg" | "max" | "min";
export type AlertStatus = "firing" | "acked" | "resolved";
export type Sample = { at: number; value: number };
export type AlertRule = {
id: string;
metric: string;
comparison: Comparison;
threshold: number;
windowMs: number;
aggregate: Aggregate;
severity: Severity;
owner: string;
runbookId: string;
};
export type Alert = {
id: string;
ruleId: string;
fingerprint: string;
severity: Severity;
owner: string;
runbookId: string;
labels: Record<string, string>;
firedAt: number;
status: AlertStatus;
ackedAt?: number;
resolvedAt?: number;
unclosed?: boolean;
};
export type AlertGroup = {
fingerprint: string;
ruleId: string;
severity: Severity;
owner: string;
count: number;
firstAt: number;
lastAt: number;
status: AlertStatus;
ids: string[];
};
export type Route = { match: Record<string, string>; owner: string };
export type Runbook = { id: string; title: string; url: string; steps: string[] };
export type LoopReport = {
total: number;
open: number;
resolved: number;
unclosed: number;
missingRunbook: number;
avgMttrMs: number | null;
};
export type RuleEvaluation = { firing: boolean; observed: number | null; windowSize: number };
export function aggregateValues(values: number[], mode: Aggregate): number | null {
if (values.length === 0) {
return null;
}
if (mode === "max") {
return Math.max(...values);
}
if (mode === "min") {
return Math.min(...values);
}
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
export function compare(observed: number, comparison: Comparison, threshold: number): boolean {
if (comparison === ">") {
return observed > threshold;
}
if (comparison === ">=") {
return observed >= threshold;
}
if (comparison === "<") {
return observed < threshold;
}
return observed <= threshold;
}
export function evaluateRule(rule: AlertRule, samples: Sample[], now: number): RuleEvaluation {
const from = now - rule.windowMs;
const windowValues = samples.filter((s) => s.at >= from && s.at <= now).map((s) => s.value);
const observed = aggregateValues(windowValues, rule.aggregate);
if (observed === null) {
return { firing: false, observed: null, windowSize: 0 };
}
return { firing: compare(observed, rule.comparison, rule.threshold), observed, windowSize: windowValues.length };
}
export function fingerprint(ruleId: string, labels: Record<string, string>): string {
const pairs = Object.keys(labels).sort().map((key) => `${key}=${labels[key]}`);
return `${ruleId}|${pairs.join(",")}`;
}
export function createAlert(
rule: AlertRule,
labels: Record<string, string>,
firedAt: number,
id?: string,
): Alert {
const key = fingerprint(rule.id, labels);
return {
id: id ?? `${key}@${firedAt}`,
ruleId: rule.id,
fingerprint: key,
severity: rule.severity,
owner: rule.owner,
runbookId: rule.runbookId,
labels: { ...labels },
firedAt,
status: "firing",
};
}
function mergeStatus(current: AlertStatus, incoming: AlertStatus): AlertStatus {
if (current === "firing" || incoming === "firing") {
return "firing";
}
if (current === "acked" || incoming === "acked") {
return "acked";
}
return "resolved";
}
export function dedupe(alerts: Alert[], windowMs = 5 * 60 * 1000): AlertGroup[] {
const sorted = [...alerts].sort((a, b) => a.firedAt - b.firedAt || a.id.localeCompare(b.id));
const groups: AlertGroup[] = [];
for (const alert of sorted) {
const windowStart = alert.firedAt - windowMs;
let target: AlertGroup | undefined;
for (let i = groups.length - 1; i >= 0; i -= 1) {
const group = groups[i];
if (group.fingerprint === alert.fingerprint && group.firstAt >= windowStart) {
target = group;
break;
}
if (group.firstAt < windowStart) {
break;
}
}
if (!target) {
groups.push({
fingerprint: alert.fingerprint,
ruleId: alert.ruleId,
severity: alert.severity,
owner: alert.owner,
count: 1,
firstAt: alert.firedAt,
lastAt: alert.firedAt,
status: alert.status,
ids: [alert.id],
});
continue;
}
target.count += 1;
target.lastAt = alert.firedAt;
target.status = mergeStatus(target.status, alert.status);
target.ids.push(alert.id);
if (alert.severity === "critical") {
target.severity = "critical";
}
}
return groups.sort((a, b) => a.firstAt - b.firstAt || a.fingerprint.localeCompare(b.fingerprint));
}
export function routeOwner(labels: Record<string, string>, routes: Route[], fallback: string): string {
let bestOwner = fallback;
let bestScore = -1;
for (const route of routes) {
const keys = Object.keys(route.match);
const matched = keys.every((key) => labels[key] === route.match[key]);
if (matched && keys.length > bestScore) {
bestScore = keys.length;
bestOwner = route.owner;
}
}
return bestOwner;
}
export function runbookFor(rule: AlertRule, runbooks: Runbook[]): Runbook | undefined {
return runbooks.find((runbook) => runbook.id === rule.runbookId);
}
export function acknowledge(alert: Alert, at: number): Alert {
if (alert.status !== "firing") {
throw new Error(`告警 ${alert.id} 当前状态为 ${alert.status},不能确认`);
}
return { ...alert, status: "acked", ackedAt: at };
}
export function resolve(alert: Alert, at: number): Alert {
if (alert.status === "resolved") {
throw new Error(`告警 ${alert.id} 已经关闭`);
}
return { ...alert, status: "resolved", resolvedAt: at };
}
export function unclosedAlerts(alerts: Alert[], now: number, slaMs: number): Alert[] {
return alerts
.filter((alert) => alert.status !== "resolved" && now - alert.firedAt >= slaMs)
.map((alert) => ({ ...alert, unclosed: true }));
}
export function loopReport(alerts: Alert[], now: number, slaMs: number): LoopReport {
const resolved = alerts.filter((alert) => alert.status === "resolved" && alert.resolvedAt !== undefined);
const unclosed = unclosedAlerts(alerts, now, slaMs).length;
const durations = resolved.map((alert) => (alert.resolvedAt as number) - alert.firedAt);
return {
total: alerts.length,
open: alerts.filter((alert) => alert.status !== "resolved").length,
resolved: resolved.length,
unclosed,
missingRunbook: alerts.filter((alert) => !alert.runbookId).length,
avgMttrMs: durations.length === 0 ? null : durations.reduce((sum, value) => sum + value, 0) / durations.length,
};
}
fingerprint 把标签按键名排序后拼接,保证标签顺序不影响结果。dedupe 在时间窗口内按指纹合并,保留首次时间、最后时间、次数与状态。合并后的状态取“最严重”:只要有一条还在 firing,整组就是 firing;严重度只要出现过 critical,整组升级为 critical。
路由解决“发给谁”。规则里写默认 owner,路由表按标签覆盖:
| 标签匹配 | 优先级 | owner |
|---|---|---|
app=checkout、region=cn | 2(更具体) | cn-checkout |
app=checkout | 1 | checkout-team |
| 无匹配 | 0 | 默认值班 |
优先级按匹配键的数量计算,键越多越具体;同具体度按声明顺序取第一个。这个规则简单,但必须写进文档,否则两个人对“谁更具体”会有不同理解。
从规则评估到闭环报告
规则评估只做一件事:evaluateRule 取窗口内的样本,按聚合方式算出一个值,再与阈值比较。
窗口是左闭右闭:sample.at >= now - windowMs 且 sample.at <= now。没有样本时返回 firing: false,而不是“保持上一次状态”。这一点是刻意的:没有数据通常意味着采集链路断了,应该有单独的“无数据告警”,而不是让旧告警一直挂着。
createAlert 生成告警时复制标签,避免调用方后续修改影响已发出的告警。acknowledge 与 resolve 都返回新对象,不原地修改,状态流转才能被审计。
闭环报告 loopReport 把分散的状态汇总成几个数,其中未闭环判定由 unclosedAlerts 完成。
unclosedAlerts 只统计未关闭且超过 SLA 的告警,并打上 unclosed: true。loopReport 再汇总总数、未关闭数、已关闭数、缺失 runbook 数与平均 MTTR。MTTR 只统计已关闭的告警;没有已关闭告警时返回 null,而不是 0——0 会被误读成“恢复得很快”。
测试与真实输出
✔ 平均值聚合 (0.559709ms)
✔ 最大值与最小值聚合 (0.062042ms)
✔ 空样本返回 null (0.047917ms)
✔ 窗口外的样本不参与计算 (0.100833ms)
✔ 阈值比较支持四个方向 (0.061292ms)
✔ 聚合值超过阈值时触发 (0.068625ms)
✔ 没有样本时不触发 (0.385959ms)
✔ 指纹与标签顺序无关 (0.077791ms)
✔ 不同规则产生不同指纹 (0.078625ms)
✔ createAlert 继承规则并复制标签 (0.1235ms)
✔ 同一指纹在窗口内合并为一条 (0.143417ms)
✔ 超出窗口后另起一组 (0.093583ms)
✔ 聚合组状态优先保留 firing (0.06975ms)
✔ 聚合组严重度升级为 critical (0.053167ms)
✔ 路由选择最具体的匹配 (0.081333ms)
✔ 没有匹配时回落到默认 owner (0.04225ms)
✔ 同具体度按声明顺序取第一个 (0.035ms)
✔ 规则可以映射到 runbook (0.057833ms)
✔ 未知 runbook 返回 undefined (0.037042ms)
✔ 确认告警进入 acked 并记录时间 (0.054709ms)
✔ 重复确认被拒绝 (0.187875ms)
✔ 关闭告警并计算 MTTR (0.093459ms)
✔ 重复关闭被拒绝 (0.046542ms)
✔ 超过 SLA 未关闭的告警被标记 (0.0485ms)
✔ 闭环报告汇总未闭环与缺失 runbook (0.041584ms)
✔ 没有已关闭告警时 MTTR 为 null (0.030209ms)
ℹ tests 26
ℹ suites 0
ℹ pass 26
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 122.132208
26 个用例重点覆盖两组边界:
- 窗口边界。 窗口外的旧样本不参与计算,没有样本时不触发。
- 聚合边界。 同一指纹在窗口内合并、超出窗口另起一组,状态与严重度按“最严重”升级;重复确认、重复关闭都会抛出。
判据与反例
判断告警是否形成闭环,看五条:
| 判据 | 通过标准 | 失败表现 |
|---|---|---|
| 可去重 | 同规则同标签只有一条主告警 | 一次故障刷屏几十条 |
| 可聚合 | 有首次、最后、次数 | 不知道故障持续多久 |
| 可路由 | 能落到具体 owner | 发给“全体”没人认领 |
| 可处置 | runbook 可打开且步骤可执行 | 链接 404 或指向首页 |
四个反例:
反例一:用告警 id 去重。 每次触发都生成新 id,指纹永远不同,去重形同虚设。指纹必须由规则与标签决定,与触发次数无关。
反例二:聚合窗口大于响应 SLA。 窗口设成 30 分钟,SLA 是 5 分钟,告警还没聚合完就已经超时。窗口应该小于或等于响应 SLA。
反例三:owner 写到团队为止。 团队名不是值班人,消息发出去没人有义务处理。owner 必须能映射到具体的 on-call 轮值。
反例四:runbook 只写“检查服务”。 没有具体命令、没有判断标准、没有回滚步骤,等于没有 runbook。每一步都应该能被执行和验证。
小结
从告警到定位的闭环,核心不是报警渠道,而是五个可计算的对象:规则、指纹、聚合组、owner、状态。
- 指纹由规则与标签决定,不能包含触发时间。
- 聚合要在时间窗口内合并,状态与严重度取最严重。
- 路由按匹配具体度选择 owner,同具体度按声明顺序。
- runbook 要能打开、能执行,缺失要计数。
- 未闭环按 SLA 标记,MTTR 只统计已关闭告警。
把这五个对象写进同一套结构,告警才从“消息”变成“工单”。
阅读导航:上一节:7.2 采样、脱敏与成本 · 下一节:8.1 数据分级与最小权限 。
常见错误与边界
- 把告警数量当健康度。 告警多不代表监控好,可能只是阈值太敏感。应该看有效告警率与闭环率。
- 去重键包含版本号。 版本号放进指纹会让一次发布产生大量“新告警”,掩盖真正的回归。版本应作为标签参与路由,而不是拆散指纹。
- 聚合后丢失原始标签。 合并成一条告警后,仍要保留触发的地区、版本列表,否则定位时还要回去翻原始消息。
- 路由规则循环覆盖。 多条规则匹配同一标签集时,必须有确定的优先级;没有优先级,不同实现会给出不同 owner。
- runbook 与规则脱节。 规则改了指标或阈值,runbook 还在讲旧指标。runbook 应该和规则放在同一个仓库,一起评审。
- 自动关闭没有证据。 指标短暂回落就自动关闭,故障可能马上复发。自动关闭至少要满足“连续 N 个窗口正常”,并保留关闭原因。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。