Skip to content

故障复盘:把当时发生的事,变成可以验证的改进 ​

“配置写错了,已经回滚,后续加强检查。”这几句话可以结束一次讨论,却很难防止下一次故障。它没有说明错误配置为什么能进入生产、为什么没有提前发现、什么信号帮助了恢复,也没有告诉别人“加强检查”什么时候才算完成。

故障复盘要留下两样东西:一份经得起核对的事件解释,以及一组有人接手、能够验收的改进。前者减少下次排障时重新猜测的成本,后者改变使事故发生或扩大影响的条件。本文不复述任何真实用户事故;贯穿全文的连接池案例和 JSON 数据都明确是合成教学材料。

Go 实验只检查记录的结构:时间线、证据字段、负责人、期限和验收结果。环境为 Go 1.27.2、Linux/amd64;通过检查不代表因果分析正确,更不代表事故已经避免。

恢复服务与解释事故,是两个不同阶段 ​

故障发生时,优先控制用户影响。值班者可以根据充分迹象先回滚,而不必等到完整证明。复盘时则要重新检查:当时的迹象是什么,动作为什么合理,恢复证据是什么,还有哪些解释没有排除。Google SRE 的事故管理与复盘章节把响应、记录和后续学习联系起来,但没有要求应急阶段等待最终根因。[1][2]

产物主要用途应包含什么不应混入什么
事故状态记录支持正在进行的恢复与沟通当前影响、负责人、已做动作、下一步未标注的确定性根因宣告
时间线保存可以核对的事件顺序时间、事件、证据、当时获知的信息用事后知识改写当时的判断
原因分析解释触发、放大和防护缺口机制、支持与反对证据、待验证假设“某人不仔细”作为分析终点
改进项把学到的东西落实具体变化、负责人、期限、验收证据无负责人、无完成条件的口号

是否做完整复盘,可以事先约定触发条件:显著用户影响、数据完整性风险、长时间恢复、重复事故,或虽未造成损失却暴露重要防护缺口的险情。小问题也值得留下短记录,但每次抖动都召开长会会消耗处理真正风险的时间。这是工作量分配,不是用事故等级决定某个人是否值得被追责。[2:1]

无指责,是为了还原决策条件 ​

无指责复盘要求参与者描述他们当时看见什么、相信什么、试图保护什么。它有一个实际理由:如果人们预期如实说明会被羞辱,就更容易隐藏细节;而被隐藏的细节往往恰好解释了系统怎样让一个错误看起来合理。Etsy 的实践文章和 Google 的复盘文化都强调这种信息获取方式。[2:2][3]

这不等于没有责任。应急操作需要授权,改进项需要负责人,错误记录需要更正;故意违规等问题也可能需要另行处理。只是学习性复盘不应变成当场审讯,更不应把“某人点击了按钮”当成技术与组织分析的终点。

写法为什么解释不足更有帮助的问题
开发粗心,把 40 写成 4没解释为什么合理范围没有校验配置来自哪里,界面是否展示单位和影响,是否有规则可拦截
值班没有及时发现没说明什么信息可见、何时可见告警覆盖哪条路径,通知是否送达,阈值与流量是否匹配
运维恢复太慢把多个等待阶段压成个人表现查证、权限、回滚、数据恢复分别用了多久,哪里可提前准备
加强意识,避免再犯无法验收,也不改变系统约束能否加入校验、演练、自动回滚或更清楚的操作反馈

更具体的问法并不自动证明“系统有错、个人永远没错”。它让团队把精力放到可改变的条件上,并保留对事实的核对空间。

先把影响和证据固定下来 ​

复盘开头应让没有参与的人快速理解:什么功能在什么时间段、哪些范围内受影响;影响数字采用何种口径;服务怎样恢复;还有什么没有恢复。例如“失败请求数”不等于“受影响人数”,重试会让一次用户操作产生多条失败请求。只能拿到请求数时就写请求数,并说明人数未知。

原始证据需要尽早保留,因为日志会过期、指标会降采样、Dashboard 默认时间会移动。保存查询表达式、绝对时间范围、时区、过滤条件和关键截图或导出结果;同时记录部署版本、配置差异与执行记录。不要为了方便把完整生产日志、凭据或个人信息复制进向全公司开放的复盘正文。访问范围应与证据敏感度匹配。

时间线至少区分“发生时间”和“知道时间” ​

下面是合成夹具中的四条记录。所有时间都假定已统一到 UTC;fixture/... 是教学标识,不是实际生产证据链接。

事件时间记录/获知时间内容证据属性
10:0010:00测试数据中的部署把连接上限从 40 改为 4fact,fixture/deployment-A
10:0210:03测试数据中的请求错误增加fact,fixture/metrics-A
10:0211:00重试可能放大排队,需要再现hypothesis,fixture/hypothesis-H1
10:0810:08测试数据中的回滚完成fact,fixture/deployment-B

第三行不能放进第二行发生时的值班者头脑里。它是后来形成的解释,只能作为待验证假设。最后一行也只是回滚完成,不足以宣告用户体验已经恢复;缺少恢复后的 SLI 时,应明确写“恢复时刻尚未确定”。

分布式系统还可能有时钟偏差、日志延迟到达和不一致的时间精度。遇到冲突,保留原始时间、来源和误差范围;不要为画一条整齐直线而虚构毫秒级顺序。代码里的单调时间线检查只适用于已经归一化的教学记录,不能代替真实系统的时钟分析。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A[部署记录<br/>10:00 配置变更] --> T[按事件时间归一化]
    B[指标导出<br/>10:02 错误增加] --> T
    C[操作记录<br/>10:08 回滚完成] --> T
    H[11:00 形成假设<br/>重试可能放大排队] --> Q[独立列出假设与验证方法]
    T --> F[已知事实时间线]
    F --> Q
    Q --> V[再现、反证、补充观测]
    V --> D[原因解释与剩余未知]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A[部署记录<br/>10:00 配置变更] --> T[按事件时间归一化]
    B[指标导出<br/>10:02 错误增加] --> T
    C[操作记录<br/>10:08 回滚完成] --> T
    H[11:00 形成假设<br/>重试可能放大排队] --> Q[独立列出假设与验证方法]
    T --> F[已知事实时间线]
    F --> Q
    Q --> V[再现、反证、补充观测]
    V --> D[原因解释与剩余未知]

这张图有意没有把“时间相邻”画成“已经证明因果”。证据整理先回答发生了什么,机制分析才回答为什么。

根因分析:把触发因素与促成条件拆开 ​

在合成案例中,配置变更是明显线索。但仅凭变更后错误升高,不能排除同一时段流量增长、依赖变慢或另一项发布。回滚后好转会增加对配置解释的信心,仍然要核对回滚是否同时改变了实例数、连接和流量。

复杂系统往往有多个条件共同作用。Richard Cook 的原始文章强调,事故通常不是单一因素独立造成,事后看起来明显的线索在事前未必显眼。Google SRE 也允许一个事故有多个可修复的根因。采用这些观点,是为了避免找到一个名字后就停止检查,不是要求画出无穷大的因果图。[4][5]

层次合成案例中的候选问题需要什么证据
触发连接上限是否真的由 40 降到 4生效配置、发布记录、实例覆盖范围
作用机制并发处理能力降低是否造成等待等待队列、连接占用、获取耗时及受控再现
放大因素重试是否增加了请求量与排队原始请求与重试计数、重试预算、调用链
检测缺口为什么没有更早发现SLI 覆盖、告警规则、抓取与通知时间
防护缺口为什么发布检查未阻止不适用的配置配置约束、容量假设、测试覆盖、灰度范围
恢复条件哪个动作让用户影响实际消失操作时间、恢复后的 SLI、积压和一致性检查

“连续问五个为什么”可以作为提问工具,但不必强行变成单线五层:为什么队列增长与为什么没有告警可能是两条不同的路径。分析应该停在证据支持、团队有能力改变的机制上,把仍不确定的关系留在待验证清单。[2:3][4:1]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    C[已知:配置变更] -.待验证机制.-> W[候选:连接等待增加]
    R[候选:重试缺少预算] -.待验证放大.-> W
    W -.待验证.-> E[已知:错误增加]
    G[待核对:发布约束和容量测试缺口] -.可能使变更未被拦截.-> C
    E --> A[安排受控再现<br/>保留支持和反对证据]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    C[已知:配置变更] -.待验证机制.-> W[候选:连接等待增加]
    R[候选:重试缺少预算] -.待验证放大.-> W
    W -.待验证.-> E[已知:错误增加]
    G[待核对:发布约束和容量测试缺口] -.可能使变更未被拦截.-> C
    E --> A[安排受控再现<br/>保留支持和反对证据]

图里的虚线都是假设,不能抄进最终复盘当作已经证实的原因。一个有用的反证问题是:如果保持连接数不变,只增加流量,是否也出现同样现象?另一个是:受影响实例与未受影响实例是否真的只差这个配置?实验不一定能完全复制生产,但能帮助排除过于简单的解释。

改进项要改到一个具体条件 ​

“加强测试”可以拆成几件成本不同的工作:配置静态范围校验、与业务负载相关的容量测试、灰度期间的自动检查、恢复演练。它们覆盖的失效方式并不相同。静态校验便宜且反馈快,但一个合法值仍可能不适合某次流量;容量测试更接近运行条件,却需要维护场景和基线。优先顺序应结合风险、覆盖、成本和实施时间,不宜每次事故都新增一套庞大系统。[6]

不够明确的改进可跟踪的改进示例验收证据
防止配置错误为已约定容量场景增加连接上限校验4 在该场景被拒绝、40 通过,回归用例进入 CI
优化重试在受控依赖变慢场景检查并限制重试预算保存原始请求、重试数和等待时间,确认或推翻 H1
完善告警为漏掉的用户路径补 SLI 与规则回放故障回放能触发、正常与短抖动不过度触发
提升恢复能力将一次需要临时找权限的恢复步骤演练并记录新值班者按预案在测试环境完成并留下结果

例子里的 4 和 40 只服务于合成场景,不是连接池的普适合法范围。不能因为一次演示把所有小于 40 的配置都禁用。

每项至少记录一个对结果负责的人或明确角色、期限、依赖、完成条件、验证证据和当前状态。角色必须能落实到当期负责人;“后端团队”而没有接手者通常不够。代码合并可以是过程节点,只有改动按预期生效且验收证据到位才适合关闭任务。如果决定不做某项,应记录接受的风险、理由和复查条件,而不是悄悄从表里删掉。[2:4][6:1]

用一个小检查器暴露遗漏 ​

程序中的 Action 有 Owner、Due、Change、Acceptance、Status、Verification。它检查负责人不能为空,并拒绝没有验证证据的“已完成”:

go
if a.Owner == "" {
    return errors.New("action missing owner")
}
if a.Status == "done" && a.Verification == "" {
    return errors.New("done action missing verification")
}

实际运行输出:

text
synthetic=true events=4 actions=2 validation=ok
missing owner: action missing owner
premature close: done action missing verification

检查器能拦住空字段、重复 ID、非法状态和未排序的时间线,却不能判断“负责人是否真的接受”“验收文字是否有意义”“证据是否真实”。填入一串无关文字也能通过结构校验,所以它只能作为评审前的提醒,不能成为自动批准复盘的系统。

测试还故意构造了七种反例:没有负责人、模糊日期、没有验收标准、没有证据就关闭、事件缺少证据、把猜测伪装成不受支持的类别、打乱时间顺序。所有反例都被拒绝。这是在验证程序的约束,并没有模拟真实数据库故障。

复盘会怎样开,之后怎样才算结束 ​

会前把初稿和待核实问题发给参与者,邀请他们补充当时的信息。讨论时先校正事实,再检查解释,最后决定改进优先级。让没有直接参与事故但了解系统的人阅读,常能发现“我们都知道”的隐含前提。不要要求每个人在会上立即给出确定根因;不知道的地方,应有一个如何缩小未知的计划。[2:5][6:2]

一个实用的文档结构是:

  1. 事件摘要与用户影响,明确数字口径和未知范围。
  2. 检测、响应、缓解、恢复的时间线与证据。
  3. 当时哪些措施有效,哪些信息或防护缺失。
  4. 已验证的机制、促成因素和仍待验证的假设。
  5. 有负责人、期限和验收条件的改进项。
  6. 附录中的查询、版本、配置差异和受控访问的证据链接。

这是写作顺序建议,不是必须填满的表格。一个没有明确恢复时刻的事故,应保留这个空缺;一个没有用户影响的险情,应写清避免了什么风险,而不是编造损失数字使复盘显得重要。

会结束后还要安排检查:改进是否逾期、依赖是否卡住、测试是否真正落地、相同模式是否在别处存在。把一条修复拓展到其他服务时,也要核对它们是否具有相同条件,不能机械推广同一阈值或配置。

最后要区分两种完成:复盘文档已经核对清楚,和所有改进已经验证关闭。前者可以先完成,后者继续跟踪。没有新的证据时,不应宣称“根因已彻底解决”;有了新的证据,也允许回头修正原来的解释。保留这种可修正性,复盘才会成为下一次判断的可靠起点。

依据与验证范围 ​

以下原始资料于 2026-10-13 核对。流程建议依团队规模与风险调整,本文没有对某个真实团队的事故率作因果或效果评价。

本次 go run .、go test ./...、go test -race ./...、go vet ./... 全部通过。没有真实事故数据、生产取证、外部任务追踪器或网络调用;合成 JSON 中的角色、日期和证据标识仅用于教学。程序不验证证据存在、不计算真实损失、不证明原因,也不创建任何任务。

完整程序、测试与合成夹具 ​

把以下四个文件放在同一目录,运行上面的四个命令。synthetic.json 必须位于当前目录,测试会每次重新读取它,再构造反例,避免不同测试互相污染。

go.mod ​

go.mod
module example.com/observability/postmortem

go 1.27.0

main.go ​

代码块 · 110 行 · 点击收起 / 展开
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"time"
)

type Event struct {
	Time       string `json:"time"`
	ObservedAt string `json:"observed_at"`
	Statement  string `json:"statement"`
	Evidence   string `json:"evidence"`
	Kind       string `json:"kind"`
}
type Action struct {
	ID           string `json:"id"`
	Owner        string `json:"owner"`
	Due          string `json:"due"`
	Change       string `json:"change"`
	Acceptance   string `json:"acceptance"`
	Status       string `json:"status"`
	Verification string `json:"verification"`
}
type Review struct {
	Synthetic bool     `json:"synthetic"`
	Incident  string   `json:"incident"`
	Events    []Event  `json:"events"`
	Actions   []Action `json:"actions"`
}

func validate(r Review) error {
	if r.Incident == "" || len(r.Events) == 0 {
		return errors.New("missing incident or timeline")
	}
	var prev time.Time
	for _, e := range r.Events {
		when, err := time.Parse(time.RFC3339, e.Time)
		if err != nil {
			return errors.New("invalid event time")
		}
		observed, err := time.Parse(time.RFC3339, e.ObservedAt)
		if err != nil || observed.Before(when) {
			return errors.New("invalid observation time")
		}
		if when.Before(prev) {
			return errors.New("timeline not sorted")
		}
		prev = when
		if e.Statement == "" || e.Evidence == "" {
			return errors.New("timeline lacks evidence")
		}
		if e.Kind != "fact" && e.Kind != "hypothesis" {
			return errors.New("unknown evidence kind")
		}
	}
	seen := map[string]bool{}
	for _, a := range r.Actions {
		if a.ID == "" || seen[a.ID] {
			return errors.New("invalid action id")
		}
		seen[a.ID] = true
		if a.Owner == "" {
			return errors.New("action missing owner")
		}
		if _, err := time.Parse("2006-01-02", a.Due); err != nil {
			return errors.New("action missing valid due date")
		}
		if a.Change == "" || a.Acceptance == "" {
			return errors.New("action missing change or acceptance")
		}
		if a.Status != "open" && a.Status != "done" {
			return errors.New("unknown action status")
		}
		if a.Status == "done" && a.Verification == "" {
			return errors.New("done action missing verification")
		}
	}
	return nil
}
func load(path string) (Review, error) {
	var r Review
	b, err := os.ReadFile(path)
	if err != nil {
		return r, err
	}
	err = json.Unmarshal(b, &r)
	return r, err
}
func main() {
	r, err := load("synthetic.json")
	if err != nil {
		panic(err)
	}
	if err = validate(r); err != nil {
		panic(err)
	}
	fmt.Printf("synthetic=%v events=%d actions=%d validation=ok\n", r.Synthetic, len(r.Events), len(r.Actions))
	bad := r
	bad.Actions = append([]Action(nil), r.Actions...)
	bad.Actions[0].Owner = ""
	fmt.Println("missing owner:", validate(bad))
	bad = r
	bad.Actions = append([]Action(nil), r.Actions...)
	bad.Actions[0].Status = "done"
	bad.Actions[0].Verification = ""
	fmt.Println("premature close:", validate(bad))
}

main_test.go ​

go
package main

import "testing"

func TestFixture(t *testing.T) {
	r, e := load("synthetic.json")
	if e != nil {
		t.Fatal(e)
	}
	if e = validate(r); e != nil {
		t.Fatal(e)
	}
}
func TestNegativeFixtures(t *testing.T) {
	edits := []func(*Review){
		func(r *Review) { r.Actions[0].Owner = "" },
		func(r *Review) { r.Actions[0].Due = "next week" },
		func(r *Review) { r.Actions[0].Acceptance = "" },
		func(r *Review) { r.Actions[0].Status = "done" },
		func(r *Review) { r.Events[0].Evidence = "" },
		func(r *Review) { r.Events[0].Kind = "guess-is-fact" },
		func(r *Review) { r.Events[0], r.Events[3] = r.Events[3], r.Events[0] },
	}
	for i, edit := range edits {
		r, e := load("synthetic.json")
		if e != nil {
			t.Fatal(e)
		}
		edit(&r)
		if validate(r) == nil {
			t.Fatalf("negative fixture %d accepted", i)
		}
	}
}

synthetic.json ​

json
{
  "synthetic": true,
  "incident": "SYNTHETIC-001",
  "events": [
    {"time":"2026-10-01T10:00:00Z","observed_at":"2026-10-01T10:00:00Z","statement":"Synthetic deployment changes connection limit from 40 to 4.","evidence":"fixture/deployment-A","kind":"fact"},
    {"time":"2026-10-01T10:02:00Z","observed_at":"2026-10-01T10:03:00Z","statement":"Synthetic request errors rise in the test dataset.","evidence":"fixture/metrics-A","kind":"fact"},
    {"time":"2026-10-01T10:02:00Z","observed_at":"2026-10-01T11:00:00Z","statement":"Retries may have amplified queueing; reproduction is still required.","evidence":"fixture/hypothesis-H1","kind":"hypothesis"},
    {"time":"2026-10-01T10:08:00Z","observed_at":"2026-10-01T10:08:00Z","statement":"Synthetic rollback completes.","evidence":"fixture/deployment-B","kind":"fact"}
  ],
  "actions": [
    {"id":"ACT-1","owner":"role:service-maintainer","due":"2026-10-08","change":"Add configuration validation for the connection limit.","acceptance":"A value of 4 is rejected in the documented capacity scenario; a value of 40 passes, with a regression test.","status":"open","verification":""},
    {"id":"ACT-2","owner":"role:reliability-reviewer","due":"2026-10-15","change":"Reproduce bounded retries during a synthetic dependency slowdown.","acceptance":"Save request counts, retry counts and queue-delay observations; accept or reject H1.","status":"open","verification":""}
  ]
}

  1. Google SRE:Managing Incidents:响应中的职责、沟通和记录。 ↩︎

  2. Google SRE:Postmortem Culture:无指责、触发条件、记录与跟踪改进。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. John Allspaw,Etsy:Blameless PostMortems and a Just Culture:还原参与者当时的理解,而非事后归罪。 ↩︎

  4. Richard I. Cook:How Complex Systems Fail:多个促成因素、事后认知与复杂系统中的适应。 ↩︎ ↩︎

  5. Google SRE:Monitoring Distributed Systems:多个根因及用户症状与内部原因的区分。 ↩︎

  6. Google SRE Workbook:Postmortem Culture:复盘质量、行动项及组织落实。 ↩︎ ↩︎ ↩︎

保留各书来源与许可 · 示例运行条件以正文为准