M5 · 事务化修复与验证闭环#
简历 Bullet Point: 为修复方案创建补偿事务,预生成补偿操作;执行前进行 CAS 漂移检测,失败自动补偿回滚;执行后通过三级递进门禁(命令成功→服务健康→指标恢复)逐级验证,形成执行-验证-回滚的自校验闭环。
开场钩子#
场景#
资源建模(M4)上线后第一次真机端到端测试:手机点飞书”同意”→ worker 执行 → 容器确实 restart 了——但 worker 日志打了 status=tool_error,处置记录标记为”失败”。
排查发现 parse_transaction_record 只认 dict 类型的返回值,但 langchain-mcp-adapters 封装 MCP 工具时会把结果包成内容块列表 [{"type": "text", "text": "<json>"}]——事务工具明明返回了 {"status": "committed"},被外层包了一层之后变成了 list,走进了 str 兜底分支被判定为 tool_error。结果是:容器真的被重启了,但系统认为处置失败了。
这暴露的不是一个代码 bug,而是一个架构问题:事务引擎做了 snapshot→CAS→apply→verify→rollback 五段式,但从”事务工具返回”到”系统理解结果”之间,还有一层序列化协议的解释鸿沟。同样的问题还出在 DB 侧:asyncpg 返回 jsonb 列是 str 类型,_proposal_from_row 直接 dict(pre_state) 会炸——dict("{"a":1}") 的结果不是你想的那个 dict,是 {'{'': ..., '"': ...}(逐字符遍历)。
修完这两处后的反思:一个事务化系统,如果执行成功但结果解析错误,后果比执行失败更严重——因为它可能触发不该触发的补偿回滚。这就是为什么我们需要的不是”执行层加一个事务”,而是从提案到执行到验证到回滚的完整闭环。
面试官切入#
“你提到了事务化闭环——能展开讲讲事务的完整生命周期吗?“
一、模块运作流程#
1.1 一句话定位#
本模块解决”修复操作执行完了但不知道有没有真正解决问题”的闭环问题。在资源建模(M4)的安全基础上,为每个修复操作提供完整的事务生命周期:快照→CAS 漂移检测→执行→验证→自动补偿回滚,多步计划用 Saga 编排保证原子性错觉,执行后通过三级递进门禁验证修复效果。
1.2 全景流程图(文字版)#
诊断完成 → LLM 处置意图 {resource, operation, params}
│
▼
┌─ propose_transaction (提案阶段, transaction.py) ──────────────────┐
│ validate_intent → policy.check_protected → 快照探测 pre_state │
│ → compute_recovery_plan → 组装 TransactionProposal │
└──────────────────────────────────────────────────────────────────┘
│
▼
审批中心 (pending) → 飞书卡片 / API → approved
│
▼
┌─ execute_approved_closed_loop (闭环执行, closed_loop.py) ────────┐
│ ① 幂等键: 同一审批 ID 复见 → duplicate_skipped │
│ ② 资源锁: URI 级互斥 → ResourceBusyError │
│ ③ 事务执行: run_transaction (engine.py) │
│ ┌─ snapshot (观测当前态) ─────────────────────────────────┐ │
│ │ precheck (CAS 漂移 + 状态机前置条件) │ │
│ │ backup (SNAPSHOT 型: File/DB/Deployment 落物理备份) │ │
│ │ apply (执行状态迁移) │ │
│ │ verify (后置条件轮询 + 稳定窗口抓 crash-loop) │ │
│ │ rollback (verify 失败 + 可逆 + auto_rollback → 补偿) │ │
│ └────────────────────────────────────────────────────────┘ │
│ ④ 三级递进门禁: command_success → service_health → metric_recovery│
│ + alerts_resolved (扩展级) │
└──────────────────────────────────────────────────────────────────┘
│
├─ committed + 门禁通过 → RESOLVED
├─ committed + 门禁未过 → needs_attention (标注, 不推翻事务)
├─ verify 失败 + 可逆 → 自动回滚 → rolled_back
├─ CAS 漂移 → state_drift (零变更中止)
└─ 回滚失败 → rollback_failed (需人工)
多步计划 (Saga):
execute_plan_closed_loop → run_saga
→ 顺序执行各步 (每步 锁+事务)
→ 某步失败 → 逆序补偿已 committed 步骤 (recovery_plan)
→ 终态: committed / failed / compensated / compensation_failedplaintext1.3 分步详解#
Step 1: 处置提案(transaction.py::propose_transaction)
- 做什么:把 LLM 输出的处置意图缝合成完整的 TransactionProposal,包含校验结果、策略判定、快照状态、Recovery Plan。
- 怎么做:
validate_intent校验意图合法性(资源 URI 解析 + REGISTRY 查操作 + 参数归一化)policy.check_protected查保护名单(进程/容器/服务/文件黑名单)state_probe探测当前态(best-effort,MCP 只读工具,失败不致命)compute_recovery_plan自动生成回滚预案:INVERSE 型 = 状态机逆操作(stop↔start),SNAPSHOT 型 = restore 到执行前备份(backup_ref 占位,执行时回填)sanitize_verify_metric消毒 planner 声明的指标验证意图(缺 promql / threshold 非数字 → None)
- 为什么这么做:提案阶段是执行前的最后一道验证,也是 Recovery Plan 的生成时机。把校验、策略、回滚方案在这里一次性算完写进提案,审批人看到的是完整的”将发生什么 + 怎么回滚”,worker 执行时从 DB 取出提案重校验(
revalidate,纵深防御——不信任从 DB 来的载荷)。
Step 2: 事务引擎(engine.py::run_transaction)
- 做什么:每个写操作是一个完整的五段式事务,对调用方而言要么 committed、要么已回滚、要么明确失败。
- 怎么做:
- SNAPSHOT:调
provider.get_state()观测当前态(回滚依据 + CAS 基准),失败 →precheck_failed - PRECHECK:CAS 漂移检测(
expected_state≠ 当前cas_field→state_drift零变更中止)+ 状态机前置条件(preconditions) - BACKUP:SNAPSHOT 型资源(File/DB/Deployment)落物理备份,备份失败即中止——快照恢复型操作没有备份 = 失去回滚能力,不允许裸奔。备份就绪后回填 Recovery Plan 的
backup_ref占位 - APPLY:调
provider.apply()执行状态迁移 - VERIFY:后置条件轮询(
verify_retries次,每次间隔verify_interval_sec)+ 稳定窗口(到达目标态后再等stabilization_sec再确认一次,抓 crash-loop 假成功) - ROLLBACK:verify 失败 +
reversible+auto_rollback→ 调provider.rollback(pre_state, artifact)自动补偿
- SNAPSHOT:调
- 为什么这么做:对标 Terraform 的 plan/apply + Saga 补偿事务。七个终态(committed / rolled_back / rollback_failed / verify_failed / state_drift / precheck_failed / failed + plan-only 的 planned)保证每次执行都有明确归宿。每步写 journal,全程可审计。
- plan-only 模式:
plan_only=True只走 snapshot + precheck + CAS,零变更返回预览(对标terraform plan),审批人看到的是执行时刻的真实状态和”将要发生什么”。
Step 3: 并发护栏(locks.py)
- 做什么:CAS 之外的两道护栏——资源锁防交错推进,幂等键防重复执行。
- 怎么做:
- 资源 URI 级锁:
ResourceLockManager在执行入口互斥,同一container://nginx同时只允许一个处置流。后端分层:默认内存实现(单 worker),Redis SET NX EX 可选(多 worker),Redis 故障自动降级内存并告警。TTL 防死锁(默认 600s)。 - 幂等键:
IdempotencyGuard首见放行、复见duplicate_skipped。是 DB 原子认领(claim_for_execution)之外的执行层第二道兜底。
- 资源 URI 级锁:
- 为什么这么做:CAS 挡的是”状态漂移后仍执行”,挡不住”两个处置流交错推进同一资源各自 CAS 都通过”。三层防重:投递去重(
approvals.find_inflight)→ DB 原子认领 → 执行幂等键。
Step 4: 多步 Saga 编排(saga.py::run_saga)
- 做什么:多步处置计划的原子性错觉。计划 = [扩容, 重启, 改配置],第 2 步 committed 后第 3 步失败——单步事务各自回滚自己,但已 committed 的前序步骤没人管。
- 怎么做:
- 顺序执行各步,每步 status == committed 才推进
- 某步失败 → 该步自身回滚由单步引擎负责,Saga 负责它之前已提交的步骤
- 维护已 committed 步骤栈,逆序执行各步的
recovery_plan - 两条不盲干红线:无 recovery_plan 跳过并标
compensation_failed;backup_ref 占位未回填(<执行时自动备份后回填>)拒绝盲补偿 - 终态四种:
committed(全成功)/failed(零变更遗留)/compensated(全补偿成功)/compensation_failed(需人工)
- 为什么这么做:对标 Saga orchestration 模式(补偿决策集中在编排器,而非 choreography 分散在各步)。纯注入式设计——
execute_step/execute_recovery由调用方提供,本模块不 import 任何执行通道。
Step 5: 三级递进门禁(regression.py)
- 做什么:事务 committed 不等于问题解决,三级递进验证修复效果。
- 怎么做:
- command_success(第一级):事务 journal 的 apply 步 ok=True + post_state 确认状态变更。没有 journal 或 post_state 是预测值 → inconclusive(跳过,不判失败)
- service_health(第二级):资源起来 ≠ 服务真在服务。按资源 scheme 选只读探针(container →
container_inspect,service →service_status),带重试(默认 3 次 / 10s 间隔)。探针不可用 → inconclusive - metric_recovery(第三级):触发处置的指标是否回落到阈值内。需 planner 声明
verify_metric = {promql, threshold, op},通过prom_query查。无 spec 或工具缺失 → 不激活。带重试(默认 2 次 / 30s 间隔) - alerts_resolved(扩展级):查
prom_active_alerts,相关告警是否不再 firing。关联判定用保守宽匹配(资源 id 出现在告警任意字段)
- 分级语义:
required级失败即整体失败并终止后续(下游没意义再查);探针/后端不可用记inconclusive不判失败(fail-open,不因 Prometheus 挂了把成功处置误标失败);但资源态观测不到判失败(资源态是处置的直接对象,不 fail-open) - 为什么这么做:对齐开源 verificationGates。
build_standard_gates是 Runbook 的verify_steps与 closed_loop 共用的唯一组装入口——两条链路声明的 gate 名字必须能对上同一份实现。
Step 6: 闭环执行层(closed_loop.py)
- 做什么:把前面四块能力接进 live 执行链,worker 每条审批走这里。
- 怎么做:
execute_approved_closed_loop= 幂等键 → 资源锁 → 事务执行 → 回归验证(锁内执行,防验证期间被并发处置干扰)。execute_plan_closed_loop= Saga 编排 + 对最后一步做回归验证。 - 开关:
REMEDIATION_CLOSED_LOOP=true(默认开),关掉回退旧单步裸执行路径。
1.4 数据流 trace(具体输入走一遍)#
场景:告警 MySQL replica lag > 10s on db-slave-02,诊断后 LLM 决定重启从库容器
① 处置意图(LLM planner 输出)
- 输入:诊断上下文 + RCA 结论
- 处理:planner 输出结构化意图
- 输出:
{resource: "container://db-slave-02", operation: "restart", params: {timeout_sec: 30}, verify_metric: {promql: "mysql_slave_seconds_behind_master{instance='db-slave-02'}", threshold: 10, op: "lt"}}
② 提案(propose_transaction)
- 输入:上面的意图
- 处理:
validate_intent→ 解析 URI 为Resource(type="container", id="db-slave-02"),查 REGISTRY 得到container.restart的 OperationSpec,归一化timeout_sec=30(范围校验 0-300 通过),reversibility=IRREVERSIBLEcheck_protected→db-slave-02不在保护名单,放行state_probe→ 探测得到{"state": "running", "created": "..."}compute_recovery_plan→restart是 IRREVERSIBLE,返回None(审批卡标注”不可回滚”)sanitize_verify_metric→ promql 非空、threshold=10.0 合法、op=“lt” 合法 → 写入提案
- 输出:TransactionProposal
{resource: "container://db-slave-02", operation: "restart", reversible: false, pre_state: {"state": "running"}, recovery_plan: null, verify_metric: {promql: "...", threshold: 10, op: "lt"}, requires_human_confirm: true, ...}
③ 审批(飞书卡片)
- 输入:提案
- 处理:飞书推送卡片,标注”重启容器 db-slave-02(⚠️不可回滚)”+ ✅同意/❌拒绝按钮
- 输出:手机点同意 →
apply_decision→ status=approved
④ 闭环执行(execute_approved_closed_loop)
- 输入:approved proposal + approval_id
- 处理:
- 幂等键
first_time("exec:req_xxx")→ True(首见,放行) - 资源锁
guard("container://db-slave-02", holder="req_xxx")→ 获取成功 revalidate(proposal)→ 重跑 validate_intent + check_protected(纵深,不信 DB 载荷)run_transaction五段式:- SNAPSHOT →
{"state": "running"}✓ - PRECHECK → CAS:
expected_state="running"vs 当前 “running” 一致 ✓;前置条件{"running","stopped","paused","restarting"}∋ “running” ✓ - BACKUP → restart 无 snapshot_artifact,跳过
- APPLY →
docker restart db-slave-02 --timeout 30→ 成功 - VERIFY → 轮询 3 次,间隔 2s,第 2 次观测到
{"state": "running"},稳定窗口 1s 后再确认 → passed
- SNAPSHOT →
- 事务结果:
status=committed,journal 记录 5 步
- 幂等键
- 输出:
{status: "committed", succeeded: true, journal: [...], post_state: {"state": "running"}}
⑤ 三级递进门禁(_run_regression)
- 输入:committed 事务记录 + proposal
- 处理:
- settle 3s(沉淀窗口)
- command_success:journal apply 步 ok=True + post_state.state=“running” 与 effect 一致 → 通过 ✓
- service_health:
container_inspect("db-slave-02")返回含 “running” → 通过 ✓ - metric_recovery:
prom_query("mysql_slave_seconds_behind_master{instance='db-slave-02'}")→ 第 1 次返回 15(> 10,未达标),等 30s 重试,第 2 次返回 3(< 10,达标)→ 通过 ✓ - alerts_resolved:
prom_active_alerts()→ 无 db-slave-02 相关告警 firing → 通过 ✓
- 输出:
RegressionResult(passed=True, checks=[...])
⑥ 最终结果
- 事务 committed + 门禁全过 → Incident 状态推进到 VERIFYING → RESOLVED
- 审计落库
remediation_transactions表,含完整 journal + 门禁结果
1.5 技术选型决策表#
| 选了什么 | 备选方案 | 为什么选它 | 什么情况下换 |
|---|---|---|---|
| Saga Orchestration(集中编排补偿) | Saga Choreography(各步自决补偿) | 补偿决策集中在编排器,一个地方看全貌,审计清晰;choreography 分散在各步,补偿链断裂不可见 | 步骤间需要异步事件驱动且编排器成为瓶颈时 |
| CAS 乐观并发(snapshot→compare→apply) | 悲观锁(先锁后读后写) | 审批是异步的(TTL 24h),悲观锁持有时间不可控;CAS 零变更中止比持锁超时更安全可控 | 审批变为同步或执行密集到 CAS 冲突率过高时 |
| 内存锁 + Redis 降级 | 纯 Redis 分布式锁 | 单 worker 演示环境内存锁足够,Redis 故障不应阻塞处置能力(可用性优先);降级可见 | 多 worker 生产环境应换 Redis + Lua 原子释放 |
| 分级门禁(verify_gates) | 全部门禁并行 + 逻辑与 | 分级递进语义:required 级失败终止下游(下游没意义再查),避免在已知失败的情况下浪费探针调用和等待 | 门禁间无依赖且需要最短验证时间时 |
| fail-open(探针不可用 → inconclusive) | fail-close(探针不可用 → 失败) | 回归验证是增信手段,不应因 Prometheus 抖动把成功处置误标失败;但 inconclusive 必须可见(标注而非静默跳过) | 安全关键场景(如金融合规)需要 fail-close 时 |
1.6 口述脚本#
处置执行的核心挑战不是”怎么执行一个操作”,而是执行完了怎么知道有没有真正解决问题。[停顿]
我的做法是把每个修复操作做成完整事务——五段式:快照当前态、CAS 漂移检测、执行、验证、自动补偿。[留钩子:CAS]
CAS 防的是审批和执行之间的时间窗竞态——审批是异步的,批准时容器可能已被别人重启过,执行前比对快照,漂移了就零变更中止。多步计划用 Saga 编排——顺序执行,某步失败逆序补偿已提交的前序步骤。[留钩子:Saga 补偿]
事务 committed 后还有三级递进门禁:命令成功只说明”操作没报错”,service_health 说明”服务真的起来了”,metric_recovery 说明”触发告警的指标真的回落了”。每级 required 未过就终止下游。探针不可用记 inconclusive 而非失败——回归验证是增信手段,不能因为监控后端抖动把成功处置误判为失败。[等追问门禁细节]
并发侧有三层防重:投递去重、DB 原子认领、执行层幂等键。资源 URI 级锁防两个处置流交错推进同一资源——CAS 挡不住这种竞态。
二、踩坑实录#
坑 1:langchain-mcp-adapters 的内容块封装导致事务结果误判#
- 现象:真机端到端测试,容器确实被 restart 了,但 worker 日志标
status=tool_error,处置记录显示失败。 - 根因:langchain-mcp-adapters 封装 MCP 工具返回时会包成内容块列表
[{"type": "text", "text": "<json>"}]。parse_transaction_record只认 dict 类型,list 走进 str 兜底被判tool_error——事务工具明明返回了{"status": "committed"},但被中间层多包了一层。 - 修法:在
parse_transaction_record最前面加内容块提取:如果 record 是 list,从中抽出所有type=text的 text 拼接,再 JSON 解析(兜底ast.literal_eval处理 Python repr 格式)。见transaction.py:209-222。 - 教训:事务系统的结果解析层必须与序列化协议同演进。MCP 工具通过 JSON-RPC 返回,但 LangChain adapter 又套了一层 content-blocks 协议——两层序列化叠加产生了解释鸿沟。结果误判比执行失败更危险:它可能触发不该触发的补偿回滚。
坑 2:asyncpg 的 jsonb 列返回 str 而非 dict,逐字符遍历致崩#
- 现象:worker 从 DB 取审批载荷重建 proposal 时,
dict(pre_state)崩溃,报错element length 1。 - 根因:asyncpg 返回 jsonb 列值类型是
str(JSON 文本),而非 Python dict。dict('{"state":"running"}')不是解析 JSON,而是遍历字符串的每个字符构造 dict——一个字符当一个 key,长度 1 的字符串变成{'{'': None, '"': None, ...},然后在后续访问.get("state")时返回 None,导致级联错误。 - 修法:
_row_to_dict补全所有 jsonb 列(pre_state / recovery_plan / tool_args / verify_metric)的反序列化:json.loads(v) if isinstance(v, str) else v。 - 教训:ORM 隐藏的类型转换假设在裸 SQL 层不成立。asyncpg 是零魔法驱动,jsonb 返回的就是原始 JSON 文本;Django/SQLAlchemy 会帮你转 dict,asyncpg 不会。这种 bug 单测不容易覆盖——单测里的 mock 返回的就是 dict,只有真 DB 才暴露。
坑 3:verify 通过不等于问题解决——crash-loop 假成功#
- 现象:容器 restart 后 verify 轮询第一次就观测到
state=running,事务标 committed。但 30 秒后容器又挂了——它在反复 crash-loop,每次启动后都能短暂 running。 - 根因:verify 只看了一次就算通过,没有稳定窗口。容器从 restart 到 crash 的间隔(15-20 秒)比 verify 的第一次轮询间隔(2 秒)长得多,所以第一次总能看到 running。
- 修法:
run_transaction的 verify 加稳定窗口(stabilization_sec,默认 1 秒):到达目标态后 sleep 一段再观测一次。第二次不在目标态则不算通过,继续轮询。此外,三级门禁的 service_health 带重试(默认 3 次 / 10s 间隔),也能抓到稳定窗口外的延迟性 crash。 - 教训:状态验证的”看到 = 确认”假设对有状态服务是错的。容器/进程的 running 状态是瞬时快照,crash-loop 在足够短的窗口内永远 running。必须引入时间维度——稳定窗口 + 多次确认。
坑 4:SNAPSHOT 型 Recovery Plan 的 backup_ref 占位与盲补偿风险#
- 现象:Saga 补偿执行到一个 File.write 步骤时,recovery_plan 里的
backup_ref值是<执行时自动备份后回填>(一个占位字符串),file_restore工具收到后试图打开一个叫这个名字的文件——当然找不到。 - 根因:Recovery Plan 在提案阶段生成,此时还没执行,File 的备份还没落——backup_ref 只能放占位。正常流程中事务引擎执行备份后会回填真实 ref,但如果备份步骤被跳过(比如测试环境 mock 掉了 snapshot_artifact),recovery_plan 里就永远是占位。
- 修法:Saga 补偿前检查
_has_unresolved_placeholder(遍历 params 值,以<开头>结尾的视为未回填占位),命中则跳过该步并标compensation_failed+ “recovery_plan 占位未回填 (备份缺失), 拒绝盲补偿”。 - 教训:补偿事务的前提条件和正向事务一样需要校验。Saga 的教科书实现通常假设 recovery_plan 在执行时已经 ready,但 SNAPSHOT 型的 backup_ref 是执行时动态生成的——如果正向事务的备份步骤异常,recovery_plan 虽然存在但参数是占位。盲目执行占位参数的补偿比不补偿更危险。
坑 5:metric_recovery 门禁的幻觉 PromQL 降级设计#
- 现象:LLM planner 声明了一条 PromQL
sum(rate(http_errors_total[5m]))来验证处置效果,但这个指标名在我们的 Prometheus 里根本不存在——Prometheus 返回空数据。 - 根因:LLM 的 PromQL 本质上是幻觉——它根据上下文”猜”了一个看起来合理的指标名,但无法保证指标真实存在。如果 metric_recovery 门禁在查不到数据时判失败,那成功的处置会因为 LLM 的幻觉被误标为”未恢复”。
- 修法:三层降级链:①
sanitize_verify_metric消毒(缺 promql / threshold 非数字 → 置 None,该门禁不激活);② prom_query 返回无数据 →_parse_first_metric_value返回 None → 门禁记 inconclusive(跳过,不判失败);③ 即便查到了数据,op 非法也回落lt。让 LLM 发挥但错了不致命。 - 教训:LLM 生成的验证规则和 LLM 生成的执行命令一样需要降级兜底。verify_metric 是”增信”(有就用,没有不影响正确性),不是”门槛”(没有就不让过)。设计外部可选输入时,默认状态应该是”不激活”而非”激活但缺数据报错”。
三、量化评估#
3.1 评估数据集构建#
- Transaction 引擎:10 个测试覆盖全部 8 个终态路径(happy path / 自动回滚 / 回滚失败 / 不可逆 / CAS 漂移 / 前置条件 / apply 异常 / 备份失败 / backup_ref 回填 / 稳定窗口抓 crash-loop),数据来源
tests/test_remediation_engine.py。 - Saga 编排:8 个测试(全 committed / 逆序补偿 / 首步失败 / 补偿失败 / 不可回滚步骤 / 占位未回填拒绝盲补偿 / 异常处理 / 补偿禁用),数据来源
tests/test_remediation_saga.py。 - 三级门禁:6 个测试(全通过 / crash-loop / 告警仍 firing / 后端抖动 inconclusive / 观测失败判失败 / 无检查配置通过),数据来源
tests/test_remediation_regression.py。 - 闭环执行:8 个测试(单步 + 回归 / 回归失败 / 幂等去重 / 资源锁互斥 / 多步 Saga / 多步回归 / 计划幂等),数据来源
tests/test_closed_loop_execution.py。 - 生命周期映射:9 个测试(执行结果→Incident 迁移全映射 + Runbook 反馈闭环),数据来源
tests/test_remediation_lifecycle.py。
3.2 评估方法#
- 脚本:
pytest tests/test_remediation_engine.py tests/test_remediation_saga.py tests/test_remediation_regression.py tests/test_closed_loop_execution.py tests/test_remediation_locks.py tests/test_remediation_lifecycle.py(共 49 个测试) - 真机验证:本地 Postgres + Redis,CAS 漂移检测、门禁三态判定、Runbook 门禁失败打回全部跑通(详见
P0-IMPLEMENTATION-REPORT.md§4.6) - 可复现:
pytest一键跑,事务引擎和 Saga 全 mock 无外部依赖
3.3 结果与解读#
| 指标 | 值 | 来源 |
|---|---|---|
| Transaction 终态路径覆盖 | 8/8 全覆盖 | test_remediation_engine.py |
| Saga 补偿逆序执行 | ✅(含 placeholder 拒绝校验) | test_remediation_saga.py |
| CAS 漂移检测 | 审批期间态变 → 零变更中止 ✅ | 真机验证 |
| 稳定窗口 | crash-loop 容器在窗口内检出 ✅ | test_stability_window |
| 门禁 inconclusive 不等于失败 | ✅(监控抖动不推翻成功处置) | test_backend_flap_inconclusive |
| 回滚成功 → ESCALATED(非 RESOLVED) | ✅ | test_remediation_lifecycle.py |
局限性:metric_recovery 门禁只有 mock 测试,未对接真实 Prometheus。真实环境下 PromQL 查询延迟、数据完整性问题未实测。inconclusive 比例没有监控——如果探针长期不可用,所有门禁都 inconclusive → 处置结果永远 pass,失去验证意义。
四、面试问答#
基础题(必问级)#
Q1: “Tool = 完整事务”具体指什么?和普通的”执行一个命令然后检查结果”有什么区别?#
答: engine.run_transaction 把每个写操作做成五段式事务:snapshot(观测当前态)→ precheck(CAS 漂移 + 前置条件)→ backup(快照型资源落物理备份)→ apply → verify(轮询 + 稳定窗口)→ rollback(verify 失败自动补偿)。对调用方而言要么 committed、要么已回滚、要么明确失败——不存在”执行了但不知道结果”的中间态。七个终态,每步写 journal,全程可审计。
和”执行 + 检查”的区别在于三点:第一,CAS 漂移检测——审批是异步的,批准时状态可能已变,执行前比对快照,漂移就零变更中止。第二,自动补偿——verify 失败 + 可逆操作 + 已授权 → 自动调 rollback,不需要人工介入。第三,稳定窗口——观测到目标态后等一段再确认,抓 crash-loop 假成功。
追问 1: CAS 防的具体是什么竞态?能举个例子吗? 答: 典型场景:“批的是 A 态、执行时已是 B 态”。审批走异步解耦(TTL 24h),运维人员批准”停止容器 nginx”时 nginx 是 running,但等 worker 执行时 nginx 已经被别人手动重启过变成了 running(看似一样但进程内状态不同)——或者更危险的,nginx 已经 stopped 了,再 stop 是幂等的但 recovery_plan 记录的逆操作是 start,补偿时会错误启动。CAS 比对
expected_state(审批时快照的状态值)与当前pre_state[cas_field],不一致 →state_drift零变更中止。File 域用 sha256 做 CAS。
追问 2: plan-only 模式是干什么的? 答: 对标
terraform plan。run_transaction(plan_only=True)真实走 snapshot + precheck + CAS 但零变更,返回pre_state + 预期 effect + 可逆性 + recovery_plan。用在审批卡片上——审批人看到的不是提案时的静态描述,而是执行时刻的真实状态和”将要发生什么”。预览同样能检测 CAS 漂移。
Q2: 多步 Saga 的补偿逻辑是怎么工作的?#
答: 单步事务只对自己负责。多步计划 [停容器 A, 重启服务 B, 改配置 C],第 3 步失败时——它自己会回滚自己,但前两步已经 committed 的变更没人管。Saga orchestrator 维护已 committed 步骤栈,某步失败时逆序执行各步的 recovery_plan。
关键设计:recovery_plan 在提案阶段随处置计划一起生成,INVERSE 型的 recovery 是状态机逆操作(stop 的 recovery 就是 start),SNAPSHOT 型的 recovery 是 restore 到执行前备份(backup_ref 由事务引擎执行时回填)。这些 recovery 操作已随原审批整体授权,补偿时不再走审批——否则补偿环节等审批就失去了自动化意义。
两条不盲干红线:recovery_plan 为 None(不可回滚操作如 restart)跳过并浮出 compensation_failed;backup_ref 仍是占位 <执行时自动备份后回填> 也拒绝盲补偿。终态四种:全成功 / 零变更遗留 / 全补偿成功 / 补偿有失败需人工。
追问 1: 为什么选 Saga Orchestration 而不是 Choreography? 答: 运维场景的补偿决策需要全局视角——你需要知道”已经提交了哪些步骤、按什么顺序回滚、哪些步骤不可回滚”。Orchestration 把这些信息集中在编排器,一个地方看全貌,审计清晰。Choreography 把补偿分散在各步,步骤间通过事件通信——补偿链断裂不可见(一个步骤的补偿事件丢了,下游永远不知道该补偿)。运维处置不是高吞吐场景,编排器不是瓶颈。
追问 2: 补偿操作本身失败了怎么办? 答: 补偿失败的步骤标记到
compensations列表里(ok=False),但 Saga 仍继续补偿更早的步骤(尽力而为,不因一步失败放弃其余)。最终 Saga 终态是compensation_failed——浮出给人工。这里不做递归补偿(补偿的补偿),因为运维场景里补偿失败通常意味着状态已经不可确定,自动化越深越危险,人工接管是正确选择。
Q3: 三级递进门禁的 fail-open 策略是什么?为什么不是 fail-close?#
答: 结论先行:回归验证是增信手段,不是门槛。设计原则是”有证据增信、没证据不减信”。
具体来说:探针/后端不可用时记 inconclusive(跳过,不判失败),不因 Prometheus 挂了把成功处置误标失败。但有两个例外做 fail-close:① 资源态观测失败(资源态是处置的直接对象,观测不到 = 无法证明处置生效);② 事务 journal 的 apply 步 ok=False(命令本身报错,不是探针问题)。
inconclusive 不是静默跳过——它会标注到 checks 列表里("inconclusive": true),审计和监控可见。如果某个门禁长期 inconclusive,运维能发现是工具缺失还是后端稳定性问题。
追问: 什么场景下需要 fail-close? 答: 安全关键场景(金融合规、数据删除操作的验证)需要 fail-close——“不能证明安全就算不安全”。可以通过
Gate.required=True+ 探针内部不返回 inconclusive 来实现。当前的 fail-open 是运维止损场景的选择——处置是为了恢复服务,监控后端抖动阻塞恢复不合理。
进阶题(区分度)#
Q4: verify_metric 从 LLM planner 到闭环门禁的端到端链路是什么?幻觉怎么办?#
答: 链路:LLM planner prompt 声明 verify_metric = {promql, threshold, op}(可选,拿不准就省略)→ _intent_from_parsed 携带 → propose_transaction / sanitize_verify_metric 消毒写入提案 → 落库 approval_requests.verify_metric JSONB → worker _proposal_from_row 重建 → closed_loop._build_metric_gate 激活门禁 → prom_query 查指标。
幻觉降级三层:① 消毒层拦缺字段(缺 promql / threshold 非数字 → 整个置 None,门禁不激活);② prom_query 返回无数据 → _parse_first_metric_value 返回 None → 门禁记 inconclusive,不判失败;③ op 非法回落 lt。这是”让 LLM 发挥但错了不致命”的典型模式。
追问: 为什么不像开源项目那样硬编码通用阈值(CPU < 1.0, mem < 90%)? 答: 因为”处置生效后哪个指标该回到多少”是场景相关的。
MySQL replica lag > 10s的处置成功标准是 lag < 10s,而不是 CPU < 1.0。硬编码 = 臆测,臆测错了比不验证更糟(把成功处置标失败)。当前设计:门禁开关默认开,但只有提案带 spec 时才真查,否则跳过——既不臆测,又保留了有明确指标时自动验证的能力。
Q5: 并发场景下的三层防重具体怎么分工?#
答: 三层,越往前越轻量,越往后越重。
- 投递去重(
approvals.find_inflight):提案阶段查同资源或同事件是否已有进行中处置流(pending/approved/executing),有则直接duplicate_inflight拒绝入队——最轻量,一次 DB 查询。 - DB 原子认领(
claim_for_execution):worker 消费时用UPDATE ... WHERE status='pending' RETURNING ...原子认领——只有一个 worker 能抢到,其余空返回。这是 exactly-once 的主防线。 - 执行幂等键(
IdempotencyGuard.first_time):执行层最后一道兜底。worker 崩溃重投导致同一条审批被消费两次——DB 认领在第一次消费时已经 update 了 status,但如果 update 后 worker 崩溃、消息被重投,第二个 worker 认领成功(因为第一次的 update 可能已 commit 到 approved/executing)——幂等键拦住这种边界。
三层分工互补:投递去重防业务重复(多次同类告警),DB 认领防并发消费,幂等键防崩溃重投。
压力题(面试官挑战设计决策)#
Q6: 审批超时为什么不能直接等价 deny?#
答: 对止损类操作,“超时 = 拒绝”可能比”执行”更危险——容器 crash-loop 打爆下游每秒都在扩大爆炸半径。但自动放行更不可接受——无人确认的写操作违背 HITL 底线。
折中方案:escalate-then-deny(approval_timeout.py)。第一次超时 → 飞书发催办卡 + expires_at 续期一轮;二次超时 → deny(可逆操作的拒绝文案附 recovery plan 说明,提示可重新发起)。任何路径都不自动放行——这是安全底线。
追问: 那高紧急度的操作岂不是因为审批延迟错过最佳处置窗口? 答: 这确实是 HITL 的固有代价。缓解方式:①
impact_score高的审批催办优先且 TTL 更短;② 飞书长连接卡片支持手机一键审批(不需要打开电脑);③ Tier 1 Runbook 处置如果是可逆操作且 RCA 置信度高,可以配置requires_human_confirm=false跳过审批(目前默认开启审批)。但无论怎么优化,写操作无人确认就执行在当前阶段不可接受。
Q7: 这套事务机制的性能开销大吗?在告警风暴下会不会成为瓶颈?#
答: 几个层次回答。第一,开销的大头在 verify 轮询和门禁重试——3 次 verify(间隔 2s)+ 稳定窗口 1s + service_health 3 次(间隔 10s)+ metric_recovery 2 次(间隔 30s)= 最坏情况 ~100 秒。但注意告警风暴场景下大部分告警在 L2 预处理层就被去重/聚合/抑制了——真正到达处置层的是收敛后的少数根因。
第二,资源锁限制同一资源同时只有一个处置流——这不是瓶颈而是正确的并发语义。同一容器不应该被两个处置流交替操作。
第三,如果真的是瓶颈,可以调参而非改架构:缩短 verify_interval、减少 retry 次数、关闭 metric_recovery 门禁。这些都是配置项(config.py 中的 remediation_gate_* 系列),不需要改代码。
追问: 锁用的内存实现,多 worker 怎么办? 答: 当前默认内存锁,适用于单 worker 演示环境。多 worker 生产环境应换 Redis SET NX EX(
RedisLockBackend已实现)。已知问题:Redis 锁的 release 是 GET + DEL 非原子,理论上两步之间另一个 worker 可能抢到——生产应换 Lua 脚本原子释放。但锁的目的是防交错推进,即便极端情况下发生并发释放,CAS 仍是最后一道保障。
五、前沿概念与延伸#
4.1 Saga Pattern / Compensating Transaction#
是什么:Saga 是一种管理分布式事务的模式——把长事务拆成一系列本地事务,每个本地事务有对应的补偿操作。如果某步失败,逆序执行已提交步骤的补偿,达到”最终一致”的效果(而非传统 2PC 的强一致)。
为什么要这么做:运维处置计划通常是多步的(先扩容再重启再改配置),但跨系统的 2PC(两阶段提交)在运维场景不可行——你没法让 Docker daemon 和 systemd 同时 prepare-commit。Saga 用补偿替代回滚,放弃了中间态的原子性(“原子性错觉”),换来了可用性和容错性。
这么做的理由:相比 2PC,Saga 不需要所有参与者支持 prepare 阶段,适合异构系统(容器 + 主机服务 + 文件 + 数据库)。相比无编排的逐步执行,Saga 提供了”失败时已提交步骤怎么办”的确定性回答。
举例说明:本项目的 saga.py 实现的是 Orchestration 模式——编排器维护已提交步骤栈,某步失败时逆序调 recovery_plan。两条设计细节体现了工程化的 Saga 和教科书的差异:① 不可逆步骤(recovery_plan=None)不阻塞其余步骤的补偿(尽力而为);② SNAPSHOT 型的 backup_ref 如果是未回填占位则拒绝盲补偿。
延伸问答:
面试官:“Saga 有两种风格——Orchestration 和 Choreography,什么情况下用哪个?” 答:Orchestration 补偿决策集中在编排器,一处可审计,适合步骤间有顺序依赖、补偿需要全局视角的场景(运维处置、订单流程)。Choreography 各步通过事件通信自决补偿,去中心化,适合高吞吐、步骤间松耦合的场景(微服务 eventual consistency)。运维处置不是高吞吐场景,且”哪些步骤需要补偿、按什么顺序”必须集中决策——Orchestration 是唯一合理选择。
4.2 CAS(Compare-And-Swap)与乐观并发控制#
是什么:CAS 是乐观并发控制的核心原语——先读取当前值,修改时比对”我读时的值”和”现在的值”,一致才写入,不一致则中止(或重试)。数据库里的版本号并发控制、CPU 的原子指令 CAS、Terraform 的 plan/apply 一致性检查,本质都是同一个模式。
为什么要这么做:运维审批是异步的——提案到执行之间可能间隔几分钟到几小时。这段时间里资源状态可能已被手动操作、其他自动化、或环境漂移改变。如果不检测漂移就执行,后果可能比不执行更严重(Recovery Plan 基于错误的 pre_state 生成了错误的逆操作)。
这么做的理由:相比悲观锁(先锁后读后写),CAS 不需要在审批等待期间持锁——审批 TTL 24 小时,悲观锁持有时间不可控,其他处置流完全被阻塞。CAS 漂移中止是零变更的(state_drift 终态),安全性最高。
举例说明:File 操作域用 sha256 做 CAS——expected_state 是提案时计算的文件哈希,执行时重算当前哈希比对。内容变了 hash 不同 → 中止。不需要版本号概念,内容本身就是最精确的”版本”。
延伸问答:
面试官:“CAS 解决了漂移,但两个处置流同时通过 CAS 怎么办?” 答:CAS 挡不住交错推进——两个流各自读到相同的 pre_state,各自 CAS 通过,交替执行。这就是为什么还需要资源 URI 级锁(
locks.py):执行入口互斥,同一资源同时只允许一个处置流。CAS + 锁 = 乐观检测 + 悲观互斥的组合——锁保证执行的串行化,CAS 保证”审批时态 = 执行时态”的一致性。两者防的是不同维度的竞态。
4.3 Verification Gates(分级门禁模式)#
是什么:把执行后的验证拆成多个独立的、分级的检查点(Gate),按严重性递进执行。每个 Gate 有自己的通过条件、重试策略、失败语义(required/optional/inconclusive)。
为什么要这么做:“执行成功”和”问题解决”之间有好几层含义:命令没报错 ≠ 状态真的变了(命令返回 0 但什么也没发生)≠ 服务真的在服务(容器 running 但端口没监听)≠ 业务指标真的恢复了(服务起来了但积压的请求还没消化)。每一层需要不同的探针、不同的等待时间、不同的失败语义。
这么做的理由:相比单一的”全部通过才算成功”,分级门禁允许更精细的控制——required 级失败终止下游(下游没意义再查),optional 级失败不阻塞(但标注可见),inconclusive 级不做判断(探针缺失时的安全降级)。相比全并行检查,分级递进避免在已知失败时浪费等待。
举例说明:本项目的 build_standard_gates 组装三级:command_success(读事务 journal,同步)→ service_health(按资源 scheme 探真健康,带 3 次重试 / 10s 间隔)→ metric_recovery(查 Prometheus 指标,带 2 次重试 / 30s 间隔)。如果 command_success 都不过(命令本身报错了),去查 service_health 没有任何意义。
延伸问答:
面试官:“你的门禁是 fail-open 的——如果面试官问’那回归验证不就是个摆设吗?’” 答:fail-open 不是”什么也不做”——它是”有证据增信、没证据不减信”。关键区分:探针不可用(Prometheus 挂了)→ inconclusive,探针返回了数据但数据说没恢复 → 失败。前者说明验证能力暂时缺失,后者说明处置确实没有效果。把这两种情况混为一谈(都判失败)才是真的把验证变成摆设——因为每次 Prometheus 抖动都产生虚假的验证失败,运维会习惯性忽略”needs_attention”标记,等到真正的验证失败也没人看了。
六、诚实边界#
这套事务化闭环在单机 OrbStack 演示环境下端到端验证过,但距离生产部署还有明确的 gap:
-
多主机执行通道:所有操作对象是本机 docker/进程/文件/服务,没有 SSH/agent 下发通道,不能操作远程主机。生产环境需要接入 Ansible / Salt / SSH agent。
-
锁的生产化:Redis 锁的 release 是 GET + DEL 非原子(两步之间理论上可能被抢),生产应换 Lua 脚本
if redis.call("get",KEYS[1])==ARGV[1] then redis.call("del",KEYS[1]) end。 -
变更管控缺失:没有变更窗口 / 冻结期 / 与 CMDB 工单联动。生产环境的处置需要知道”现在是不是发布冻结期”、“这个操作需不需要挂 CMDB 工单号”。
-
告警关联是保守宽匹配:
_alert_matcher_for用资源 id 出现在告警任意字段来判断相关性——会误匹配(资源名出现在不相关告警的注解里)。生产应走告警指纹 / 标签精确关联。 -
Saga 补偿没有超时和重试:当前实现补偿失败就标
compensation_failed浮出人工。生产环境可能需要补偿操作本身的重试策略(指数退避 + 最大次数)。 -
metric_recovery 依赖 LLM 声明 PromQL:LLM 幻觉的 PromQL 虽然降级为 inconclusive 不致命,但也意味着这一层验证在很多场景下等于没有。未来可以从历史 Incident 的指标关联中自动推导验证规则,而非完全依赖 LLM 声明。
-
单步事务与多步 Saga 的选择对调用方不透明:worker 目前只走单步闭环,多步 Saga 的
execute_plan_closed_loop入口已实现但未接入处置 planner 的多步计划输出。