M6 · Incident 状态机与生命周期#
一句话定位: Incident 是系统的第一类公民——告警只是喂给它的信号。状态机定义 Incident 从检测到解决(或升级)的完整生命周期,驱动全链路各模块的协调流转,是整个 V4 架构的骨架。
开场钩子#
场景#
V3 时代系统只有三个告警状态:open、mitigated、closed,外加一个 suppressed。有一次线上出现一个诡异 bug:一条告警被处置后标成了 closed,但 5 分钟后同类告警再次 firing,系统重新建了一条任务、跑了一遍完整诊断、再次处置、再次 closed——然后 10 分钟后又来了。当天这条告警反复触发了 7 次,每次都从头跑 Tier 3 全套 LLM 推理,烧了一大笔 token。
复盘发现根因不是系统 bug,而是架构缺失:V3 里”告警”和”诊断任务”是一对一的,没有”Incident”这个实体把同类告警聚起来。每条新告警都是独立的——系统没有”这个问题我已经查过了、修过了、还在复发”的概念。更致命的是,没有状态机来约束流转:一条告警可以从任意状态跳到任意状态,closed 直接跳到 mitigated 也不会报错。
这就是 V4 引入 Incident 生命周期状态机的原因:告警是信号,Incident 才是事件;状态机约束合法流转、阻止非法跳转,同时用拉回计次防止”处置→复发→再处置”的无限循环。
面试官切入#
“你提到了 Incident 状态机——为什么不直接用告警的状态?Incident 和告警的区别是什么?“
一、模块运作流程#
1.1 一句话定位#
Incident 状态机解决的核心问题是:谁来协调整条故障处理链路的推进? 告警入口、预处理层、调查引擎、审批决策、处置执行、验证门禁——这些模块各自独立,但需要一个统一的”当前进展到哪了”的判定。状态机就是这个判定的单一事实来源:每个模块做完自己的事,向状态机发一个事件,状态机决定下一步该谁接手。
1.2 全景流程图(文字版)#
告警入口 (webhook / 手动 / 巡检)
│
│ 创建 Incident (status=DETECTED)
▼
L2 预处理层 ──preprocess_start──→ TRIAGING ──preprocess_done──→ INVESTIGATING
│ │
│ ┌─────────────┤
│ │ │
│ new_alert_merged rca_confirmed
│ (自迁移,增量再调查) │
│ │ ▼
│ └─────── RCA_READY
│ │
│ ┌────────────┬────┴──────────┐
│ autonomy_auto autonomy_approval autonomy_denied
│ │ │ │
│ ▼ ▼ ▼
│ REMEDIATING REMEDIATING ESCALATED
│ │
│ execution_done
│ │
│ ▼
│ VERIFYING
│ ┌────┴────┐
│ verify_passed verify_failed
│ │ │
│ ▼ ▼
│ RESOLVED ESCALATED
│ │ │
│ new_alert_merged human_resolved
│ (拉回,计次,超限→ESCALATED) │
│ │ ▼
│ └───→ INVESTIGATING / ESCALATED
│
│ 失败路径 (任何阶段):
│ rca_insufficient ──→ ESCALATED
│ execution_failed ──→ ESCALATED
│ autonomy_handoff ──→ ESCALATED
│
└── ESCALATED ──human_resolved──→ RESOLVEDplaintext1.3 分步详解#
Step 1: 状态枚举与语义
8 个状态,每个有明确的业务语义:
| 状态 | 语义 | 谁负责推进 |
|---|---|---|
DETECTED | 原始告警已归一化,Incident 已创建 | webhook / 手动建任务 |
TRIAGING | L2 预处理中(聚合/关联/Impact 评分) | PreprocessingEngine |
INVESTIGATING | L3 调查中(Tier 1→2→3 递进) | investigation graph |
RCA_READY | 根因已定,待决策”自动执行 / 审批 / 拒绝” | audit.py |
REMEDIATING | 处置执行中 | remediation_worker |
VERIFYING | 三级验证门禁中 | remediation_worker |
RESOLVED | 验证通过,事件关闭 | 终态 |
ESCALATED | 人工接管 | 终态(人可手动 resolve) |
设计决策:8 态而不是更多。每新增一个状态就多一条迁移边要维护、多一个非法组合要测试。8 态刚好覆盖”检测→分流→调查→决策→执行→验证→关闭/升级”的完整链路,每一步都有对应的代码模块负责推进。
Step 2: 迁移矩阵与默认拒绝
迁移矩阵是一个 dict[tuple[IncidentStatus, str], IncidentStatus]——(当前状态, 事件) → 目标状态。不在字典里的组合 = 非法迁移,next_status() 抛 IllegalTransitionError。
为什么默认拒绝:状态机被绕过是 bug,不是可容忍的运行时分支。如果允许”宽容跳转”(比如 DETECTED 直接到 REMEDIATING),调查层就被跳过了——处置一个没查过的故障,后果不可控。
矩阵里目前有 17 条合法迁移(含自迁移),对应 15 个事件。合法迁移/所有可能组合 = 17 / (8×15) = 14%——86% 的状态-事件组合是非法的,这就是”默认拒绝”的含义。
Step 3: 事件驱动迁移
15 个事件分三类:
- 链路推进事件:
preprocess_start、preprocess_done、rca_confirmed、execution_done、verify_passed——正常链路上每一步做完发一个,推 Incident 往下走。 - 失败/决策事件:
rca_insufficient、autonomy_denied、autonomy_handoff、execution_failed、verify_failed——各种”做不下去了”的出口,全部收敛到ESCALATED。 - 特殊事件:
new_alert_merged(增量/拉回)、pullback_exhausted(拉回超限)、human_resolved(人工关闭)、autonomy_auto/autonomy_approval(执行授权)。
Step 4: 并发安全——行锁串行化
IncidentRepository.transition() 在事务里起手 SELECT ... FOR UPDATE 拿行锁。两个 Worker 同时对同一 Incident 发事件时,后者会看到前者写完的状态再做矩阵校验,而不是双双基于旧态跳转。跨 Incident 不互斥——行锁粒度最小化。
Step 5: RESOLVED 拉回与防无限循环
RESOLVED 收到 new_alert_merged 会被拉回 INVESTIGATING——同 correlation_key 的新告警说明问题复发了。但每次拉回都计次(resolved_count),超过 MAX_RESOLVED_PULLBACKS(默认 3)次后,resolve_pullback_event() 改发 pullback_exhausted → ESCALATED。
为什么要有上限:反复复发说明根因没被真正解决,继续自动化只是空转、白烧 token。交人工是更负责任的做法。
Step 6: 遗留状态平滑迁移
V3 的 open/mitigated/closed/suppressed 四态需要平滑过渡。两层保障:
- DDL 层:
UPDATE incidents SET status = 'detected' WHERE status = 'open'(app/db/postgres.py) - 代码层:
coerce_status()做运行时映射(兜住”DDL 跑之前就被读到的旧行”) - 认不出来的一律当
DETECTED——最保守,从头走链路。
Step 7: 审计日志
每次 transition() 往 incident_transitions 表写一行审计记录:from_status、to_status、event、actor、context(JSON)。这是事件时间线的事实来源——调试”这个 Incident 为什么到了 ESCALATED”时,看 list_transitions() 就能还原完整流转历史。
Step 8: RESOLVED 触发向量索引
事故解决后 transition() 异步调度 index_incident_safe()——把这次事故写进 Tier 2 的检索语料。这是”相似故障”档位的唯一数据来源。索引放在事务外:embedding 调用慢且会失败,不能占着行锁,更不能让它把状态机拖垮。
1.4 数据流 trace#
场景:一条 MySQL replica lag > 10s on db-slave-02 告警的完整生命周期
Step 1 — 告警入口 (webhook.py):
输入: AlertmanagerPayload { alerts: [{alertname: "MySQLReplicaLag",
service: "db-slave-02", severity: "critical", labels: {threshold: "10s"}}] }
处理: _normalize_alert → NormalizedAlert
_correlation_key → "alertmanager:mysql-alerts:group_{hash}"
_stable_id → incident_id = "inc_{sha256[:24]}"
输出: IncidentIngestResult { incident_id, task_id, task_created: true }
Incident DB 行: { id: "inc_xxx", status: "detected", severity: "critical", service: "db-slave-02" }
Step 2 — L2 预处理 (webhook.py::_preprocess):
事件: PREPROCESS_START
transition: DETECTED → TRIAGING
处理: PreprocessingEngine 跑去重/抑制/时间窗聚合/拓扑关联/Impact 评分
事件: PREPROCESS_DONE
transition: TRIAGING → INVESTIGATING
输出: IncidentContext { impact_score: 0.72, primary_alertname: "MySQLReplicaLag",
root_cause_candidates: ["db-slave-02"], topology_context: [...] }
审计日志 2 行: [DETECTED→TRIAGING], [TRIAGING→INVESTIGATING]
Step 3 — L3 调查 (audit.py):
输入: IncidentContext (随任务从队列流入 Worker)
处理: Tier 1 Runbook 匹配 → miss
Tier 2 向量检索历史 Incident → miss
Tier 3 假设驱动调查 → 生成假设 "replica lag caused by long-running query"
→ 定向取证 → confidence 0.85 ≥ 0.8 → confirmed
事件: RCA_CONFIRMED
transition: INVESTIGATING → RCA_READY
输出: Investigation { tier_completed: "tier3", hit: true, root_cause: {...}, confidence: 0.85 }
审计日志 1 行: [INVESTIGATING→RCA_READY]
Step 4 — 执行决策 (remediation_worker.py):
处理: impact_score 0.72 + severity critical → 需审批
任务标记: awaiting_approval
飞书推送 → oncall 一键审批 → 通过
事件: AUTONOMY_APPROVAL
transition: RCA_READY → REMEDIATING
审计日志 1 行: [RCA_READY→REMEDIATING (actor=remediation_worker)]
Step 5 — 处置执行:
处理: 资源域建模工具执行 → 快照→CAS→执行→验证
事件: EXECUTION_DONE
transition: REMEDIATING → VERIFYING
审计日志 1 行: [REMEDIATING→VERIFYING]
Step 6 — 三级验证门禁:
处理: 命令成功 ✓ → 服务健康 ✓ → 指标恢复 ✓(replica lag 回到 <1s)
事件: VERIFY_PASSED
transition: VERIFYING → RESOLVED
副作用: 异步触发 index_incident_safe() → 写入 Tier 2 检索语料
审计日志 1 行: [VERIFYING→RESOLVED]
Step 7 — 复发拉回 (假设 30 分钟后同类告警再次 firing):
输入: 同 correlation_key 的新告警
事件: NEW_ALERT_MERGED
transition: RESOLVED → INVESTIGATING (resolved_count: 0→1)
审计日志 1 行: [RESOLVED→INVESTIGATING, context: {alert_id: "al_yyy"}]
...如果再次解决后又复发,resolved_count 累加,第 4 次触发 pullback_exhausted → ESCALATED
完整审计日志 (incident_transitions):
#1: DETECTED → TRIAGING (preprocess_start, actor=system)
#2: TRIAGING → INVESTIGATING (preprocess_done, actor=system)
#3: INVESTIGATING → RCA_READY (rca_confirmed, actor=system)
#4: RCA_READY → REMEDIATING (autonomy_approval, actor=remediation_worker)
#5: REMEDIATING → VERIFYING (execution_done, actor=remediation_worker)
#6: VERIFYING → RESOLVED (verify_passed, actor=remediation_worker)plaintext1.5 技术选型决策表#
| 选了什么 | 备选方案 | 为什么选它 | 什么情况下换 |
|---|---|---|---|
| Python dict 迁移矩阵 | Statechart 库(transitions/pytransitions)/ XState | 纯函数 + 常量,不碰数据库,单测直接覆盖不需要起 Postgres;17 条迁移用 dict 足够,引入框架是过度工程 | 状态数超过 20 或需要层次状态/并行状态时考虑 Statechart |
| Postgres 行锁(SELECT FOR UPDATE) | 分布式锁(Redis/etcd)/ 乐观锁(version 字段 CAS) | 状态机的读写都在同一张表,行锁是 Postgres 原生能力,零额外依赖;粒度最小(单行),跨 Incident 不互斥 | 跨库分片或微服务拆分后行锁不可用,改用分布式锁 |
| 审计日志(incident_transitions 表) | Event Sourcing(事件即事实) | 审计日志够用——只需”看历史”不需”从事件重建状态”;Event Sourcing 的重放/投影/schema 演进成本太高 | 如果需要从事件流重建任意时间点的状态快照,或多个下游需要订阅同一事件流 |
| 遗留状态双层映射(DDL + 代码) | 只做 DDL 迁移 / 只做代码映射 | DDL 覆盖存量行但跑之前可能被读到,代码兜住这个窗口;任一层出问题另一层还能撑住 | V3 数据完全清理后可去掉代码层映射 |
1.6 口述脚本#
开场(20s):“V4 重构的第一个决定是引入 Incident 状态机——告警不再是一等公民,Incident 才是。告警只是喂给 Incident 的信号。”
为什么(20s):“V3 的问题是告警和诊断任务一对一,没有’这个问题我查过了’的概念。同类告警反复触发就反复从头跑 Tier 3,白烧 token。状态也只有 3 个,从任意状态跳到任意状态都不报错——没有约束。”
怎么做(30s):“8 个状态覆盖完整链路,17 条合法迁移用 dict 矩阵管理。默认拒绝——不在矩阵里的组合直接抛异常。持久化用 Postgres 行锁保证同 Incident 串行,跨 Incident 不互斥。每次迁移落审计日志。”
留钩子(10s):“比较有意思的是 RESOLVED 拉回机制——已关闭的事件可以被同类新告警拉回调查,但超过 3 次就直接升级人工,因为反复复发说明根因没被真正解决。”
面试官大概率追问”拉回机制”或”默认拒绝在实践中有没有踩到过” → 接踩坑实录。
二、踩坑实录#
坑 1:V3 遗留状态在 V4 启动窗口内被读到#
- 现象:V4 部署后第一批请求报
IllegalTransitionError: 非法状态迁移: open --preprocess_start--> ?。open不在 V4 的IncidentStatus枚举里。 - 根因:DDL 迁移(
UPDATE incidents SET status = 'detected' WHERE status = 'open')和应用代码部署之间有时间窗口。窗口内旧行的open状态被新代码读到,IncidentStatus("open")抛ValueError。即使 DDL 先跑,asyncpg 连接池的长连接可能在 DDL 之前就拿到了缓存的查询结果。 - 修法:双层防御——代码层加
coerce_status()做运行时映射(open → DETECTED,mitigated → REMEDIATING,closed → RESOLVED,suppressed → ESCALATED),认不出来的一律当DETECTED(最保守:从头走链路)。DDL 层和代码层各自独立生效,任一层在位都不会炸。 - 教训:数据库迁移和代码部署之间的时间窗口是必须考虑的。只在一层做映射,另一层一定会在某个时间窗口里翻车。对枚举类型的变更,代码侧必须有 fallback 映射。
坑 2:手动诊断入口的 Incident 卡在 DETECTED,后续迁移全部非法#
- 现象:聊天升级和定时巡检触发的诊断,调查完后发
RCA_CONFIRMED事件时抛IllegalTransitionError: 非法状态迁移: detected --rca_confirmed--> ?。但 webhook 路径的正常告警没这个问题。 - 根因:webhook 路径上 L2 预处理层会推两步(
preprocess_start→preprocess_done),把 Incident 从DETECTED推到INVESTIGATING。但手动诊断入口(create_manual_task)创建 Incident 后直接入队,不走 L2——Incident 停在DETECTED,而迁移矩阵里(DETECTED, rca_confirmed)不存在。 - 修法:在
audit.py::_ensure_investigating()里补两步try_transition——preprocess_start+preprocess_done。用try_transition(推不动不抛)而不是transition(推不动抛):webhook 路径上已经推过的重试任务推不动,属正常。 - 教训:多入口系统的每条入口路径,必须把前置状态推到一致的起点。状态机的”默认拒绝”在这里发挥了正面作用——它暴露了入口路径不一致的问题,而不是”宽容跳转”把问题掩盖。
坑 3:审计日志写进事务导致 embedding 拖垮状态机#
- 现象:Incident 到达
RESOLVED后偶现 5-10 秒延迟才返回TransitionResult,期间行锁一直持有,同 Incident 的其他迁移全部排队。 - 根因:最初版本把 RESOLVED 后的向量索引(embedding 调用)放在
transition()事务内——embedding 走外部 HTTP API,慢且不稳定(P99 > 3s),占着 Postgres 行锁等它回来。行锁的持有时间从 <10ms 变成了 3-10s。 - 修法:把向量索引移到事务提交后的 fire-and-forget 异步任务(
_schedule_incident_indexing())。索引失败只打日志不影响状态机。用_INDEX_TASKS: set[asyncio.Task]持强引用防 GC 回收。 - 教训:事务内只做必须原子化的操作——读当前态、校验迁移、写新态、落审计日志。任何可以容忍失败或延迟的操作(embedding、通知、索引)都必须放事务外。 行锁的持有时间直接决定了状态机的吞吐量。
坑 4:fire-and-forget 的索引任务被 GC 静默回收#
- 现象:日志里出现
Task was destroyed but it is pending!警告,RESOLVED 的事故偶尔不进检索语料(Tier 2 后续召不回它)。 - 根因:
asyncio.create_task()返回的 Task 只被 asyncio 事件循环持弱引用。如果没有其他地方持强引用,Task 可能在跑完前被 Python GC 回收。回收后事件循环打 warning 但不重试。 - 修法:用模块级
_INDEX_TASKS: set[asyncio.Task]持强引用,Task 完成后通过add_done_callback(_INDEX_TASKS.discard)自行摘除。 - 教训:asyncio 的 fire-and-forget 必须显式持有 Task 引用。这是 Python asyncio 的设计——事件循环不负责防止 Task 被 GC。官方文档里有一行不起眼的 warning 提到这一点,但很容易忽略。
三、量化评估#
3.1 评估数据集构建#
- 迁移矩阵覆盖:单测覆盖 17 条合法迁移(正向路径 + 失败路径 + 自迁移 + 拉回 + 拉回超限)和代表性非法迁移(
DETECTED→VERIFY_PASSED、RESOLVED→AUTONOMY_AUTO等)。数据来源tests/test_incident_state_machine.py(11 个测试用例)。 - 遗留状态映射:覆盖 V3 所有 4 个遗留值 +
None+ 无法识别的值 + 已是 V4 枚举的值。
3.2 评估方法#
- 脚本:
pytest tests/test_incident_state_machine.py - 对照组:无状态机(V3 的自由跳转)vs 有状态机(默认拒绝白名单)
- 可复现:纯函数测试,不碰 Postgres,
pytest一键跑
3.3 结果与解读#
| 指标 | 值 | 来源 |
|---|---|---|
| 合法迁移数 | 17 条(含 1 条自迁移) | state_machine.py::TRANSITIONS dict 计数 |
| 非法组合占比 | 86%(矩阵外的状态-事件组合) | 8 状态 × 15 事件 = 120 组合,合法 17 条 |
| 失败路径收敛点 | 100% 到 ESCALATED(5 条失败路径) | test_failure_paths_escalate |
| 拉回上限 | 3 次(MAX_RESOLVED_PULLBACKS) | state_machine.py 常量 |
| 遗留状态映射覆盖 | 4 个 V3 值 + 2 个边界值(None/garbage) | test_legacy_status_coercion |
| 单测通过率 | 100%(11/11) | pytest tests/test_incident_state_machine.py |
局限性:纯函数层测试不覆盖行锁并发场景(需要多 Worker 并发压测 + 真实 Postgres)。审计日志的完整性(每次迁移必落)依赖 transition() 的事务完整性,目前无独立校验机制。
四、面试问答#
基础题(必问级)#
Q1: 为什么需要 Incident 状态机?不用状态机行不行?#
答: 不行。状态机解决两个问题:
第一,协调多模块的流转顺序。预处理、调查、审批、处置、验证——这些模块各自独立开发、独立部署,但必须按顺序接力。没有状态机,每个模块都需要自己判断”前一步做完了没”,判断逻辑散落在各处,维护成本极高。有了状态机,每个模块只需要:做完自己的事 → 发一个事件 → 状态机告诉下一步该谁。
第二,阻止非法流转。V3 没有约束,closed 可以直接跳到 mitigated。这意味着一个 Incident 可能在没有调查的情况下直接进入处置——状态机的默认拒绝确保这不会发生。
追问 1: 你说默认拒绝——生产中有没有遇到过合法操作被拒的情况? 答: 有,就是手动诊断入口的问题。手动建的 Incident 停在
DETECTED,调查完发rca_confirmed被拒——因为(DETECTED, rca_confirmed)不在矩阵里。修法是在调查前先推两步到INVESTIGATING。这恰好说明默认拒绝的价值:它暴露了入口路径不一致的 bug,而不是”宽容跳转”把问题掩盖了。
追问 2: 为什么不用现成的状态机库? 答: 迁移矩阵只有 17 条,一个 dict + 一个函数就能表达。引入
pytransitions或xstate要学它们的 DSL、处理它们的序列化/持久化、适配它们的回调机制。收益是”少写 30 行代码”,代价是”多一个运行时依赖 + 多一个概念映射层”。当状态数 < 20、没有层次状态/并行状态需求时,raw dict 是更好的选择。
Q2: Incident 和告警是什么关系?一个 Incident 对应多少条告警?#
答: 一对多。多条告警通过 correlation_key 聚合到同一个 incident_group,每个 group 对应一个 incident。correlation_key 的计算规则:如果告警带 Alertmanager 的 groupKey,用 alertmanager:{receiver}:{groupKey};否则用 window:{cluster}:{namespace}:{service}:{time_bucket} 做时间窗聚合。
Incident 是第一类公民的含义是:状态机挂在 Incident 上而不是告警上。一条新告警到来时,系统先查有没有同 correlation_key 的已有 Incident——有就并入(new_alert_merged 事件),没有才新建。这样”磁盘满”告警每 5 分钟发一次,不会建 12 个独立诊断,而是一个 Incident 收 12 条告警。
追问: 如果一个 Incident 已经 RESOLVED 了,同类告警又来了怎么办? 答:
RESOLVED收到new_alert_merged会被拉回INVESTIGATING——状态机里有这条迁移。拉回时resolved_count加 1,超过 3 次改发pullback_exhausted→ESCALATED。拉回不清空已有证据——之前的调查结论保留,相当于”增量再调查”。
进阶题(区分度)#
Q3: transition() 的并发安全是怎么保证的?#
答: Postgres 行锁。transition() 在事务里起手 SELECT status, resolved_count FROM incidents WHERE id = $1 FOR UPDATE。FOR UPDATE 拿到行锁后,做矩阵校验、写新状态、落审计日志,最后事务提交释放锁。
两个 Worker 同时对同一 Incident 发事件:先拿到锁的正常执行;后者在 FOR UPDATE 上阻塞,等前者事务提交后读到新状态再做校验。如果前者已经把状态推到了不兼容的位置,后者的校验会命中”非法迁移”——这是正确行为。
关键设计:锁粒度是单行(单个 Incident),不是表锁。不同 Incident 的迁移完全并行,吞吐量只受单 Incident 的事件频率限制,而一个 Incident 的事件频率通常很低(秒级间隔)。
追问: 行锁会不会成为性能瓶颈? 答: 不会。行锁的持有时间 = 读状态 + dict 查一次 + 写两行(incidents + incident_transitions)≈ 几毫秒。把 embedding 调用移到事务外后,P99 < 10ms。一个 Incident 的事件频率是秒级(告警间隔最短也是 Alertmanager 的
group_wait30s),而行锁持有 10ms,差了 3 个数量级。瓶颈不在行锁,在 LLM 调用。
Q4: try_transition() 和 transition() 什么时候用哪个?#
答: transition() 是严格版——非法迁移抛 IllegalTransitionError。适用于”状态机被绕过是 bug”的场景,比如 webhook 入口首次推 preprocess_start。
try_transition() 是宽容版——非法迁移不抛,返回 None + 打 WARNING。适用于”顺手推一下,但推不动也不该把主链路搞挂”的场景,比如:
- Worker 收尾时推
rca_confirmed——如果 Incident 已经被另一个 Worker 推过了,推不动是正常的。 - 手动诊断入口补推
preprocess_start/preprocess_done——已经在INVESTIGATING的重试任务推不动,属正常。 remediation_worker推执行后的状态——处置结果已经落库了,推不动状态机不应该把处置结果吞掉。
判断标准:这个迁移失败会不会导致数据不一致?会 → 用 transition(),让它炸出来。不会 → 用 try_transition(),静默跳过。
压力题(面试官挑战设计决策)#
Q5: 你的状态机是运行时校验——如果有人直接改数据库把状态改了呢?#
答: 改了就改了。状态机管的是”通过代码路径的合法流转”,不管”直接操作数据库”。如果有人手动 UPDATE incidents SET status = 'resolved' 跳过了验证门禁,审计日志里不会有这次迁移的记录——Incident 的审计日志出现断层(最后一条是 INVESTIGATING→RCA_READY,但当前状态已经是 resolved),运维排查时能发现。
更根本的防线是数据库权限:Worker 用的 DB 账号只允许通过应用代码操作,直接改表需要 DBA 权限。如果 DBA 手动改了——那是 DBA 的决定,系统不应该阻止有意为之的人工干预。
追问: 那能不能加一个 DB trigger 做校验? 答: 可以但不值得。DB trigger 的问题:(1)迁移矩阵的逻辑会分散在 Python 和 SQL 两个地方,维护成本翻倍;(2)trigger 里抛异常的错误信息不如 Python 的
IllegalTransitionError友好;(3)trigger 对直接UPDATE的防护价值有限——DBA 可以ALTER TABLE DISABLE TRIGGER。投入产出不成比例。
Q6: 8 个状态够用吗?比如”等待审批”为什么不是一个独立状态?#
答: 这是有意的取舍。“等待审批”目前挂在 RCA_READY 状态上——RCA 已确认但尚未开始处置,审批决策就发生在这个窗口。如果把它拆成独立状态(AWAITING_APPROVAL),迁移矩阵多 3-4 条边(RCA_READY→AWAITING_APPROVAL、AWAITING_APPROVAL→REMEDIATING、AWAITING_APPROVAL→ESCALATED、可能还有超时迁移),但业务语义没有本质变化——它只是 RCA_READY 的一个子状态。
诊断任务(diagnosis_tasks)上倒是有 awaiting_approval 状态——因为任务需要在等审批时对外可见(前端展示”等待审批中”),但这是任务的可观测性需求,不是 Incident 生命周期的需求。
判断标准:新增一个状态的条件是”这个状态会改变后续模块的行为”。AWAITING_APPROVAL 不改变——审批通过后接 REMEDIATING,和 RCA_READY→REMEDIATING 一样。增加状态只增加了维护成本,没有增加表达力。
五、前沿概念与延伸#
5.1 有限状态机 vs Statechart(层次化状态机)#
是什么:有限状态机(FSM)是状态 + 事件 + 迁移的平坦模型。Statechart(Harel Statechart)在 FSM 之上加了层次状态(状态嵌套/子状态)、并行状态(多个正交区域同时活跃)、历史状态(记住子状态退出时的位置)。
为什么要这么做:纯 FSM 在状态数增长时会”状态爆炸”——N 个独立维度各有 M 个状态,纯 FSM 需要 M^N 个状态,Statechart 用并行区域只需 N×M。层次状态解决”多个子状态共享同一个异常出口”的问题——不用给每个子状态都画一条到 ERROR 的边,只需在父状态上画一条。
这么做的理由:本项目选择纯 FSM 而不是 Statechart,原因是 8 个状态、17 条迁移,远未到”状态爆炸”的程度。没有并行状态需求——一个 Incident 在任一时刻只在一个状态上。没有层次状态的强需求——5 条失败路径到 ESCALATED 虽然冗余,但 17 条迁移全部显式列出的可读性比层次状态+隐式继承更好。
举例说明:XState(JavaScript 生态的 Statechart 实现)在前端 UI 状态管理中很流行——表单有”编辑中/提交中/成功/失败”四个状态,每个下面可能有子状态。本项目如果用 Statechart,INVESTIGATING 可以有 TIER1/TIER2/TIER3 三个子状态,ESCALATED 下面可以有 HUMAN_NOTIFIED/HUMAN_ACCEPTED 子状态。但目前 Tier 信息存在 investigation_tier 字段而不是子状态里——因为 Tier 是调查的输出结果,不影响状态机的迁移逻辑。
延伸问答:
面试官:“什么时候你会考虑从 FSM 切换到 Statechart?” 答:两个信号。第一,状态数超过 15-20 且出现明显的”N 个子状态共享同一组出口迁移”模式——层次状态能消除重复。第二,需要并行区域——比如一个 Incident 同时在”技术调查”和”业务通知”两条正交链路上推进,每条链路有自己的状态。目前系统是串行链路,不需要并行区域。
5.2 Event Sourcing(事件溯源)#
是什么:不存储当前状态,只存储状态变更事件的有序序列。当前状态通过重放(replay)事件序列推导出来。每个事件是不可变的事实记录。
为什么要这么做:传统方式(只存当前状态)会丢失”怎么到这个状态的”信息。Event Sourcing 天然保留完整历史——任何时间点的状态都可以通过重放事件序列重建。审计、调试、回放、时间旅行都变得可能。
这么做的理由:本项目选择了状态存储 + 审计日志的折中方案,而不是完整的 Event Sourcing。原因:
- Event Sourcing 需要从事件重建状态(投影),重建逻辑本身需要维护和测试——我们只有 8 个状态,直接存当前值更简单。
- Event Sourcing 的 schema 演进(新增/修改事件类型)需要迁移所有历史事件——审计日志的 JSON
context字段更灵活。 - 不需要”从事件重建任意时间点的状态快照”——审计日志
list_transitions()已经能还原完整流转历史。
但 incident_transitions 表本质上就是 Event Sourcing 的”事件存储”简化版——每次迁移落一行,按时间排序就是事件流。只是我们不依赖它来推导当前状态,而是直接读 incidents.status。
举例说明:Kafka 生态里的 Event Sourcing:每个 Incident 状态变更作为一条 Kafka 消息发到 topic,多个消费者(通知服务、统计服务、仪表盘)各自维护自己的投影。本项目如果需要”调查耗时统计”或”每日 ESCALATED 率趋势”,可以从 incident_transitions 表做离线分析,不需要完整的 Event Sourcing 基础设施。
延伸问答:
面试官:“你的审计日志和 Event Sourcing 的核心区别是什么?” 答:Event Sourcing 的事件是事实的唯一来源——当前状态必须从事件推导,不允许直接读。我们的审计日志是辅助信息——当前状态直接读
incidents.status,审计日志用于调试和审计,即使审计日志丢了系统也不会坏。这意味着我们不需要维护投影逻辑、不需要处理事件 schema 演进、不需要考虑重放性能。代价是”审计日志和实际状态可能不一致”(如果有人直接改了数据库),但这个风险通过 DB 权限控制可以接受。
5.3 Saga Pattern 在 Incident 生命周期中的体现#
是什么:Saga 是长事务的分布式协调模式——把一个大事务拆成多个步骤,每步有对应的补偿操作,任一步失败触发前序步骤的逆向补偿。
为什么要这么做:Incident 的生命周期跨越多个异步步骤(预处理→调查→审批→执行→验证),不可能用一个数据库事务锁住全程。每一步可能在不同的 Worker 上跑,中间可能经过 Redis 队列,可能等待人工审批。
这么做的理由:状态机本身就是 Saga 的协调器(orchestration-based saga)——它记录”当前走到哪一步了”,任何一步失败都有明确的出口(→ ESCALATED)。但本项目的状态机不做”逆向补偿”——ESCALATED 不会触发”撤销调查结论”或”删除证据”。原因是:调查过程中积累的证据即使最终 ESCALATED 也有价值(人工接手时可以看)。处置层的 Saga(M5)才做真正的事务化补偿回滚。
举例说明:一个 Incident 从 INVESTIGATING 到 ESCALATED(调查置信度不足):调查过程中积累的 3 个假设、12 条证据、2 次工具调用记录全部保留。人工接手后可以看”系统查了什么、为什么不确定”,不需要从零开始。这是”前向补偿”(向前推进到人工处理)而不是”逆向补偿”(撤销前序步骤)。
延伸问答:
面试官:“Saga 的 orchestration 和 choreography 两种模式,你用的是哪种?” 答:Orchestration——状态机是中心协调器,每个模块做完向状态机报告,状态机决定下一步。Choreography 是去中心化的:每个模块自己决定”做完后通知谁”。Orchestration 的优势是流程可见——看迁移矩阵就知道全部合法路径。Choreography 的优势是解耦——新增一个步骤不需要改协调器。我们的 Incident 生命周期是严格线性的(不是网状的),Orchestration 更合适。
六、诚实边界#
-
并发安全只在单 Postgres 实例上验证。行锁保证同 Incident 串行,但如果 Postgres 做了读写分离或跨分片,行锁语义可能改变。目前系统是单实例 Postgres,不存在这个问题,但也意味着没做过分布式场景的验证。
-
没有状态机可视化工具。迁移矩阵只在代码和文档里,没有 dashboard 能实时展示一个 Incident 的状态流转图、当前状态分布、各状态停留时长。调试靠
list_transitions()+ 看日志。 -
ESCALATED 是终态但不是”结束”。
ESCALATED后系统不再自动处理,但也不主动通知 oncall “这个事件需要你看”——通知依赖 Worker 里的飞书推送,不是状态机本身的职责。如果飞书推送失败,ESCALATED 的事件会静默等待human_resolved。 -
拉回上限 3 次是经验值,不是调参结果。
MAX_RESOLVED_PULLBACKS = 3的选择没有数据支撑——“3 次以上算反复复发”是拍脑袋定的。生产环境的合理值可能因业务不同而不同(高频抖动场景可能需要更高上限)。 -
审计日志没有独立的完整性校验。如果
transition()里写审计日志的 SQL 失败(但主表更新成功),状态会变但审计记录缺失。目前两者在同一个事务里,理论上不会出现不一致——但如果未来拆分(比如审计日志走 Kafka),就需要额外的一致性保证。 -
状态机是同步的,不支持”定时超时迁移”。比如
REMEDIATING超过 30 分钟没有execution_done→ 自动ESCALATED——这需要外部定时器(cron/定时任务)扫描超时 Incident 并发事件,状态机本身不做定时器。目前没有实现这个超时机制。