Postmortem Template: 每次生产事故复盘都用的七段式模板(附理由)

一款面向 SaaS 事故的固定结构复盘模板——背景 → 事故 → 调查 → 根因 → 修复 → 经验教训 → 关联模块。这一格式将每次事故转化为可复用的默认修复方案,让同一故障永不复发。

Huifer
Huifer
July 21, 202610 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,TanStack Ship 的独立开发者和维护者。 我从 2024 年开始写事故复盘,此前因为一次破损的计费流程导致两家企业客户流失。这一模板是最终定稿的版本——此后 TanStack Ship 基础设施上的每次事故都按此格式撰写,包括 D1 写锁风暴、Stripe webhook 重复扣款和邮件队列重放。

已核实来源:TanStack Ship GitHub 组织 · TanStack Start 文档 最后更新:2026-07-21 · 更新日志


TL;DR: 一份优秀的事故复盘是一份固定结构的文档,包含七个章节:背景(Background)、事故(Incident)、调查(Investigation)、根因(Root Cause)、修复(Fix)、经验教训(Lessons)和关联模块(Linked Module)。这一结构从设计上强制无责叙事,使每次事故可复现——更重要的是,将每场火灾转化为交付时的默认配置,确保同一故障永不复发。如果你用其他格式写下一份复盘,你正在浪费 SaaS 中最昂贵的反馈循环。


为什么固定结构比自由式复盘更好

TanStack Ship 计费模块的第一次生产事故是 Stripe webhook 竞争条件导致 18 位客户被重复扣款。我坐下来写复盘,结果写出了三页东拉西扯的散文——一半道歉,一半意识流,一半含糊的"我们应该监控一下这个"。我再也没有重读过。同样的竞争条件在接下来一个季度里又出现了两次。

第二次写事故复盘时,我用了固定七段式模板。团队(就我一个人,但这套规范可以扩展)在六个月后仍能直接阅读,从模板中复现 bug,并把复盘催生的防护措施作为默认配置交付上线。这就是本文要介绍的结构。

自由式复盘失败有三个结构性原因:

  1. 它们滑向道歉。 没有一个章节标注为"修复",写作者的默认模式就变成了解释和借口。读者带走的是写作者的情绪,而不是防止复发的代码变更。
  2. 它们隐藏时间线。 没有一个章节标注为"事故"并标注起止时间戳,读者就无法判断响应是好是坏。5 分钟的事故和 5 小时的事故在叙事形式下看起来是一样的。
  3. 它们永远不会成为产品默认值。 没有一个明确的"关联模块"章节,复盘就只是一份历史文件。有了这个章节,它就是一张交付工单。

固定结构是一种强制函数。每个章节都有其职责。如果你填不满某个章节,说明你还没有理解这起事故。如果某个章节很短,那里的工作做得不够扎实。结构即真相。

七个章节详解

以下是本文使用的章节顺序,以及每个章节的定位和它要回答的问题。

1. 背景(Background)——事故发生前系统处于什么状态?

两段文字。描述发生故障的系统、它承受的负载,以及对客户可见的接口面积。读者在看到出了什么问题之前,应该能够想象出拓扑结构和请求图。这里要命名产品、模块、第三方服务以及量级(例如"3200 名付费客户,计费路由 200 req/s,D1 SQLite 后端,Stripe 实时模式")。

2. 事故(Incident)——用户看到了什么?

带 HH:MM:SS 时间戳的项目符号时间线。从第一个用户可见的信号开始(500 错误飙升、webhook 重试邮件、Stripe 支持工单)。到烟雾散去的时刻结束。包含客户影响数量——支付失败次数、损失积分、退款数量。这里不需要散文,只有时间戳和数字。

3. 调查(Investigation)——追踪记录显示了哪些信息?

这是优秀复盘中最长的章节。逐步展示检查命令链、按时间窗口引用的仪表板截图,以及关键证据出现的时刻。引用实际的错误字符串。展示实际的 SQL 查询。目标是可复现。如果读者无法据此追踪到一次新的复现,这个章节就写得太模糊了。

4. 根因(Root Cause)——为什么会发生?

一段文字,通常三到五句话。不要用泛泛的英文描述(例如"消费者没有保留批次级预留,所以重试发送了重复的 Stripe 请求")。不要在这里推测——只陈述追踪记录和代码审查所证明的事实。如果你还不清楚,留空这个章节为"根因待定"并发布后续补充。

5. 修复(Fix)——代码和配置发生了什么变化?

代码差异、配置变更,或两者兼有。展示实际移动的代码行,包含文件路径和 commit hash。如果修复是一个队列,展示队列定义。如果修复是一个防护措施,展示防护代码。读者必须能够将你的修复应用到类似系统中。

6. 经验教训(Lessons)——团队学到了什么?

这是复盘产生价值的地方。两到四个要点。每个经验教训都应该是团队可以在下一次事故之前应用的规则,而不是空洞的说教。"所有 Stripe webhook 必须在 event.id 上实现幂等"是一条经验教训。"我们应该更小心地处理 webhook"不是。

7. 关联模块(Linked Module)——这个教训还存在于哪些地方?

TanStack Ship 专用。每个复盘都要回答:哪个模块现在编码了这个经验教训?D1 复盘关联到计费模块的队列备份写入路径。Stripe 复盘关联到 webhook 台账。关联模块章节是事故到产品默认配置的桥梁——正是这部分将一份复盘变成了交付产物。

模板骨架实战

以下是 TanStack Ship 基础设施上每个复盘都会搭建的 markdown 骨架。它放在仓库的 /docs/postmortems/<日期>-<slug>.md 下,成为可搜索历史的一部分。

markdown
# <事故标题>

**日期:** YYYY-MM-DD
**严重级别:** SEV-1 / SEV-2 / SEV-3
**作者:** Huifer
**状态:** 已解决 / 已缓解 / 进行中

## 背景
<系统 + 负载上下文 2 段>

## 事故
- HH:MM:SS — <第一个信号>
- HH:MM:SS — <寻呼 / 客户工单 / 仪表板告警>
- HH:MM:SS — <缓解措施已上线>
- HH:MM:SS — <完全解决>

客户影响:<指标 + 单位>

## 调查
<按顺序的追踪记录、查询、仪表板引用>

## 根因
<3–5 句英文原因的简要说明 + 证据>

## 修复
- <commit hash> — <一句话总结>
- <配置变更> — <一句话总结>

## 经验教训
- <规则 1>
- <规则 2>
- <规则 3>

## 关联模块
<现在编码该修复的 TanStack Ship 模块路径>

骨架有意保持最小化。每个章节都是一个占位符。如果你发现自己在一个章节里写了一页以上,要么这个章节是正确的修复发布位置(提交后返回继续完善),要么你在写一篇论文而非产物——把论文拆分出去。

何时写复盘

在故障解决后 72 小时内撰写。这不是随意的——这个窗口期内追踪记录仍然可用,调查上下文也还很新鲜。超过 72 小时,日志保留可能已经轮转了相关数据。一周后,"为什么我先看那个仪表板"的上下文就消失了。

触发点是解决,不是发现。你在事故被修复后写复盘,而不是在它仍然活跃的时候。事故期间,你的精力用于缓解。复盘是为未来准备的——火已经灭了。

即使是小事故也要写。为 30 分钟的事故写复盘的习惯,会锻炼出应对 30 小时事故的技能。模板在任何规模下都适用。

唯一例外:如果你还不知道根因,发布一份标注"根因待定"的不完整复盘,而不是等完美理解。局部信息会积累,沉默不会。

无责叙事,实操层面

"无责"在复盘文化中被大量使用。大多数时候它意味着"文档中不提人名"。这只是地板,不是天花板。无责叙事在实操中有三个具体做法:

  1. 用人名替换为系统角色。 "Alice 的迁移脚本弄坏了"变成"14:02 引入的 Schema 迁移导致索引被删除"。读者现在知道的是迁移,而不是人。这只是一次小的编辑,但框架效果很大。
  2. 用机制替换为意图。 "我们忘了加幂等性"变成"消费者在重试前没有保留事件 ID"。错误变成缺失的机制——可修复、可复现、可辩护。
  3. 用时间替换为责备。 "值班的人睡着了"变成"页面在 11 分钟内路由到一部开启飞行模式的手机"。现在的问题是值班路由如何配置,而不是一个人是否有错。

检验方法:大声朗读复盘,把团队名换成"Acme Engineering",检查文档是否仍然合理。如果你们团队的文化依赖于文档中出现谁的名字,说明结构需要更多打磨。

复盘如何变成 TanStack Ship 的默认值

我坚持写这个模板的原因是它已经回报了时间成本。2024 年以来的每份复盘都产生了一次代码变更,作为默认值交付。这一模式是机械性的:

  • "修复"章节产生一个 commit。
  • "关联模块"章节命名修复所在的位置。
  • TanStack Ship 的下一个版本将修复作为新项目的默认值交付。
  • 新项目无需阅读复盘即可获得这个经验教训。

这与开源安全态势的逻辑相同:事故为代码库买单。每次生产故障都为采用该模板的每个项目支付了防止下一次故障发生的默认值。对于在 TanStack Ship 基础设施上维护 12+ 个生产应用的个人开发者来说,这个循环是让复盘保持可负担的唯一方式。

TanStack Ship 复盘系列的具体案例:

这些默认值中的每一个都是一份变成交付产物的复盘。模板就是这个循环运转的漏斗。

这个模板不适合做什么

这个模板刻意不做以下事情:

  • 它不是状态更新。 状态更新属于聊天工具或状态页,不属于复盘。状态更新关乎当下;复盘关乎过去。
  • 它不是对外公关声明。 当事故对客户公开影响时,对外版本通常是一份更短、更平淡的文档。内部复盘应保留工程真相;对外声明可以是一份经过清洗的摘要。
  • 它不是客户道歉。 "修复"章节才是道歉。正确的道歉是那个 commit hash,它防止同一客户再次遇到同一个 bug。
  • 它不是回顾会。 回顾会问"作为团队我们应该如何不同地做事"。复盘问"系统应该拒绝做什么"。回顾会改变文化;复盘改变代码。

为什么我删掉了大多数模板都有的"检测"章节

从 SRE 书籍中借鉴的复盘模板通常会在背景和事故之间添加一个"检测"章节:"我们是如何发现的?"我从这个模板中移除了它——如果你把时间线写好,这个问题已经在"事故"章节中回答了。

如果时间线写着"14:02——PagerDuty 从计费路由 SLO 燃烧告警触发",这就是检测。如果时间线写着"14:42——Stripe 开案称 18 名客户提交了重复扣款争议",这也是检测。把检测写成独立章节既增加了重复工作,又产生了重复内容。

例外情况:检测失败,客户先发现了 bug。在调查章节下加一行:"检测缺口——首个信号来自客户报告,时间为 HH:MM:SS,内部未触发任何告警。"然后继续。经验教训属于经验教训章节,不是结构槽位。

如何阅读本系列其余文章

如果你正在评估 TanStack Ship,想知道该模板的默认值是否来自真实事故数据,复盘系列就是审计跟踪。每篇文章都遵循此模板,命名生产条件,包含实际数字,并关联到现在编码该修复的模块。当你对比 TanStack Ship 中的模块与竞争对手的模板时,应该问问对方能否展示每个功能背后的事故驱动默认值——大多数做不到。

对于独自运营生产 SaaS 的开发者,我建议保持以下最低复盘节奏:

  • 每次生产事故后,在痕迹还新鲜的时候用这个模板在 48 小时内写复盘。
  • 每季度回顾过去 90 天的关联模块章节——这份清单才是真正的产品路线图。
  • 引入新依赖时,问问自己这个依赖能否防止最近一次复盘中记录的事故。如果答案是不能,那这个依赖就不值得占用你的槽位。

规范本身很小,但对单人工程团队来说,长期积累下来的效果会非常显著。


阅读第一篇事故报告: /blog/postmortem-d1-write-lock——200 req/s 下的 D1 写锁风暴,以及成为计费模块默认值的队列备份修复方案。或者浏览完整的事故复盘归档和功能模块列表,看看哪些默认值来自哪些火灾事故。

如果你想要这个七段式模板作为一个独立文件,可以直接放入自己的仓库,模板随 TanStack Ship 仓库的 boilerplate 一起提供,位于 /docs/postmortems/TEMPLATE.md TanStack Ship 仓库中。如果你正在比较不同 boilerplate 默认值的实力,vs-shipfast 和 vs-makerkit 的对比详细分析了复盘驱动的模块如何对抗竞品替代方案。