复盘会开完,问题还在
「回声」在三个月里发生了两次几乎一样的故障:批量导入任务占满数据库连接池,导致下单接口超时。第一次事故之后,团队认真开了一场复盘会,会议室坐满了人,白板上写满了时间线。会议结束时,主持人总结:“大家回去加强监控,导入任务也要注意资源隔离。”群里一片“收到”。
三个月后,同样的导入任务、同样的连接池、同样的下单超时。唯一不同的是这次事故持续得更久,因为值班同学以为“上次已经修过了”。事故复盘文档被翻出来,大家发现最后一行写着“加强监控”,没有负责人,没有截止时间,也没有验收标准。没有人知道这句话该由谁做、做到什么程度算完成。
这不是态度问题,而是复盘流程缺少一个强制结构:每一条改进都必须是一条可验收的动作,有具体的人、具体的截止时间、具体的完成定义。没有这个结构,复盘会越开越熟练,问题却原地不动。
这一节讨论如何把“加强监控”翻译成“由谁在什么时候把哪个指标加到哪个面板,并用什么查询验证”,以及如何用代码检查动作表是否真的可执行。
复盘产出的是动作,不是感受
复盘会上最常见的产出是感受:大家很辛苦、沟通不及时、监控不够、流程需要优化。这些感受可能是真的,但它们无法被验收。把它们放进同一张表,和可验收的动作对比,差异会非常明显:
| 复盘结论 | 是否可验收 | 问题 | 改写后的动作 |
|---|---|---|---|
| 加强监控 | 否 | 不知道加什么、加到哪里、谁来看 | 由林在 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 必须是具体的人。
- 动作没有截止时间。没有期限的动作永远不会进入超期列表。
- 只列止血动作,不列治本动作。指标恢复不等于根因消除。
- 根因停在“人为失误”。继续往下拆到系统设计,才能得到可实现的改动。
- 动作表列得太多。三条做到验收,胜过二十条全部过期。
- 没有超期检查机制。动作写进文档却不被追踪,等于没写。
- 把已完成的动作继续算作超期。状态字段必须参与过滤,否则提醒会失去可信度。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。