面试知识库

面试 Q&A — 告警风暴治理#

本文档覆盖:告警风暴的 5 层递进消化架构(限流→归一分组→任务去重→队列削峰→并发槽), 附真实压测数据(2026-06-30, burst 300 QPS)。


零、前置知识:Alertmanager 是什么#

Alertmanager 是 Prometheus 生态的告警路由组件,处在 Prometheus → Alertmanager → 接收方(本系统) 这条链路的中间。Prometheus 负责采集指标、评估告警规则,一旦规则触发就把 firing 告警推给 Alertmanager;Alertmanager 负责对这些告警做分组、去重、静默、路由,再通过 webhook 推给下游。

几个贯穿全文的关键概念:

概念含义举例
alertname告警规则的名称HighCpuUsagePodCrashLooping
labels附在告警上的键值对,区分不同实例/服务{instance="node-03", service="api-gw", severity="warning"}
fingerprintAlertmanager 根据 alertname + labels 全集算出的哈希,唯一标识一条告警a3c7e2f0b1d9...
receiver路由目标的名称,决定告警推给谁oncall-sreinfra-team
group_byAlertmanager 的分组规则,把同类告警合并成一批推送group_by: [alertname, service] → 同一服务的同名告警只推一次
groupKey一次分组推送的标识,由 group_by 的 label 值拼成{}/alertname=HighCpuUsage
group_wait / group_interval分组等待时间 / 同组重复推送间隔group_wait: 30s(新组等 30s 再推)、group_interval: 5m(同组每 5 分钟重推一次)

为什么理解这些很重要:本系统的去重和分组逻辑大量复用了 Alertmanager 已有的结构——fingerprint 用来判断”是不是同一条告警”,groupKey 用来判断”是不是同一类故障”,receiver 用来做来源级限流。不了解这些字段的含义,后面的幂等键和关联键就看不懂为什么这么拼。


一、整体设计#

Q1: 能举个具体例子,说一下怎么治理告警风暴的?#

一台机器 node-03 CPU 持续飙高,Alertmanager 配了 HighCpuUsage 规则。由于评估间隔 15s + group_wait/group_interval,5 分钟内同一个 group 推了 75 条 firing 告警。它们的 alertnameinstancereceivergroupKey 完全一样,只有 startsAt 和采样值不同。

系统用 5 层递进消化,逐层把 75 条收敛成 1 个诊断任务:

  1. 限流:两道 Redis Lua 固定窗口计数器(单 IP 50/s、单 receiver 500/min)。正常 Alertmanager 频率远低于阈值,75 条全部放行。但如果有人配错 group_interval=0s 刷接口,这层直接 429。
  2. 告警去重:每条告警算出幂等键 {receiver}:{groupKey}:{fingerprint}:{5分钟时间桶}:{status}。同一桶内 key 完全一致,入库 ON CONFLICTseen_count += 1。75 条 → DB 里 1 行
  3. 事件分组:用 alertmanager:{receiver}:{groupKey} 做关联键,SHA256 取前 24 位得到确定性 incident_group_id——任何 API 进程算出同样的值,无需查 DB。75 条 → 1 个事件组
  4. 任务去重:Postgres 部分唯一索引 UNIQUE (dedup_key) WHERE status IN ('pending','running')INSERT ON CONFLICT 原子操作。第 1 条告警建任务入队;第 2-75 条只 repeat_count += 1,不建新任务。75 次创建 → 1 个诊断任务
  5. 并发槽:Worker 用 Redis ZSET 分布式槽位(上限 32),读队列前先抢槽,满了退避等待。即使 100 个任务排队,也不会同时打爆 LLM 配额。
机制75 条进来出去多少
L1 限流Redis Lua 固定窗口75 条 HTTP POST75 条(未超限)
L2 告警去重idempotency_key + ON CONFLICT75 条告警DB 里 1 行
L3 事件分组correlation_key → SHA25675 条告警1 个 incident_group
L4 任务去重部分唯一索引 + xmax=075 次创建1 个诊断任务入队
L5 并发控制Redis ZSET 分布式槽位1 个任务≤32 并发保护

压缩比 75:1。核心思路:每层用不同粒度的唯一键做幂等,从 IP 级 → 告警级 → 事件组级 → 任务级逐层收敛。

附加细节:入口就用便宜的告警特征做 fast/deep 预判(不调 LLM)——一个 payload 里告警数 ≥ 10(告警风暴),或一组 firing 告警按 alertname→故障域 静态表覆盖了 ≥2 个故障域(跨域联动),都跳过 fast 直接走 deep,避免在明显复杂/大范围的场景里浪费 30 秒的 fast 尝试。跨域判断细节见 03-agent-orchestration.md Q5 的 L0 部分。

Q2: 5 层分别保护什么资源?合并成 1-2 层不行吗?#

每层保护的资源不同:

保护的资源如果去掉会怎样
IP 限流API 进程 CPU/内存一个配错的 Alertmanager 能把 API 打满
归一分组Postgres 写入量 + 队列深度200 条告警建 200 个任务,队列爆炸
任务去重Worker 吃到的任务数同一个故障被诊断几十次,浪费 LLM 配额
队列削峰API 响应延迟API 进程内跑诊断,一个慢任务拖死整个接口
并发槽LLM RPM/TPM 配额32 个 Worker 同时调 LLM,超配额被限流

5 层看起来多,但每层的实现都很薄:限流是 7 行 Lua,分组是一个 SHA256 哈希,去重是一个 ON CONFLICT,削峰是一个 XADD,并发槽是 6 行 Lua。复杂度在设计不在实现。

Q3: 你说”逐层缩减流量”,能给个具体数字吗?#

以实际压测的 burst 场景(300 QPS × 8 秒 = 2400 条告警)为例:

第 1 层 (限流):    2400 → 464 条通过 (80.7% 被 429 拦截)
第 2 层 (分组):     464 → ~50 个 incident_group (相同 groupKey/fingerprint 归组)
第 3 层 (去重):      50 → ~50 个诊断任务 (每组最多 1 个活跃任务)
第 4 层 (入队):      50 个任务进 Redis Streams, API 在 P50 4ms 内返回
第 5 层 (并发槽):   32 个同时执行, 其余排队等槽
plaintext

从 2400 条告警到 32 个并发诊断,压缩比 75:1


二、第 1 层:限流#

Q4: 限流策略具体是什么?为什么设两道?#

# webhook.py:112-121 — 两道限流
await rate_limiter.enforce("webhook_key", identity, 50, 1)    # 道 1: 单 IP/Key 50/sec
await rate_limiter.enforce("webhook_src", source, 500, 60)    # 道 2: 单 source 500/min
python

两道卡不同维度:

  • 第 1 道 (IP/Key): 防单个 Alertmanager 实例疯狂重推。identity 优先取 X-API-Key 头,没有就用 IP。
  • 第 2 道 (receiver): 防单个告警源(如 infra-team receiver)在正常推送频率下也能打满系统。多个 Alertmanager 实例共享一个 receiver name 时,靠这道卡总量。

两道都过了才进业务逻辑。任何一道超限直接 429 + Retry-After 头。

Q5: 429 的时候告警不就丢了吗?#

不会丢。Alertmanager 自带重试机制——收到非 2xx 会按 repeat_interval 重推。429 响应里的 Retry-After 告诉它等多久重试。

而且被 429 的大多是短时间内的重复告警。Alertmanager 的 group_wait / group_interval 已经做了第一层聚合,到我们这里被 429 的大概率是同一批告警的重复推送。真正新出现的告警在下一个窗口就能通过。

Q6: 压测数据怎么验证限流效果的?#

burst 模式:10 QPS 平稳 → 300 QPS 突发 8 秒 → 10 QPS 回落:

阶段QPS成功率P99429 数
预热10100%43ms0
突发30019.3%30ms1,936
回落10100%43ms0

三个关键结论:

  1. 精准:正常流量全放行,超额全拦截,无误杀。
  2. 快速:被拦的请求 P99 仅 30ms,不浪费后端资源。
  3. 可恢复:突发结束后立即恢复 100%,系统没被打出亚健康。

三、第 2 层:告警归一化与关联分组#

Q7: 告警分组的逻辑是什么?200 条告警怎么变成 5 个组?#

两种分组策略,优先用 Alertmanager 原生的 groupKey:

# repository.py:125-133
def _correlation_key(payload, alert):
    if alert.group_key:
        # 策略 1: Alertmanager 已按 group_by 聚合, 直接用
        return f"alertmanager:{alert.receiver}:{alert.group_key}"
    # 策略 2: 按 集群+命名空间+服务+时间窗 自主聚合
    return f"window:{cluster}:{namespace}:{service}:{bucket}"
python

策略 1(优先):Alertmanager 的 group_by 规则把同一组告警发在一个 payload 里,groupKey 是它的聚合标识。例如按 alertname + service 分组,同一个服务的同名告警共享一个 groupKey。

策略 2(兜底):如果告警没有 groupKey(非 Alertmanager 来源,或者配置不全),就按 集群 + 命名空间 + 服务 + 时间窗 聚合。时间窗是把 startsAt 截断到一个固定窗口(如 5 分钟),同一个服务在同一窗口内的告警归为一组。

为什么用 SHA256 做 ID

incident_group_id = _stable_id("ig", correlation_key)  # SHA256(correlation_key)[:24]
python

同一个 correlation_key 无论到达多少次、从哪个 API 进程进来,算出的 group_id 都一样。不需要先查数据库再决定归属,避免了分布式场景下的竞态。

Q8: 面试追问:如果分组分得太粗(把不相关的告警归到一起)怎么办?#

这是一个设计取舍:

  • 分太粗:两个不同故障被归成一组 → 只建一个诊断任务 → 可能漏诊一个故障
  • 分太细:同一个故障被拆成 N 组 → 建 N 个诊断任务 → 浪费 LLM 调用

我的选择是 宁粗勿细

  1. Alertmanager 的 group_by 规则是运维团队自己配的,他们最了解告警之间的关联。
  2. 兜底策略按 service + 时间窗 分组,同一个服务 5 分钟内的告警大概率是同一故障。
  3. 即使偶尔误合,诊断 Agent 看到的 query 文本里包含所有告警信息,不会真的漏掉。

如果要做精确分组,可以加一层 LLM 语义聚类——但那本身就是一次 LLM 调用,在告警风暴时不划算。

Q9: 告警归一化具体做了什么?#

# repository.py — _normalize_alert()
NormalizedAlert(
    id=_stable_id("alert", f"{fingerprint}:{starts_at}"),  # 确定性 ID
    idempotency_key=f"{fingerprint}:{starts_at}",          # 幂等 key
    fingerprint=fingerprint,      # Alertmanager 的告警指纹
    alertname=alert.labels["alertname"],
    severity=alert.labels["severity"],
    service=alert.labels.get("service", ""),
    instance=alert.labels.get("instance", ""),
    ...
)
python

核心是把 Alertmanager 的自由格式 labels/annotations 转成统一的结构体。idempotency_key = fingerprint:startsAt 保证同一条告警(相同指纹+相同触发时间)无论推多少次都产生相同 ID,数据库 ON CONFLICT 时直接跳过。


四、第 3 层:任务级去重#

Q10: 同一组告警怎么保证只建一个诊断任务?#

用 Postgres 的部分唯一索引 + INSERT ON CONFLICT

-- 索引: 同一 dedup_key 只允许存在一个 pending 或 running 状态的任务
CREATE UNIQUE INDEX idx_diagnosis_task_dedup_active
  ON diagnosis_tasks (dedup_key)
  WHERE status IN ('pending', 'running');
sql
-- 插入: 有活跃任务则只更新 repeat_count, 不建新任务
INSERT INTO diagnosis_tasks (..., dedup_key) VALUES (..., $incident_group_id)
ON CONFLICT (dedup_key) WHERE status IN ('pending', 'running')
DO UPDATE SET
    repeat_count = diagnosis_tasks.repeat_count + 1,
    last_seen_at = now()
RETURNING id, (xmax = 0) AS inserted
sql
  • dedup_key = incident_group_id:同一个告警组共享一个 dedup_key
  • xmax = 0 判断是否是新插入:是 → task_created=True → 入队;否 → 只更新 repeat_count,不入队
  • 当任务结束(状态变为 succeeded / failed),索引不再覆盖,后续同组告警可以建新任务

Q11: 为什么不用 SELECT + INSERT 两步操作?(高频追问)#

经典的并发竞态:

时间线:
  告警 A: SELECT → 没有活跃任务 → INSERT (task-1)
  告警 B: SELECT → 没有活跃任务 → INSERT (task-2)  ← 两个任务!
plaintext

两条告警几乎同时到达,都看到”没有活跃任务”,各建了一个。结果同一个故障被诊断两次。

INSERT ON CONFLICT 在数据库层原子完成 “有则更新/无则插入”,由唯一索引保证互斥,不存在竞态窗口。

Q12: 部分唯一索引是什么?为什么不用普通唯一索引?(高频追问)#

普通唯一索引 UNIQUE (dedup_key) 意味着一个 dedup_key 永远只能有一行。但我的需求是:

  • 同一 dedup_key 的 pending/running 任务最多一个(正在处理中的)
  • 同一 dedup_key 可以有多个 succeeded/failed 的历史任务(已处理完的)

部分唯一索引 UNIQUE (dedup_key) WHERE status IN ('pending', 'running') 只对符合条件的行建索引,完美匹配这个需求。

这是 Postgres 特有的能力,MySQL 不支持。如果用 MySQL,要么加一列 is_active + 普通唯一索引 (dedup_key, is_active),要么在应用层加分布式锁。

Q13: repeat_count 有什么用?#

两个用途:

  1. 可观测性repeat_count=50 说明这个故障在诊断期间又触发了 50 次告警,风暴还在持续。运维可以在诊断结果页看到这个数字,判断风暴规模。
  2. 诊断质量提示:如果 repeat_count 很高但诊断结果没提到相关告警,可能说明分组不够准确,有些告警被错误归组了。

五、第 4 层:队列削峰与优先级#

Q14: 为什么要用队列?直接在 API 进程里跑诊断不行吗?#

不行。诊断一次要 30 秒到 5 分钟(Planner → Executor × N 轮 → Reporter + LLM + RAG + MCP 工具),如果在 API 进程里同步跑:

uvicorn worker-1: 接到诊断请求 → 跑 3 分钟 → 这 3 分钟内该 worker 不能处理其他请求
4 个 worker 同时卡住 = API 完全不可用
plaintext

所以 submit 接口只做 “校验 → 落库 → 入队 → 返回 task_id”,P50 仅 13ms。真正的诊断推给独立的 Worker 进程。

这也是同步 SSE 入口 (/diagnose) 需要并发槽控制的原因——它确实在 API 进程内跑诊断,但被槽限死了数量(最多 16 个),不会拖垮整个 API。

Q15: 4 级优先级是怎么做的?critical 告警真的能插队吗?#

用 4 条独立的 Redis Stream,Worker 按严格优先级顺序消费:

# redis_streams.py:184-195 — 非阻塞优先级 pass
for stream in [critical_stream, high_stream, normal_stream, low_stream]:
    rows = await client.xreadgroup(stream, count=1, block=None)  # 非阻塞
    if rows:
        return rows  # 有就立即返回, 不看低优先级
# 全空 → BLOCK 等待所有 stream
python

假设队列里有 100 个 info 级告警在排队,这时一条 critical 告警进来:

  1. critical 被 XADD 到 aiops:incident:critical stream
  2. Worker 下一轮先扫 critical stream → 立即拿到这条 → 不会去看 normal/low stream
  3. 100 个 info 继续等

不是”把 critical 插到队头”,而是”Worker 总是先看 critical 队列”。

Q16: 为什么不用单队列 + priority 字段排序?#

Redis Streams 不支持按字段排序消费——XREADGROUP 只能按消息 ID(时间序)读。如果用单队列,要实现优先级就得:

方案 A:客户端全量读出来排序 → O(N) 内存,队列深了就爆 方案 B:用 Redis Sorted Set 代替 Stream → 失去 consumer group、ACK、pending 等消费语义 方案 C:多队列分优先级 → 就是我的做法

多队列的代价是 Worker 要扫多条 stream,但只有 4 条,非阻塞 XREADGROUP 每条 < 1ms,总开销 < 4ms,完全可接受。

Q17: 队列满了怎么办?有限长吗?#

# redis_streams.py — enqueue_task
message_id = await client.xadd(
    stream, fields={...},
    maxlen=settings.incident_queue_maxlen,  # 默认 10000
    approximate=True,                        # 近似裁剪, 性能更好
)
python

每条 stream 用 MAXLEN ~10000 做近似裁剪——超过后 Redis 自动丢弃最旧的消息。approximate=True 让 Redis 不用每次 XADD 都精确计数,性能更好。

10000 条消息 × 4 条 stream = 最多缓冲 40000 个任务。按 3 个 Worker 每分钟消化 3-6 个任务算,可以缓冲几个小时的风暴。如果几个小时还没消完,说明需要加 Worker 或者故障本身就需要人工介入了。


六、第 5 层:并发槽#

Q18: 并发槽在告警风暴里起什么作用?#

队列能缓冲任务,但不能限制 Worker 的消费速度。如果 8 个 Worker 同时从队列拿任务、同时调 LLM:

8 Worker × 6 次 LLM 调用/诊断 = 48 RPM
如果都是 deep 模式: 8 × 15 次 = 120 RPM ← 可能超百炼配额
plaintext

并发槽限制的是 “正在跑诊断的 Worker 数量”,而不是 “Worker 进程数量”:

# diagnosis_worker.py:57-83
async with distributed_slot("worker_diagnosis", limit=32, wait=False):
    await self.handle_message(message_id, item)
# 满了 → DistributedLimitBusy → 退避 0.5s, 不读新任务
python

Worker 必须先抢到槽才能读队列、执行诊断。满了就等,而不是疯狂消费。

Q19: Worker 先抢槽再读队列,还是先读队列再抢槽?(高频追问)#

先抢槽再读(当前实现)。原因:

如果先读再抢,Worker 已经 XREADGROUP 拿到了消息(进入 pending),但抢不到槽就不能执行。消息卡在 pending 里,其他 Worker 看不到,也没人处理。要么手动 XAUTOCLAIM 回收,要么等 pending 超时,都增加复杂度。

先抢槽再读:没槽就不读,消息留在 stream 里谁都能拿。简单可靠。


七、全链路数据流#

Q20: 一条告警从 Alertmanager 到被诊断,完整经过哪些步骤?#

Q21: 这个链路里最慢的环节是哪个?#

分两段看:

入账阶段(API 内,同步):最慢的是 Postgres 事务(5-10ms),总 P50 约 13ms。瓶颈不在这。

诊断阶段(Worker 内,异步):最慢的是 LLM 调用,单次 2-30 秒。一个 deep 模式诊断可能调 15 次 LLM,总耗时 2-5 分钟。这就是为什么必须异步、必须队列、必须并发槽——不能让这个耗时拖住 API。


八、设计取舍#

Q22: 这套方案最大的风险是什么?(高频追问)#

Redis 单点。限流、队列、并发槽全部依赖同一个 Redis。Redis 挂了:

  • 限流 fail-open → 放行所有请求
  • 入队失败 → API 返回 500
  • Worker 读不到任务 → 停止消费

短期内等于系统瘫痪。生产环境必须上 Redis Sentinel 或 Cluster。

Q23: 告警风暴时,队列深度一直涨怎么办?#

/api/v1/queue/status 的 depth 和 by_level:

{
  "depth": 215,
  "by_level": {"critical": 50, "high": 56, "normal": 53, "low": 56},
  "pending": 3,
  "slots": {"worker_diagnosis": {"used": 3, "limit": 32}},
  "alive_workers": 3
}
json

判断逻辑:

  • depth 在涨但 pending > 0 → Worker 在消化,只是速度跟不上 → 加 Worker 进程
  • slots.used < limitpending = 0 → Worker 有空闲但没去读 → 检查 Worker 日志
  • slots.used = limit → 并发满了 → 提高 WORKER_DIAGNOSIS_CONCURRENCY(前提是 LLM 配额够)
  • alive_workers = 0 → Worker 全挂了 → 紧急重启

Q24: 如果重来,你会改什么?(高频追问)#

  1. 加告警抑制层:在分组之上加一层 parent-child 抑制,交换机告警抑制其下所有节点告警,进一步减少不必要的诊断。
  2. 动态限流:目前限额是静态配置的。可以根据队列深度动态调整——队列快满时收紧限流,队列空了放宽。
  3. 分组策略可配置:目前分组逻辑是硬编码的。不同团队对”什么告警该归一组”有不同理解,应该开放为规则引擎。
  4. Webhook 批量入库:目前 for 循环逐条 alert 做 Postgres 事务,告警数多时可以改成 batch insert 减少事务次数。

九、和业界方案的对比#

Q25: PagerDuty / OpsGenie 是怎么做告警风暴治理的?你的方案有什么不同?#

维度PagerDuty/OpsGenie本项目
聚合基于规则的 alert grouping (时间窗+标签)类似,但多一层 Postgres 去重
抑制Suppression rules (父级告警抑制子级)未实现
限流平台内部限流,对外透明显式 429 + Retry-After
诊断人工 oncall + runbook自动化 LLM 诊断(核心差异)
队列内部消息系统Redis Streams 4 级优先级

最大差异是 诊断是自动化的。PagerDuty 的风暴治理目标是”减少 oncall 的 alert fatigue”——聚合后推一条通知给人。本项目的目标是”每个 incident_group 自动跑一次 LLM 诊断出根因”,所以需要更严格的流量控制(LLM 配额是真金白银)。

缺失的部分:告警抑制(parent alert suppresses child alerts)。比如”交换机挂了”应该抑制其下所有节点的告警。目前靠 Alertmanager 自带的 inhibit_rules 做,系统内部没有独立抑制层。

Q26: 和纯队列方案(比如直接用 Kafka)比,你的优势在哪?#

纯 Kafka 方案:

Alertmanager → Kafka topic → Consumer group → 诊断
plaintext

问题:

  1. 没有任务级去重:100 条告警消费 100 次诊断,浪费 LLM。Kafka 做去重要么用 exactly-once(需要事务),要么自己在消费端去重。
  2. 优先级不原生:Kafka 不支持优先级消费,要开 N 个 topic + 调整 consumer 权重,手动做。
  3. 运维成本:额外部署 Kafka + ZooKeeper/KRaft,对日均 5k-20k 告警的场景是大炮打蚊子。

本项目的方案:Postgres 做去重和事实源 + Redis Streams 做缓冲队列。共享已有的 Redis(限流/缓存也用它),零额外部署。