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