《后端架构设计·架构基础与边界》2.2 订单系统的五个 ADR

从 ADR-003 到 ADR-007 给出 arch-lab 卷一的五份具体决策:存储选型、下单同步与履约异步的边界、模块间端口通信、outbox 事件持久化、单部署单元与模块开关灰度;每份都有状态、约束、备选否决理由、正负后果和重评触发条件,并把登记表编码成可校验的 Go 包与表驱动测试。

从 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(&reg)
			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 一次真实评审记录与风险台账 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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