从风险清单到决策链
12.3 留下的是一张风险清单:超卖、单库可用性、outbox 积压、开关失控。清单能提示危险,却不能回答后来者最常问的那句话:当时为什么选这条路,又否决了什么。风险条目描述的是“可能出什么事”,决策记录描述的才是“我们决定怎么应对、愿意付什么代价”。本节的交付物不是再写七份 ADR,而是把卷一已经发生的决策整理成一条可检查的链:需求与约束变成驱动因素,驱动因素约束决策,决策留下正负后果,决策之间再用依赖关系排序;链上的每一环都要能指向具体证据,指向不了的就诚实标成未知。
需求先变成可检验的驱动因素
需求不能直接写成“要高性能”。它必须先落到可观察的约束上,才能成为某个 ADR 的 driver。下表把 12.1 的需求和 12.3 的场景压缩成六条驱动;其中“峰值未测”是刻意保留的未知项,因为它决定了哪些结论现在不能下。
| 需求或约束 | 证据状态 | 驱动因素 | 约束了哪些决策 |
|---|---|---|---|
| 两人团队,没有独立 DBA | 团队事实 | 运维简单、决策可逆 | ADR-001、ADR-003、ADR-007 |
| 日订单不到万级,峰值未测 | 12.1 的未知项 | 暂不把吞吐当首要约束 | ADR-003、ADR-004 |
| 任何情况下不得超卖 | 业务硬规则 | 库存与订单强一致 | ADR-002、ADR-003 |
| 支付回调 500ms 内返回 | 12.3 的质量场景 | 支付后异步推进 | ADR-004、ADR-006 |
| 进程崩溃不能丢领域事件 | 2.3 的评审条件 | 至少一次投递且可重放 | ADR-006 |
| 未来可能按模块拆分 | 1.3 的重评触发条件 | 边界可演化、依赖单向 | ADR-001、ADR-005 |
这张表最重要的不是六行结论,而是第二列。把“峰值未测”写成“峰值一万”会让 ADR-003 看起来已经被数据支持,实际上它只是被当前规模容忍。驱动因素一旦伪造,后面的依赖链会一起失真。
七条决策不是并列的
把七条 ADR 按编号排成目录,会掩盖它们之间的先后关系。ADR-006 不是在真空中选了 outbox,它同时被 ADR-003 的单库事务和 ADR-004 的异步履约约束;如果先推翻 004,006 的备选比较要重做。
ADR-001 模块化单体
├── ADR-002 下单同步预留库存
│ └── ADR-003 PostgreSQL 单写库
│ └── ADR-004 下单同步、履约异步
│ └── ADR-006 领域事件写入 outbox
├── ADR-005 模块间只经端口通信
│ └── ADR-007 单部署单元与模块开关
主链是 001 → 002 → 003 → 004 → 006。它说明 outbox 是这条链的末端结论,不是可以单独替换的中间件选型;想换掉 006,必须先回答 003 是否仍是单写库、004 是否仍允许支付后异步。另一条支线 001 → 005 → 007 与主链并行,所以灰度开关可以先做,但它依赖端口边界,不能反过来“先上开关再补端口”。
依赖关系还暴露了承重墙。ADR-003 被 004 和 006 同时依赖,它的重评触发条件(持续写入超过 5k QPS、主库切换 RTO 超过 5 分钟)一旦命中,受影响的不止存储选型,还包括履约时序和事件投递方式。索引的价值就在这里:它让“改一条 ADR”变成一次可计算的影响分析,而不是凭记忆判断。
索引只存可校验的事实
ADR 正文回答“为什么”,索引只存评审程序能读的骨架。下表是本节采用的字段,以及每个字段要拦住的事故。
| 字段 | 为什么必须 | 对应校验 |
|---|---|---|
| id | 稳定引用,不能靠标题 | 格式与重复 |
| status | 区分已接受、提议、被取代 | 枚举合法性 |
| date | 决策发生时间,不是文章发布时间 | RFC3339 解析 |
| drivers | 决策必须挂在需求上 | 不能为空 |
| depends_on | 决策之间的先后约束 | 悬空引用与环 |
| alternatives | 至少两个被认真比较的候选 | 数量与否决理由 |
| consequences | 同时记录收益和代价 | 正负都不能缺 |
| superseded_by | 被取代时指向继任者 | 状态与目标一致 |
字段少是刻意的。索引不复制论证、不保存会议纪要,也不记录 owner 和审批流;那些内容变化快,适合留在 ADR 正文或协作工具里。索引只保留“改一条会不会破坏另一条”的结构信息。
校验器:把文档事故变成测试失败
下面这个包把索引变成可执行结构。它检查十二类问题:编号格式、重复编号、非法状态、非法日期、缺少驱动、备选不足两个、备选没有否决理由、后果缺少正面或负面、悬空依赖、依赖环、孤立 ADR,以及 superseded 状态与目标不一致。
// file: internal/adrindex/index.go
// tier: L1
// verify: go test ./internal/adrindex/ -count=1 -v
package adrindex
import (
"regexp"
"strings"
"time"
)
type Status string
const (
StatusProposed Status = "proposed"
StatusAccepted Status = "accepted"
StatusSuperseded Status = "superseded"
StatusRejected Status = "rejected"
)
func (s Status) Valid() bool {
switch s {
case StatusProposed, StatusAccepted, StatusSuperseded, StatusRejected:
return true
}
return false
}
type Alternative struct {
Name string
Reason string
}
type Consequences struct {
Positive []string
Negative []string
}
type Entry struct {
ID string
Title string
Status Status
Date string
Drivers []string
DependsOn []string
Alternatives []Alternative
Consequences Consequences
SupersededBy string
}
type Index struct{ Entries []Entry }
type Issue struct {
Code, ID, Detail string
}
const (
CodeInvalidID = "INVALID_ID"
CodeDuplicateID = "DUPLICATE_ID"
CodeInvalidStatus = "INVALID_STATUS"
CodeInvalidDate = "INVALID_DATE"
CodeNoDriver = "NO_DRIVER"
CodeNoAlternative = "NO_ALTERNATIVE"
CodeAlternativeNoReason = "ALTERNATIVE_NO_REASON"
CodeNoConsequence = "NO_CONSEQUENCE"
CodeDanglingDependency = "DANGLING_DEPENDENCY"
CodeDependencyCycle = "DEPENDENCY_CYCLE"
CodeIsolatedADR = "ISOLATED_ADR"
CodeSupersededMismatch = "SUPERSEDED_MISMATCH"
)
var idPattern = regexp.MustCompile(`^ADR-\d{3}$`)
// Validate 只检查索引自洽,不判断决策内容是否正确。
func (in Index) Validate() []Issue {
var issues []Issue
seen := make(map[string]bool, len(in.Entries))
for _, e := range in.Entries {
if !idPattern.MatchString(e.ID) {
issues = append(issues, Issue{CodeInvalidID, e.ID, "want ADR-NNN"})
}
if seen[e.ID] {
issues = append(issues, Issue{CodeDuplicateID, e.ID, "declared twice"})
}
seen[e.ID] = true
if !e.Status.Valid() {
issues = append(issues, Issue{CodeInvalidStatus, e.ID, string(e.Status)})
}
if _, err := time.Parse(time.RFC3339, e.Date); err != nil {
issues = append(issues, Issue{CodeInvalidDate, e.ID, e.Date})
}
if len(e.Drivers) == 0 {
issues = append(issues, Issue{CodeNoDriver, e.ID, "no requirement driver"})
}
if len(e.Alternatives) < 2 {
issues = append(issues, Issue{CodeNoAlternative, e.ID, "need two candidates"})
}
for _, alt := range e.Alternatives {
if strings.TrimSpace(alt.Reason) == "" {
issues = append(issues, Issue{CodeAlternativeNoReason, e.ID, alt.Name})
}
}
if len(e.Consequences.Positive) == 0 || len(e.Consequences.Negative) == 0 {
issues = append(issues, Issue{CodeNoConsequence, e.ID, "need both signs"})
}
if (e.Status == StatusSuperseded) != (e.SupersededBy != "") {
issues = append(issues, Issue{CodeSupersededMismatch, e.ID, "status/target mismatch"})
}
}
dependents := make(map[string]bool, len(in.Entries))
for _, e := range in.Entries {
for _, dep := range e.DependsOn {
if !seen[dep] {
issues = append(issues, Issue{CodeDanglingDependency, e.ID, dep})
continue
}
dependents[dep] = true
}
}
for _, e := range in.Entries {
if len(e.DependsOn) == 0 && !dependents[e.ID] {
issues = append(issues, Issue{CodeIsolatedADR, e.ID, "no edge in or out"})
}
}
if in.hasCycle() {
issues = append(issues, Issue{CodeDependencyCycle, "", "DependsOn contains a cycle"})
}
return issues
}
func (in Index) hasCycle() bool {
graph := make(map[string][]string, len(in.Entries))
for _, e := range in.Entries {
graph[e.ID] = append(graph[e.ID], e.DependsOn...)
}
state := make(map[string]int, len(in.Entries))
var visit func(string) bool
visit = func(id string) bool {
if state[id] == 1 {
return true
}
if state[id] == 2 {
return false
}
state[id] = 1
for _, dep := range graph[id] {
if visit(dep) {
return true
}
}
state[id] = 2
return false
}
for id := range graph {
if visit(id) {
return true
}
}
return false
}
孤立 ADR 的定义值得单独说明:一条记录既没有 depends_on,也没有任何其他记录依赖它,就说明它没有接入决策网络。它可能是刚写的提议,也可能是被遗忘的旧文档;无论哪种,评审时都应该被看见。依赖环则用深度优先搜索的着色法检测,命中灰节点即存在回边。
卷一登记表
登记表是 2026-10-06 评审前的快照:ADR-001 到 005 已接受,006 和 007 仍是提议。每条都保留两个被否决的候选方案和正负后果,否则校验器会直接报错。
// file: internal/adrindex/volume_one.go
// tier: L1
// verify: go test ./internal/adrindex/ -run TestVolumeOneIndex -v
package adrindex
// VolumeOne 是卷一七条决策的索引;正文论证仍留在各 ADR 文档里。
func VolumeOne() Index {
return Index{Entries: []Entry{
{ID: "ADR-001", Title: "模块化单体作为卷一形态", Status: StatusAccepted, Date: "2026-10-05T10:00:00+08:00", Drivers: []string{"REQ-01", "REQ-02"},
Alternatives: []Alternative{{"按模块拆成四个微服务", "两人团队承担不起跨服务事务、发布和监控成本"}, {"纯分层大泥球", "没有模块边界,后续无法增量拆分"}},
Consequences: Consequences{Positive: []string{"单事务、单部署、测试与运维成本最低"}, Negative: []string{"边界只能靠包约束和静态检查"}}},
{ID: "ADR-002", Title: "创建订单时同步预留库存", Status: StatusAccepted, Date: "2026-10-06T09:00:00+08:00", Drivers: []string{"REQ-03"}, DependsOn: []string{"ADR-001"},
Alternatives: []Alternative{{"下单后异步扣减库存", "用户可见路径出现超卖窗口"}, {"支付成功后再预留", "支付前库存可能已被其他订单占用"}},
Consequences: Consequences{Positive: []string{"超卖在同步路径被挡住"}, Negative: []string{"下单延迟包含库存调用,库存故障会阻塞下单"}}},
{ID: "ADR-003", Title: "订单主存储选用 PostgreSQL 单写库", Status: StatusAccepted, Date: "2026-10-06T10:00:00+08:00", Drivers: []string{"REQ-03", "REQ-06"}, DependsOn: []string{"ADR-001", "ADR-002"},
Alternatives: []Alternative{{"内存存储作为生产主库", "重启丢单,无法多进程共享和审计"}, {"文档库存订单快照", "跨订单、库存、outbox 的事务与查询能力不足"}},
Consequences: Consequences{Positive: []string{"订单与库存同事务提交,备份恢复路径单一"}, Negative: []string{"写吞吐受单主库限制,数据库成为下单可用性上限"}}},
{ID: "ADR-004", Title: "下单同步、履约异步", Status: StatusAccepted, Date: "2026-10-06T11:00:00+08:00", Drivers: []string{"REQ-04"}, DependsOn: []string{"ADR-002", "ADR-003"},
Alternatives: []Alternative{{"支付回调内同步履约", "回调延迟绑定仓库系统,重试会重复发货"}, {"连库存预留也异步", "把超卖窗口留给用户可见路径"}},
Consequences: Consequences{Positive: []string{"支付回调快,履约故障可重试"}, Negative: []string{"PAID 与 FULFILLED 之间存在时间窗,消费者必须幂等"}}},
{ID: "ADR-005", Title: "模块间只经端口接口通信", Status: StatusAccepted, Date: "2026-10-06T12:00:00+08:00", Drivers: []string{"REQ-05"}, DependsOn: []string{"ADR-001"},
Alternatives: []Alternative{{"共享一个 repository 包", "改一张表波及所有模块,测试必须连库"}, {"允许跨模块直接 SQL join", "短期快,长期绕过不变量和归属校验"}},
Consequences: Consequences{Positive: []string{"模块可独立测试、替换实现,未来可拆分"}, Negative: []string{"接口数量增加,跨模块事务需要显式 unit-of-work"}}},
{ID: "ADR-006", Title: "领域事件写入 outbox", Status: StatusProposed, Date: "2026-10-06T14:00:00+08:00", Drivers: []string{"REQ-04", "REQ-05"}, DependsOn: []string{"ADR-003", "ADR-004"},
Alternatives: []Alternative{{"提交后进程内 channel 发布", "崩溃即丢,无法重放"}, {"handler 内直接 produce Kafka", "数据库与 Kafka 双写,任一侧失败都会不一致"}},
Consequences: Consequences{Positive: []string{"事件与状态同事务,可重放和审计"}, Negative: []string{"relay 带来延迟,outbox 需要清理与积压监控"}}},
{ID: "ADR-007", Title: "单部署单元,按模块开关灰度", Status: StatusProposed, Date: "2026-10-06T16:00:00+08:00", Drivers: []string{"REQ-01", "REQ-05"}, DependsOn: []string{"ADR-001", "ADR-005"},
Alternatives: []Alternative{{"每个模块独立部署", "发布与运维成本回到微服务"}, {"只做部署级 canary", "整个进程一起切,无法隔离单个模块的新路径"}},
Consequences: Consequences{Positive: []string{"爆炸半径小,回滚快"}, Negative: []string{"开关组合增加测试矩阵,旧开关会变成永久分支"}}},
}}
}
注意 ADR-001 没有 depends_on,但它不是孤立节点,因为 002、003、005、007 都依赖它。孤立判断必须同时看入边和出边,只看“有没有依赖别人”会把根决策误判成孤儿。
规则由表驱动测试固定
测试分两层。第一层断言真实登记表零问题,并抽查三条关键依赖边确实存在;第二层复制登记表、每次只破坏一处,验证对应错误码会出现。这样新增 ADR 时不用重写测试,规则被误删时也会立刻报警。
// file: internal/adrindex/index_test.go
// tier: L1
// verify: go test ./internal/adrindex/ -count=1 -v
package adrindex
import "testing"
func hasCode(issues []Issue, want string) bool {
for _, issue := range issues {
if issue.Code == want {
return true
}
}
return false
}
func TestVolumeOneIndex(t *testing.T) {
idx := VolumeOne()
if issues := idx.Validate(); len(issues) != 0 {
t.Fatalf("VolumeOne() has %d issue(s): %v", len(issues), issues)
}
want := map[string]string{"ADR-003": "ADR-002", "ADR-004": "ADR-003", "ADR-006": "ADR-004"}
for _, e := range idx.Entries {
if dep, ok := want[e.ID]; ok {
found := false
for _, got := range e.DependsOn {
found = found || got == dep
}
if !found {
t.Fatalf("%s must depend on %s, got %v", e.ID, dep, e.DependsOn)
}
}
}
}
func TestValidateRules(t *testing.T) {
tests := []struct {
name string
mutate func(*Index)
want string
}{
{"invalid id", func(in *Index) { in.Entries[0].ID = "ADR-1" }, CodeInvalidID},
{"duplicate id", func(in *Index) { in.Entries[1].ID = in.Entries[0].ID }, CodeDuplicateID},
{"invalid status", func(in *Index) { in.Entries[0].Status = "done" }, CodeInvalidStatus},
{"invalid date", func(in *Index) { in.Entries[0].Date = "2026-10-05" }, CodeInvalidDate},
{"no driver", func(in *Index) { in.Entries[0].Drivers = nil }, CodeNoDriver},
{"one alternative", func(in *Index) { in.Entries[0].Alternatives = in.Entries[0].Alternatives[:1] }, CodeNoAlternative},
{"alternative without reason", func(in *Index) { in.Entries[0].Alternatives[0].Reason = "" }, CodeAlternativeNoReason},
{"no negative consequence", func(in *Index) { in.Entries[0].Consequences.Negative = nil }, CodeNoConsequence},
{"dangling dependency", func(in *Index) { in.Entries[1].DependsOn = append(in.Entries[1].DependsOn, "ADR-999") }, CodeDanglingDependency},
{"dependency cycle", func(in *Index) { in.Entries[0].DependsOn = []string{"ADR-006"} }, CodeDependencyCycle},
{"isolated adr", func(in *Index) {
in.Entries = append(in.Entries, Entry{ID: "ADR-099", Title: "占位", Status: StatusAccepted,
Date: "2026-10-09T00:00:00+08:00", Drivers: []string{"REQ-99"},
Alternatives: []Alternative{{"A", "r"}, {"B", "r"}},
Consequences: Consequences{Positive: []string{"p"}, Negative: []string{"n"}}})
}, CodeIsolatedADR},
{"superseded without target", func(in *Index) { in.Entries[0].Status = StatusSuperseded }, CodeSupersededMismatch},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := VolumeOne()
tt.mutate(&idx)
if !hasCode(idx.Validate(), tt.want) {
t.Fatalf("issues = %v, want %s", idx.Validate(), tt.want)
}
})
}
}
表驱动测试覆盖了十二条规则,但没有覆盖“决策是否正确”。这是有意的边界:校验器能证明索引自洽,不能证明 PostgreSQL 一定够用,也不能证明 outbox 一定不丢事件。后者要靠 2.3 的评审条件和后续实验。
真实运行结果
以下命令在本机 macOS(darwin/arm64,Go 1.26.0)真实执行,输出未改写:
$ go test ./internal/adrindex/ -count=1
ok archlab/internal/adrindex 0.478s
$ go test ./internal/adrindex/ -run TestValidateRules -v | grep -c '^ --- PASS'
12
grep -c 的 12 对应十二条规则子用例;完整 -v 输出还会列出每条的 PASS 行。测试通过只能说明登记表没有结构性问题,不能替代评审。
索引读出的三件事
第一,最长依赖链决定了改动顺序:001 → 002 → 003 → 004 → 006 意味着 outbox 的重新评估必须排在存储和异步边界之后,跳过前两步直接换消息中间件会把旧约束带进新方案。第二,高扇入节点是承重墙:ADR-003 同时被 004 和 006 依赖,它的触发条件应该被监控,而不是等到“业务发展了”才想起来复审。第三,并行支线适合先试点:005 → 007 不与主链耦合,灰度开关可以先小流量验证,但先上开关再补端口会让开关控制到错误的调用路径。
与 2.2 和站内 ADR 文章的分工
2.2 已经写出 ADR-003 到 ADR-007 的完整论证,本节补上 ADR-001 和 ADR-002,把它们放进同一张依赖图,并交付索引校验器。站内的 架构决策记录(ADR) 讲的是 ADR 的起源、模板和团队落地;本节不重复模板,只回答“卷一这七条决策如何互相约束、哪些结构错误能让测试直接失败”。需要回看单条论证时,先读 2.2 订单系统的五个 ADR 。
常见错误与边界
第一,把索引写成目录。只有标题和链接,没有 drivers、depends_on 和 consequences,评审时仍然只能靠记忆判断影响范围。
第二,依赖方向写反。depends_on 表示“我依赖谁”,不是“谁依赖我”;方向一反,拓扑顺序和影响分析都会失真。
第三,备选方案写成稻草人。“什么都不做”和“全部重写”不算认真比较,至少要有两个在当时约束下可信的候选,并写清否决理由。
第四,后果只写正面。没有代价的决策通常意味着代价还没被发现,而不是不存在;校验器强制正负都要有,就是在逼作者补上这一半。
第五,日期填成文章发布日期,或以为校验通过就等于决策正确。决策日期影响“当时掌握了什么证据”的判断;索引只能证明结构自洽,约束是否变化、方案是否仍成立,要靠评审、实验和触发条件监控。
小结
卷一的需求先被压缩成六条驱动因素,再收敛为七条带依赖关系的 ADR:模块化单体是根,同步库存预留和单写库构成主链,履约异步与 outbox 依次挂在后面,端口边界和灰度开关是并行支线。internal/adrindex 把这些关系编码成可校验结构,孤立 ADR、缺失备选、无后果、非法日期和依赖环都会让测试失败。下一节用一次针对候选方案的评审,检验这张索引在真实争论里是否够用。
阅读导航:上一节:12.3 质量属性场景与架构风险清单 · 下一节:13.2 评审一次真实架构方案 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。