一次只改价格的发布
arch-lab 的 catalog 团队改了一次价格字段:unit_price_cents 从整数换成带币种的对象。单元测试全绿,catalog 服务滚动发布也成功,但 order-api 的反序列化没跟上,创建订单在十二分钟里失败了四成。事后复盘,问题不在某个函数写错,而在发布前没人能回答:catalog 这次发布,究竟会让哪些单元不可用?
部署单元就是回答这个问题的单位。它不是一个包,也不是一个团队,而是能独立发布、独立扩容、独立失败的一组进程与数据。本节不写容器编排教程,只处理三件事:订单系统里哪些模块必须同进程,哪些可以独立部署;单点和故障域怎么登记;一次发布的影响面如何从依赖图算出来。4.1 的模块化单体和 6.1 的数据所有权已经给了模块边界,这里把边界推进到运行时。
部署单元回答的三个问题
模块、进程、部署单元、故障域是四个不同层次。模块是编译期组织,进程是运行期隔离,部署单元是发布与扩缩的最小单位,故障域是“一起坏掉”的范围。一个部署单元可以包含多个模块,也可以只包含一个;一个故障域可以包含多个部署单元。把四者混为一谈,就会出现“包拆得很细,发布仍然全站一起上”的假解耦。
| 概念 | 回答的问题 | arch-lab 中的例子 |
|---|---|---|
| 模块 | 代码怎么组织、谁能 import 谁 | internal/order、internal/inventory |
| 部署单元 | 哪组进程一起发布、一起扩容 | order-api 的三个副本 |
| 故障域 | 哪些单元会因同一个基础设施事件一起失效 | 同一可用区、同一数据库主库 |
切分的第一原则不是“一个限界上下文一个服务”,而是事务边界与故障传播方向。必须原子提交的状态留在同一个部署单元里;能独立失败、且调用方有明确降级路径的能力,才值得单独部署。
订单系统的切法
arch-lab 最终是四个业务单元加三个数据单元。注意 order-api 里同时装了订单聚合、HTTP 入口、幂等中间件和 PostgreSQL 适配器,这不是偷懒,而是被事务边界逼出来的。
| 部署单元 | 包含模块 | 副本/可用区 | 发布节奏 | 切分理由 |
|---|---|---|---|---|
| order-api | cmd/orderd、order、ports、adapters、config、httpserver、idempotency | 3 / a,b,c | 每天 | 创建订单需要订单行、明细、幂等键、outbox 一次本地事务提交 |
| catalog-svc | catalog、ports、postgres 适配器 | 2 / a,b | 每天多次 | 读多写少,价格查询可用缓存降级 |
| inventory-svc | inventory、ports、postgres 适配器 | 3 / a,b,c | 每周 | 库存规则独立演进,预留必须强一致 |
| payment-worker | payment、ports、postgres 适配器 | 2 / a,b | 每周 | 外部渠道延迟高、重试多,用异步隔离 |
| orders-db | PostgreSQL 订单库 | 1 主 / a | 随 schema 变更 | 订单的事实来源,单写者 |
| catalog-db | PostgreSQL 目录库 | 1 主 / b | 随 schema 变更 | 目录的事实来源,可重建缓存 |
| inventory-db | PostgreSQL 库存库 | 1 主 / c | 随 schema 变更 | 预留与释放的事实来源 |
order-api 内部必须同进程的部分可以精确到一句话:一次创建订单要在同一个本地事务里写入 order、order_items、idempotency_keys 和 outbox_events。如果把幂等键搬到独立服务,创建路径就变成“先查幂等服务、再写订单库、再补幂等记录”,重试窗口里会出现重复订单,或者需要引入分布式事务。把幂等中间件留在进程内,不是因为它简单,而是因为它和订单写入共享同一个原子边界。
catalog 与 inventory 可以独立,理由不同。catalog 的失败可以用五分钟陈旧的价格快照降级,用户仍能下单;inventory 的失败不能降级,因为超卖比拒绝下单更贵,所以它保持同步强依赖,但必须有超时和明确的拒绝语义。payment 独立则是因为外部渠道的 P99 延迟和重试策略与订单主链路完全不同,放在同一个进程里会让一次渠道抖动拖垮下单。
单点不是一句“上多副本”
副本数只解决进程级故障,不解决数据单写者。三个数据库都是单主:主库所在节点故障,写入就中断,直到托管故障转移完成。这不是设计缺陷,而是关系型数据库的常态。关键区别是单点有没有被声明、有没有恢复承诺。arch-lab 在部署图里把三个库标成 AcceptedSPOF,并写明 RTO 与 RPO;没有写恢复说明的单副本单元,校验器一律报 UNDECLARED_SPOF。第二个常见假高 Availability 是“三个副本都在一个可用区”。副本数大于等于二、但可用区只有一个时,校验器报 ZONE_CONCENTRATION。三个副本只能扛进程崩溃,扛不住可用区网络分区。第三个是故障域重叠:调用方和它的强依赖都只部署在同一个可用区。比如 inventory-svc 为了降低延迟被钉在 az-c,inventory-db 也只在 az-c,那么 az-c 故障会同时带走服务和数据库,重试、熔断都没有意义。校验器只在“双方都只有一个可用区且相同”时报告,因为多可用区单元仍有一半容量可用,属于容量下降而不是完全重叠。
一次发布的影响面
影响面不是“调用了这个服务的所有单元”,而是会被同步强依赖拖住的单元闭包。异步边不算:payment-worker 发布时,order-api 只是把消息留在 outbox,下单仍然成功。下面的表来自 arch-lab 的依赖图,右侧是 11.1 校验器里 BlastRadius 的输出。
| 发布/故障单元 | 同步影响闭包 | 用户可见影响 | 处置 |
|---|---|---|---|
| catalog-svc | order-api | 价格查询变慢,下单改用缓存价 | 缓存价上限 5 分钟,发布前预热 |
| inventory-svc | order-api | 下单被拒绝,浏览不受影响 | 保留 800ms 超时,快速失败 |
| payment-worker | 无 | 支付确认延迟,下单不受影响 | outbox 重试,异步告警 |
| orders-db | order-api、payment-worker | 下单与支付确认都失败 | 故障转移,RTO 90 秒 |
把部署图写成可校验的数据
部署图如果只存在于白板和 PPT 里,下一次发布就会漂移。下面的 deploymap 包把单元、副本、可用区、依赖和降级路径写成结构,Validate 一次返回全部问题。它检查三类硬约束:未声明单点、跨单元强依赖缺少超时或降级、故障域重叠;另有可用区集中这一辅助检查。
// file: internal/deploymap/deploymap.go
// tier: L1
// verify: go test ./internal/deploymap/
package deploymap
import (
"fmt"
"sort"
)
// Unit 是一个可以独立发布、独立扩容、独立失败的最小运行时单位。
// AcceptedSPOF 不是豁免,而是要求把恢复手段写出来。
type Unit struct {
Name string
Replicas int
Zones []string
AcceptedSPOF bool
SPOFMitigation string
}
// CallKind 只区分同步与异步:同步会把下游延迟和故障传回调用方,
// 异步则把两者隔开,这是发布影响面计算的核心区别。
type CallKind string
const (
Sync CallKind = "SYNC"
Async CallKind = "ASYNC"
)
// Dependency 中 Required 表示失败会阻断调用方核心路径;TimeoutMS 为 0
// 表示没有上限;Fallback 为空表示没有设计降级路径。
type Dependency struct {
From string
To string
Kind CallKind
Required bool
TimeoutMS int
Fallback string
}
type Plan struct {
Units []Unit
Deps []Dependency
}
type Finding struct {
Code string
Subject string
Detail string
}
// Validate 一次返回全部问题:一个未声明单点往往同时带来故障域重叠。
func (p Plan) Validate() []Finding {
var out []Finding
seen := make(map[string]bool, 8)
add := func(code, subject, detail string) {
key := code + "\x00" + subject
if !seen[key] {
seen[key] = true
out = append(out, Finding{Code: code, Subject: subject, Detail: detail})
}
}
units := make(map[string]Unit, len(p.Units))
for _, u := range p.Units {
units[u.Name] = u
if u.Replicas <= 1 && !u.AcceptedSPOF {
add("UNDECLARED_SPOF", u.Name, fmt.Sprintf("replicas=%d", u.Replicas))
}
// 多副本全挤在一个可用区,副本数只是账面数字。
if zones := unique(u.Zones); u.Replicas >= 2 && len(zones) == 1 {
add("ZONE_CONCENTRATION", u.Name, "all replicas in "+zones[0])
}
}
for _, d := range p.Deps {
from, okFrom := units[d.From]
to, okTo := units[d.To]
if !okFrom || !okTo || d.From == d.To || d.Kind != Sync || !d.Required {
continue
}
subject := d.From + " -> " + d.To
// 跨单元强依赖不是“有调用”就算问题,而是既无超时又无降级时,
// 下游的故障会无界地传成上游的故障。
switch {
case d.TimeoutMS <= 0:
add("CROSS_UNIT_STRONG_DEP", subject, "required sync call has no timeout")
case d.Fallback == "":
add("CROSS_UNIT_STRONG_DEP", subject, "required sync call has no fallback")
}
z1, ok1 := onlyZone(from)
z2, ok2 := onlyZone(to)
if ok1 && ok2 && z1 == z2 {
add("FAILURE_DOMAIN_OVERLAP", subject, "both units only in "+z1)
}
}
return out
}
// BlastRadius 返回某单元发布或故障时,会被同步调用链拖住的单元集合。
// 异步边被刻意排除:消息积压是运维问题,不是调用方不可用。
func (p Plan) BlastRadius(name string) []string {
reverse := make(map[string][]string, len(p.Deps))
for _, d := range p.Deps {
if d.Kind == Sync && d.Required {
reverse[d.To] = append(reverse[d.To], d.From)
}
}
seen := map[string]bool{name: true}
queue := []string{name}
var out []string
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for _, caller := range reverse[cur] {
if !seen[caller] {
seen[caller] = true
out = append(out, caller)
queue = append(queue, caller)
}
}
}
sort.Strings(out)
return out
}
func onlyZone(u Unit) (string, bool) {
zones := unique(u.Zones)
if len(zones) != 1 {
return "", false
}
return zones[0], true
}
func unique(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
if v != "" && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
校验器只验证“声明之间是否自洽”,不验证“声明是否符合事实”。它不会调用编排 API 查副本实际在哪个节点,也不解析 Go 源码推 import。数据由发布流水线或架构评审维护,配合 8.3 的适应度函数使用:前者管运行时事实,后者管代码依赖。
基线零违规,五类漂移必须报警
测试先构造 arch-lab 的合法部署图:七个单元、八条依赖,其中六条是跨单元同步强依赖,全部有超时和降级;三个数据库声明为可接受单点。然后逐条变异,每次只改一处,断言对应错误码和主体出现。
// file: internal/deploymap/deploymap_test.go
// tier: L1
// verify: go test ./internal/deploymap/
package deploymap
import (
"strings"
"testing"
)
// baseline 是 arch-lab 在 2026-10 的部署图:四个业务单元、三个数据单元。
// 三个库各有单主,但都登记为可接受单点并写明恢复手段。
func baseline() Plan {
return Plan{
Units: []Unit{
{Name: "order-api", Replicas: 3, Zones: []string{"az-a", "az-b", "az-c"}},
{Name: "catalog-svc", Replicas: 2, Zones: []string{"az-a", "az-b"}},
{Name: "inventory-svc", Replicas: 3, Zones: []string{"az-a", "az-b", "az-c"}},
{Name: "payment-worker", Replicas: 2, Zones: []string{"az-a", "az-b"}},
{Name: "orders-db", Replicas: 1, Zones: []string{"az-a"}, AcceptedSPOF: true, SPOFMitigation: "managed failover, WAL archive, RTO 90s, RPO 5min"},
{Name: "catalog-db", Replicas: 1, Zones: []string{"az-b"}, AcceptedSPOF: true, SPOFMitigation: "managed failover, nightly snapshot, RTO 15min, RPO 24h"},
{Name: "inventory-db", Replicas: 1, Zones: []string{"az-c"}, AcceptedSPOF: true, SPOFMitigation: "managed failover, WAL archive, RTO 90s, RPO 5min"},
},
Deps: []Dependency{
{From: "order-api", To: "catalog-svc", Kind: Sync, Required: true, TimeoutMS: 300, Fallback: "cached price, max 5m stale"},
{From: "order-api", To: "inventory-svc", Kind: Sync, Required: true, TimeoutMS: 800, Fallback: "reject with INVENTORY_UNAVAILABLE"},
{From: "order-api", To: "payment-worker", Kind: Async, Required: false},
{From: "order-api", To: "orders-db", Kind: Sync, Required: true, TimeoutMS: 1000, Fallback: "fail request"},
{From: "catalog-svc", To: "catalog-db", Kind: Sync, Required: true, TimeoutMS: 500, Fallback: "serve cached catalog"},
{From: "inventory-svc", To: "inventory-db", Kind: Sync, Required: true, TimeoutMS: 1000, Fallback: "fail reservation"},
{From: "payment-worker", To: "orders-db", Kind: Sync, Required: true, TimeoutMS: 2000, Fallback: "outbox retry"},
{From: "payment-worker", To: "inventory-svc", Kind: Async, Required: false},
},
}
}
func TestBaselineIsClean(t *testing.T) {
p := baseline()
strong := 0
for _, d := range p.Deps {
if d.Kind == Sync && d.Required && d.From != d.To {
strong++
}
}
findings := p.Validate()
t.Logf("units=%d deps=%d strong_cross_unit=%d findings=%d",
len(p.Units), len(p.Deps), strong, len(findings))
if len(findings) != 0 {
t.Fatalf("baseline should be clean, got %v", findings)
}
}
func TestValidateCatchesDeploymentDrift(t *testing.T) {
cases := []struct {
name string
mutate func(*Plan)
code string
subject string
}{
{"single point without declaration", func(p *Plan) {
u := unitIndex(p, "orders-db")
p.Units[u].AcceptedSPOF, p.Units[u].SPOFMitigation = false, ""
}, "UNDECLARED_SPOF", "orders-db"},
{"replicas in one zone", func(p *Plan) {
p.Units[unitIndex(p, "catalog-svc")].Zones = []string{"az-a"}
}, "ZONE_CONCENTRATION", "catalog-svc"},
{"strong dependency without timeout", func(p *Plan) {
p.Deps[depIndex(p, "order-api", "inventory-svc")].TimeoutMS = 0
}, "CROSS_UNIT_STRONG_DEP", "order-api -> inventory-svc"},
{"strong dependency without fallback", func(p *Plan) {
p.Deps[depIndex(p, "payment-worker", "orders-db")].Fallback = ""
}, "CROSS_UNIT_STRONG_DEP", "payment-worker -> orders-db"},
{"database and caller in same zone", func(p *Plan) {
p.Units[unitIndex(p, "inventory-svc")].Zones = []string{"az-c"}
}, "FAILURE_DOMAIN_OVERLAP", "inventory-svc -> inventory-db"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := baseline()
tc.mutate(&p)
if !has(p.Validate(), tc.code, tc.subject) {
t.Fatalf("want %s on %q, got %v", tc.code, tc.subject, p.Validate())
}
})
}
}
func TestBlastRadiusFollowsSyncEdgesOnly(t *testing.T) {
p := baseline()
want := map[string]string{
"catalog-svc": "order-api",
"orders-db": "order-api,payment-worker",
"inventory-db": "inventory-svc,order-api",
"payment-worker": "",
}
for unit, w := range want {
if got := strings.Join(p.BlastRadius(unit), ","); got != w {
t.Fatalf("%s: want %q, got %q", unit, w, got)
}
}
}
func unitIndex(p *Plan, name string) int {
for i := range p.Units {
if p.Units[i].Name == name {
return i
}
}
panic(name)
}
func depIndex(p *Plan, from, to string) int {
for i := range p.Deps {
if p.Deps[i].From == from && p.Deps[i].To == to {
return i
}
}
panic(from + " -> " + to)
}
func has(findings []Finding, code, subject string) bool {
for _, f := range findings {
if f.Code == code && f.Subject == subject {
return true
}
}
return false
}
实测输出
在本机 Go 1.26.0 下执行 go test ./...,整套通过;基线用例的真实输出如下;完整套件 go test ./... 已通过。gofmt -l . 无输出,go vet ./... 无告警。
$ go test ./internal/deploymap/ -run TestBaselineIsClean -v
=== RUN TestBaselineIsClean
deploymap_test.go:46: units=7 deps=8 strong_cross_unit=6 findings=0
--- PASS: TestBaselineIsClean (0.00s)
PASS
ok archlab/internal/deploymap 0.308s
ADR-011:库存同步、支付异步
背景:下单要同时占用库存和发起支付,两个外部能力都可能超时。决策:库存预留在 order-api 内同步调用,800ms 超时,失败即拒绝下单;支付通过 outbox 异步交给 payment-worker。备选一:两者都同步,实现最简单,但支付渠道 P99 抖动会直接抬高下单失败率,且渠道重试会占用请求线程。备选二:两者都异步,下单最快,但用户会在支付后才发现库存不足,需要取消与退款补偿,客服成本更高。后果:order-api 必须实现库存预留的补偿释放,payment-worker 必须消费幂等,监控要新增“已支付未确认”和“预留未支付”两个滞留指标。接受这些复杂度,换取支付渠道故障与下单链路的隔离。
常见错误与边界
第一,按团队而不是按事务边界切部署单元。团队边界适合划分 ownership,不适合决定哪些表必须一起提交;两者冲突时,先保事务,再调团队协作方式。第二,把“多副本”当成高可用。副本都在一个可用区、或者数据库仍是单主时,高可用只是局部成立。第三,把单点藏起来。单点不可耻,不声明才可耻;没有 RTO/RPO 的单点等于没有恢复计划。第四,把异步当免费。异步只是把同步调用换成了 outbox、幂等、补偿和积压监控,边界仍然要有人维护。
边界也要说清楚:这个模型不覆盖 DNS、负载均衡、云控制面和外部支付渠道,它们也是单点,但不在 arch-lab 的部署图里;模型也不替代故障演练,BlastRadius 算的是声明上的闭包,真实级联还要靠混沌测试验证。本节保持 L1,不声称在 Kubernetes 或多节点环境跑过。
小结
部署单元不是“一个微服务”,而是事务边界、发布节奏和故障域的交叉点。arch-lab 用四个业务单元和三个数据单元把订单系统切开:订单聚合、幂等和 outbox 必须同进程,catalog 可以用缓存降级,inventory 保持同步但必须有超时,payment 用异步隔离外部渠道。deploymap 把未声明单点、无保护强依赖和故障域重叠变成可测试的违规,BlastRadius 则把“一次发布影响谁”从口头判断变成同步闭包。下一节处理这些单元启动时依赖的配置与密钥,它们同样需要边界和校验。
阅读导航:上一节:10.3 第三方支付渠道的信任边界与密钥管理 · 下一节:11.2 配置、密钥与环境差异 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。