56.6 无指责复盘
一句话
无指责不是“不追究”,而是把提问从“谁干的”换成“什么条件让这件事容易发生”;复盘的产出必须是可验证的行动项,不是“加强意识”。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “无指责 = 不处理人” | 无指责针对的是复盘过程,不是绩效与纪律。故意违规、隐瞒、破坏安全红线,仍然要按制度处理 |
| “复盘 = 找根因” | 复杂系统很少有单一根因。写成“多个贡献因素 + 触发条件”,才修得动 |
| “行动项越多越好” | 行动项越多,完成率越低。一次复盘聚焦 3–5 个高杠杆项,比列 20 条更有用 |
| “复盘写完就结束” | 没有跟踪与验证的复盘,只是文档。行动项要进 backlog、有 owner/截止/验证,并定期回看 |
无指责的语言:同一件事,两种问法
| 指责式 | 无指责式 | 差别 |
|---|---|---|
| “某某粗心,把 TTL 写成了 0” | “配置变更没有范围校验,TTL=0 能通过评审” | 从“人”转到“系统允许了什么” |
| “值班的人没看告警” | “告警路由到了错误的群,且没有升级” | 从“态度”转到“机制” |
| “测试没覆盖到” | “这类配置没有对应的单测/校验” | 从“谁漏了”转到“缺什么护栏” |
| “为什么不早说” | “什么条件下信息没能及时同步” | 从“动机”转到“信息流” |
| “下次注意” | “下次由什么检查/自动化保证” | 从“意愿”转到“可验证” |
🔑 无指责的核心不是“说话客气”,而是假设人是善意的、在信息不全时做了合理选择,然后把注意力放到“系统为什么让这个选择导致事故”。
复盘的结构:五段
| 段 | 写什么 | 常见错误 |
|---|---|---|
| 影响 | 用户/业务/资损/时长,用数字 | 只写“影响较大” |
| 时间线 | 发现、认领、宣布、止血、恢复的关键时间点 | 只有“晚上挂了,后来好了” |
| 贡献因素 | 多个因素 + 触发条件 + 放大器 | 只写“根因:配置错误” |
| 做得好的 | 哪些机制起了作用(告警、开关、回滚) | 只写问题,不记录有效实践 |
| 行动项 | 3–5 条,有 owner/截止/验证/类型 | “加强培训”“注意检查” |
时间线要精确到分钟,因为发现慢、决策慢、执行慢是三类不同的问题,改法完全不同(见 56.5 事故响应)。
行动项的质量标准
| 要素 | 要求 | 反例 |
|---|---|---|
| 具体 | 说清改什么、改哪里 | “优化配置管理” |
| 有 owner | 具体到人/团队 | “相关同学” |
| 有截止 | 日期,不是“尽快” | “下个迭代” |
| 可验证 | 怎么证明做完了、有效 | “培训完成” |
| 类型 | prevent / detect / mitigate / process | 全是“加监控” |
好的行动项长这样:
回调加幂等键并补唯一索引 | owner: order | due: 2026-11-10 | 验证: 重复回调测试通过 | 类型: prevent
坏的行动项长这样:
加强值班培训 | owner: — | due: — | 验证: — | 类型: process
一个能跑的“复盘质量评分器”
程序按四维打分:行动项(50)、覆盖(20)、时间线(15)、无指责(15)。低于 70 分不合格,必须补齐后再关闭。
package main
import (
"fmt"
"os"
"strings"
)
type action struct {
desc string
owner string
due string
verify string
kind string // prevent / detect / mitigate / process
}
type postmortem struct {
title string
timeline []string
factors []string
actions []action
text string // 用于检查指责性措辞
}
var vague = []string{"加强", "注意", "提高意识", "重视", "小心"}
var blame = []string{"粗心", "责任心", "不小心", "人为失误", "某某"}
func score(p postmortem) (int, []string) {
var findings []string
total := 0
// ① 行动项 50 分:每条要有 owner、截止时间、验证方式,且不能是空话
good := 0
for _, a := range p.actions {
ok := a.owner != "" && a.due != "" && a.verify != ""
vagueHit := ""
for _, v := range vague {
if strings.Contains(a.desc, v) {
vagueHit = v
}
}
if ok && vagueHit == "" {
good++
} else if vagueHit != "" {
findings = append(findings, fmt.Sprintf("行动项「%s」是空话(含“%s”),无法验证", a.desc, vagueHit))
} else {
findings = append(findings, fmt.Sprintf("行动项「%s」缺 owner/截止/验证方式", a.desc))
}
}
total += min(good, 5) * 10
// ② 覆盖 20 分:至少要有 prevent + detect + mitigate
kinds := map[string]bool{}
for _, a := range p.actions {
kinds[a.kind] = true
}
cover := 0
for _, k := range []string{"prevent", "detect", "mitigate"} {
if kinds[k] {
cover += 7
}
}
if cover > 20 {
cover = 20
}
total += cover
if cover < 20 {
findings = append(findings, "行动项覆盖不全:prevent/detect/mitigate 至少各一条")
}
// ③ 时间线 15 分:至少 3 个带时间的事件
timed := 0
for _, t := range p.timeline {
if strings.ContainsAny(t, "0123456789") {
timed++
}
}
if timed >= 3 {
total += 15
} else {
total += timed * 5
findings = append(findings, fmt.Sprintf("时间线只有 %d 个带时间的事件(至少 3 个)", timed))
}
// ④ 无指责 15 分
blamed := false
for _, b := range blame {
if strings.Contains(p.text, b) {
blamed = true
findings = append(findings, fmt.Sprintf("出现指责性措辞「%s」;改成“什么条件让错误容易发生”", b))
}
}
if !blamed {
total += 15
}
if len(p.factors) < 2 {
findings = append(findings, "只写了单一根因;写成多个贡献因素更利于修复")
}
return total, findings
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func report(p postmortem) bool {
s, fs := score(p)
fmt.Printf("== %s ==\n得分:%d/100\n", p.title, s)
if len(fs) == 0 {
fmt.Println("检查:无问题")
} else {
fmt.Println("检查:")
for _, f := range fs {
fmt.Println(" - " + f)
}
}
ok := s >= 70
if ok {
fmt.Println("结论:合格(可以关闭复盘,行动项进入跟踪)\n")
} else {
fmt.Println("结论:不合格(补齐行动项/时间线后再关闭)\n")
}
return ok
}
func main() {
good := postmortem{
title: "复盘 A:支付回调超时导致重复扣款",
timeline: []string{
"02:03 告警:支付成功率下降",
"02:09 认领并宣布 S1",
"02:41 关闭重试,止血",
"03:20 核对账目,确认恢复",
},
factors: []string{
"回调接口没有幂等键,重试会重复扣款",
"重试策略是固定间隔,故障时放大了流量",
"账务对账是 T+1,发现滞后",
},
actions: []action{
{"回调加幂等键并补唯一索引", "order", "2026-11-10", "重复回调测试通过", "prevent"},
{"支付成功率按 1 分钟窗口告警", "order", "2026-11-05", "注入 5% 失败验证触发", "detect"},
{"重试改为指数退避 + 抖动", "order", "2026-11-12", "压测对比重试流量", "mitigate"},
{"对账改为准实时(5 分钟)", "finance", "2026-11-30", "差异在 5 分钟内可见", "detect"},
{"发布检查单增加“幂等性”一项", "platform", "2026-11-08", "下个发布按新清单执行", "process"},
},
text: "故障由固定间隔重试放大,加上回调缺少幂等键导致。",
}
bad := postmortem{
title: "复盘 B:配置错误导致缓存击穿",
timeline: []string{
"晚上发现缓存挂了",
"重启后恢复",
},
factors: []string{"缓存配置写错了"},
actions: []action{
{"加强值班培训", "", "", "", "process"},
{"给缓存命中率加告警", "cache", "2026-11-15", "演练触发一次", "detect"},
{"注意检查配置", "", "", "", "process"},
},
text: "这次事故是某某粗心,把 TTL 写成了 0,责任心需要加强。",
}
okA := report(good)
okB := report(bad)
fmt.Printf("两份复盘都合格?%v\n", okA && okB)
if !okA || !okB {
os.Exit(1)
}
}
本机运行结果(退出码 1):
== 复盘 A:支付回调超时导致重复扣款 ==
得分:100/100
检查:无问题
结论:合格(可以关闭复盘,行动项进入跟踪)
== 复盘 B:配置错误导致缓存击穿 ==
得分:17/100
检查:
- 行动项「加强值班培训」是空话(含“加强”),无法验证
- 行动项「注意检查配置」是空话(含“注意”),无法验证
- 行动项覆盖不全:prevent/detect/mitigate 至少各一条
- 时间线只有 0 个带时间的事件(至少 3 个)
- 出现指责性措辞「粗心」;改成“什么条件让错误容易发生”
- 出现指责性措辞「责任心」;改成“什么条件让错误容易发生”
- 出现指责性措辞「某某」;改成“什么条件让错误容易发生”
- 只写了单一根因;写成多个贡献因素更利于修复
结论:不合格(补齐行动项/时间线后再关闭)
两份复盘都合格?false
通用的复盘方法论(何时复盘、谁来组织、如何跨团队)见 82.6 复盘方法论(待补)。
复盘 B 的三个问题很典型:空话行动项、没有时间线、指责个人。它的“根因”只有一句“配置写错了”,但真正要修的是“为什么 TTL=0 能通过评审、为什么没有配置校验、为什么发现要等到晚上”。
复盘会:60 分钟怎么开
| 阶段 | 时长 | 做什么 | facilitator 的作用 |
|---|---|---|---|
| 回顾时间线 | 15 分钟 | 按分钟对齐事实,只补信息不辩论 | 阻止“当时你应该” |
| 找贡献因素 | 20 分钟 | 用“什么条件让这件事容易发生”追问 | 把“人”转成“系统” |
| 定行动项 | 20 分钟 | 3–5 条,现场定 owner/截止/验证 | 阻止“加强/注意” |
| 收尾 | 5 分钟 | 确认跟踪方式与下次回看时间 | 记录未决问题 |
三条会议纪律:
- 当事人先讲,其他人后问;先还原事实,再讨论改进。
- 不允许“如果当时你……”:把“你”换成“系统/流程/工具”。
- 行动项必须现场认领:没有 owner 的行动项,等于没有。
行动项追踪:复盘的价值在闭环
| 指标 | 算法 | 经验目标 |
|---|---|---|
| 行动项完成率 | 按期完成 / 总数 | ≥ 80% |
| 逾期率 | 逾期 / 总数 | ≤ 10% |
| 复验通过率 | 验证方式被真正执行的比例 | 100% |
| 复发率 | 同类事故在 6 个月内再次发生 | 0 |
行动项要进统一 backlog(与需求同一套优先级),而不是写在复盘文档里自生自灭;涉及缺陷与回归用例的,按 53.10 回归测试与缺陷管理 的闭环走;技术债类的行动项并入技术债管理(见 58.2 技术债管理)。每周/每双周回看一次逾期项,连续逾期要升级。
生产落地:指名 → 可抄 → 可验 → 可退
指名:复盘模板用 Markdown/Confluence/Notion 均可,关键是字段固定;行动项进 Jira/Linear/GitHub Issues,带 postmortem 标签与 due date;复盘评审用固定的 60 分钟议程。工具不是重点,字段与跟踪才是。
可抄(复盘模板骨架):
## 影响
- 用户:约 40% 支付失败,持续 38 分钟
- 资损:约 ¥12 万(待财务确认)
- SLO:错误预算消耗 18%
## 时间线
- 02:03 告警触发
- 02:09 认领并宣布 S1
- 02:41 回滚完成,止血
- 03:20 核心链路验证通过,恢复
## 贡献因素
1. 回调缺少幂等键(触发条件)
2. 固定间隔重试放大流量(放大器)
3. 对账 T+1,发现滞后(检测缺口)
## 做得好的
- 开关 30 秒关闭了新逻辑;回滚脚本可用
## 行动项
| 行动 | owner | due | 验证 | 类型 |
|---|---|---|---|---|
| 回调加幂等键 + 唯一索引 | order | 2026-11-10 | 重复回调测试 | prevent |
| 支付成功率 1 分钟窗口告警 | order | 2026-11-05 | 注入 5% 失败 | detect |
可验:本节评分器真跑过,输出如上;把它接进复盘模板的 CI/检查脚本,低于 70 分不允许关闭。人工验证:随机抽 3 份历史复盘,检查行动项是否都有 owner/截止/验证,完成率是否 ≥ 80%。
可退:复盘结论被推翻(新证据出现)时,重新打开复盘、更新贡献因素与行动项,并在原文档标注“已修订”;不要另起一份,导致两份结论并存。行动项做错方向时,允许关闭并说明原因,但要记录“为什么这条不需要了”。
反模式
| 反模式 | 后果 |
|---|---|
| 追责式提问(谁干的/为什么没注意) | 信息隐藏,下一次事故更晚暴露 |
| 只写单一根因 | 修了一个点,同类问题换个地方再发 |
| 行动项是“加强/注意/提高意识” | 无法验证,等于没做 |
| 行动项没有 owner/截止 | 永远停在“待办” |
| 一次列 20 条行动项 | 完成率低,重点被稀释 |
| 复盘文档写完不跟踪 | 同类事故复发 |
| 只写问题,不写“做得好的” | 无法沉淀有效机制 |
| 复盘会开成辩论会 | 时间浪费在“当时应该”,没有改进 |
自检
Q1 “无指责”是否意味着不处理责任人?
▸ 答案:不是。无指责针对的是复盘过程:假设人在当时的信息与条件下做了合理选择,重点分析系统为何允许错误发生。故意违规、隐瞒、绕过安全红线,仍然按制度处理。把两者混为一谈,会让团队要么不敢说真话,要么以为“无指责 = 可以乱来”。
Q2 为什么“只写单一根因”通常修不好问题?
▸ 答案:复杂系统的事故通常由“触发条件 + 放大器 + 检测缺口”共同造成,单一根因只覆盖其中一段。比如“配置写错”是触发条件,但没有校验、没有评审、发现太晚才是让它变成事故的原因。写成多个贡献因素,才能分别用 prevent/detect/mitigate 去修。
Q3 一个合格的行动项必须包含哪些要素?
▸ 答案:具体动作、owner、截止日期、验证方式、类型(prevent/detect/mitigate/process)。缺一个就会出现“做了但不知道有没有效”或“没人负责”。验证方式是关键:它把“完成了”变成“有效了”。
Q4 行动项完成率多少算健康?只看完成率够吗?
▸ 答案:经验目标 ≥ 80%,逾期率 ≤ 10%。但只看完成率不够:还要看复验通过率(验证是否真的执行)与复发率(同类事故是否再发)。完成率可以通过“关闭容易的项”刷高,复发率不会骗人。
Q5 复盘会为什么要 facilitator,且当事人先讲?
▸ 答案:facilitator 负责守住“无指责”的边界,阻止“如果当时你……”式的提问,把讨论拉回系统与流程。当事人先讲能保证事实完整,避免其他人先入为主;同时让当事人感到安全,后续才会主动暴露细节。没有 facilitator 的复盘会,很容易退化成追责或辩论。
关联
- 前置:56.5 事故响应、53.10 回归测试与缺陷管理
- 下游:58.2 技术债管理、82.6 复盘方法论(待补)