复盘会上的第一句话,决定了后面两小时
「回声」大促结束后的第二天下午,复盘会准时开始。主持人刚说完“大家简单说说”,运营负责人就接了一句:“这次主要就是流量太大。”会议室里有人点头,有人低头看手机。接下来的四十分钟,讨论围绕“流量为什么这么大”“明年是不是要提前扩容”来回打转,最后留下一条结论:下次多准备机器。
两周后,另一场品类促销上线,同一个下单接口又在相似的时间点排队。值班同学翻出上次的复盘文档,发现除了“流量太大”和“多准备机器”之外,没有任何可执行的信息:不知道流量从几点开始涨,不知道哪个接口先撑不住,不知道当时做了什么止损,也不知道谁负责在下次活动前验证。
复盘的价值不在于给事故起一个名字,而在于把“当时发生了什么”变成“下次怎么更早发现、更快止损”。要做到这一点,复盘至少要留下三样东西:一条按时间排列的事件线、一份可核对的影响清单、一组有负责人和截止时间的待办。缺任何一样,复盘都会退化成情绪总结。
时间线、影响、待办三件事
时间线不是会议纪要,而是把监控、日志、聊天记录和人工操作按时间对齐。影响不是一句“用户体验受损”,而是具体到哪些用户、哪个入口、持续多久、损失如何估算。待办不是“加强监控”,而是具体到谁、在什么时间之前、交付什么可验收的结果。
| 复盘要素 | 要回答的问题 | 合格样例 | 不合格样例 |
|---|---|---|---|
| 时间线 | 什么时候发生了什么 | 20:03 下单 p99 超过 1 秒,20:07 手动限流 | 晚上高峰期出了问题 |
| 影响 | 谁受到了什么影响 | 约 12% 下单请求失败,持续 15 分钟 | 用户体验受损 |
| 证据 | 依据是什么 | 监控截图、网关日志、值班群记录 | 大家都记得 |
| 待办 | 谁在何时前做什么 | 张三在 11 月 10 日前补下单队列告警 | 加强监控 |
| 验收 | 怎么确认做完了 | 演练时告警在 30 秒内触发 | 已关注 |
表里的“合格样例”都是案例假设值,不是真实事故数据。真正要带走的是每一行的追问方式:时间线要能对齐到分钟,影响要能落到比例和时长,证据要能指向具体文件,待办要能被人和日期验收。
时间线要保留“不知道”的部分
复盘时经常遇到信息缺口:某个时间点的操作没有记录,某个告警没有截图,某位同学已经记不清当时为什么这么做。合格的做法是把缺口标出来,例如“20:10 至 20:14 的操作无记录”,而不是用推测填满。推测一旦写进时间线,后续分析就会建立在错误前提上。
影响要区分用户影响和业务影响
用户影响是“多少用户、多长时间、看到了什么”,业务影响是“多少订单、多少 GMV、多少客服工单”。两者不能互相替代。一次持续五分钟的故障,用户影响可能很小,但如果发生在开场第一分钟,业务影响可能很大;反过来,一次持续两小时的低频接口故障,业务影响不大,但少数重度用户会非常不满。
“流量太大”不是结论
“流量太大”描述的是一个现象,不是原因。它至少可以被拆成四个问题:流量从哪来、为什么超过预期、系统为什么没扛住、为什么没有更早发现。四个问题的答案分别指向不同的改进方向。
流量从哪来,可能是活动页投放、外部导流、爬虫或重试放大。为什么超过预期,可能是预测口径错了,也可能是安全系数不够。系统为什么没扛住,可能是容量不足、慢查询、锁竞争、下游超时或降级动作不合理。为什么没更早发现,可能是告警阈值太松、监控覆盖不到、或者告警发了但没人看。
把这四个问题拆开,待办才会具体。只写“流量太大”,待办只能写成“多准备机器”;拆开之后,可能会得到“给入口流量加打标”“把下单队列积压纳入告警”“把详情接口降级从 503 改成缓存快照”“在活动前做一次全链路压测”四条不同的动作。
区分直接原因和系统性原因
直接原因通常是某个具体故障,例如数据库连接池耗尽。系统性原因则是为什么这个故障能造成这么大影响,例如没有连接池告警、降级动作不合理、值班手册没有对应流程。复盘如果只写直接原因,修复往往停留在“换更大的连接池”;只有写到系统性原因,才能避免同类问题换一个接口再次发生。
待办要能被拒绝
好的待办允许负责人说“这个做不完”或“这个不该我做”。如果待办写得足够具体,负责人就能判断工作量、依赖和优先级;如果待办是“加强监控”,没有人能判断它到底要做什么。复盘主持人应该鼓励负责人当场确认或拒绝,而不是会后把待办默默塞进文档。
一次没有待办的复盘如何复发
第一次事故发生在品类促销开场。下单接口 p99 从 80 ms 涨到 3 秒,部分请求失败。值班同学在 20:07 手动把下单接口限流到 3000 QPS,20:15 流量回落后恢复。事故处理本身没有大问题,系统在二十分钟内恢复。
第二天的复盘会得出了三条结论:流量太大、下单接口容量不足、下次多准备机器。文档里没有时间线,没有影响比例,也没有负责人和截止时间。大家认为事情已经过去,会议结束。
两周后的第二场促销,同一个下单接口在开场后第六分钟再次排队。这次的原因并不完全相同:第一次是入口流量超过预测,第二次是优惠券核销变慢导致下单线程池被占满。但两次事故有一个共同点:下单队列积压没有独立告警,值班同学都是先看到用户投诉,再去看监控。
如果第一次复盘留下了时间线,就会发现“用户投诉”早于“监控告警”;如果留下了待办,就会有一条“给下单队列积压加告警,负责人张三,截止某月某日”。这条待办不需要很大的工程量,却能让第二次事故提前几分钟被发现。复盘没有留下待办,等于把同一类问题的发现成本又付了一遍。
更隐蔽的损失是知识没有沉淀。第二次事故发生时,新值班同学不知道第一次已经遇到过类似现象,也不知道当时限流到 3000 QPS 是有效的。复盘文档里只有“流量太大”,无法提供任何可复用的判断依据。
用事件流生成时间线
配套代码 internal/promo/review.go 只做两件事:把事件按时间排序并拒绝重复时间,以及从事件名生成待办。它不读取监控系统,也不判断根因,因为根因需要人结合证据分析,代码只负责把事实排列整齐。
// file: internal/promo/review.go
// tier: L1
// verify: go test ./internal/promo/
package promo
import (
"fmt"
"sort"
)
// Event 是复盘时间线上的一个事件。
// At 是 Unix 秒,Name 是事件名,Impact 是对用户或业务的影响描述。
type Event struct {
At int64
Name string
Impact string
}
// Timeline 返回按时间升序排列的事件副本,不修改输入。
// 同一秒出现两个事件时拒绝排序:先后顺序无法从数据判断,
// 复盘时很容易把因果讲反。
func Timeline(es []Event) ([]Event, error) {
out := make([]Event, len(es))
copy(out, es)
sort.SliceStable(out, func(i, j int) bool { return out[i].At < out[j].At })
for i := 1; i < len(out); i++ {
if out[i].At == out[i-1].At {
return nil, fmt.Errorf("promo: duplicate event time %d: %q and %q", out[i].At, out[i-1].Name, out[i].Name)
}
}
return out, nil
}
// Lessons 把事件名转成待办,不推断根因,也不替代人工复盘。
// 同一个事件名只生成一条,避免同一问题在待办里重复出现。
func Lessons(es []Event) []string {
ordered := make([]Event, len(es))
copy(ordered, es)
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].At < ordered[j].At })
seen := make(map[string]struct{}, len(ordered))
var out []string
for _, e := range ordered {
if e.Name == "" {
continue
}
if _, ok := seen[e.Name]; ok {
continue
}
seen[e.Name] = struct{}{}
if e.Impact != "" {
out = append(out, fmt.Sprintf("复核事件「%s」:确认影响「%s」的止损动作与负责人", e.Name, e.Impact))
continue
}
out = append(out, fmt.Sprintf("复核事件「%s」:补齐触发条件、止损动作与负责人", e.Name))
}
return out
}
Timeline 返回副本而不是原地排序,是为了让调用方仍然可以拿到原始顺序。复盘时经常需要对比“我们以为的顺序”和“实际发生的顺序”,如果函数把输入改掉,这个对比就做不了。
重复时间被当成错误,而不是用稳定排序保留输入顺序。同一秒的两个事件,谁先谁后无法从时间戳判断;如果强行排序,复盘时很容易把“限流”写成“告警”的原因,或者反过来。拒绝重复时间,是迫使记录者把时间精确到更细的粒度,或者把两个事件合并成一个。
Lessons 只根据事件名和影响描述生成待办,不推断根因。例如事件名是“下单 p99 超过 1 秒”,待办是“复核事件「下单 p99 超过 1 秒」:确认影响「部分用户下单失败」的止损动作与负责人”,而不是“数据库连接池不足,需要扩容”。后者是结论,应该由人基于证据写进复盘文档。
同一事件名只生成一条待办,是为了避免时间线里重复出现的告警把待办列表刷满。复盘待办应该对应问题,而不是对应告警次数。
Lessons 内部重新排序而不是调用 Timeline,是因为它的签名不返回 error。即使时间线有重复时间,待办生成仍然应该可用;用稳定排序能保证同一份输入每次得到同样的待办顺序,方便评审时逐条对照。
测试:排序、重复时间与待办生成
复盘工具的测试要锁住三件事:时间线排序不修改输入、重复时间报错、待办按事件名去重并按时间排序。下面的测试还覆盖了空时间线和空事件名两个边界。
// file: internal/promo/review_test.go
// tier: L1
// verify: go test ./internal/promo/
package promo
import (
"strings"
"testing"
)
func TestTimelineSortsWithoutMutatingInput(t *testing.T) {
es := []Event{
{At: 300, Name: "恢复", Impact: "错误率回落"},
{At: 100, Name: "监控告警", Impact: "下单错误率上升"},
{At: 200, Name: "手动限流", Impact: "部分请求排队"},
}
got, err := Timeline(es)
if err != nil {
t.Fatalf("Timeline returned error: %v", err)
}
want := []string{"监控告警", "手动限流", "恢复"}
for i, name := range want {
if got[i].Name != name {
t.Fatalf("event %d = %q, want %q", i, got[i].Name, name)
}
}
if es[0].Name != "恢复" {
t.Fatalf("Timeline mutated input, first event = %q", es[0].Name)
}
}
func TestTimelineRejectsDuplicateTime(t *testing.T) {
es := []Event{
{At: 100, Name: "告警"},
{At: 100, Name: "限流"},
}
if _, err := Timeline(es); err == nil {
t.Fatal("want error for two events at the same second")
} else if !strings.Contains(err.Error(), "duplicate event time") {
t.Fatalf("error %q should identify duplicate time", err)
}
}
func TestTimelineAcceptsEmpty(t *testing.T) {
got, err := Timeline(nil)
if err != nil {
t.Fatalf("Timeline(nil) returned error: %v", err)
}
if len(got) != 0 {
t.Fatalf("Timeline(nil) = %v, want empty", got)
}
}
func TestLessonsFollowTimeOrderAndDedupe(t *testing.T) {
es := []Event{
{At: 300, Name: "恢复", Impact: ""},
{At: 100, Name: "监控告警", Impact: "下单错误率上升"},
{At: 200, Name: "监控告警", Impact: "重复记录"},
}
got := Lessons(es)
if len(got) != 2 {
t.Fatalf("Lessons returned %d items, want 2: %v", len(got), got)
}
if !strings.Contains(got[0], "监控告警") || !strings.Contains(got[0], "下单错误率上升") {
t.Fatalf("first lesson = %q, want earliest event with impact", got[0])
}
if !strings.Contains(got[1], "恢复") {
t.Fatalf("second lesson = %q, want recovery event", got[1])
}
for _, s := range got {
if !strings.Contains(s, "复核") {
t.Fatalf("lesson %q should be a todo starting with 复核", s)
}
}
}
func TestLessonsSkipsEmptyName(t *testing.T) {
got := Lessons([]Event{{At: 100, Name: ""}, {At: 200, Name: "恢复"}})
if len(got) != 1 || !strings.Contains(got[0], "恢复") {
t.Fatalf("Lessons = %v, want only the named event", got)
}
}
TestTimelineSortsWithoutMutatingInput 同时检查排序结果和输入未被修改。只检查排序结果,会漏掉“原地排序把调用方数据打乱”的问题;只检查输入不变,又无法确认排序确实发生。
TestTimelineRejectsDuplicateTime 不只看是否报错,还检查错误里是否包含 duplicate event time。这是为了让调用方能够区分“时间线有重复”和“其他输入错误”,也方便日志检索。
TestLessonsFollowTimeOrderAndDedupe 用三个事件验证两件事:待办按时间顺序生成,重复事件名只保留一条。它特意让第二个“监控告警”携带不同的影响描述,确保去重是按事件名,而不是按整条记录。
TestLessonsSkipsEmptyName 覆盖空事件名。时间线里出现空名字,通常是数据采集或手工录入出了问题;跳过它比生成一条“复核事件「」”更有用。
测试没有判断待办是否“足够好”。待办的质量取决于事件名和影响描述写得是否具体,这需要复盘主持人把关。代码能保证顺序、去重和边界,不能替代人对内容的判断。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/promo/ -run 'TestTimeline|TestLessons' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestTimelineSortsWithoutMutatingInput
--- PASS: TestTimelineSortsWithoutMutatingInput (0.00s)
=== RUN TestTimelineRejectsDuplicateTime
--- PASS: TestTimelineRejectsDuplicateTime (0.00s)
=== RUN TestTimelineAcceptsEmpty
--- PASS: TestTimelineAcceptsEmpty (0.00s)
=== RUN TestLessonsFollowTimeOrderAndDedupe
--- PASS: TestLessonsFollowTimeOrderAndDedupe (0.00s)
=== RUN TestLessonsSkipsEmptyName
--- PASS: TestLessonsSkipsEmptyName (0.00s)
PASS
ok webapp/internal/promo 0.245s
五个用例全部通过,退出码为 0。ok 后面的耗时是一次本机运行的值,换机器或换一次运行都会变化。
小结
复盘不是给事故写一个更体面的名字,而是把事实整理成可复用的判断依据。时间线回答“什么时候发生了什么”,影响回答“谁受到了什么损失”,待办回答“谁在何时之前做什么、怎么验收”。
“流量太大”不是结论,只是现象。把它拆成流量来源、预测偏差、系统瓶颈和发现延迟四个问题,才能得到具体、可执行、可拒绝的待办。没有负责人和截止时间的复盘,等于把同一类问题的发现成本又付了一遍。
本卷到这里结束。下一卷从团队边界与系统边界开始,讨论当系统和人一起变多时,架构如何避免变成组织结构的镜像。
阅读导航:上一节:11.2 限流、降级与预案 · 下一节:12.1 团队边界与系统边界 。
常见错误与边界
- 复盘结论只写“流量太大”。它是现象,不是原因,无法推导出可执行待办。
- 时间线用“晚上高峰期”代替具体分钟。没有精确时间,就无法对齐监控、日志和人工操作。
- 用推测填补信息缺口。记不清就写“无记录”,不要把猜测写进事实。
- 影响只写“用户体验受损”。要落到用户比例、持续时长和具体入口。
- 待办写“加强监控”。没有负责人、截止时间和验收标准,等于没有待办。
- 把直接原因当系统性原因。只修一个连接池,同类问题会换一个接口再来一次。
- 待办不允许被拒绝。负责人当场无法判断工作量,会后也不会真正执行。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。