复盘是为了学习,不是追责
事件结束后,价值在于你决定改变什么。一场追责的复盘会得到一个防御性的团队;一场问「我们能改变什么,让这种事不再发生」的复盘,会得到持久的改进。 追责扼杀诚实的报告 一个人害怕被追责时,记录就会缩水,真正的原因会被藏起来。安全的选择是只讲对自己有利的部分,于是时间线就断了。复盘里的时间线应当是固定的事实,人只是系统里的一个受约束部件,而不是失败的原因。 两个机制最容易造成失真。一是后见之明:结果已知之后,每一步看起来都像明显错误,但当事人在当时并没有这个结果。二是把某个人当成最终原因就停下来。单个人的动作几乎总是叠加在没写检查、轮班过载、交接含糊、预算把工作挤到一起之上。有用的复盘会一路追问,直到答案是能被改变的某个设计决定或流程缺口,而不是一个名字。 复盘的结构 一份合格的复盘文档有四个部分。时间线是按时间顺序的中立记录,写清楚谁在什么时候做了什么、看到了什么,和解释分开。原因部分把触发事件与促成条件分开,深层修复通常藏在这些条件里。行动计划是最重要的一部分:每条有一名负责人、一个期限和一个可验证的完成条件,是一条不完成就会失败的明确任务,而不是一条散会后就消失的看了再说。 两道评审关口保证它不糊弄。第一道重读时间线并逐一核对,看每条行动是否真的对应一个促成原因,而不只是最表面的症状。第二道淘汰无法验证的行动,比如多小心、以后注意,把它们变成具体的改动:补上检查、收紧告警、加测试、更新手册。没有负责人也没有验证步骤的条目,只是愿望,不是变更。 后续跟进才是重点 碰头会是中间点,不是终点。只开会不跟进的复盘会重复上演:几周后同一个故障换一层皮回来,同一张图、同一个告警、同一个意外。真正闭环的做法是约定一周后复查,每条行动由负责人报告真正落地的变更。有些变更上线后不成立,文档也应该写进去,因为只要这条跟进在不断修正并换成更好的方案,它依然是一个闭环。 能长期坚持的团队,会在日历上放一个短的、固定周期的复查,并只用变更是否落地、是否验证来评价一次复盘,而不会用开会时的感觉。真正有用的信号是同类原因的复发率在下降,而不是总故障数在下降——因为诚实上报的团队可能前期显得更糟,恰恰要保持这种诚实,才是这套做法存在的意义。 坏掉的样子 追责式复盘有四种典型坏法。第一,时间线缩水:防御性删改去掉不确定的观察,只剩告警到修复两点,中间的判断、重试和交接全消失。如果文档里看不到任何人的困惑、误判和交接,说明时间线是被过滤过的,不是记录下来的。第二,行动项没有负责人和验证:写着加强监控,却没有名字、期限和检查方法。根因是把文档当成结果,而不是把变更当成结果;识别方法是看每条行动是否不用动生产就能标完成,能,就是愿望。第三,只修触发事件,不修促成条件:回滚了发布,却留下缺失的门禁、误报的告警和含糊的手册。逐条问每个促成条件对应哪条改动,如果行动只对得上触发事件,就是不完整。第四,后续跟进悄悄流产:复查会从不排期,负责人被别的事带走,几周后同因换皮再来。带着未关闭行动清单的同形故障,就是它的签名。 快速验证 把下面代码存为 postmortem_check.py,在空目录运行 python3 postmortem_check.py。它只用标准库,在内存里构造一好一坏两份复盘,不写任何文件。 def check_review(doc): errors = [] for key in ("timeline", "causes", "actions"): if not doc.get(key): errors.append(f"missing {key}") for i, action in enumerate(doc.get("actions", [])): for field in ("owner", "due", "proof"): if not str(action.get(field, "")).strip(): errors.append(f"action {i} missing {field}") return errors GOOD = { "timeline": ["00:01 alert fired", "00:07 rollback done"], "causes": {"trigger": "bad deploy", "conditions": ["no canary check"]}, "actions": [ {"task": "add canary check", "owner": "ana", "due": "2027-03-12", "proof": "CI blocks deploy without canary"}, ], } BAD = { "timeline": ["00:01 alert fired"], "causes": {"trigger": "bad deploy", "conditions": []}, "actions": [ {"task": "be more careful", "owner": "", "due": "soon", "proof": ""}, ], } def main(): good_errors = check_review(GOOD) bad_errors = check_review(BAD) assert good_errors == [], f"good sample should pass, got: {good_errors}" assert any("missing owner" in e for e in bad_errors), f"bad sample should flag owner, got: {bad_errors}" assert any("missing proof" in e for e in bad_errors), f"bad sample should flag proof, got: {bad_errors}" print("PASS: good review has timeline, causes, and fully owned actions") print(f"FAIL as expected: bad review -> {'; '.join(bad_errors)}") print("summary: 1 passed check, 1 failed check (expected 1 and 1)") if __name__ == "__main__": main() 好样本因含时间线、原因与负责人齐全的行动而通过;坏样本因缺负责人且验证为空而失败。断言要求好样本零错误,坏样本必报缺负责人与缺验证。预期看到一行通过、一行按名指出缺失字段的失败,以及一过一败的汇总。 ...