V4 Layer: Incident 状态机#
目录:
app/incidents/state_machine.py,app/incidents/models.py,app/incidents/repository.py上游: L1 接入层(告警归一化入队) → 下游: L2 预处理 / L3 调查引擎 / L5 执行层
参考: incident.io / Rootly / FireHydrant 事件生命周期;Netflix Dispatch;LangGraph checkpointer
1.1 为什么 Incident 是第一类公民#
V3 的世界观是”一条告警 = 一个诊断任务”——告警进来就开诊断,诊断完就结束。这有三个致命问题:
- 缺乏贯穿始终的事件生命周期:一条告警从接入到处置到验证到关闭,没有统一的实体串联,审计靠的是散落各表的 task_id、agent_run_id、evidence_id 拼凑。
- 告警风暴无法降噪聚合:100 条同源告警进来就是 100 个独立的诊断任务,全部打满 LLM。
- 人工介入难表达:审批、暂停、恢复、拉回、人工关闭——这些生命周期操作没有载体。
V4 的回答是:告警只是喂给 Incident 的信号。告警经预处理归并进 Incident,Incident 有完整的 8 态生命周期,所有后续操作(调查、处置、验证、学习)都挂在 Incident 上。
1.2 八态生命周期#
DETECTED ──► TRIAGING ──► INVESTIGATING ──► RCA_READY ──► REMEDIATING ──► VERIFYING ──► RESOLVED
▲ │ │
│ └──► ESCALATED ◄────────────────────────────────────────┘
│ │
│ └──► human_resolved ──► RESOLVED
│
└── new_alert_merged (增量再调查) ──┘plaintext| 状态 | 含义 | 关键行为 |
|---|---|---|
DETECTED | 原始告警已归一化 | 手动诊断 / 聊天升级 / 定时巡检建的 Incident 停在此态 |
TRIAGING | L2 预处理中 | 去重/抑制/聚合/拓扑/Impact 评分 |
INVESTIGATING | L3 调查中 | Tier 1→2→3 递进;可自迁移(新告警并入) |
RCA_READY | 根因已定,待决策 | 自治分级:AUTO / APPROVAL / HANDOFF |
REMEDIATING | 处置执行中 | 事务化工具:快照→CAS→执行 |
VERIFYING | 三级验证门禁中 | 命令成功 → 服务健康 → 指标恢复 |
RESOLVED | 验证通过,事件关闭 | 可被新告警拉回 INVESTIGATING |
ESCALATED | 人工接管 | 审批拒绝/置信不足/验证失败回滚后 |
1.3 迁移矩阵与默认拒绝#
# app/incidents/state_machine.py
TRANSITION_MAP: dict[IncidentStatus, dict[str, IncidentStatus]] = {
IncidentStatus.DETECTED: {
"preprocess_start": IncidentStatus.TRIAGING,
},
IncidentStatus.TRIAGING: {
"preprocess_done": IncidentStatus.INVESTIGATING,
},
# ... 完整映射
}
def transition(current: IncidentStatus, event: str) -> IncidentStatus:
allowed = TRANSITION_MAP.get(current, {})
if event not in allowed:
raise IllegalTransitionError(current, event)
return allowed[event]python默认拒绝:TRANSITION_MAP 没列的迁移全部抛 IllegalTransitionError。不存在”默认放行”的路径。
1.4 并发规则#
- 同 Incident 串行:Postgres
FOR UPDATE行锁,同一incident_id同一时刻只有一个活跃迁移。 - 跨 Incident 并行:不同 Incident 互不阻塞,受全局执行槽限流。
- 拉回上限:
RESOLVED被同correlation_key新告警拉回时resolved_count += 1,超过 3 次直接ESCALATED。
1.5 审计日志#
每次迁移写 incident_transitions 表:from_status → to_status、触发事件、actor(system/human/<user_id>)、上下文 JSON。这是面试里最能体现”可审计”的一张表。
1.6 一个真 bug 及其教训#
处置命中人工审批(interrupt())时 Incident 僵死在 INVESTIGATING。
最初把 INVESTIGATING → RCA_READY 挂在 tier_completed 事件上——那是 runner 在图跑完之后才发的。一旦处置卡在审批 interrupt,astream 提前返回,这个事件根本不会出现。
修法:把 RCA_CONFIRMED 迁移挂到 rca 事件(由 tier 节点在图内发出,interrupt 之前一定已经发过)。escalate=True 时不发 rca 事件——BEST_EFFORT 的”最像的猜测”不是确认的根因,绝不能据此推进 RCA_READY。
另外补了 _ensure_investigating():手动诊断 / 聊天升级 / 定时巡检建的 Incident 停在 DETECTED,不先推一步的话后续 RCA_CONFIRMED 就是非法迁移。
模拟面试问答#
🔥 热点拷问#
面试官:你说 Incident 是第一类公民,但实际项目里有多少种方式能创建 Incident?手动诊断、Webhook 告警、定时巡检——创建入口这么多,状态初始化不会出问题吗?
确实有多个入口。Webhook 告警经 L2 预处理后自然从 DETECTED → TRIAGING → INVESTIGATING 走完整链路。但手动诊断和聊天升级建的 Incident 会直接停在 DETECTED,没有经过预处理。这就是 _ensure_investigating() 存在的原因——调查链路启动前先检查,如果还在 DETECTED 就补发 preprocess_start + preprocess_done 两个事件推到 INVESTIGATING。这不是 hack,而是设计上的补丁:预处理本质上是”纯规则的批量降噪”,单条手动诊断不需要降噪,直接进调查是合理的。
追问:8 个状态会不会太多?incident.io 不是只有 active/resolved/declined 三个吗?
incident.io 的三态是面向用户的视图,它背后的事件时间线(Event Timeline)其实记录了分诊、调查、处置等更细粒度的阶段。我们是 AIOps 自动化系统,不是给人看的 dashboard——系统需要知道当前事件处于哪个自动化阶段,才能决定下一步做什么。如果只有 active/resolved,“正在调查”和”正在处置”在系统眼里一样,但前者应该递进 Tier,后者应该等验证门禁——合并了就没法自动驱动。8 个状态看着多,但每个都对应一个不同的自动化行为,删任何一个都会导致某个阶段的驱动逻辑失去锚点。
面试官:默认拒绝 + 行锁 + 审计日志,这些放到一起性能怎么样?每次状态迁移都要写两张表、加行锁,高并发下 Postgres 扛得住吗?
先说规模:状态迁移的频率不是请求频率——100 条告警经 L2 降噪后可能只产生 1-2 个 Incident,一个 Incident 的生命周期内迁移次数是个位数(DETECTED → TRIAGING → INVESTIGATING → RCA_READY → REMEDIATING → VERIFYING → RESOLVED 最多 7 次)。所以瓶颈不在迁移频率,而在告警接入。行锁是 FOR UPDATE,只锁当前 Incident 那一行,跨 Incident 完全并行。审计日志是 INSERT 不是 UPDATE,写入代价很低。真正的性能风险在 L2 预处理——去重和时间窗聚合需要高频读写 Redis,那块用的是 SET NX + 窗口过期,不经过 Postgres。
追问:RESOLVED 被新告警拉回 INVESTIGATING,拉回上限 3 次——这个数字怎么定的?拉回 4 次就交人工,万一第 4 次是真的新故障呢?
3 次是保守的工程判断,不是业务需求推导出来的。核心逻辑是:如果同一个 correlation_key 的故障反复出现又反复”解决”,说明之前的修复不是根治,每次拉回都在重复无效工作。交人工不是说”放弃”,而是说”自动化链路在这个问题上不收敛,需要人换个思路”。如果第 4 次是完全不同的新故障但碰巧 correlation_key 相同,那是 correlation_key 设计的问题——应该让不同故障有不同的 key,而不是放宽拉回上限。当然 3 是可配的,不是硬编码。
深度追问链#
面试官:你把告警和 Incident 解耦了,但 Alertmanager 有自己的分组逻辑(group_by / group_wait),你的 L2 和它的分组是怎么协作的?会不会冲突?
不冲突,是两层不同粒度的聚合。Alertmanager 的 group_by 是第一层,它把同 alertname + service 的告警攒成一个 webhook POST 推过来。我们的 L2 是第二层,做的是跨 POST 的时间窗聚合——Alertmanager 按 group_by 拆成两批投递的上下游告警,在我们这里通过拓扑关联归并进同一个 Incident。设计上是互补的:Alertmanager 做初步分组减少推送次数,L2 做语义关联把”同一场故障的不同症状”归并。
继续追问:那如果 Alertmanager 的 group_by 设得很粗(比如只按 alertname),一个 POST 里混了不同服务的告警,L2 怎么拆?
L2 的聚合是基于 service + 时间窗的,不是基于 Alertmanager 的 POST 边界。一个 POST 里的多条告警会被逐条处理:先去重,再按 service + 时间窗聚合,不同 service 的告警会归入不同的 Incident。Alertmanager POST 的边界在 L2 里是透明的——PreprocessStore 维护的是跨请求的全局视图。
再追问:那状态机的 _ensure_investigating() 补发两个事件推到 INVESTIGATING——审计日志里会不会出现两条”没有实际工作”的迁移记录?这不是污染审计吗?
会出现,但不是污染。审计日志记录的是状态迁移事实,不是”有没有做实际工作”。这两条记录的 actor 是 system,context 里标注 skip_preprocessing=true。审计的价值在于事后重建时间线——“这个 Incident 为什么直接跳到了 INVESTIGATING?“有日志可查。如果不记录,反而会出现审计断层。
常规问题#
面试官:incident_transitions 表会不会无限增长?你有清理策略吗?
一个 Incident 的生命周期内迁移次数是个位数,表增长速度 = Incident 数 × ~7。假设每天 100 个 Incident,一年 ~25 万条记录,单表查询完全没问题。清理策略跟着 Incident 走——Incident 按保留策略清理时 ON DELETE CASCADE 自动清理关联的迁移记录。不需要单独的 TTL。
面试官:ESCALATED 是终态吗?人工介入后怎么关闭?
不是终态。ESCALATED 有一条迁移 human_resolved → RESOLVED,人工介入后通过 API 手动关闭。设计上 ESCALATED 是”自动化链路走不通了”的挂起态,不是”放弃”。人工处理完后仍然需要显式关闭,否则这个 Incident 会永远挂在 dashboard 上——这正是想要的效果:不让未解决的事件静默消失。
反思与改进#
面试官:如果重来,状态机这块你会改什么?
两个改进。一是状态机应该更早引入,不应该等到 V4。V3 用 open/mitigated/closed/suppressed 四个状态硬凑生命周期,导致很多状态判断散落在业务代码里(“诊断完了但还没处置”用什么状态表示?V3 的答案是……没有,靠 task 状态推断)。二是拉回机制应该更灵活——目前是简单的次数上限,更好的做法是”指数冷却期”:每次拉回后下一次拉回的冷却期翻倍(5min → 10min → 20min),超过阈值才 ESCALATED。这样既防抖动又不会误杀真正的新发故障。
面试官:状态机是纯代码实现的(Python dict),为什么不用一个正式的状态机库?比如 transitions 或 pytransitions?
考虑过 transitions 库,但最终没用,原因是我们的状态机足够简单——8 个状态、15 条迁移规则,一个 dict + 一个函数就够了。引入库带来的好处(图可视化、回调钩子)当前用不上,反而多了一个依赖和一层抽象。状态机的复杂度不在”状态多”,而在”谁触发、何时触发、触发后的副作用(写审计、推事件)“——这些是业务逻辑,状态机库帮不了。如果未来状态数超过 20 或需要嵌套子状态,那时候再引入库也不迟。