从 ADR-003 开始,不重复前两条
1.3 的 ADR-001 已经确定模块化单体,1.2 的 ADR-002 已经确定下单同步库存预留,这两条在卷一是冻结前提。本节从 ADR-003 开始,讨论它们没有回答的五个问题:状态存在哪里、支付之后哪些事同步做、模块之间怎么调用、领域事件怎么不丢、单进程怎么灰度。
ADR 模板一句话带过:状态、背景、决策、备选、后果、重新评估触发条件。重点不在模板,而在五份具体决策的约束和否决理由。每份记录都写明“什么情况下应该推翻它”,因为决策的质量不取决于今天选对,而取决于环境变化时能不能被重新审视。
ADR-003:订单主存储选用 PostgreSQL 单写库
ADR-003:订单主存储选用 PostgreSQL 单写库
状态:已接受
背景:订单、库存预留、支付记录与 outbox 必须同成败;团队两人,没有可独立
运维的 DBA;日订单不到万级,但要求崩溃后可恢复、可按客户查历史。
决策:PostgreSQL 18.4 单主库;所有写操作经仓储端口;订单与库存预留
在同一本地事务提交;版本号做条件更新;内存适配器只用于 L1 测试。
备选 A:内存存储作为生产主库——重启丢单、无法多进程共享、没有审计,
否决。
备选 B:文档库存订单快照——跨订单、库存、outbox 的多文档事务与查询
能力不足,团队缺少运维经验,否决。
后果:正面——单库 ACID、备份恢复路径单一、outbox 可与业务同事务写入;
负面——写吞吐受单主库限制,数据库成为下单可用性上限,迁移需停机窗口。
触发:持续写入超过 5k QPS;主库切换 RTO 超过 5 分钟;某模块要求独立存储。
单写库不是“先进”的选择,而是当前一致性约束下最省心的选择。它把订单与库存的同成败交给一个本地事务,代价是写路径没有第二个可用性副本。
ADR-004:下单同步、履约异步
ADR-004:下单同步、履约异步
状态:已接受
背景:ADR-002 已把库存预留放在创建订单的同步路径;支付回调必须快速
返回,而拣货、开票、通知可以滞后;用户需要立即看到 PAID。
决策:创建订单:校验、预留库存、写订单在同一请求内完成;支付回调:
验签、迁移到 PAID、写 outbox 后立即返回 200;履约由消费者异步推进,
PAID 表示“已受理履约”,FULFILLED 只在履约成功后写入。
备选 A:支付回调内同步履约——回调延迟绑定仓库系统,重试会重复发货,
否决。
备选 B:连库存预留也异步——与 ADR-002 冲突,把超卖窗口留给用户可见
路径,否决。
后果:正面——回调快、履约故障可重试、下单与库存仍同成败;
负面——PAID 与 FULFILLED 之间存在时间窗,需要 outbox 与幂等消费。
触发:支付网关要求同步返回履约结果;履约 SLA 收紧到 1 秒;outbox 最老
事件年龄 P99 连续一周超过 30 秒。
ADR-002 和 ADR-004 的边界是时间轴:支付之前,库存必须同步扣住;支付之后,履约可以异步推进。把两者混成一句“下单异步”会同时破坏超卖防护和回调延迟。
ADR-005:模块间只经端口接口通信
ADR-005:模块间只经端口接口通信,不直接跨模块调仓储
状态:已接受
背景:模块化单体的包边界已经存在,但直接 import 另一个模块的 repository
会让表结构与事务边界偷偷耦合;测试也不得不启动数据库。
决策:每个模块在自己的 ports 中声明所需接口;调用方只依赖接口,不依赖
具体仓储;跨模块只读查询使用独立的 query port;只读 reporting join
必须标注边界,不得写业务规则;deplint 阻止领域包 import 适配器。
备选 A:共享一个 repository 包——改一张表会波及所有模块,测试必须连库,
否决。
备选 B:允许跨模块直接 SQL join——短期快,长期绕过不变量与归属校验,
否决。
后果:正面——模块可独立测试、替换实现、未来拆分;负面——接口数量增加,
跨模块事务需要显式 unit-of-work,部分查询要多次调用。
触发:跨模块只读查询需要三次以上串行调用;某模块独立部署;端口超过
20 个且无法按用例归并。
这条决策把 1.3 的依赖方向从“目录约定”升级为“调用约定”。目录只能阻止错误的 import,端口还规定了跨模块调用应该长什么样。
ADR-006:领域事件写入 outbox
ADR-006:领域事件写入 outbox,不在进程内直接发布
状态:提议(待评审)
背景:支付成功、订单取消都需要通知履约与通知模块;先提交数据库再
Publish 存在双写窗口,进程崩溃会永久丢事件;卷一还没有 Kafka。
决策:状态变更与 outbox 行在同一本地事务提交;relay 轮询未发送事件,
至少一次投递;消费者按 event_id 幂等;领域方法不持有消息总线。
备选 A:提交后进程内 channel 发布——崩溃即丢,无法重放,否决。
备选 B:handler 内直接 produce Kafka——数据库与 Kafka 双写,任一侧失败
都会产生不一致,否决。
后果:正面——事件与状态同事务、可重放、可审计;负面——relay 带来延迟,
outbox 表需要清理与积压监控,消费者必须幂等。
触发:崩溃注入证明提交后事件不丢;Kafka 提供可用的 transactional outbox;
事件量使轮询成本超过业务写入成本。
状态是“提议”而不是“已接受”,因为双写窗口只是被设计消除,还没有被实验证明。2.3 的评审会把“提交后 kill -9 能否补发”列为转正条件。
ADR-007:单部署单元,按模块开关灰度
ADR-007:卷一单部署单元,按模块开关控制灰度
状态:提议(待评审)
背景:模块化单体只有一个 orderd 进程,但新下单路径、库存策略、履约
消费者仍需小流量验证;独立部署多个服务会违背 ADR-001 的成本约束。
决策:一次构建、一次部署;灰度通过模块开关完成,按客户白名单或百分比
放量;开关只控制代码路径,不改变数据 schema;每个开关登记 owner、
默认值、到期日;保留一个总 kill switch。
备选 A:为每个模块独立部署——发布与运维成本回到微服务,否决。
备选 B:只做部署级 canary——整个进程一起切,无法隔离单个模块的新路径,
否决。
后果:正面——爆炸半径小、回滚快、无需拆服务;负面——开关组合增加测试
矩阵,旧开关会变成永久分支,排障需先确认开关状态。
触发:某模块需要独立扩缩容或发布;开关超过 12 个或有超过 90 天的开关;
kill switch 无法在 5 分钟内回退。
开关是灰度工具,不是配置系统。它必须有过期时间,否则“临时开关”会在两年后变成没人敢删的分支。这条也留到评审时验证 kill switch 的实际回退时间。
把五份决策编码成可校验的登记表
五份文档最怕的不是写错,而是索引漂移:编号重复、状态拼错、被取代的记录指向一个不存在的 ADR。这些错误不需要人读全文就能发现,所以交给代码。
// file: internal/adr/adr.go
// tier: L1
// verify: go test ./internal/adr/ -count=1 -v
package adr
import "regexp"
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
}
// Record 只保留可机器校验的元数据;正文仍在 ADR 文档里。
type Record struct {
ID string
Title string
Status Status
Supersedes []string
SupersededBy string
Triggers []string
}
type Registry struct{ Records []Record }
type Issue struct {
Code string
Ref string
Why string
}
const (
CodeBadID = "BAD_ID"
CodeDuplicateID = "DUPLICATE_ID"
CodeBadStatus = "BAD_STATUS"
CodeMissingTriggers = "MISSING_TRIGGERS"
CodeUnknownReference = "UNKNOWN_REFERENCE"
CodeSupersededMismatch = "SUPERSEDED_MISMATCH"
CodeSelfReference = "SELF_REFERENCE"
)
var idPattern = regexp.MustCompile(`^ADR-\d{3}$`)
// Validate 校验编号唯一、状态合法、取代关系指向已登记条目。
func (r Registry) Validate() []Issue {
var issues []Issue
byID := make(map[string]bool, len(r.Records))
for _, rec := range r.Records {
if !idPattern.MatchString(rec.ID) {
issues = append(issues, Issue{CodeBadID, rec.ID, "want ADR-NNN"})
}
if byID[rec.ID] {
issues = append(issues, Issue{CodeDuplicateID, rec.ID, "declared twice"})
}
byID[rec.ID] = true
}
for _, rec := range r.Records {
if !rec.Status.Valid() {
issues = append(issues, Issue{CodeBadStatus, rec.ID, "unknown status"})
}
if len(rec.Triggers) == 0 {
issues = append(issues, Issue{CodeMissingTriggers, rec.ID, "no trigger"})
}
if rec.Status == StatusSuperseded && rec.SupersededBy == "" {
issues = append(issues, Issue{CodeSupersededMismatch, rec.ID, "needs SupersededBy"})
}
if rec.Status != StatusSuperseded && rec.SupersededBy != "" {
issues = append(issues, Issue{CodeSupersededMismatch, rec.ID, "only superseded sets target"})
}
for _, ref := range append([]string{rec.SupersededBy}, rec.Supersedes...) {
if ref == "" {
continue
}
if ref == rec.ID {
issues = append(issues, Issue{CodeSelfReference, rec.ID, "references itself"})
} else if !byID[ref] {
issues = append(issues, Issue{CodeUnknownReference, rec.ID, "missing " + ref})
}
}
}
return issues
}
五条记录本身也编码在同一个包里。状态是 2.3 评审前的快照:003 至 005 已接受,006 和 007 仍是提议。
// file: internal/adr/records.go
// tier: L1
// verify: go test ./internal/adr/ -run TestVolumeOneIsValid -v
package adr
func VolumeOne() Registry {
return Registry{Records: []Record{
{ID: "ADR-003", Title: "存储选型:PostgreSQL 单写库", Status: StatusAccepted,
Triggers: []string{"持续写入超过 5k QPS", "主库故障切换 RTO 超过 5 分钟", "某模块要求独立存储"}},
{ID: "ADR-004", Title: "下单同步、履约异步", Status: StatusAccepted,
Triggers: []string{"支付网关要求同步返回履约结果", "履约 SLA 收紧到 1 秒", "outbox 最老事件年龄 P99 超过 30 秒"}},
{ID: "ADR-005", Title: "模块间只经端口接口通信", Status: StatusAccepted,
Triggers: []string{"跨模块只读查询需要三次以上串行调用", "某模块独立部署", "端口超过 20 个"}},
{ID: "ADR-006", Title: "领域事件经 outbox 持久化", Status: StatusProposed,
Triggers: []string{"崩溃注入证明提交后事件不丢", "Kafka 提供 transactional outbox", "轮询成本超过业务写入成本"}},
{ID: "ADR-007", Title: "卷一单部署单元,按模块开关灰度", Status: StatusProposed,
Triggers: []string{"某模块需要独立扩缩容", "开关超过 12 个或超过 90 天", "kill switch 无法在 5 分钟内回退"}},
}}
}
测试分两层:先断言真实登记表零问题,再用“复制三条记录、破坏一处”的表驱动用例验证每条规则。这样新增 ADR 时不会重写测试,规则被删时也会立刻报警。
// file: internal/adr/adr_test.go
// tier: L1
// verify: go test ./internal/adr/ -count=1 -v
package adr
import "testing"
func hasCode(issues []Issue, want string) bool {
for _, issue := range issues {
if issue.Code == want {
return true
}
}
return false
}
func TestVolumeOneIsValid(t *testing.T) {
reg := VolumeOne()
if issues := reg.Validate(); len(issues) != 0 {
t.Fatalf("VolumeOne() has %d issue(s): %v", len(issues), issues)
}
}
func TestRegistryValidate(t *testing.T) {
base := func() Registry {
return Registry{Records: []Record{
{ID: "ADR-003", Status: StatusAccepted, Triggers: []string{"t"}},
{ID: "ADR-004", Status: StatusSuperseded, SupersededBy: "ADR-005", Triggers: []string{"t"}},
{ID: "ADR-005", Status: StatusAccepted, Supersedes: []string{"ADR-004"}, Triggers: []string{"t"}},
}}
}
tests := []struct {
name string
mutate func(*Registry)
want string
}{
{"bad id", func(r *Registry) { r.Records[0].ID = "ADR-3" }, CodeBadID},
{"duplicate id", func(r *Registry) { r.Records[1].ID = "ADR-003" }, CodeDuplicateID},
{"bad status", func(r *Registry) { r.Records[0].Status = "done" }, CodeBadStatus},
{"missing triggers", func(r *Registry) { r.Records[0].Triggers = nil }, CodeMissingTriggers},
{"superseded without target", func(r *Registry) { r.Records[1].SupersededBy = "" }, CodeSupersededMismatch},
{"accepted with target", func(r *Registry) { r.Records[0].SupersededBy = "ADR-005" }, CodeSupersededMismatch},
{"dangling reference", func(r *Registry) { r.Records[1].SupersededBy = "ADR-999" }, CodeUnknownReference},
{"self reference", func(r *Registry) { r.Records[0].Supersedes = []string{"ADR-003"} }, CodeSelfReference},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := base()
tt.mutate(®)
if !hasCode(reg.Validate(), tt.want) {
t.Fatalf("issues = %v, want %s", reg.Validate(), tt.want)
}
})
}
}
真实运行结果
以下输出来自本机 macOS(darwin/arm64,Go 1.26.0):
$ go test ./internal/adr/ -count=1 -v
=== RUN TestVolumeOneIsValid
--- PASS: TestVolumeOneIsValid (0.00s)
=== RUN TestRegistryValidate
=== RUN TestRegistryValidate/bad_id
=== RUN TestRegistryValidate/duplicate_id
=== RUN TestRegistryValidate/bad_status
=== RUN TestRegistryValidate/missing_triggers
=== RUN TestRegistryValidate/superseded_without_target
=== RUN TestRegistryValidate/accepted_with_target
=== RUN TestRegistryValidate/dangling_reference
=== RUN TestRegistryValidate/self_reference
--- PASS: TestRegistryValidate (0.00s)
--- PASS: TestRegistryValidate/bad_id (0.00s)
--- PASS: TestRegistryValidate/duplicate_id (0.00s)
--- PASS: TestRegistryValidate/bad_status (0.00s)
--- PASS: TestRegistryValidate/missing_triggers (0.00s)
--- PASS: TestRegistryValidate/superseded_without_target (0.00s)
--- PASS: TestRegistryValidate/accepted_with_target (0.00s)
--- PASS: TestRegistryValidate/dangling_reference (0.00s)
--- PASS: TestRegistryValidate/self_reference (0.00s)
PASS
ok archlab/internal/adr 0.474s
登记表校验能证明索引自洽,不能证明五条决策正确。它可以防止“ADR-006 被 008 取代,但 008 不存在”这种文档事故;决策本身是否成立,仍然要靠 2.3 的评审和后续实验。
与站内 ADR 文章的分工
站内的 架构决策记录(ADR) 已经讲过 ADR 的起源、Nygard 模板、状态流转和团队落地方式。本节不重复模板教学,只交付 arch-lab 的五份具体记录,并把它们变成可校验的索引。一个回答“ADR 怎么写”,本节回答“这五个决策到底选了什么、否决了什么、什么时候反悔”。
常见错误与边界
第一,把 ADR 写成技术选型说明。只写“选 PostgreSQL 因为成熟”,不写否定了什么、代价是什么,后人无法判断约束是否变化。
第二,五份 ADR 互相重复。ADR-004 必须引用 ADR-002 的边界,而不是重新论证同步库存预留;重复论证会让两份文档在修改时漂移。
第三,状态永远是“已接受”。没有“提议”和“已取代”,ADR 就退化成一次性宣传材料,无法表达“还没验证”和“已被推翻”。
第四,重新评估条件写成“以后再说”。“QPS 超过 5k”“开关超过 90 天”才是可观测的触发条件;“业务发展了”不是。
第五,登记表只有编号没有触发条件。校验器能发现悬空引用,但无法替你判断某条 ADR 是否该复审;触发条件必须一起入库。
第六,以为 ADR 能替代实验。ADR-006 的双写窗口是否真的被消除,只能靠崩溃注入验证;文档记录的是意图,不是证据。
小结
五份 ADR 覆盖了模块化单体在卷一必须回答的五个问题:状态落在 PostgreSQL 单写库,支付前同步预留、支付后异步履约,模块间只经端口调用,事件经 outbox 持久化,单部署单元用模块开关灰度。每份都写明了备选与否决理由,以及可观测的重评触发条件。internal/adr 把这些元数据变成可执行校验,编号重复、状态非法和悬空取代关系会直接让测试失败。下一节用一次评审演练检验这五条决策,并建立风险台账。
阅读导航:上一节:2.1 arch-lab 的四级视图(C4) · 下一节:2.3 一次真实评审记录与风险台账 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。