一份“人为失误”的复盘,三个月后原样复发
《裂隙战场》的一次登录雪崩,复盘报告用了四十分钟就写完了:值班同学在大促前误改了一个缓存 TTL,导致排行榜缓存同时失效,回源打满数据库,登录接口超时。结论是“操作失误”,改进项是“加强变更审批与人员培训”。
三个月后,同一个系统再次雪崩。这次没有人误改 TTL:一次例行发布把缓存预热脚本的顺序调反了,效果与上次完全相同。报告里那条“加强培训”无法验收,也没有人能说清它到底有没有被执行。
这不是复盘写得不够长,而是复盘停在了第一层。“人为失误”不是根因,而是根因分析的起点。 真正要回答的是:为什么一个 TTL 能拖垮登录?为什么变更审批没有拦住?为什么监控没有更早发现?如果这三个问题没有答案,改进项就只能落在“人更小心”上,而人总会不小心。以下时间与比例均为叙事示例,不是真实运营数据。
这一节讨论复盘的边界、时间线与根因的写法、改进项的质量标准,以及如何用可运行的检查把“改进闭环”从口号变成可追踪的状态。第 6.3 节讲事故分级与响应组织,本节只讲事故结束之后的事。
复盘的边界:对事不对人,不等于没有责任人
“对事不对人”常被误解成“不追究任何责任”。它的准确含义是:复盘阶段只讨论系统与决策,不评价个人品格;但每个改进项必须有明确的责任人。 前者保护信息,后者保证闭环。
如果复盘的第一个问题是“谁干的”,后续信息就会失真:当事人会淡化自己的操作,旁观者会省略对自己不利的细节,时间线会变成互相防御的叙事。相反,如果只谈系统、不落到责任人,改进项就会漂在空中,没人认领,也没人验收。
边界可以写成三条规则:
- 时间线只记录事实与来源,不写“反应迟钝”“缺乏责任心”这类评价。
- 根因分析追问机制,不追问动机;动机问题交给人事流程,不混进技术复盘。
- 每条改进项必须有唯一负责人和截止时间,负责人可以是团队而不是个人,但不能为空。
这三条规则同时保护了心理安全与执行力度。缺了第一条,没人说真话;缺了第三条,复盘变成务虚会。
时间线:只写事实,不写评价
时间线是复盘的地基。它不需要长,但每一条都应该是可复核的事实:什么时间、谁、做了什么、依据是什么。
| 时间 | 事实 | 来源 |
|---|---|---|
| 10:02 | 登录错误率超过 5% 阈值,告警触发 | 监控 |
| 10:04 | 值班确认影响全服,拉起应急会议 | 值班记录 |
| 10:11 | 发现排行榜缓存命中率降至 12% | 监控 |
| 10:18 | 关闭登录链路的排行榜依赖,错误率回落 | 变更记录 |
| 10:41 | 缓存重建完成,服务恢复 | 监控 |
时间线里最容易被写坏的是“发现”与“判断”。例如“10:04 值班判断是缓存问题”不是事实,而是判断;事实应该是“10:04 值班查看了缓存命中率面板”。把判断混进时间线,会让后续分析误以为当时已经掌握了足够信息。
时间线还要覆盖三个关键点:发现、止血、恢复。 只写发现和恢复,会漏掉止血动作的耗时;只写止血和恢复,会看不清发现为什么慢。第 6.3 节的分级决定响应强度,这里的时间线决定复盘能不能算出每一段的延迟。
根因要拆三层
根因分析最常见的退化,是把“直接原因”当成“根因”。直接原因回答“怎么坏的”,系统性原因回答“为什么系统没兜住”,逃逸原因回答“为什么没更早发现”。三层都写,改进项才有落点。
| 层级 | 要回答的问题 | 示例(《裂隙战场》登录雪崩) |
|---|---|---|
| 直接原因 | 触发故障的机制是什么 | 排行榜缓存 key 同时过期,回源打满数据库 |
| 系统性原因 | 为什么这个机制能造成全局影响 | 登录链路同步依赖排行榜;缓存无请求合并;容量按均值设计 |
| 逃逸原因 | 为什么现有控制没有拦住或更早发现 | 压测未覆盖缓存集体失效;监控只看平均延迟,不看回源 QPS |
三层不是三个平行选项,而是一条因果链。直接原因解释现象,系统性原因解释放大,逃逸原因解释滞后。只修直接原因,下一次换个触发点还会坏;只修系统性原因,可能漏掉“为什么没发现”;只修逃逸原因,则是在给一个仍然脆弱的系统加警报。
拆根因时可以用“连续追问”,但要避免两个陷阱。第一个是追问到“人”就停止:为什么误改 TTL?因为审批没拦住;为什么审批没拦住?因为审批只看 diff 不看影响面。到这里才是可改进的系统问题。第二个是追求唯一根因:复杂事故通常有多个 contributing factor,强行收敛成一个,会丢掉真正值得修的环节。
为什么不能停在“人为失误”
“人为失误”之所以危险,不是因为它一定错,而是因为它不可验收、不可复现、不可自动化。你无法为“更小心”写测试,也无法在发布门禁里检查“责任心是否提升”。
把“人为失误”翻译成系统语言,通常能得到三类问题:
| 表面结论 | 可改进的系统问题 | 可验收的改进项 |
|---|---|---|
| 误改配置 | 配置缺少范围校验与影响面预览 | 配置 schema + dry-run 差异报告 |
| 忘记审批 | 审批流程可以被跳过 | 高风险变更强制双人审批 |
| 没看监控 | 关键指标不在值班面板上 | 面板与告警规则纳入发布检查 |
翻译的关键是问:如果换一个同样熟练、同样疲惫的人,在这个系统里做同样的操作,是否还会出错? 如果答案是会,那就不是人的问题,而是系统没有为人的局限性设计护栏。
这不是为个人开脱。故意违规、隐瞒事实仍然要按制度处理;但技术复盘的目标是让“普通人按正常流程操作”不会造成全局故障。这个目标只能靠系统设计达成。
处置得失:把运气和机制分开
复盘报告里常见“响应及时、处置得当”这类评价。它的问题不是不真实,而是没有信息量:下次遇到同类事故,读者无法从中知道该复制什么、该避免什么。
处置部分至少写两组内容:起作用的动作与没起作用的动作。
- 起作用的动作:关闭排行榜依赖后错误率回落,说明降级开关有效,但依赖是硬编码的,切换需要发版。
- 没起作用的动作:扩容登录服务没有缓解,因为瓶颈在数据库回源,不在登录进程数。
第二组比第一组更有价值。它揭示了团队当时的错误假设,也标出了监控或文档的缺口。很多“处置得当”其实是运气:如果故障晚发生十分钟,值班可能已经下班;如果缓存没有自动重建,恢复时间会更长。复盘要把运气成分指出来,否则组织会高估自己的恢复能力。
改进项的四要素
一条合格的改进项至少有四个字段:负责人、截止时间、验收标准、复发检测。缺任何一个,它都会退化成愿望。
| 字段 | 回答的问题 | 不合格示例 | 合格示例 |
|---|---|---|---|
| 负责人 | 谁对结果负责 | 平台组 | 张三(平台组),周会同步 |
| 截止时间 | 什么时候必须完成 | 尽快 | 2026-11-20 |
| 验收标准 | 怎么判断做完了 | 优化登录 | 登录链路移除排行榜同步调用,依赖拓扑断言通过 |
| 复发检测 | 下次同类问题靠什么发现 | 加强监控 | 回源 QPS 超过基线 3 倍时告警,并关联登录错误率 |
验收标准必须是可观察的事实,不是动作。 “增加缓存监控”是动作,“缓存命中率低于 50% 且回源 QPS 超过基线时在 2 分钟内告警”才是验收标准。前者可以永远“进行中”,后者只有通过与不通过。
复发检测是四要素里最容易被省略的。它回答的是:改进项做完之后,如果同类问题换个形态再来,系统能不能更早发现?没有检测器的改进项,只能防住已知的那一个入口。
改进项还要排优先级。优先级不是重要性的形容词,而是排队规则:
| 优先级 | 判据 | 排期要求 |
|---|---|---|
| P0 | 不修会再次造成资损、全服不可用或合规问题 | 当前迭代内完成 |
| P1 | 不修会显著延长发现或恢复时间 | 两个迭代内完成 |
| P2 | 降低维护成本、改善可读性 | 进入常规排期 |
P0 太多等于没有优先级。如果一个复盘产出十几条 P0,说明团队没有做取舍,后续只会全部延期。
改进闭环:跟踪、验收、复发检测
复盘结束不等于闭环。闭环至少包含三个动作:跟踪状态、验收结果、检测复发。
跟踪状态要有固定节奏。每周或每个迭代检查一次未完成项,重点看三类信号:逾期、负责人变更、验收标准被悄悄放宽。第三类最隐蔽:原本要求“登录链路移除同步依赖”,几周后变成“增加超时时间”,动作做了,但问题没解决。
验收结果要回到事故场景。最可靠的验收方式是重放或演练:在隔离环境里重现当时的触发条件,确认系统不再以同样方式失败。不能重放的,至少要有可查询的指标或断言。第 8.2 节会把这类断言纳入架构适应度函数,本节只要求每条改进项有可执行的验收动作。
复发检测要把“同类”定义清楚。建议用归一化签名,而不是自由文本标题:登录|排行榜缓存|超时 这样的签名,能在新事故发生时快速关联历史。签名相同不一定是同一个 bug,但足以触发一次对照检查。
可以用三个指标观察闭环健康度(以下为示例口径,不是行业基准):
| 指标 | 示例目标 | 说明 |
|---|---|---|
| 改进项按期关闭率 | 80% 以上 | 只看 P0/P1,P2 单独统计 |
| 平均关闭周期 | P0 两周内,P1 六周内 | 从复盘定稿起算 |
| 同类事故复发率 | 90 天内低于 10% | 以归一化签名统计 |
指标的作用不是考核个人,而是暴露流程瓶颈。如果按期关闭率长期偏低,可能是改进项排得太多,也可能是验收标准定得不现实;两种情况的对策完全不同。
可运行片段:模板校验、改进项跟踪与复发检测
internal/postmortem 把上面的规则落成可测试的检查。template.go 校验复盘是否具备时间线、影响、三层根因、处置得失与改进项;它会把“人为失误”这类只写直接归责的结论标记为 direct_blame_only,强制作者补上系统性原因。
actions.go 负责改进项跟踪与复发检测。每条改进项检查负责人、截止时间、验收标准、复发检测与优先级;逾期的未完成项会被标记。Recurrences 用归一化签名在历史事故里找窗口内的同类事件,结果按时间排序,便于在复盘会上直接引用。
// file: internal/postmortem/actions.go
// tier: L1
// verify: go test ./internal/postmortem/
package postmortem
import (
"sort"
"time"
)
// Priority 决定改进项的排队顺序,不是重要性的形容词。
type Priority int
const (
PriorityP0 Priority = iota // 防复发或防资损,必须先做
PriorityP1 // 缩短发现或恢复时间
PriorityP2 // 降低维护成本,可排期
)
func (p Priority) String() string {
switch p {
case PriorityP0:
return "P0"
case PriorityP1:
return "P1"
case PriorityP2:
return "P2"
default:
return "invalid"
}
}
// Action 是一条改进项。Detector 回答“下次同类问题靠什么发现”,
// 它是改进闭环与“加强培训”式空话的分界线。
type Action struct {
ID string
Owner string
Due time.Time
Acceptance string
Detector string
Priority Priority
Done bool
}
// Validate 校验单条改进项;now 用于判断是否逾期。
func (a Action) Validate(now time.Time) []Finding {
var out []Finding
add := func(code, detail string) {
out = append(out, Finding{Subject: a.ID, Code: code, Detail: detail})
}
if blank(a.ID) {
add("id_missing", "action id is required")
}
if blank(a.Owner) {
add("owner_missing", "owner is required")
}
if a.Due.IsZero() {
add("due_missing", "due date is required")
} else if !a.Done && a.Due.Before(now) {
add("overdue", "due date has passed")
}
if blank(a.Acceptance) {
add("acceptance_missing", "acceptance criteria are required")
}
if blank(a.Detector) {
add("detector_missing", "recurrence detector is required")
}
if a.Priority < PriorityP0 || a.Priority > PriorityP2 {
add("priority_invalid", "priority must be P0, P1 or P2")
}
sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
return out
}
// Track 汇总一组改进项的校验结果并按 ID、Code 排序,便于在发布门禁里比对。
func Track(actions []Action, now time.Time) []Finding {
var out []Finding
for _, a := range actions {
out = append(out, a.Validate(now)...)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Subject != out[j].Subject {
return out[i].Subject < out[j].Subject
}
return out[i].Code < out[j].Code
})
return out
}
// Incident 是用于复发检测的历史事件。Signature 是归一化后的故障特征,
// 例如 "login|leaderboard-cache|timeout",而不是自由文本标题。
type Incident struct {
ID string
At time.Time
Signature string
}
// Recurrences 返回窗口内与当前事件同特征的历史事件 ID。
// 只统计当前事件之前发生的事件,结果按时间先后排序。
func Recurrences(history []Incident, current Incident, window time.Duration) []string {
type hit struct {
id string
at time.Time
}
var hits []hit
for _, h := range history {
if h.ID == current.ID || h.Signature != current.Signature {
continue
}
delta := current.At.Sub(h.At)
if delta < 0 || delta > window {
continue
}
hits = append(hits, hit{h.ID, h.At})
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].at.Equal(hits[j].at) {
return hits[i].id < hits[j].id
}
return hits[i].at.Before(hits[j].at)
})
out := make([]string, 0, len(hits))
for _, h := range hits {
out = append(out, h.id)
}
return out
}
这套检查不评价复盘写得好不好,只回答“这份复盘能不能被追踪”。它适合放进复盘模板的 CI:报告提交时跑一次,缺字段就打回;改进项每周跑一次,逾期就进入周会清单。
本机验证命令与结果:
cd .local/checks/gsarch-lab
gofmt -l internal/postmortem
go test -count=1 ./internal/postmortem/
gofmt -l 无输出;go test 退出码 0,该包 5 个表驱动测试全部通过。测试覆盖模板缺项、时间线乱序、只写“人为失误”、改进项字段缺失、逾期判定与复发窗口边界。
三个反模式
反模式一:把复盘写成表扬稿。 通篇“响应迅速、处置得当”,只记录成功动作,不记录错误假设。这样的报告无法复制,也无法防止下一次误判。
反模式二:改进项越多越好。 十几条 P0 同时开工,结果每条都做一半。改进项要按“不修会怎样”排序,P0 只留真正会复发或造成资损的。
反模式三:只跟踪,不验收。 看板上一片绿色,验收标准却是“已完成优化”。没有可观察结果的改进项,等于没有改进。
小结
复盘的对象是系统与决策,不是个人品格;但每条改进项必须有责任人。时间线只写可复核的事实,覆盖发现、止血、恢复三个节点。根因拆成直接原因、系统性原因、逃逸原因三层,才能从“怎么坏的”走到“为什么没兜住、为什么没早发现”。
“人为失误”不是根因,而是根因分析的起点。把它翻译成系统语言,才能得到可验收的改进项。改进项的四要素是负责人、截止时间、验收标准、复发检测;优先级是排队规则,不是形容词。闭环靠跟踪、验收、复发检测三个动作维持,缺一个都会退回“写完就归档”。
下一节用三个复合案例演示这套方法:活动配置超发、排行榜缓存击穿、GM 批量补偿重复执行。每个案例都会给出时间线、证据、三层根因与架构改进,并把数字明确标注为示例值。
阅读导航:上一节:7.1 故障树与定位路径 · 下一节:7.3 真实事故案例拆解 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。