《后端架构设计·高性能与高可用》10.3 故障复盘与改进闭环

实现 arch-lab 的故障复盘记录校验器:时间线、影响面、触发与贡献因素、带 owner 截止日期和验证方式的行动项,以及禁止只写人为失误的规则;用一次明确标注为演练的订单超时事件生成完整记录,并测试拒绝缺 owner、缺验证和只写注意的提交。

复盘的目标不是找到“那个人”

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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查