复盘的目标不是找到“那个人”
10.2 让告警能到达正确的人,但告警关闭不等于问题解决。复盘要回答的是:为什么现有告警、测试、发布流程没有更早拦住它,以及接下来改什么、谁改、怎么证明改好了。把复盘写成“某某操作失误,已批评教育”,等于宣布系统下次还会以同样的方式失败。
复盘与 SLO 的关系也不是文档关系:一次事故消耗了多少错误预算、是否需要冻结发布、行动项是否改变 SLO 口径,都应该在同一张记录里能追溯。SLO 的定义与预算口径见 SLA、SLO 与容量规划 ,本节只实现 arch-lab 的复盘记录校验器,并用一次演练事件把闭环跑通。
记录的最小结构
| 区块 | 必须回答 | 校验规则 |
|---|---|---|
| 时间线 | 何时发现、何时止血、何时恢复 | 至少 4 条、时间有序、首条不晚于发现时间 |
| 影响面 | 多少用户、多少订单、持续多久 | 时长大于 0,用户或订单至少一项大于 0 |
| 触发因素 | 直接导火索是什么 | 至少 1 条,不允许空字符串 |
| 贡献因素 | 为什么没防住、没早发现、没快速恢复 | 至少 1 条,且至少一条是系统因素 |
| 根因 | 哪个机制缺失导致可重复失败 | 不能只写“人为失误/疏忽/注意” |
| 行动项 | 改什么、谁负责、何时完成、怎么验证 | 至少 2 条,owner 实名,截止日在恢复后 90 天内,验证方式在枚举内 |
| 复盘人 | 谁参与了结论确认 | 至少 1 人 |
“贡献因素”与“根因”分开,是为了避免把所有原因压缩成一句话。触发因素是“这次为什么发生”,贡献因素是“为什么影响这么大、为什么没早发现”,根因是“哪个系统性缺口让同类事件可以再次发生”。三者混在一起,行动项就会退化成“加强培训”。
校验器:把不可执行的记录挡在提交前
校验器不判断技术结论对不对,只判断记录是否具备可执行性。行动项的描述少于 8 个字符、只写“注意”、owner 为空或写“待定”、截止日期早于恢复时间或超过 90 天、验证方式不在 test/alert/dashboard/runbook/drill 之内,都会被拒绝。根因或全部贡献因素去掉“人为失误、疏忽、粗心、注意”等词后为空,也会被拒绝。
// file: internal/incident/review.go
// tier: L1
// verify: go test ./internal/incident/
package incident
import (
"fmt"
"strings"
"time"
)
type Mode string
const (
ModeDrill Mode = "drill"
ModeProduction Mode = "production"
)
type TimelineEntry struct {
At time.Time
Source string
Actor string
Action string
}
type Impact struct {
Users int
Orders int
DurationMinutes int
RevenueCents int64
}
type ActionItem struct {
ID string
Description string
Owner string
Due time.Time
Verification string
Status string
}
type Review struct {
IncidentID string
Mode Mode
Title string
DrillNotice string
OpenedAt time.Time
DetectedAt time.Time
ResolvedAt time.Time
Timeline []TimelineEntry
Impact Impact
Triggers []string
ContributingFactors []string
RootCause string
ActionItems []ActionItem
Reviewers []string
}
var humanOnlyPhrases = []string{"人为失误", "操作失误", "疏忽", "不小心", "粗心",
"责任心", "培训不足", "注意", "意识不强"}
// humanOnly 判断一句话去掉“人的态度”之后是否还剩系统因素。
func humanOnly(s string) bool {
for _, p := range humanOnlyPhrases {
s = strings.ReplaceAll(s, p, "")
}
return strings.Trim(s, " ,。;、,.;::的\n\t") == ""
}
var verifications = map[string]bool{"test": true, "alert": true, "dashboard": true,
"runbook": true, "drill": true}
func (r Review) Validate() error {
var errs []string
add := func(f string, a ...any) { errs = append(errs, fmt.Sprintf(f, a...)) }
if r.IncidentID == "" || r.Title == "" {
add("incident_id and title are required")
}
if r.Mode != ModeDrill && r.Mode != ModeProduction {
add("mode must be drill or production")
}
if r.Mode == ModeDrill && !strings.Contains(r.DrillNotice, "演练") {
add("drill review must state that it is a drill")
}
if r.OpenedAt.IsZero() || r.DetectedAt.Before(r.OpenedAt) || !r.ResolvedAt.After(r.DetectedAt) {
add("opened/detected/resolved order is invalid")
}
if len(r.Timeline) < 4 {
add("timeline needs at least 4 entries")
}
var prev time.Time
for i, e := range r.Timeline {
if e.Source == "" || e.Actor == "" || e.Action == "" {
add("timeline[%d] has empty source/actor/action", i)
}
if !prev.IsZero() && e.At.Before(prev) {
add("timeline[%d] is out of order", i)
}
prev = e.At
}
if len(r.Timeline) > 0 && r.Timeline[0].At.After(r.DetectedAt) {
add("timeline must start at or before detection")
}
if r.Impact.DurationMinutes <= 0 || (r.Impact.Users <= 0 && r.Impact.Orders <= 0) {
add("impact needs duration and affected users or orders")
}
if len(r.Triggers) == 0 {
add("at least one trigger is required")
}
if len(r.ContributingFactors) == 0 {
add("at least one contributing factor is required")
}
if humanOnly(r.RootCause) {
add("root cause must not be human error only")
}
systemic := false
for _, f := range r.ContributingFactors {
if !humanOnly(f) {
systemic = true
}
}
if !systemic {
add("at least one contributing factor must be systemic, not human error only")
}
if len(r.ActionItems) < 2 {
add("at least two action items are required")
}
seen := map[string]bool{}
for i, a := range r.ActionItems {
if a.ID == "" || seen[a.ID] {
add("action[%d] has empty or duplicate id", i)
}
seen[a.ID] = true
if len([]rune(a.Description)) < 8 || humanOnly(a.Description) {
add("action %s description is too vague", a.ID)
}
if a.Owner == "" || a.Owner == "TBD" || a.Owner == "待定" {
add("action %s needs a named owner", a.ID)
}
if !a.Due.After(r.ResolvedAt) || a.Due.After(r.ResolvedAt.Add(90*24*time.Hour)) {
add("action %s due date must be within 90 days after resolution", a.ID)
}
if !verifications[a.Verification] {
add("action %s needs a verifiable method", a.ID)
}
if a.Status != "open" && a.Status != "done" {
add("action %s has unknown status %q", a.ID, a.Status)
}
}
if len(r.Reviewers) == 0 {
add("at least one reviewer is required")
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
return nil
}
一次演练:订单创建超时
以下事件、人员、时间与数字全部为虚构演练数据,用于演示记录格式,不对应任何真实生产事故。演练设定:2026-10-20 10:00 注入“订单服务连接池上限从 40 调到 20”的变更,10:06 告警触发,10:35 恢复,记录中明确标注 mode=drill 与演练声明。
| 时间 | 来源 | 动作 |
|---|---|---|
| 10:00 | 变更系统 | 演练注入:连接池上限 40 调为 20 |
| 10:06 | 告警引擎 | 5xx 比例与 P99 同时越线,触发 RB-ORDER-5XX |
| 10:08 | 订单服务 | 确认创建接口 P99 8.2s,TIMEOUT 占 61% |
| 10:14 | 订单服务 | 回滚连接池配置,P99 降至 900ms |
| 10:22 | 库存服务 | 确认预留超时仍在,扩容两个副本 |
| 10:35 | 订单服务 | 错误率回到 0.4%,结束演练 |
影响面写作 1200 名用户、860 笔订单、持续 35 分钟;收入列填 0,因为这是演练,不伪造金额。触发因素两条:连接池变更未过容量评审、库存两个副本同节点部署。贡献因素三条:连接池饱和度没有按服务维度上报、重试没有预算导致 2.3 倍放大、runbook 只写重启没有回滚步骤。根因写成“容量变更缺少评审与回滚开关,叠加副本同节点与无预算重试”,而不是“值班同学改错了配置”。
把记录写成可执行测试
演练记录直接写成测试夹具,校验器就是提交门禁。TestDrillReviewPasses 证明完整记录能通过;TestReviewRejects 用八个变异分别去掉 owner、换成不可验证的“口头确认”、把行动项写成“注意”、把根因写成“人为失误”、让贡献因素只剩“疏忽/不小心”、把截止日放到恢复之前、打乱时间线、删除演练声明,逐条确认都会被拒绝。
// file: internal/incident/review_test.go
// tier: L1
// verify: go test ./internal/incident/
package incident
import (
"testing"
"time"
)
var cst = time.FixedZone("CST", 8*3600)
func at(hour, minute int) time.Time {
return time.Date(2026, 10, 20, hour, minute, 0, 0, cst)
}
func due(day int) time.Time {
return time.Date(2026, 10, day, 18, 0, 0, 0, cst)
}
// drill 是一次明确标注为演练的订单超时事件,所有数字均为虚构。
func drill() Review {
return Review{
IncidentID: "DRILL-2026-10-20-ORDER-TIMEOUT",
Mode: ModeDrill,
Title: "订单创建超时(演练)",
DrillNotice: "本记录为 2026-10-20 本地演练,参与人与数据均为虚构,不对应真实生产事故。",
OpenedAt: at(10, 0),
DetectedAt: at(10, 6),
ResolvedAt: at(10, 35),
Timeline: []TimelineEntry{
{At: at(10, 0), Source: "变更系统", Actor: "值班工程师", Action: "演练注入:连接池上限 40 调为 20"},
{At: at(10, 6), Source: "告警引擎", Actor: "监控", Action: "5xx 比例与 P99 同时越线,触发 RB-ORDER-5XX"},
{At: at(10, 8), Source: "订单服务", Actor: "值班工程师", Action: "确认创建接口 P99 8.2s,TIMEOUT 占 61%"},
{At: at(10, 14), Source: "订单服务", Actor: "值班工程师", Action: "回滚连接池配置,P99 降至 900ms"},
{At: at(10, 22), Source: "库存服务", Actor: "值班工程师", Action: "确认预留超时仍在,扩容两个副本"},
{At: at(10, 35), Source: "订单服务", Actor: "值班工程师", Action: "错误率回到 0.4%,结束演练"},
},
Impact: Impact{Users: 1200, Orders: 860, DurationMinutes: 35, RevenueCents: 0},
Triggers: []string{
"连接池上限从 40 降到 20 的变更未经过容量评审",
"库存服务两个副本调度到同一节点,节点抖动时同时失效",
},
ContributingFactors: []string{
"连接池饱和度没有按服务维度上报,告警只看 5xx 结果",
"重试没有预算,超时后请求量放大 2.3 倍",
"runbook 只写重启,没有回滚配置的步骤",
},
RootCause: "连接池容量变更缺少评审与回滚开关,叠加库存副本同节点部署,超时后的无预算重试放大了排队",
ActionItems: []ActionItem{
{ID: "A-1", Description: "增加按服务维度的连接池饱和度面板与 80% 预警",
Owner: "leeting", Due: due(27), Verification: "dashboard", Status: "open"},
{ID: "A-2", Description: "把连接池变更纳入容量评审并补齐自动回滚步骤",
Owner: "chen", Due: due(31), Verification: "runbook", Status: "open"},
{ID: "A-3", Description: "为创建链路增加重试预算测试,覆盖两倍放大场景",
Owner: "wang", Due: due(31), Verification: "test", Status: "open"},
},
Reviewers: []string{"leeting", "chen"},
}
}
func TestDrillReviewPasses(t *testing.T) {
r := drill()
if err := r.Validate(); err != nil {
t.Fatalf("valid drill review rejected: %v", err)
}
t.Logf("incident=%s mode=%s timeline=%d triggers=%d factors=%d actions=%d",
r.IncidentID, r.Mode, len(r.Timeline), len(r.Triggers), len(r.ContributingFactors), len(r.ActionItems))
t.Logf("impact_users=%d orders=%d duration_min=%d", r.Impact.Users, r.Impact.Orders, r.Impact.DurationMinutes)
}
func TestReviewRejects(t *testing.T) {
cases := []struct {
name string
mutate func(*Review)
}{
{"missing_owner", func(r *Review) { r.ActionItems[0].Owner = "" }},
{"missing_verification", func(r *Review) { r.ActionItems[1].Verification = "口头确认" }},
{"vague_action", func(r *Review) { r.ActionItems[2].Description = "注意" }},
{"human_only_root_cause", func(r *Review) { r.RootCause = "人为失误" }},
{"human_only_factors", func(r *Review) { r.ContributingFactors = []string{"疏忽", "不小心"} }},
{"due_before_resolution", func(r *Review) { r.ActionItems[0].Due = at(10, 0) }},
{"timeline_out_of_order", func(r *Review) { r.Timeline[1], r.Timeline[2] = r.Timeline[2], r.Timeline[1] }},
{"missing_drill_notice", func(r *Review) { r.DrillNotice = "" }},
}
for _, tc := range cases {
r := drill()
tc.mutate(&r)
if err := r.Validate(); err == nil {
t.Fatalf("%s was not rejected", tc.name)
}
}
t.Logf("rejections=%d", len(cases))
}
真实运行结果
$ go test -v ./... -count=1
=== RUN TestDrillReviewPasses
review_test.go:64: incident=DRILL-2026-10-20-ORDER-TIMEOUT mode=drill timeline=6 triggers=2 factors=3 actions=3
review_test.go:66: impact_users=1200 orders=860 duration_min=35
--- PASS: TestDrillReviewPasses (0.00s)
=== RUN TestReviewRejects
review_test.go:90: rejections=8
--- PASS: TestReviewRejects (0.00s)
PASS
ok archlab/internal/incident 0.493s
以上为本机 macOS(darwin/arm64,Go 1.26.0)单次运行,命令 go test -v ./... -count=1,原始输出在 .local/reports/be-arch/be-arch-perf-10-3-incident-review-improvement-tests.txt。记录中的 1200 用户、860 订单、35 分钟和 8.2s/900ms 都是演练夹具里的虚构值,不是生产测量;它们的作用是让校验器有具体输入可断言。
复盘、告警、SLO 的闭环
闭环有四个连接点。第一,告警触发复盘:10.2 的每条通知都带 runbook,runbook 的第一节就是“何时升级为事故并启动复盘”。第二,复盘产出行动项:每条行动项必须绑定验证方式,test 对应新增回归测试,alert 对应新增或调整 10.2 的规则,dashboard 对应补齐观测面板,runbook 对应更新处置步骤,drill 对应下一次演练验证。第三,行动项反向修改检测:如果复盘发现“5xx 告警太晚”,就应新增或调整规则,而不是只写“加强监控”。第四,SLO 复核:事故消耗的错误预算是否触发发布冻结、目标是否要调整,回到 SLO 文档评审,而不是留在复盘附件里。
这条闭环里最容易断的是“行动项完成”与“验证”之间。把 Verification 做成枚举而不是自由文本,就是为了让每条行动项都能被追问:测试在哪、告警叫什么、面板在哪、runbook 哪一节、哪次演练。没有验证方式的行动项,在提交时就被校验器拒绝。
常见错误与边界
第一,根因只写“人为失误”。人一定会犯错,系统设计必须假设这一点;校验器会拒绝去掉人的态度词后为空的根因。
第二,行动项没有 owner 或截止日期。“后续优化”没有责任人,就不会发生;owner 必须是具体的人,不是团队名。
第三,把“注意”“加强意识”当行动项。它们无法验证,也无法判断是否完成;行动项必须能被测试、告警、面板、手册或演练验证。
第四,时间线靠回忆补。时间线要来自告警、变更、日志的时间戳,而不是事后估计;校验器只能检查顺序,不能检查真实性。
第五,演练记录不标注。演练数据如果混进生产复盘库,会污染统计和审计;mode=drill 与演练声明是必填项。
第六,截止日期无限远。90 天是示例上限,超过它的行动项应该拆小或重新评估优先级,而不是挂在看板上。
小结
复盘记录的价值不在篇幅,而在可执行性:时间线能还原过程,影响面能对齐优先级,触发与贡献因素分开能避免“一句话根因”,行动项的 owner、截止日期和验证方式能保证改进落地,禁止只写人为失误则强制寻找系统缺口。本节用一次明确标注为演练的订单超时事件生成了完整记录,并用八个变异证明校验器会拒绝缺 owner、缺验证、只写“注意”和只归因于人的提交。下一章转向数据层,从索引、查询与慢 SQL 开始。
阅读导航:上一节:10.2 告警:从症状到行动的闭环 · 下一节:11.1 索引、查询与慢 SQL 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。