29.7 从模型到服务边界

一句话

服务边界的默认解是“一个限界上下文 = 一个服务 = 一份数据所有权”;聚合不是部署单元,子域不是服务——只有在“独立伸缩、独立发布、独立团队”三个信号真的出现时,才把一个上下文拆成多个服务。

三层映射:模型元素对到架构元素

模型元素 架构元素 不能混淆的原因
子域 团队 / 产品线(组织) 子域是业务划分,回答“谁负责这块业务”
限界上下文 服务 / 模块(部署与代码边界) 上下文是模型边界,可以先落成模块化单体里的模块
聚合 事务边界(不是部署单元) 一个服务通常含多个聚合,一个聚合绝不能单独成服务
领域事件 跨边界集成(异步) 事件既是集成手段,也是对外契约的来源
上下文映射 集成方式与契约(同步 API / 异步事件 / ACL) 映射关系决定契约形态,不是反过来

🔑 一句话记:上下文决定“切成几块”,聚合决定“每块里的事务怎么划”,子域决定“谁来做”。 三者不是一层的东西,画在同一层就一定会出现“一个聚合一个服务”。

默认映射:1 上下文 = 1 服务 = 1 份数据

理由 说明
数据所有权 一个上下文独占自己的表/schema,别的上下文只能通过 API 或事件拿数据,禁止跨上下文 join(共享数据库反模式见 32.6 共享数据库反模式)
独立发布 上下文之间没有编译期依赖,可以各自发布(55.1 发布策略)
契约清晰 每个上下文对外只有一组 API + 一组事件,契约测试可覆盖(53.5 契约测试)
故障隔离 一个上下文的故障不会通过共享库/共享线程池传染

例外:多个上下文可以合成一个服务,条件是四条同时成立——同一团队、总是一起改(共变率 ≥ 0.6)、没有独立伸缩需求、没有合规隔离要求。合成之后,上下文仍然存在,只是用包边界强制(见 26.6 模块化与包设计),而不是用网络边界。

什么时候把一个上下文拆成多个服务

服务粒度的一般方法(多细算细、怎么量化)是 30.2 服务粒度 的主场;这里只给“上下文级”的拆与合判据。

信号 阈值(经验值) 依据
写吞吐差异巨大 两个聚合的写 QPS 相差 ≥ 10 倍 独立伸缩(37.1 垂直扩展与水平扩展)
事务时长差异巨大 一方有 ≥ 50ms 的长事务 资源隔离:长事务不能占着短接口的连接池
团队归属不同 2 个团队各自维护 康威定律(74.1 康威定律及其逆定律)
合规 / 审计隔离 支付、身份、医疗数据 物理边界要求
故障爆炸半径 一方是公网入口 隔离:入口被打挂不能拖垮内部
发布节奏差异 一天 50 次 vs 一月 1 次 共享发布通道排队(27.1 单体架构的适用性)

🔑 反向判据更可靠:如果两个上下文之间需要强一致事务,那它们就不是两个上下文,是边界画错了。看到“下单要同时写订单表和库存表”就说明这两块本来是一个事务边界,拆开只会把本地事务变成分布式事务。

拆之前先算运维面

每多一个服务,多出来的不是“一个进程”,而是六类资产:

每个服务至少需要 数量
部署单元(镜像 + 编排配置) 1
独立数据库 / schema 1
CI/CD 流水线 1
监控看板 + 告警规则 1
值班轮换(on-call 归属) 1
契约测试 + 版本兼容矩阵 1
服务数 运维资产数 维护人力(按 0.2 人/服务估算)
5 30 1 人
10 60 2 人
20 120 4 人
50 300 10 人

(0.2 人/服务是估算口径,不是行业数据;它的用途是让你在拆第 20 个服务前,先算一遍这笔账。)

⚠️ 服务数是有 固定成本 的:从 1 个拆到 3 个,你多了 12 类资产和一条跨服务链路;从 3 个拆到 10 个,边际成本才下降。所以“先拆 2 个试试”几乎总是亏的。

边界画错怎么发现:用共变率验证

边界画得对不对,看两个上下文是不是总在同一个提交里被改。下面这段程序读一份提交记录,算出两两共变率:

package main

import (
	"fmt"
	"sort"
	"strings"
)

// 一次提交里改动的文件(路径第一段就是上下文)
var commits = [][]string{
	{"trade/order.go", "trade/order_test.go", "pricing/price.go"},
	{"trade/order.go", "pricing/price.go"},
	{"trade/cancel.go", "pricing/refund_rule.go"},
	{"pricing/price.go", "pricing/coupon.go"},
	{"trade/order.go", "trade/cancel.go", "pricing/price.go"},
	{"inventory/stock.go", "trade/order.go"},
	{"inventory/stock.go", "inventory/lock.go"},
	{"fulfillment/ship.go", "fulfillment/ship_test.go"},
	{"fulfillment/ship.go", "notification/sms.go"},
	{"trade/order.go", "fulfillment/ship.go"},
	{"pricing/price.go", "pricing/coupon_test.go"},
	{"inventory/stock.go", "fulfillment/ship.go"},
	{"notification/sms.go", "notification/template.go"},
	{"trade/order.go", "pricing/price.go", "inventory/stock.go"},
	{"catalog/product.go", "catalog/product_test.go"},
	{"catalog/product.go", "pricing/price.go"},
	{"trade/cancel.go", "pricing/refund_rule.go", "fulfillment/ship.go"},
	{"fulfillment/ship.go", "trade/order_test.go"},
	{"notification/sms.go", "trade/order.go"},
	{"inventory/lock.go", "inventory/stock.go", "trade/order.go"},
}

// contextSet 返回一次提交涉及的上下文集合(同名文件只算一次)
func contextSet(files []string) map[string]bool {
	s := map[string]bool{}
	for _, f := range files {
		if i := strings.Index(f, "/"); i > 0 {
			s[f[:i]] = true
		}
	}
	return s
}

func main() {
	total := map[string]int{}
	for _, c := range commits {
		for ctx := range contextSet(c) {
			total[ctx]++
		}
	}

	var ctxs []string
	for c := range total {
		ctxs = append(ctxs, c)
	}
	sort.Strings(ctxs)

	fmt.Printf("=== 各上下文被改动的提交数(共 %d 次提交)===\n", len(commits))
	for _, c := range ctxs {
		fmt.Printf("%-14s %2d 次\n", c, total[c])
	}

	fmt.Println("\n=== 两两共变率 = 同时改的次数 / min(各自次数) ===")
	type pair struct {
		a, b string
		rate float64
		both int
	}
	var pairs []pair
	for i := 0; i < len(ctxs); i++ {
		for j := i + 1; j < len(ctxs); j++ {
			a, b := ctxs[i], ctxs[j]
			both := 0
			for _, c := range commits {
				s := contextSet(c)
				if s[a] && s[b] {
					both++
				}
			}
			if both == 0 {
				continue
			}
			m := total[a]
			if total[b] < m {
				m = total[b]
			}
			pairs = append(pairs, pair{a, b, float64(both) / float64(m), both})
		}
	}
	sort.Slice(pairs, func(i, j int) bool { return pairs[i].rate > pairs[j].rate })
	for _, p := range pairs {
		flag := ""
		if p.rate >= 0.6 {
			flag = "  ← 边界可疑:合并,或加防腐层"
		}
		fmt.Printf("%-12s × %-12s 共同改 %2d 次,共变率 %.2f%s\n", p.a, p.b, p.both, p.rate, flag)
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== 各上下文被改动的提交数(共 20 次提交)===
catalog         2 次
fulfillment     6 次
inventory       5 次
notification    3 次
pricing         9 次
trade          11 次

=== 两两共变率 = 同时改的次数 / min(各自次数) ===
pricing      × trade        共同改  6 次,共变率 0.67  ← 边界可疑:合并,或加防腐层
inventory    × trade        共同改  3 次,共变率 0.60  ← 边界可疑:合并,或加防腐层
catalog      × pricing      共同改  1 次,共变率 0.50
fulfillment  × trade        共同改  3 次,共变率 0.50
fulfillment  × notification 共同改  1 次,共变率 0.33
notification × trade        共同改  1 次,共变率 0.33
fulfillment  × inventory    共同改  1 次,共变率 0.20
inventory    × pricing      共同改  1 次,共变率 0.20
fulfillment  × pricing      共同改  1 次,共变率 0.17

怎么读这张表:

读数 含义 动作
pricing × trade = 0.67 定价规则一改,交易也跟着改——两个「上下文」有 2/3 的变更需要协调发布 要么合并(定价是交易的一部分),要么用 ACL 把定价模型隔离出来;保持两个独立服务是最差解
inventory × trade = 0.60 刚好卡在阈值上 灰区:查这 3 次共同提交——如果是“下单扣库存”这类跨聚合协调,属于正常业务耦合;如果是“交易表加了库存字段”,就是边界被侵蚀
fulfillment × notification = 0.33 通知确实是履约的下游消费者 拆开正确;通知可以继续独立演进(它还会服务交易、营销)
catalog × pricing = 0.50 商品与定价有耦合但不高 保留边界,用事件同步商品变更

⚠️ 共变率是证据,不是结论。三种情况会让它失真:① 同一个人顺手改了两个上下文(耦合是“人的习惯”不是“模型”);② 编译期耦合(共享 DTO 改一处编译两处);③ 两个上下文都还没真正开发,从来没一起改过——这时低共变率不代表边界对,只代表数据不足。所以至少要用 3 个月、≥ 50 次提交的数据,并排除单人提交。

契约从映射关系来

服务边界定了之后,跨边界怎么连,由 29.2 上下文映射 (七种关系) 决定,不是自由发挥:

映射关系 契约形态 版本策略
ACL 下游自带 DTO + 映射函数,上游契约只进翻译层 上游随便改,改完只修翻译层
OHS + PL OpenAPI / protobuf 契约文件 + 版本号 语义化版本(51.3 语义化版本与变更日志)
客户-供应商 内部 API + 双方约定的排期 契约测试兜底
领域事件(集成事件) 事件 schema,只加不删 版本放 type 里
数据库 绝不作为契约 共享库就是分布式单体(30.3 分布式单体反模式)

演进顺序:不要一步到位

阶段 做什么 判断可以进入下一阶段的信号
① 模型未稳 模块化单体,上下文 = 包(27.2 模块化单体) 连续 3 个月包边界没被打破
② 边界稳定、团队 ≥ 2 按上下文拆服务,一个上下文一个服务 拆完能独立发布、独立回滚
③ 单服务成瓶颈 在上下文内部按聚合的写吞吐拆(极少数) 有明确的写 QPS 数据支撑
④ 旧系统替换 绞杀者模式逐步替换(28.5 遗留系统集成 (绞杀者 / 防腐层)(待补)) 新旧并行且数据一致

🔑 服务边界的验收标准是四条:能不能独立发布、独立回滚、独立扩容、独立值班。四条有一条做不到,就不要拆成两个服务——你会得到一个需要联合发布、联合回滚、联合值班的“分布式单体”。

反模式

反模式 后果
按数据库表拆服务 一个业务事务变 N 个分布式事务,最终一致 + 对账成本全上来
一个聚合一个服务 服务数量爆炸,跨服务事务遍地,运维面乘 6
按现有团队编制拆服务 组织一调整,服务边界全部失效(康威逆定律用错了方向)
上下文共享一个数据库 分布式单体:最难改、最贵、最没有收益的形态(30.3 分布式单体反模式)
用同步 RPC 串起 5 个上下文 可用性相乘:0.999^5 ≈ 99.50%,每月停机从 43 分钟涨到 3.6 小时
按“前后端”切 每个业务能力都被切两半,前后端各自维护一套规则
拆完不做数据迁移,还读旧库 数据所有权名存实亡,两个服务同时写一张表

自检

Q1 一个 5 人团队维护的电商系统,有 6 个上下文(交易、定价、库存、履约、通知、商品),全部跑在一个模块化单体里。有人提议“按上下文拆成 6 个微服务”。用本节的判据给出建议。

▸ 答案:先不拆。判据是三个信号:独立伸缩(各上下文流量相近,没有 10 倍差异)、独立发布(5 人团队、单产品线,发布通道不饱和)、独立团队(一个团队,不存在团队归属冲突)——三个都不成立。拆成 6 个服务要多付 36 类运维资产与 5 条跨服务链路,而收益(独立伸缩/发布/隔离)当前都不存在。正确动作是保持模块化单体,用包边界强制上下文边界(26.6),等某个上下文真的出现瓶颈(如库存写 QPS 是其他模块 10 倍)再单独拆它。

Q2 为什么“两个上下文需要强一致事务”说明边界画错了,而不是说明需要分布式事务?

▸ 答案:因为强一致事务的存在,恰恰证明这两个模型共享同一个不变量——而不变量是聚合边界的定义(29.3)。如果“下单”必须同时保证“订单创建”和“库存扣减”在同一个原子操作里,那它们本来就属于同一个事务边界,也就是同一个聚合/同一个服务。用分布式事务去实现它,等于用 100 倍的复杂度去模拟一个本地事务,而且失败模式更多(中间态、补偿、悬挂)。正确做法是把它们合回一个边界,或者明确改变业务规则(接受最终一致,用预占 + 事件推进)。

Q3 共变率显示 pricing × trade = 0.67,团队决定“那就加个 ACL 隔开”。这个决定合理吗?还需要看什么?

▸ 答案:不一定合理,要先看共变的原因。三种可能:① 定价规则确实属于交易的一部分(比如优惠计算逻辑),那 ACL 只是把同一个东西翻译两次,应该合并;② 定价是独立能力,但交易的代码直接读了定价表/定价 DTO(编译期耦合),这时该做的是断依赖 + ACL;③ 同一个人顺手改(人的耦合),那共变率会随时间消失,什么都不用做。判别方法:看共同提交里改的是同一份规则还是两份不同的规则,以及是否有跨上下文的 import。只有 ② 才该上 ACL。

Q4 一个团队把“通知”拆成了独立服务,理由是“通知会服务多个上下文”。这个理由充分吗?

▸ 答案:部分充分,但不足以单独成立。通知服务面对 3 个以上上游,确实符合 OHS + PL 的形态(上游发布事件,通知订阅),拆开能让它独立演进、独立扩容(短信洪峰)。但要注意两点:① 如果只是发短信/邮件,用量小且没有独立伸缩需求,一个共享库就够了,拆服务是过度设计;② 拆出去之后,通知的业务规则(什么时候发、发给谁、模板怎么选)属于谁?如果规则留在各上游,通知服务就退化成“发送管道”;如果规则进通知服务,它就成了一个有独立模型的上下文(通知偏好、频控、模板版本),需要自己的聚合与事件。这个问题的答案决定它是“基础设施”还是“业务上下文”,两者运维要求完全不同。

关联