文档目录

8.5 配套代码:告警规则与有效率复盘

对应小节:8.5 告警设计:燃烧率与领先指标 两部分:① 完整的告警规则(含 runbook);② 告警有效率复盘脚本。

一、完整的告警规则

# observability/alerts.yml
groups:

  # ═══════════════════════════════════════════════════════════
  # P0:page(立即叫人)
  # ═══════════════════════════════════════════════════════════
  - name: p0-burn-rate
    rules:
      - alert: ErrorBudgetBurnFast
        # 双窗口:长窗口抑制误报 + 短窗口保证灵敏
        expr: |
          slo:orders_availability:burn_rate_1h > 14.4
          and
          slo:orders_availability:burn_rate_5m > 14.4
        for: 2m
        labels:
          severity: page
          slo: orders-availability
        annotations:
          summary: "订单查询错误预算燃烧过快(1h + 5m 双窗口同时超标)"
          description: |
            当前燃烧率 {{ $value | humanize }}×,按此速度约
            {{ printf "%.1f" (div 30 (div 1 $value)) }} 天将耗尽 30 天预算。
          runbook: |
            【5 分钟内的动作】
            1. 打开 Grafana 的「SLO 与错误预算」看板,看「按接口分解」面板
               → 确定是哪个接口在消耗预算
            2. 查看最近 30 分钟的发布记录
               → 如果有发布,优先考虑回滚(见 runbook-rollback)
            3. 查看相邻的「饱和度」面板
               → 连接池 pending / 队列深度 / CPU 节流,哪一项在涨?
            4. 如果 5 分钟内无法定位 → 升级给值班负责人

  - name: p0-critical-unavailable
    rules:
      - alert: ServiceUnavailable
        expr: |
          sum(rate(http_requests_total{status!~"5.."}[2m]))
            / sum(rate(http_requests_total[2m])) < 0.9
        for: 2m
        labels:
          severity: page
        annotations:
          summary: "整体成功率低于 90%(持续 2 分钟)"
          runbook: |
            1. 确认是全局还是部分实例(看 per-instance 分解)
            2. 检查依赖:数据库可达?Redis 可达?下游可达?
            3. 检查最近的变更(发布/配置/DB 迁移)
            4. 考虑限流降级(保护核心链路)

  # ═══════════════════════════════════════════════════════════
  # P1:ticket(当天处理)
  # ═══════════════════════════════════════════════════════════
  - name: p1-leading-indicators
    rules:
      # ① 连接池排队 —— 最早的信号
      - alert: ConnectionPoolSaturated
        expr: max(db_pool_pending) > 5
        for: 10m
        labels:
          severity: ticket
        annotations:
          summary: "连接池排队(pending > 5 持续 10 分钟)"
          runbook: |
            ⚠️ 【先查慢查询,不要先加池】
            1. 查最贵的语句:
               psql -c "SELECT calls, mean_exec_time, total_exec_time, left(query,50)
                        FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;"
            2. 查长事务与锁等待:
               SELECT pid, now()-xact_start AS age, state, wait_event_type
               FROM pg_stat_activity WHERE xact_start IS NOT NULL
               ORDER BY age DESC LIMIT 10;
            3. 只有在【查询正常】且【并发确实高】时,才用 Little's Law 调整池大小
            详见第 6.5 节

      # ② 容器 CPU 节流 —— "本地好、上线慢"的头号原因
      - alert: ContainerCpuThrottled
        expr: |
          rate(container_cpu_cfs_throttled_periods_total{container="app"}[5m])
          / rate(container_cpu_cfs_periods_total{container="app"}[5m]) > 0.25
        for: 10m
        labels:
          severity: ticket
        annotations:
          summary: "容器被 CPU 配额节流 > 25%"
          runbook: |
            1. 确认 CPU limit:cat /sys/fs/cgroup/cpu.max
            2. 判断 CPU 需求是否合理(用火焰图找浪费,比如日志/序列化)
            3. 需求合理 → 提高 limit;需求不合理 → 优化代码
            详见第 4.8 节

      # ③ 线程池队列积压
      - alert: ExecutorQueueBacklog
        expr: executor_queue_depth > 100
        for: 10m
        labels:
          severity: ticket
        annotations:
          summary: "线程池队列积压 > 100"
          runbook: |
            1. 查线程在忙什么(是阻塞 IO 还是真的在算):
               jcmd <pid> Thread.print | grep -A3 "BLOCKED\|socketRead\|HikariPool"
            2. 如果是阻塞 IO → 考虑异步化或增加隔离
            3. 如果是真的在算 → 考虑扩容或优化算法
            详见第 6.5 节

      # ④ 错误预算消耗过半
      - alert: ErrorBudgetHalfConsumed
        expr: slo:orders_availability:budget_remaining < 0.5
        for: 1h
        labels:
          severity: ticket
        annotations:
          summary: "错误预算已消耗 50%(30 天窗口)"
          runbook: |
            1. 看「按接口分解」确定主要消耗来源
            2. 如果是缓慢消耗 → 安排优化
            3. 如果是突发消耗 → 按 ErrorBudgetBurnFast 的 runbook 处理
            4. 考虑暂停非必要的功能发布(第 8.4 节的预算策略)

      # ⑤ 容量余量不足
      - alert: CapacityHeadroomLow
        expr: |
          max_over_time(sum(rate(http_requests_total[5m]))[1h:5m])
            / on() group_left capacity_safe_limit > 0.8
        for: 30m
        labels:
          severity: ticket
        annotations:
          summary: "峰值 QPS 已达到安全容量的 80%"
          runbook: |
            1. 确认流量趋势(是临时高峰还是持续增长)
            2. 持续增长 → 启动扩容流程(第 8.7 节的容量表)
            3. 检查是否有异常流量(爬虫、攻击、重试风暴)

  # ═══════════════════════════════════════════════════════════
  # P2:log(定期 review,不打扰人)
  # ═══════════════════════════════════════════════════════════
  - name: p2-observe-only
    rules:
      - alert: GcPauseHigh
        expr: |
          histogram_quantile(0.99,
            sum by (le) (rate(jvm_gc_pause_seconds_bucket[5m]))) > 0.2
        for: 10m
        labels:
          severity: log
        annotations:
          summary: "GC 停顿 P99 > 200ms(仅记录)"
          runbook: |
            ⚠️ 先观察,不要立刻行动:
            1. 把 GC 停顿时间戳与 P99 尖刺做时间对齐(第 6.6 节)
            2. 对齐 → 升级为 ticket 并处理
            3. 不对齐 → 考虑删除此告警(它经常不是真问题)

      - alert: SlowRequestCountHigh
        expr: increase(http_request_duration_seconds_count{le="1.0"}[10m]) == 0
        for: 10m
        labels:
          severity: log
        annotations:
          summary: "10 分钟内没有请求慢于 1 秒(分布过于干净,可能是采样问题)"

      - alert: FileDescriptorUsageHigh
        expr: process_open_fds / process_max_fds > 0.7
        for: 30m
        labels:
          severity: log
        annotations:
          summary: "文件描述符使用率 > 70%(可能是泄漏的前兆)"
          runbook: "观察趋势:如果持续上升,按第 3.5 节的浸泡分析方法排查"

二、告警有效率复盘脚本

# tools/alert-review.py <ALERTMANAGER_API> [DAYS]
"""
统计每条告警的触发次数与有效率,用于校准阈值。

有效率 = 需要人行动的触发次数 / 总触发次数

用法:
    python3 tools/alert-review.py http://alertmanager:9093 30
"""
import json
import sys
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta


def fetch_alerts(alertmanager_url, days):
    """从 Alertmanager 拉取历史告警"""
    since = (datetime.now() - timedelta(days=days)).isoformat()
    url = f"{alertmanager_url}/api/v2/alerts?" + urllib.parse.urlencode({
        "active": "false",
        "silenced": "false",
        "inhibited": "false",
    })
    try:
        with urllib.request.urlopen(url, timeout=10) as r:
            return json.load(r)
    except Exception as e:
        print(f"⚠️  无法从 Alertmanager 拉取数据:{e}", file=sys.stderr)
        return []


def main(url, days):
    alerts = fetch_alerts(url, days)

    print("═" * 88)
    print(f"告警复盘(最近 {days} 天)")
    print("═" * 88)
    print()

    if not alerts:
        print("(没有拉到数据 —— 请确认 Alertmanager 地址,或改用日志/工单系统统计)")
        print()
        print("替代方案:手工统计")
        print("  1. 从告警渠道(Slack/PagerDuty)导出历史")
        print("  2. 统计每条告警的触发次数")
        print("  3. 人工标注哪些需要行动(有效)、哪些是误报")
        print("  4. 填入下面的表格")
        print()
        print_alerts_template()
        return

    by_name = defaultdict(lambda: {"total": 0, "severity": ""})
    for a in alerts:
        name = a.get("labels", {}).get("alertname", "?")
        by_name[name]["total"] += 1
        by_name[name]["severity"] = a.get("labels", {}).get("severity", "?")

    print(f"{'告警名':<40}{'级别':>10}{'触发次数':>10}   建议")
    print("-" * 88)
    for name, info in sorted(by_name.items(), key=lambda x: -x[1]["total"]):
        n = info["total"]
        if n > 30:
            advice = "⚠️ 触发太频繁 —— 检查阈值是否太紧"
        elif n > 10:
            advice = "观察:确认有效率"
        else:
            advice = "✅ 频率合理"
        print(f"{name[:38]:<40}{info['severity']:>10}{n:>10}   {advice}")

    print()
    print("═" * 88)
    print("下一步:人工标注有效率")
    print()
    print_alerts_template()


def print_alerts_template():
    print("""统计模板:

| 告警名 | 触发次数 | 有效次数 | 有效率 | 处理 |
| --- | --- | --- | --- | --- |
| ErrorBudgetBurnFast | | | | 保持 |
| ConnectionPoolSaturated | | | | |
| GcPauseHigh | | | | |
| ... | | | | |

判断标准:
  有效率 > 80%  → 保持(甚至可以升级级别)
  有效率 30%~80% → 检查阈值与 for 持续时间
  有效率 < 30%  → 放宽阈值、降级、或删除

调整手段(按优先级):
  ① 提高阈值(用第 5 章的噪声底线确定)
  ② 增加 for 持续时间(避免瞬时波动触发)
  ③ 改用双窗口(长窗口 + 短窗口)
  ④ 降级(page → ticket → log)
  ⑤ 删除(如果指标与真实问题不相关)

⚠️ 核心原则:宁可不告警,也不要每天误报。
   因为告警一旦被忽略,真实故障也会被一起忽略。""")
    print("═" * 88)


if __name__ == "__main__":
    url = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:9093"
    days = int(sys.argv[2]) if len(sys.argv) > 2 else 30
    main(url, days)

三、runbook 模板

每条告警都应该有一个 runbook——它决定了「值班同学能不能独立处理」。

<!-- runbooks/ConnectionPoolSaturated.md -->

# ConnectionPoolSaturated

## 这是什么
数据库连接池出现排队(`db_pool_pending > 5` 持续 10 分钟)。

## 为什么重要
`pending > 0` 说明**请求在等连接**——系统余量正在被耗尽。
即使当前延迟还达标,一旦流量再涨一点就会崩(排队的非线性,第 0 章 0.5 节)。

## 5 分钟内的动作

### 第一步:确认不是误报
```bash
curl -s localhost:8080/metrics | grep db_pool
# 期望看到 pending > 0;如果 pending = 0,可能是告警延迟,稍等

第二步:查慢查询(⭐ 不要先加池)

psql -c "SELECT calls, mean_exec_time, total_exec_time, left(query,50)
         FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;"
  • 有慢查询(mean 高或 total 大)→ 根因是查询慢,优化 SQL/加索引
  • 查询都快 → 继续第三步

第三步:查长事务与锁

psql -c "SELECT pid, now()-xact_start AS age, state, wait_event_type, left(query,50)
         FROM pg_stat_activity WHERE xact_start IS NOT NULL
         ORDER BY age DESC LIMIT 10;"
  • 有长事务 → 根因是事务范围过大,检查是否有忘记提交的事务
  • wait_event_type = Lock → 查阻塞链

第四步:只有在前面都正常时,才考虑调池

用 Little’s Law 计算:

所需连接 = 峰值 QPS × 单次查询 P99

不要做的事

  • ❌ 不要立刻加连接池 —— 那会把压力转移到数据库(第 7.6 节)
  • ❌ 不要重启服务(会丢失现场,且不能解决问题)

相关文档

  • 第 6.5 节:池化类瓶颈的诊断
  • 第 7.6 节:连接池大小的推导
  • 第 4.7 节:数据库与中间件观测

## 四、动手改造

| 改动 | 观察什么 |
| --- | --- |
| 用 `alert-review.py` 分析你的告警 | 找出触发最频繁的那些 |
| 给 `GcPauseHigh` 加 runbook 并按 runbook 处理一次 | 体会"有 runbook"与"没有 runbook"的差别 |
| 把 `ConnectionPoolSaturated` 的阈值从 5 改成 0 | 触发次数会上升——**验证"阈值要合理"** |
| 把某条告警从 page 降级为 ticket | 观察值班负担的变化 |
| 给每条告警写 runbook | 这是让告警可被独立处理的关键 |

## 五、这段代码的局限

- **`alert-review.py` 依赖 Alertmanager API**:如果告警走的是其他渠道(Slack、PagerDuty),需要改用它们的 API 或手工统计。
- **「有效率」需要人工标注**:脚本无法判断"这次触发是否需要行动"——**这是复盘的核心,必须人工做**。
- **runbook 需要随系统演进更新**:指标名、命令、阈值变化后,runbook 也要更新(否则会误导值班同学)。
- **`capacity_safe_limit` 需要作为一种指标上报**:它来自容量规划(第 8.7 节),需要手工配置。
- **告警规则里的 `slo:*` 指标依赖 recording rules**(第 8.4 节),必须先配置好。