《后端架构设计·架构基础与边界》11.1 部署单元与故障域:订单系统怎么切

以 arch-lab 订单系统的七个部署单元为例,说明哪些模块必须同进程、哪些可以独立发布,单点与故障域如何登记;给出可运行的 deploymap 校验器,把未声明单点、无超时或无降级的跨单元强依赖、故障域重叠变成 go test 能抓住的违规,并用同步闭包计算一次发布的影响面。

一次只改价格的发布

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-apicmd/orderd、order、ports、adapters、config、httpserver、idempotency3 / a,b,c每天创建订单需要订单行、明细、幂等键、outbox 一次本地事务提交
catalog-svccatalog、ports、postgres 适配器2 / a,b每天多次读多写少,价格查询可用缓存降级
inventory-svcinventory、ports、postgres 适配器3 / a,b,c每周库存规则独立演进,预留必须强一致
payment-workerpayment、ports、postgres 适配器2 / a,b每周外部渠道延迟高、重试多,用异步隔离
orders-dbPostgreSQL 订单库1 主 / a随 schema 变更订单的事实来源,单写者
catalog-dbPostgreSQL 目录库1 主 / b随 schema 变更目录的事实来源,可重建缓存
inventory-dbPostgreSQL 库存库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-svcorder-api价格查询变慢,下单改用缓存价缓存价上限 5 分钟,发布前预热
inventory-svcorder-api下单被拒绝,浏览不受影响保留 800ms 超时,快速失败
payment-worker无支付确认延迟,下单不受影响outbox 重试,异步告警
orders-dborder-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 配置、密钥与环境差异 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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