《互联网产品的服务端架构·规模化与架构演进》6.3 复盘与改进闭环

复盘会开得很热闹,三个月后同类故障再次发生,改进项却还停在文档里。本节把「加强监控」拆成可验收的动作,给出超期检查与动作校验的纯函数实现和测试,并复盘一次没有 owner、没有截止时间、最终被所有人遗忘的复盘闭环事故,最后给出动作表进入工单系统的落地方式。

复盘会开完,问题还在

「回声」在三个月里发生了两次几乎一样的故障:批量导入任务占满数据库连接池,导致下单接口超时。第一次事故之后,团队认真开了一场复盘会,会议室坐满了人,白板上写满了时间线。会议结束时,主持人总结:“大家回去加强监控,导入任务也要注意资源隔离。”群里一片“收到”。

三个月后,同样的导入任务、同样的连接池、同样的下单超时。唯一不同的是这次事故持续得更久,因为值班同学以为“上次已经修过了”。事故复盘文档被翻出来,大家发现最后一行写着“加强监控”,没有负责人,没有截止时间,也没有验收标准。没有人知道这句话该由谁做、做到什么程度算完成。

这不是态度问题,而是复盘流程缺少一个强制结构:每一条改进都必须是一条可验收的动作,有具体的人、具体的截止时间、具体的完成定义。没有这个结构,复盘会越开越熟练,问题却原地不动。

这一节讨论如何把“加强监控”翻译成“由谁在什么时候把哪个指标加到哪个面板,并用什么查询验证”,以及如何用代码检查动作表是否真的可执行。

复盘产出的是动作,不是感受

复盘会上最常见的产出是感受:大家很辛苦、沟通不及时、监控不够、流程需要优化。这些感受可能是真的,但它们无法被验收。把它们放进同一张表,和可验收的动作对比,差异会非常明显:

复盘结论是否可验收问题改写后的动作
加强监控否不知道加什么、加到哪里、谁来看由林在 11 月 10 日前把导入任务连接池使用率加到订单面板,阈值 80%
注意资源隔离否没有具体隔离对象和验收方式由陈在 11 月 15 日前把导入任务迁到独立连接池,压测并发 50 时下单 p99 不超过 200ms
沟通不及时否没有定义及时的标准由王在 11 月 8 日前把 sev1 的自动呼人链路接入值班系统并演练一次
回滚脚本缺失是有明确产物由赵在 11 月 12 日前提交回滚脚本,并在预发环境执行一次

判断一条结论是否可验收,可以问三个问题:做完之后能指出哪个具体产物?谁来证明它做完了?如果没做,什么时候会被发现?三个问题里有一个答不上来,这条结论就还不能进动作表。

动作表不是越长越好。一次复盘能真正推动的动作通常只有三到五条,多了就会稀释注意力。宁可把三条做到验收,也不要列二十条然后全部过期。动作的数量应该由事故的根因数量决定,而不是由会议时长决定。

根因要写到“可控”为止

“人为失误”几乎不是一个根因,因为它的改进动作只能是“加强培训”,而培训很难验收。把根因往下追一层,通常会落到可控的系统设计上:为什么这个操作没有二次确认?为什么这个任务没有并发上限?为什么这个错误没有被监控捕获?

「回声」第一次复盘的根因写的是“研发同学对连接池理解不足”。这个结论没有错,但它把改进责任推给了个人。第二次复盘时,团队把它改写成“导入任务和在线交易共用连接池,且没有并发上限”,对应的动作就变成了“独立连接池 + 并发限制 + 压测验收”,责任落在系统而不是个人。

根因写到可控为止,不等于追究到某个人。它的标准是:这个层面的改动能否被工程实现、被测试验证、被监控观测。能,就可以停;不能,就继续往下拆。

动作要区分“止血”和“治本”

事故发生后,团队通常会先做止血动作:扩容、回滚、加限流。这些动作必须做,但它们不是复盘的全部。止血解决“现在不再流血”,治本解决“以后不再受同样的伤”。两类动作要分开列,因为它们的截止时间和验收方式不同。

止血动作通常以小时或天为单位,验收标准是“指标恢复”;治本动作以周或月为单位,验收标准是“同类故障的触发条件被消除”。把两类动作混在一起,容易出现“指标恢复了,复盘就结束了”的错觉。

一次被“加强监控”拖了三个月的故障

第一次事故发生在 8 月中旬。导入任务上线后,一个大商户一次性上传了 12 万行数据,任务把连接池占满,下单接口在 14 分钟内大量超时。值班同学重启应用后恢复,复盘会定下了四条结论:加强监控、注意资源隔离、优化导入性能、完善回滚流程。

四条结论都没有 owner。会议纪要由主持人整理后发到群里,没有人被明确指派,也没有截止时间。两周后,主持人离职,纪要沉到了聊天记录底部。导入任务仍然和在线交易共用连接池,仍然没有并发上限。

11 月中旬,另一个商户上传了 9 万行数据,同样的故事重演。这次事故持续了 38 分钟,影响约两万笔订单。复盘会上,有人翻出三个月前的文档,问了一句:“当时为什么没做?”会议室安静了几秒,然后有人说:“当时以为有人跟。”

第二次复盘,团队换了一种做法。会议结束前,主持人逐条确认:每条动作的 owner 是谁、截止时间是哪天、验收标准是什么。没有 owner 的结论不准进动作表;owner 不在会议现场的,当场打电话确认。最终只留下四条动作,每条都写进了工单系统。

两周后,系统自动列出超期动作:一条连接池迁移因为依赖排期被推迟,owner 在超期前一天主动申请了延期,并说明了新的截止时间。这条动作最终在 12 月初完成,压测报告附在工单里。此后再没有发生同类故障。

两次复盘的差别不在会议质量,而在动作是否有结构。第一次的结论听起来更全面,但没有一条能被验收;第二次的结论更少,但每一条都能被追踪。复盘的价值不在于会上说了什么,而在于会后有多少动作真正落地。

用两个纯函数守住动作表的底线

配套代码 internal/slo/postmortem.go 只做两件事:找出已经超期且未完成的动作,校验动作表是否具备可执行的基本条件。它不读工单系统,也不发提醒。把这两个判断收敛成纯函数,是为了让“这条动作能不能被验收”在复盘结束前就能被检查。

// file: internal/slo/postmortem.go
// tier: L1
// verify: go test ./internal/slo/

package slo

import "fmt"

// Action 是复盘产出的一个改进项。
// Owner 是具体的人,不是团队名;DueAt 是截止时间(Unix 秒);
// Done 表示是否已经验收,而不是“已经开始做”。
type Action struct {
	Owner string
	DueAt int64
	Done  bool
}

// Overdue 返回在 now 时刻已经超期且尚未完成的动作,保持输入顺序。
// DueAt 等于 now 时不算超期,给当天到期的动作留出完整一天。
// 返回值不修改输入切片,调用方可以安全地继续使用原切片。
func Overdue(as []Action, now int64) []Action {
	var out []Action
	for _, a := range as {
		if !a.Done && a.DueAt < now {
			out = append(out, a)
		}
	}
	return out
}

// Validate 检查动作表是否可被验收。
// 空 owner 意味着没有人负责;DueAt 非正数意味着没有截止时间;
// 同一 owner 在同一截止时间出现两次,通常是复制粘贴造成的重复动作。
func Validate(as []Action) error {
	type key struct {
		owner string
		dueAt int64
	}
	seen := make(map[key]struct{}, len(as))
	for i, a := range as {
		if a.Owner == "" {
			return fmt.Errorf("slo: action at index %d has empty owner", i)
		}
		if a.DueAt <= 0 {
			return fmt.Errorf("slo: action %q has invalid due time %d", a.Owner, a.DueAt)
		}
		k := key{owner: a.Owner, dueAt: a.DueAt}
		if _, ok := seen[k]; ok {
			return fmt.Errorf("slo: duplicate action for owner %q due at %d", a.Owner, a.DueAt)
		}
		seen[k] = struct{}{}
	}
	return nil
}

几个设计点值得展开。

Action 只有三个字段,刻意不包含标题和描述。这不是说标题不重要,而是希望动作的身份由“谁在什么时候完成”决定,而不是由一句漂亮的描述决定。两条描述不同但 owner 和截止时间相同的动作,很可能就是同一条动作被复制了两次,应该合并而不是分别跟踪。

Overdue 用 DueAt < now 而不是 <=。截止时间通常表示“当天结束前完成”,如果 now 正好等于 DueAt,动作仍然有最后一刻的机会,不应该在到期瞬间就被标成超期。这个边界看起来小,但会影响自动化提醒的体感:每天凌晨把当天到期的动作全部标红,会让人迅速忽略提醒。

Overdue 返回新切片,不修改输入。调用方可能同时需要完整动作表和超期子集,原地排序或删除会让两个视图互相污染。返回 nil 而不是空切片,是 Go 的惯例,调用方用 len 判断即可。

Validate 把空 owner 和非正截止时间都当成错误。空 owner 是最常见的失败模式:复盘会上说“这个我们后续处理”,听起来有人负责,实际上没有人。非正截止时间通常来自表单默认值或解析失败,它会让动作永远不进入超期列表。

Validate 用 owner 和截止时间组成重复键,而不是只比较 owner。同一个人可以负责多条截止时间不同的动作,这不应该被拒绝;但如果 owner 和截止时间都相同,大概率是重复录入。描述不同但键相同的两条动作,应该由人确认后合并。

代码没有解决“动作是否真的有效”。一条动作可以填满 owner、截止时间和验收标准,却对根因毫无作用。有效性需要在复盘评审和下一次事故复盘中判断,代码只能守住形式底线。形式不能保证质量,但没有形式,质量连被检查的机会都没有。

测试:超期边界、空表与重复动作

动作表的逻辑不复杂,但边界很多:已完成的不算超期、当天到期的不算超期、空表应该被允许、同一人不同截止时间不应该被误判为重复。下面的测试把这些情况固定下来。

// file: internal/slo/postmortem_test.go
// tier: L1
// verify: go test ./internal/slo/

package slo

import "testing"

func TestOverdue(t *testing.T) {
	now := int64(1761800000)
	as := []Action{
		{Owner: "lin", DueAt: now - 10, Done: false},
		{Owner: "chen", DueAt: now - 10, Done: true},
		{Owner: "wang", DueAt: now, Done: false},
		{Owner: "zhao", DueAt: now + 10, Done: false},
	}

	got := Overdue(as, now)
	if len(got) != 1 {
		t.Fatalf("Overdue returned %d actions, want 1", len(got))
	}
	if got[0].Owner != "lin" {
		t.Fatalf("Overdue owner = %q, want lin", got[0].Owner)
	}

	if len(as) != 4 {
		t.Fatalf("Overdue mutated input, len = %d", len(as))
	}
	if got := Overdue(nil, now); got != nil {
		t.Fatalf("Overdue(nil) = %v, want nil", got)
	}
}

func TestValidate(t *testing.T) {
	good := []Action{
		{Owner: "lin", DueAt: 1761800000},
		{Owner: "chen", DueAt: 1761886400},
	}
	if err := Validate(good); err != nil {
		t.Fatalf("valid actions: %v", err)
	}
	if err := Validate(nil); err != nil {
		t.Fatalf("empty actions should be allowed: %v", err)
	}

	noOwner := []Action{{Owner: "", DueAt: 1761800000}}
	if err := Validate(noOwner); err == nil {
		t.Fatal("want error for empty owner")
	}

	noDue := []Action{{Owner: "lin", DueAt: 0}}
	if err := Validate(noDue); err == nil {
		t.Fatal("want error for missing due time")
	}

	dup := []Action{
		{Owner: "lin", DueAt: 1761800000},
		{Owner: "lin", DueAt: 1761800000},
	}
	if err := Validate(dup); err == nil {
		t.Fatal("want error for duplicate action")
	}

	sameOwnerDifferentDue := []Action{
		{Owner: "lin", DueAt: 1761800000},
		{Owner: "lin", DueAt: 1761886400},
	}
	if err := Validate(sameOwnerDifferentDue); err != nil {
		t.Fatalf("same owner with different due times should be allowed: %v", err)
	}
}

TestOverdue 同时放了四种动作:已超期未完成、已超期已完成、正好到期、尚未到期。期望结果只有第一条。这四个用例把 Done 过滤、严格小于比较和未来时间都覆盖到了。测试还检查了输入切片长度没有变化,防止实现里误用原地删除。

Overdue(nil, now) 返回 nil 是一个容易被忽略的边界。空动作表在复盘刚创建时很常见,它不应该报错,也不应该返回一个非 nil 的空切片让调用方误以为有结果。

TestValidate 覆盖了四种非法输入和两种合法输入。空表合法,因为新复盘还没有录入动作;同一 owner 不同截止时间合法,因为一个人可以负责多条动作;同一 owner 同一截止时间非法,因为那几乎一定是重复录入。

测试没有覆盖“动作描述为空”和“验收标准为空”,因为当前结构里没有这两个字段。如果未来给 Action 增加描述字段,必须补充相应的校验和用例。结构变更时,测试要跟着变更,而不是让旧测试继续通过却失去意义。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/slo/ -run 'TestOverdue|TestValidate' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestOverdue
--- PASS: TestOverdue (0.00s)
=== RUN   TestValidate
--- PASS: TestValidate (0.00s)
PASS
ok  	webapp/internal/slo	0.369s

两个用例全部通过,退出码为 0。-run 只筛选了复盘动作相关的用例;同包还有 SLO 计算和故障分级的测试,需要全量确认时去掉 -run 再跑一次。

小结

复盘的产出不是会议纪要,而是一张可验收的动作表。每条动作必须有具体的人、具体的截止时间和可验证的完成定义;“加强监控”“注意隔离”这类结论,只有被翻译成具体产物之后才算动作。

动作表还需要有超期检查。没有人检查的动作,和没有写下来的动作没有区别。把 owner、截止时间和完成状态放进工单系统,定期列出超期项,才能让复盘真正形成闭环。

下一节进入新的主题:反爬与接口防刷。稳定性体系解决“系统自身不出问题”,而反爬解决“外部流量如何不把系统拖垮”,两者在容量和告警上会再次相遇。

阅读导航:上一节:6.2 故障分级与应急 · 下一节:7.1 反爬与接口防刷 。

常见错误与边界

  • 把感受当动作。“加强监控”“注意沟通”无法验收,必须翻译成具体产物。
  • 动作没有 owner。写“团队负责”等于没有人负责,owner 必须是具体的人。
  • 动作没有截止时间。没有期限的动作永远不会进入超期列表。
  • 只列止血动作,不列治本动作。指标恢复不等于根因消除。
  • 根因停在“人为失误”。继续往下拆到系统设计,才能得到可实现的改动。
  • 动作表列得太多。三条做到验收,胜过二十条全部过期。
  • 没有超期检查机制。动作写进文档却不被追踪,等于没写。
  • 把已完成的动作继续算作超期。状态字段必须参与过滤,否则提醒会失去可信度。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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