Feed 的推拉之争,半年后又吵了一遍
「回声」的发现页 Feed 需要回答一个经典问题:用户发一条内容后,是立刻写进所有粉丝的收件箱(写扩散),还是等粉丝刷新时再合并关注列表(读扩散)。
第一版团队选择了写扩散。当时的理由是读路径简单:粉丝打开首页只需要读自己的收件箱,不用实时合并几百个关注对象。这个决定被记录在一份编号为 ADR-0007 的文档里,正文只有一句话:“Feed 采用写扩散。”
半年后,一位拥有假设 500 万粉丝的明星账号发了一条内容。写扩散需要为每个粉丝生成一条收件箱记录,案例假设这次写入放大了 500 万行,重试和补偿又把实际写入推到 800 万行。写入队列积压了 40 分钟,普通用户的发文也受到影响。
新接手的团队被要求解决这个问题。他们翻到 ADR-0007,想知道当初为什么选写扩散、有没有考虑过读扩散、切换成本是多少。文档里只有那一句话。原作者已经转岗,评审记录散落在聊天工具里,没有人能说清“500 万粉丝”这个边界当初是否被评估过。
团队只好重新开会,从零开始讨论。三周后他们决定改成读扩散,结果读路径的 P99 从 80 毫秒涨到 900 毫秒(案例假设值),热门账号的每次刷新都要合并大量关注关系。又过了两周,团队开始回滚。一次本来可以在半天内查清的取舍,最终消耗了一个多月。
问题不在写扩散或读扩散哪个更对,而在于那份 ADR 没有提供任何可复盘的信息。它记录了“选了什么”,却没有记录“为什么选、还考虑过什么、代价是什么、什么情况下应该改”。
一份 ADR 至少回答五个问题
架构决策记录不是会议纪要,也不是设计文档的替代品。它的最小职责是让一个没有参与当时讨论的人,在半年后能回答五个问题:当时面对什么约束、考虑过哪些备选、为什么选这个、接受了什么代价、什么情况下会被取代。
| 部分 | 要回答的问题 | 只有一句话时的后果 | 合格标准 |
|---|---|---|---|
| Context | 当时的用户量、读写比、团队和成本约束是什么 | 后人不知道结论的适用边界 | 写明案例假设值和已知未知 |
| Options | 认真考虑过哪些方案,各自的 pros/cons | 无法判断是否漏掉关键备选 | 至少两个,包含被否决的理由 |
| Decision | 最终选哪个,对应哪个备选 | 决策与备选脱节,无法追溯 | 必须精确匹配某个 option |
| Consequences | 接受了哪些代价,哪些指标会变差 | 只看到收益,看不到债务 | 至少一条可验证的后果 |
| Status / 后继 | 当前是 proposed、accepted、rejected 还是 superseded | 过期决策继续被引用 | superseded 必须给出后继 ID |
这张表是案例假设的评审清单,不是行业标准。不同团队可以增删字段,但不能删掉“备选”和“后果”这两项,因为它们正是事后复盘的入口。
状态字段看起来最不起眼,实际很关键。一份 ADR 被接受后不会永远有效;流量结构、成本、合规要求变化时,它应该被新的 ADR 取代。没有状态和后继关系,旧文档会一直躺在 wiki 里,被后来者当成仍然有效的结论。
备选不是凑数
很多 ADR 的“备选”一栏写着“方案 A / 方案 B”,但两个方案只是同一件事的两种实现细节,真正的取舍没有被讨论。合格的备选至少应该覆盖三类:
- 做:选一个主方案,并写清它牺牲了什么。
- 不做或延后:明确记录“当前阶段不解决”的理由,避免半年后有人以为没人想过。
- 买或借:如果外部服务能覆盖,应该把成本、锁定和合规风险写出来,而不是默认自研。
被否决的备选同样要保留 pros/cons。后人需要知道“当时为什么没选读扩散”,而不是只看到“最终选了写扩散”。否决理由往往比决定本身更有信息量,因为它记录了当时的约束。
只写了「我们选了 X」的那份文档
回到 ADR-0007。它的问题可以逐条对应上面的表:
- 没有 Context。后人不知道 80:1 的读写比、20 万 DAU 和 500 万粉丝上限是案例假设值还是实测值,也不知道这些数字是否已经变化。
- 没有 Options。读扩散、写扩散、混合模式三种方案没有被并列比较,无法判断团队是否漏掉了“普通账号写扩散、大 V 读扩散”的混合方案。
- Decision 只有一句“采用写扩散”,没有说明它对应哪个备选,也没有说明哪些场景不适用。
- 没有 Consequences。写放大、队列积压、热点账号的补偿成本都没有被记录,团队只看到了读路径简单。
- 没有 Status 和后继。半年后即使有人想取代它,也不知道该把哪份新文档挂到它下面。
这些缺失带来的后果是连锁的。新团队先花一周还原历史,又花两周重新评估,最后选了一个原团队当初可能已经否决过的方案;回滚又花了两周。真正昂贵的不是重新讨论,而是讨论时缺少共同的事实基础。
更隐蔽的代价是信任。每次有人问“为什么当初这么设计”,得到的回答都是“不知道,反正已经这样了”,久而久之,团队会倾向于绕开旧系统而不是理解它。架构债务里最难还的一类,就是没有人记得原因的决策。
把 ADR 写成可校验的结构
ADR 的完整性可以部分机械化。下面的代码来自 webapp-lab,Validate 检查 ID、标题、背景、状态、备选数量、备选名唯一、决策必须对应某个备选、后果非空;SupersededBy 单独检查一份已取代的 ADR 是否给出了合法后继。
把后继检查放在单独方法里,是因为 ADR 的正文不应该在状态变化时被重写。一份 accepted 的文档被取代时,原文保持不变,只追加“被谁取代”;后继 ID 属于状态迁移,不属于原始决策内容。这样历史记录不会被悄悄改写。
// file: internal/platform/design/adr.go
// tier: L1
// verify: go test ./internal/platform/design/
package design
import (
"fmt"
"strings"
)
const (
StatusProposed = "proposed"
StatusAccepted = "accepted"
StatusRejected = "rejected"
StatusSuperseded = "superseded"
)
// Option 是被认真考虑过的备选方案。没有 pros/cons 的选项只是口号。
type Option struct {
Name string
Pros []string
Cons []string
}
// ADR 是一份架构决策记录:背景、备选、决定和后果必须同时存在。
type ADR struct {
ID string
Title string
Status string
Context string
Options []Option
Decision string
Consequences []string
}
// Validate 检查 ADR 的结构是否足以接受评审。
func (a ADR) Validate() error {
if strings.TrimSpace(a.ID) == "" {
return fmt.Errorf("design: ADR id is empty")
}
if strings.TrimSpace(a.Title) == "" {
return fmt.Errorf("design: ADR %q has no title", a.ID)
}
if strings.TrimSpace(a.Context) == "" {
return fmt.Errorf("design: ADR %q has no context", a.ID)
}
switch a.Status {
case StatusProposed, StatusAccepted, StatusRejected, StatusSuperseded:
default:
return fmt.Errorf("design: ADR %q has invalid status %q", a.ID, a.Status)
}
if len(a.Options) < 2 {
return fmt.Errorf("design: ADR %q needs at least two options", a.ID)
}
seen := make(map[string]bool, len(a.Options))
for _, opt := range a.Options {
name := strings.TrimSpace(opt.Name)
if name == "" {
return fmt.Errorf("design: ADR %q has an unnamed option", a.ID)
}
if seen[name] {
return fmt.Errorf("design: ADR %q has duplicate option %q", a.ID, name)
}
seen[name] = true
for _, list := range [][]string{opt.Pros, opt.Cons} {
for _, item := range list {
if strings.TrimSpace(item) == "" {
return fmt.Errorf("design: ADR %q option %q has an empty pro/con", a.ID, name)
}
}
}
}
decision := strings.TrimSpace(a.Decision)
if decision == "" {
return fmt.Errorf("design: ADR %q has no decision", a.ID)
}
if !seen[decision] {
return fmt.Errorf("design: ADR %q decision %q is not one of the options", a.ID, decision)
}
if len(a.Consequences) == 0 {
return fmt.Errorf("design: ADR %q has no consequences", a.ID)
}
for _, consequence := range a.Consequences {
if strings.TrimSpace(consequence) == "" {
return fmt.Errorf("design: ADR %q has an empty consequence", a.ID)
}
}
return nil
}
// SupersededBy 校验一份已取代的 ADR 是否真的给出了后继。
// 后继必须非空、不能指向自己;调用前 Status 必须是 superseded。
func (a ADR) SupersededBy(successor string) error {
if a.Status != StatusSuperseded {
return fmt.Errorf("design: ADR %q is %q, not superseded", a.ID, a.Status)
}
successor = strings.TrimSpace(successor)
if successor == "" {
return fmt.Errorf("design: ADR %q has no successor", a.ID)
}
if successor == strings.TrimSpace(a.ID) {
return fmt.Errorf("design: ADR %q cannot supersede itself", a.ID)
}
return nil
}
代码里有几个有意为之的边界:
Validate不检查备选的 pros/cons 是否为空列表。一个刚提出的备选可能还没评估完,强制要求会逼人造句;但一旦填了条目,空字符串会被拒绝。- 决策必须精确匹配某个备选名。这能防止“决定写混合方案,但备选里根本没有混合方案”的脱节。
SupersededBy不修改 ADR,只做校验。持久化层负责把后继关系写进索引,这样正文保持不可变,审计链更清晰。
它不能判断 Context 写得是否充分,也不能判断 Consequences 是否真的覆盖了主要代价。这些仍然需要人来评审;校验器的作用是把“明显不完整”挡在评审之前。
测试:单备选、错决策与缺失后继
ADR 校验最容易被绕过的三个地方是:只有一个备选、决策与备选对不上、状态写错。下面的测试把这三类情况固定住,同时覆盖空标题、空背景、重复备选、空后果和缺失后继。
// file: internal/platform/design/adr_test.go
// tier: L1
// verify: go test ./internal/platform/design/
package design
import "testing"
func validADR() ADR {
return ADR{
ID: "ADR-0007",
Title: "Feed fan-out strategy",
Status: StatusAccepted,
Context: "Case assumption: 200k DAU, 80:1 read/write ratio, " +
"celebrity accounts can have 5M followers.",
Options: []Option{
{Name: "fan-out-on-write", Pros: []string{"read is cheap"}, Cons: []string{"write amplification for celebrities"}},
{Name: "fan-out-on-read", Pros: []string{"write stays simple"}, Cons: []string{"read latency grows with follow count"}},
{Name: "hybrid", Pros: []string{"bounded cost for both paths"}, Cons: []string{"two code paths to operate"}},
},
Decision: "hybrid",
Consequences: []string{"celebrity posts use read-time merge", "ordinary accounts use write-time fan-out"},
}
}
func TestValidateAcceptsReviewableADR(t *testing.T) {
if err := validADR().Validate(); err != nil {
t.Fatalf("want valid ADR, got %v", err)
}
}
func TestValidateRejectsIncompleteADR(t *testing.T) {
cases := []struct {
name string
drop func(*ADR)
}{
{"id", func(a *ADR) { a.ID = " " }},
{"title", func(a *ADR) { a.Title = "" }},
{"context", func(a *ADR) { a.Context = "" }},
{"status", func(a *ADR) { a.Status = "draft" }},
{"one-option", func(a *ADR) { a.Options = a.Options[:1]; a.Decision = a.Options[0].Name }},
{"unnamed-option", func(a *ADR) { a.Options[0].Name = "" }},
{"duplicate-option", func(a *ADR) { a.Options[1].Name = a.Options[0].Name }},
{"decision-not-option", func(a *ADR) { a.Decision = "do-nothing" }},
{"no-consequences", func(a *ADR) { a.Consequences = nil }},
{"empty-consequence", func(a *ADR) { a.Consequences = []string{"ok", " "} }},
{"empty-pro", func(a *ADR) { a.Options[0].Pros = []string{" "} }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
a := validADR()
c.drop(&a)
if err := a.Validate(); err == nil {
t.Fatalf("want error when %s is broken", c.name)
}
})
}
}
func TestSupersededByRequiresSuccessor(t *testing.T) {
a := validADR()
a.Status = StatusSuperseded
if err := a.SupersededBy("ADR-0012"); err != nil {
t.Fatalf("want valid successor, got %v", err)
}
if err := a.SupersededBy(""); err == nil {
t.Fatal("want error for empty successor")
}
if err := a.SupersededBy("ADR-0007"); err == nil {
t.Fatal("want error for self successor")
}
a.Status = StatusAccepted
if err := a.SupersededBy("ADR-0012"); err == nil {
t.Fatal("want error when status is not superseded")
}
}
测试里最值得看的是 decision-not-option 和 one-option。前者防止决策与备选脱节,后者防止“只有一个方案也叫决策”。status 用例则把 draft 这类看起来合理但不在生命周期里的状态拒之门外,避免每份 ADR 各自发明状态名。
TestSupersededByRequiresSuccessor 覆盖四种情况:合法后继、空后继、自我后继、状态不是 superseded。自我后继看起来荒谬,但在复制粘贴模板时很常见,必须显式拒绝。
测试没有覆盖的部分包括:备选是否真的独立、后果是否完整、Context 里的数字是否有来源。这些属于评审质量,不是结构校验能解决的。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/platform/design/ -run 'TestValidateAcceptsReviewableADR|TestValidateRejectsIncompleteADR|TestSupersededByRequiresSuccessor' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateAcceptsReviewableADR
--- PASS: TestValidateAcceptsReviewableADR (0.00s)
=== RUN TestValidateRejectsIncompleteADR
=== RUN TestValidateRejectsIncompleteADR/id
=== RUN TestValidateRejectsIncompleteADR/title
=== RUN TestValidateRejectsIncompleteADR/context
=== RUN TestValidateRejectsIncompleteADR/status
=== RUN TestValidateRejectsIncompleteADR/one-option
=== RUN TestValidateRejectsIncompleteADR/unnamed-option
=== RUN TestValidateRejectsIncompleteADR/duplicate-option
=== RUN TestValidateRejectsIncompleteADR/decision-not-option
=== RUN TestValidateRejectsIncompleteADR/no-consequences
=== RUN TestValidateRejectsIncompleteADR/empty-consequence
=== RUN TestValidateRejectsIncompleteADR/empty-pro
--- PASS: TestValidateRejectsIncompleteADR (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/id (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/title (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/context (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/status (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/one-option (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/unnamed-option (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/duplicate-option (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/decision-not-option (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/no-consequences (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/empty-consequence (0.00s)
--- PASS: TestValidateRejectsIncompleteADR/empty-pro (0.00s)
=== RUN TestSupersededByRequiresSuccessor
--- PASS: TestSupersededByRequiresSuccessor (0.00s)
PASS
ok webapp/internal/platform/design 0.224s
PASS 和退出码 0 是判定标准。-run 里的正则按测试函数名匹配,子测试会随父测试一起运行;如果只想跑某个子测试,可以在正则后追加 /子测试名。
小结
ADR 的价值不在格式,而在它强迫团队把“背景、备选、决定、后果”同时写下来。只写“我们选了 X”的文档,在决定当天看起来高效,在半年后会把同一场讨论重新启动一次,而且这次没有原始事实。
至少两个备选、决策必须对应备选、后果必须可验证、superseded 必须有后继,这四条规则可以用代码固定下来。校验器挡不住糟糕的判断,但能挡住不完整的记录,让评审至少从同一张表开始。
下一节进入卷一的下一章,从注册登录和第三方身份开始,看看一个看似简单的入口怎样牵出会话、风控和账号合并的边界。
阅读导航:上一节:2.2 领域模型与数据所有权 · 下一节:3.1 注册登录与第三方身份 。
常见错误与边界
- 把 ADR 写成会议纪要。纪要记录谁说了什么,ADR 记录面对什么约束、选了哪条路、接受了什么代价。
- 只写一个备选。没有比较就没有决策,只有一个方案时应该写清“为什么不考虑其他方案”。
- 决策与备选不对应。正文说选了混合方案,备选表里却没有混合方案,是最常见的脱节。
- 只写收益不写后果。没有 Consequences 的 ADR 会让后来者以为切换是免费的。
- 状态永久停在 accepted。流量、成本、合规变化后没有后继,旧决策会继续被引用。
- 用“以后补文档”代替记录。补文档的人通常不是做决定的人,补出来的是合理化,不是历史。
- 把 ADR 当设计文档。ADR 不描述全部实现细节,它只记录关键取舍和边界,细节应放在设计文档里。
- 用校验器代替评审。结构完整不等于判断正确,Context 的数字来源和 Consequences 的完整性仍然需要人来看。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。