《互联网产品的客户端架构·规模化与架构演进》7.3 从告警到定位的闭环

一次故障刷出三百条告警,值班同学先花二十分钟分辨哪些是同一件事。本节把告警做成状态机与可计算对象:规则字段、指纹去重、窗口聚合、按具体度路由 owner、runbook 映射与未闭环标记,并用闭环报告把 MTTR、缺失手册与超时告警汇总出来。

大促前夜,告警群里三百条消息

某应用在大促前夜接入了新的监控。零点刚过,值班群被三百多条消息刷屏:同一条 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=cn2(更具体)cn-checkout
app=checkout1checkout-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 个窗口正常”,并保留关闭原因。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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