面试知识库

M6 · Incident 状态机与生命周期#

一句话定位: Incident 是系统的第一类公民——告警只是喂给它的信号。状态机定义 Incident 从检测到解决(或升级)的完整生命周期,驱动全链路各模块的协调流转,是整个 V4 架构的骨架。


开场钩子#

场景#

V3 时代系统只有三个告警状态:openmitigatedclosed,外加一个 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 全景流程图(文字版)#

1.3 分步详解#

Step 1: 状态枚举与语义

8 个状态,每个有明确的业务语义:

状态语义谁负责推进
DETECTED原始告警已归一化,Incident 已创建webhook / 手动建任务
TRIAGINGL2 预处理中(聚合/关联/Impact 评分)PreprocessingEngine
INVESTIGATINGL3 调查中(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_startpreprocess_donerca_confirmedexecution_doneverify_passed——正常链路上每一步做完发一个,推 Incident 往下走。
  • 失败/决策事件rca_insufficientautonomy_deniedautonomy_handoffexecution_failedverify_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_exhaustedESCALATED

为什么要有上限:反复复发说明根因没被真正解决,继续自动化只是空转、白烧 token。交人工是更负责任的做法。

Step 6: 遗留状态平滑迁移

V3 的 open/mitigated/closed/suppressed 四态需要平滑过渡。两层保障:

  1. DDL 层:UPDATE incidents SET status = 'detected' WHERE status = 'open'app/db/postgres.py
  2. 代码层:coerce_status() 做运行时映射(兜住”DDL 跑之前就被读到的旧行”)
  3. 认不出来的一律当 DETECTED——最保守,从头走链路。

Step 7: 审计日志

每次 transition()incident_transitions 表写一行审计记录:from_statusto_statuseventactorcontext(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 告警的完整生命周期

1.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_startpreprocess_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_PASSEDRESOLVED→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_PULLBACKSstate_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 + 一个函数就能表达。引入 pytransitionsxstate 要学它们的 DSL、处理它们的序列化/持久化、适配它们的回调机制。收益是”少写 30 行代码”,代价是”多一个运行时依赖 + 多一个概念映射层”。当状态数 < 20、没有层次状态/并行状态需求时,raw dict 是更好的选择。

Q2: Incident 和告警是什么关系?一个 Incident 对应多少条告警?#

答: 一对多。多条告警通过 correlation_key 聚合到同一个 incident_group,每个 group 对应一个 incidentcorrelation_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_exhaustedESCALATED。拉回不清空已有证据——之前的调查结论保留,相当于”增量再调查”。

进阶题(区分度)#

Q3: transition() 的并发安全是怎么保证的?#

答: Postgres 行锁。transition() 在事务里起手 SELECT status, resolved_count FROM incidents WHERE id = $1 FOR UPDATEFOR UPDATE 拿到行锁后,做矩阵校验、写新状态、落审计日志,最后事务提交释放锁。

两个 Worker 同时对同一 Incident 发事件:先拿到锁的正常执行;后者在 FOR UPDATE 上阻塞,等前者事务提交后读到新状态再做校验。如果前者已经把状态推到了不兼容的位置,后者的校验会命中”非法迁移”——这是正确行为。

关键设计:锁粒度是单行(单个 Incident),不是表锁。不同 Incident 的迁移完全并行,吞吐量只受单 Incident 的事件频率限制,而一个 Incident 的事件频率通常很低(秒级间隔)。

追问: 行锁会不会成为性能瓶颈? 答: 不会。行锁的持有时间 = 读状态 + dict 查一次 + 写两行(incidents + incident_transitions)≈ 几毫秒。把 embedding 调用移到事务外后,P99 < 10ms。一个 Incident 的事件频率是秒级(告警间隔最短也是 Alertmanager 的 group_wait 30s),而行锁持有 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_APPROVALAWAITING_APPROVAL→REMEDIATINGAWAITING_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。原因:

  1. Event Sourcing 需要从事件重建状态(投影),重建逻辑本身需要维护和测试——我们只有 8 个状态,直接存当前值更简单。
  2. Event Sourcing 的 schema 演进(新增/修改事件类型)需要迁移所有历史事件——审计日志的 JSON context 字段更灵活。
  3. 不需要”从事件重建任意时间点的状态快照”——审计日志 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 从 INVESTIGATINGESCALATED(调查置信度不足):调查过程中积累的 3 个假设、12 条证据、2 次工具调用记录全部保留。人工接手后可以看”系统查了什么、为什么不确定”,不需要从零开始。这是”前向补偿”(向前推进到人工处理)而不是”逆向补偿”(撤销前序步骤)。

延伸问答

面试官:“Saga 的 orchestration 和 choreography 两种模式,你用的是哪种?” 答:Orchestration——状态机是中心协调器,每个模块做完向状态机报告,状态机决定下一步。Choreography 是去中心化的:每个模块自己决定”做完后通知谁”。Orchestration 的优势是流程可见——看迁移矩阵就知道全部合法路径。Choreography 的优势是解耦——新增一个步骤不需要改协调器。我们的 Incident 生命周期是严格线性的(不是网状的),Orchestration 更合适。


六、诚实边界#

  1. 并发安全只在单 Postgres 实例上验证。行锁保证同 Incident 串行,但如果 Postgres 做了读写分离或跨分片,行锁语义可能改变。目前系统是单实例 Postgres,不存在这个问题,但也意味着没做过分布式场景的验证。

  2. 没有状态机可视化工具。迁移矩阵只在代码和文档里,没有 dashboard 能实时展示一个 Incident 的状态流转图、当前状态分布、各状态停留时长。调试靠 list_transitions() + 看日志。

  3. ESCALATED 是终态但不是”结束”ESCALATED 后系统不再自动处理,但也不主动通知 oncall “这个事件需要你看”——通知依赖 Worker 里的飞书推送,不是状态机本身的职责。如果飞书推送失败,ESCALATED 的事件会静默等待 human_resolved

  4. 拉回上限 3 次是经验值,不是调参结果MAX_RESOLVED_PULLBACKS = 3 的选择没有数据支撑——“3 次以上算反复复发”是拍脑袋定的。生产环境的合理值可能因业务不同而不同(高频抖动场景可能需要更高上限)。

  5. 审计日志没有独立的完整性校验。如果 transition() 里写审计日志的 SQL 失败(但主表更新成功),状态会变但审计记录缺失。目前两者在同一个事务里,理论上不会出现不一致——但如果未来拆分(比如审计日志走 Kafka),就需要额外的一致性保证。

  6. 状态机是同步的,不支持”定时超时迁移”。比如 REMEDIATING 超过 30 分钟没有 execution_done → 自动 ESCALATED——这需要外部定时器(cron/定时任务)扫描超时 Incident 并发事件,状态机本身不做定时器。目前没有实现这个超时机制。