面试 Q&A — 告警风暴治理#
本文档覆盖:告警风暴的 5 层递进消化架构(限流→归一分组→任务去重→队列削峰→并发槽), 附真实压测数据(2026-06-30, burst 300 QPS)。
零、前置知识:Alertmanager 是什么#
Alertmanager 是 Prometheus 生态的告警路由组件,处在 Prometheus → Alertmanager → 接收方(本系统) 这条链路的中间。Prometheus 负责采集指标、评估告警规则,一旦规则触发就把 firing 告警推给 Alertmanager;Alertmanager 负责对这些告警做分组、去重、静默、路由,再通过 webhook 推给下游。
几个贯穿全文的关键概念:
| 概念 | 含义 | 举例 |
|---|---|---|
| alertname | 告警规则的名称 | HighCpuUsage、PodCrashLooping |
| labels | 附在告警上的键值对,区分不同实例/服务 | {instance="node-03", service="api-gw", severity="warning"} |
| fingerprint | Alertmanager 根据 alertname + labels 全集算出的哈希,唯一标识一条告警 | a3c7e2f0b1d9... |
| receiver | 路由目标的名称,决定告警推给谁 | oncall-sre、infra-team |
| group_by | Alertmanager 的分组规则,把同类告警合并成一批推送 | 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 告警。它们的 alertname、instance、receiver、groupKey 完全一样,只有 startsAt 和采样值不同。
系统用 5 层递进消化,逐层把 75 条收敛成 1 个诊断任务:
- 限流:两道 Redis Lua 固定窗口计数器(单 IP 50/s、单 receiver 500/min)。正常 Alertmanager 频率远低于阈值,75 条全部放行。但如果有人配错
group_interval=0s刷接口,这层直接 429。 - 告警去重:每条告警算出幂等键
{receiver}:{groupKey}:{fingerprint}:{5分钟时间桶}:{status}。同一桶内 key 完全一致,入库ON CONFLICT只seen_count += 1。75 条 → DB 里 1 行。 - 事件分组:用
alertmanager:{receiver}:{groupKey}做关联键,SHA256 取前 24 位得到确定性incident_group_id——任何 API 进程算出同样的值,无需查 DB。75 条 → 1 个事件组。 - 任务去重:Postgres 部分唯一索引
UNIQUE (dedup_key) WHERE status IN ('pending','running'),INSERT ON CONFLICT原子操作。第 1 条告警建任务入队;第 2-75 条只repeat_count += 1,不建新任务。75 次创建 → 1 个诊断任务。 - 并发槽:Worker 用 Redis ZSET 分布式槽位(上限 32),读队列前先抢槽,满了退避等待。即使 100 个任务排队,也不会同时打爆 LLM 配额。
| 层 | 机制 | 75 条进来 | 出去多少 |
|---|---|---|---|
| L1 限流 | Redis Lua 固定窗口 | 75 条 HTTP POST | 75 条(未超限) |
| L2 告警去重 | idempotency_key + ON CONFLICT | 75 条告警 | DB 里 1 行 |
| L3 事件分组 | correlation_key → SHA256 | 75 条告警 | 1 个 incident_group |
| L4 任务去重 | 部分唯一索引 + xmax=0 | 75 次创建 | 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/minpython两道卡不同维度:
- 第 1 道 (IP/Key): 防单个 Alertmanager 实例疯狂重推。identity 优先取
X-API-Key头,没有就用 IP。 - 第 2 道 (receiver): 防单个告警源(如
infra-teamreceiver)在正常推送频率下也能打满系统。多个 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 | 成功率 | P99 | 429 数 |
|---|---|---|---|---|
| 预热 | 10 | 100% | 43ms | 0 |
| 突发 | 300 | 19.3% | 30ms | 1,936 |
| 回落 | 10 | 100% | 43ms | 0 |
三个关键结论:
- 精准:正常流量全放行,超额全拦截,无误杀。
- 快速:被拦的请求 P99 仅 30ms,不浪费后端资源。
- 可恢复:突发结束后立即恢复 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 调用
我的选择是 宁粗勿细:
- Alertmanager 的
group_by规则是运维团队自己配的,他们最了解告警之间的关联。 - 兜底策略按
service + 时间窗分组,同一个服务 5 分钟内的告警大概率是同一故障。 - 即使偶尔误合,诊断 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 insertedsqldedup_key = incident_group_id:同一个告警组共享一个 dedup_keyxmax = 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 有什么用?#
两个用途:
- 可观测性:
repeat_count=50说明这个故障在诊断期间又触发了 50 次告警,风暴还在持续。运维可以在诊断结果页看到这个数字,判断风暴规模。 - 诊断质量提示:如果 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 等待所有 streampython假设队列里有 100 个 info 级告警在排队,这时一条 critical 告警进来:
- critical 被 XADD 到
aiops:incident:criticalstream - Worker 下一轮先扫 critical stream → 立即拿到这条 → 不会去看 normal/low stream
- 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, 不读新任务pythonWorker 必须先抢到槽才能读队列、执行诊断。满了就等,而不是疯狂消费。
Q19: Worker 先抢槽再读队列,还是先读队列再抢槽?(高频追问)#
先抢槽再读(当前实现)。原因:
如果先读再抢,Worker 已经 XREADGROUP 拿到了消息(进入 pending),但抢不到槽就不能执行。消息卡在 pending 里,其他 Worker 看不到,也没人处理。要么手动 XAUTOCLAIM 回收,要么等 pending 超时,都增加复杂度。
先抢槽再读:没槽就不读,消息留在 stream 里谁都能拿。简单可靠。
七、全链路数据流#
Q20: 一条告警从 Alertmanager 到被诊断,完整经过哪些步骤?#
Alertmanager POST /webhook/alertmanager { alerts: [...] }
│
├─ [限流] rate_limiter.enforce × 2 (Lua eval, ~1ms)
│ └─ 超限 → 429 + Retry-After
│
├─ [遍历] for alert in payload.alerts:
│ ├─ [跳过] alert.status != "firing" → skip
│ ├─ [归一] _normalize_alert → NormalizedAlert
│ ├─ [分组] _correlation_key → "alertmanager:sre-oncall:..."
│ ├─ [ID] SHA256(correlation_key) → incident_group_id
│ │
│ ├─ [事务] BEGIN
│ │ ├─ UPSERT alerts (idempotency_key 幂等)
│ │ ├─ UPSERT incident_groups (correlation_key 唯一)
│ │ ├─ UPSERT incidents
│ │ ├─ INSERT incident_group_alerts (多对多关系)
│ │ ├─ UPDATE alert_count
│ │ └─ INSERT diagnosis_tasks ON CONFLICT (dedup_key)
│ │ ├─ 新建 → task_created=True
│ │ └─ 已有 → repeat_count++, task_created=False
│ ├─ COMMIT
│ │
│ └─ [入队] if task_created:
│ └─ XADD aiops:incident:{level} (按 severity 选 stream)
│
└─ [返回] 200 { accepted: [...], skipped: [...] } ← P50 13ms
Worker (独立进程, 常驻循环):
├─ [抢槽] distributed_slot("worker_diagnosis", limit=32)
│ └─ 满了 → sleep 0.5s → 重试
├─ [读任务] XREADGROUP critical → high → normal → low
├─ [执行] Planner → Executor(Skill + MCP) → Reporter
├─ [写回] UPDATE diagnosis_tasks SET status='succeeded'
└─ [释放] ZREM 释放槽 + XACK 确认消息plaintextQ21: 这个链路里最慢的环节是哪个?#
分两段看:
入账阶段(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 < limit但pending = 0→ Worker 有空闲但没去读 → 检查 Worker 日志slots.used = limit→ 并发满了 → 提高WORKER_DIAGNOSIS_CONCURRENCY(前提是 LLM 配额够)alive_workers = 0→ Worker 全挂了 → 紧急重启
Q24: 如果重来,你会改什么?(高频追问)#
- 加告警抑制层:在分组之上加一层 parent-child 抑制,交换机告警抑制其下所有节点告警,进一步减少不必要的诊断。
- 动态限流:目前限额是静态配置的。可以根据队列深度动态调整——队列快满时收紧限流,队列空了放宽。
- 分组策略可配置:目前分组逻辑是硬编码的。不同团队对”什么告警该归一组”有不同理解,应该开放为规则引擎。
- 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问题:
- 没有任务级去重:100 条告警消费 100 次诊断,浪费 LLM。Kafka 做去重要么用 exactly-once(需要事务),要么自己在消费端去重。
- 优先级不原生:Kafka 不支持优先级消费,要开 N 个 topic + 调整 consumer 权重,手动做。
- 运维成本:额外部署 Kafka + ZooKeeper/KRaft,对日均 5k-20k 告警的场景是大炮打蚊子。
本项目的方案:Postgres 做去重和事实源 + Redis Streams 做缓冲队列。共享已有的 Redis(限流/缓存也用它),零额外部署。