DDD 的边界不止一个:从模型、代码到部署单元
画出限界上下文以后,常见的两种结局是:继续写一个边界模糊的单体,或者按图机械地拆出一堆服务。问题出在把几种不同的边界当成了一个:模型边界、代码边界、数据边界、事务边界、团队边界和部署边界可以重合,但不必相等。
活动报名平台有四个上下文:活动、报名、计费、通知。按「一个上下文一个服务」拆成四个服务,报名时要同步调用通知服务发短信。实测把通知拆成独立的 HTTP 服务后,通知服务一停,20 次报名全部失败;通知处理变慢、客户端超时重试时,5 次报名全部回滚,短信却发出去 15 条。同一段业务规则,在同一个进程里没有这些问题。
本文把「边界」拆成六个维度,用同一组报名用例分别跑在模块化单体和拆分后的部署里,看拆分带来了哪些能力,又带来了哪些必须处理的失败。
一、先说结论
- 限界上下文首先是模型和语言的边界:它回答一个词、一条规则在哪个范围内有效,不直接决定进程怎么部署。
- 模块化单体是有效的起点:边界可以用模块依赖规则在单体里执行,实测违规调用会被测试拦下;但只引用对方编译期常量的依赖,字节码工具看不到。
- 业务规则不随部署方式变化:同一组 3 个报名用例,在同进程和 HTTP 拆分两种部署下都通过。
- 拆分之后多出来的是失败方式:对方停机、超时后对方仍在处理、重复请求、额外延迟、跨进程追踪,都要显式处理。实测本机 HTTP 调用的中位耗时是同进程调用的 40 多倍。
- 部署边界由运行约束触发:独立扩缩容、故障隔离、发布节奏和团队所有权是拆分的证据;「这是一个限界上下文」本身不是。
二、六种边界
| 边界 | 回答的问题 | 实现手段 | 画错的信号 |
|---|---|---|---|
| 模型(逻辑)边界 | 一个词、一个模型在哪里有唯一含义 | 限界上下文、统一语言、防腐层 | 同一个类被多个团队各自解释 |
| 代码边界 | 哪些代码可以互相引用 | 包、构建模块、可见性、架构测试 | 改一个上下文要重新编译另一个 |
| 数据边界 | 谁能修改哪些数据 | 表或库的所有权、只通过 API 或事件读取对方数据 | 两个模块直接改同一张表 |
| 事务边界 | 哪些规则必须一次提交 | 聚合、本地事务 | 一个请求要跨多个库保持原子 |
| 团队边界 | 谁对模型、代码和线上结果负责 | 团队所有权、值班 | 一次发布需要三个团队签字 |
| 部署边界 | 哪些代码一起发布、一起扩缩容、一起失败 | 进程、服务、容器 | 为了一个小改动同时发布多个服务 |
这些边界之间有依赖关系:模型边界为代码和数据边界提供依据,事务边界决定了哪些东西不能拆到两个进程里。但它们是候选映射,不是等号。一个限界上下文可以只是单体里的一个模块,也可以因为扩缩容需要拆成多个进程;两个关系紧密的上下文也可以放在同一个服务里。
三、第一阶段:模块化单体
四个上下文先放在一个进程里,各自一个模块,数据各自一组表。通知上下文对外只暴露一个包:
notification.api NotificationPort、ConfirmationNotice(发布语言)
notification.internal 模板、渠道、投递记录
registration 报名上下文:只能依赖 notification.api
deployment 同进程实现与 HTTP 实现,报名模块不知道自己被怎样部署「报名只能依赖通知的 api 包」写成一条 ArchUnit 规则,在构建时执行:
noClasses().that().resideInAPackage("..registration..")
.should().dependOnClassesThat().resideInAnyPackage("..notification.internal..", "..deployment..");实测主代码满足规则;一个直接调用通知内部模板方法的夹具被拦下:
Method <...leak.registration.RegistrationMessages.confirmed(String)>
calls method <...leak.notification.internal.SmsTemplates.confirmed(String)>规则也有盲区。另一个夹具只引用了通知内部的 static final String 常量,规则报告 0 条违规。javap 显示字符串已经被编译器内联进调用方,字节码里没有任何对那个类的访问指令。按字节码检查依赖的工具看不到这类耦合,但对方改了模板文案,这边不会跟着变。共享常量应该放进 api 包,作为发布语言的一部分。
模块化单体的好处是边界可以低成本地调整:两个模块之间挪一个类,只是一次重构,不需要改接口、迁数据、协调两次发布。边界还没稳定时,这比拆成服务安全得多。
四、什么时候拆:证据而不是图
通知是四个上下文里最先值得拆分的,理由不是「它是一个限界上下文」,而是几条运行上的证据:
- 负载特征不同:活动开票时通知量是平时的几十倍,而报名本身的计算很轻;
- 故障来源不同:短信渠道经常超时,渠道故障不应该拖慢报名;
- 发布节奏不同:模板和渠道每周都在调整,报名规则很少变;
- 团队不同:通知由平台团队维护,多个业务共用。
反过来,满足下面几条时,暂时不拆:
- 边界还在频繁调整,最近几次需求都要同时改两个上下文;
- 两个上下文之间调用频繁,而且大多数调用需要同步拿到结果;
- 团队没有能力运维更多的服务、链路追踪和告警;
- 拆分之后需要跨服务事务才能保持某条规则,这通常说明边界画错了。
五、拆分之后多出来的东西
把通知拆成独立的 HTTP 服务,报名模块只换了 NotificationPort 的实现:客户端带 300ms 超时、最多 3 次重试,请求头带 X-Trace-Id。报名用例本身一行没改,它在提交前同步调用通知,失败时报名一起回滚。
业务规则没有变化。同一组 3 个用例(确认与候补、重复报名、只通知已确认者)在同进程和 HTTP 两种部署下都通过。
新的失败方式出现了:
通知服务停止:20 次报名失败 20 次(ConnectException),已确认 0
通知耗时 800ms、客户端超时 300ms、最多 3 次:
5 次报名失败 5 次,已确认 0,HTTP 调用 15 次,通知服务实际发出短信 15 条,每次报名约 900ms
同样的设置,通知服务按 noticeId 去重:
5 次报名失败 5 次,已确认 0,HTTP 调用 15 次,通知服务实际发出短信 5 条第二组结果最值得注意:客户端超时不代表服务端没有处理。通知服务在超时之后仍然处理完了每一个请求,于是参会人收到了「已报名」的短信,报名却因为「通知失败」被回滚了。按 noticeId 去重只能把 15 条减到 5 条,解决不了「短信发了、报名不存在」这个根本矛盾。
根本的修正是改变调用的位置:通知不该在报名事务里同步调用,而是在报名提交之后,由 Outbox 可靠地交给通知服务,通知服务按事件 id 去重。这样通知服务停机只会让短信晚到,不会让报名失败。具体做法和实测见 上下文集成。
每次调用都更慢了。中位耗时同进程 11µs,本机 HTTP 487µs(各 500 次,前 100 次预热不计)。这还是本机回环网络,没有跨机器、没有 TLS、没有负载均衡。一个用例里如果有十几次这样的调用,延迟会叠加到用户能感知的程度。
排查需要跨进程的上下文。报名请求的 trace-7f3a 通过请求头传到了通知服务的日志里,出问题时可以把两边的日志串起来。单体里这件事不需要做。
六、第三阶段:跨上下文的查询
拆分之后,运营想看「每个场次的报名数和已收金额」,数据分别在报名和计费两个上下文。跨库 JOIN 做不到,逐个调用两边的 API 再在内存里拼,慢且脆弱。常见的做法是建立一个读模型:由两边发布的事件投影而来,专门服务这个查询,接受几秒的延迟。怎样判断需要读模型、怎样处理投影延迟和重放,见 CQRS 不是两套系统;更一般的跨库查询手段见 分库之后的关联查询。
七、共享能力放在哪里
通知被多个业务共用,很容易被推广成「所有公共能力都沉淀成平台」。能力平台值得做的前提是:
- 能力本身稳定,多个场景确实以相同的方式使用它;
- 有明确的负责团队,对契约、版本和可用性负责;
- 各业务的差异由适配层吸收,而不是不断往平台模型里加字段。
反例同样常见:为了复用,把不同上下文里的同名概念强行合并成一个「统一用户」「统一订单」;平台团队只维护代码、不对线上结果负责;用共享数据库代替稳定的契约。这些做法把多个上下文的变化重新绑在了一起,正是限界上下文要避免的事。
八、常见误区
- 「一个限界上下文就是一个微服务」:上下文是模型边界;部署方式要看负载、故障隔离、发布节奏和团队。
- 「一个聚合就是一张表」:聚合是一致性边界,可能对应多张表;一张宽表也可能被几个上下文以不同模型读取。
- 「每个服务一个库就解耦了」:如果一个服务还在直接读另一个服务的表,库分开了,数据边界仍然是混在一起的。
- 「模块化单体是过渡状态,最终都要拆」:边界清晰、团队规模适中的单体可以长期运行;拆分要有证据。
- 「拆开以后系统更可靠」:拆分让故障可以隔离,前提是调用方式改成能容忍对方失败;同步调用加重试反而放大了故障。
小结
限界上下文给出的是模型边界,代码、数据、事务、团队和部署边界要分别决定。先在单体里用模块和架构测试让边界可执行,边界稳定、并且有运行上的证据时,再把个别上下文拆成独立的部署单元。拆分不改变业务规则,但会带来停机、超时、重复、延迟和追踪这些新问题,每一个都要有对应的机制。下一篇回到单个上下文内部,讨论 领域模型怎样落到数据库。
配套实验
- codesphere-labs/ddd/deployment-boundaries:模块边界规则与两个违规夹具(含常量内联的盲区),同一组报名用例在同进程与 HTTP 拆分下运行,通知服务停机、超时后仍在处理、调用延迟与 traceId 传递(验证记录)
参考资料
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 14 章(维护模型完整性)
- Martin Fowler,BoundedContext
- Martin Fowler,MonolithFirst
- JLS 21 §13.1 The Form of a Binary:对常量变量的引用在编译期解析为值
- ArchUnit User Guide