《互联网产品的服务端架构·产品、流量与基础能力》2.3 一份可评审的架构决策记录

以「回声」Feed 写扩散与读扩散的取舍为例,说明一份可评审的 ADR 必须同时写清背景、至少两个备选、决定、后果与后继关系,并给出能拒绝单备选、决策不对应选项、非法状态和缺失后继的 Go 校验器,最后复盘一份只写了「我们选了 X」的决策记录如何在半年后让团队重新吵一遍。

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 的完整性仍然需要人来看。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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