Skip to content

DDD 的边界不止一个:从模型、代码到部署单元 ​

画出限界上下文以后,常见的两种结局是:继续写一个边界模糊的单体,或者按图机械地拆出一堆服务。问题出在把几种不同的边界当成了一个:模型边界、代码边界、数据边界、事务边界、团队边界和部署边界可以重合,但不必相等。

活动报名平台有四个上下文:活动、报名、计费、通知。按「一个上下文一个服务」拆成四个服务,报名时要同步调用通知服务发短信。实测把通知拆成独立的 HTTP 服务后,通知服务一停,20 次报名全部失败;通知处理变慢、客户端超时重试时,5 次报名全部回滚,短信却发出去 15 条。同一段业务规则,在同一个进程里没有这些问题。

本文把「边界」拆成六个维度,用同一组报名用例分别跑在模块化单体和拆分后的部署里,看拆分带来了哪些能力,又带来了哪些必须处理的失败。

一、先说结论 ​

  • 限界上下文首先是模型和语言的边界:它回答一个词、一条规则在哪个范围内有效,不直接决定进程怎么部署。
  • 模块化单体是有效的起点:边界可以用模块依赖规则在单体里执行,实测违规调用会被测试拦下;但只引用对方编译期常量的依赖,字节码工具看不到。
  • 业务规则不随部署方式变化:同一组 3 个报名用例,在同进程和 HTTP 拆分两种部署下都通过。
  • 拆分之后多出来的是失败方式:对方停机、超时后对方仍在处理、重复请求、额外延迟、跨进程追踪,都要显式处理。实测本机 HTTP 调用的中位耗时是同进程调用的 40 多倍。
  • 部署边界由运行约束触发:独立扩缩容、故障隔离、发布节奏和团队所有权是拆分的证据;「这是一个限界上下文」本身不是。

二、六种边界 ​

边界回答的问题实现手段画错的信号
模型(逻辑)边界一个词、一个模型在哪里有唯一含义限界上下文、统一语言、防腐层同一个类被多个团队各自解释
代码边界哪些代码可以互相引用包、构建模块、可见性、架构测试改一个上下文要重新编译另一个
数据边界谁能修改哪些数据表或库的所有权、只通过 API 或事件读取对方数据两个模块直接改同一张表
事务边界哪些规则必须一次提交聚合、本地事务一个请求要跨多个库保持原子
团队边界谁对模型、代码和线上结果负责团队所有权、值班一次发布需要三个团队签字
部署边界哪些代码一起发布、一起扩缩容、一起失败进程、服务、容器为了一个小改动同时发布多个服务

这些边界之间有依赖关系:模型边界为代码和数据边界提供依据,事务边界决定了哪些东西不能拆到两个进程里。但它们是候选映射,不是等号。一个限界上下文可以只是单体里的一个模块,也可以因为扩缩容需要拆成多个进程;两个关系紧密的上下文也可以放在同一个服务里。

模型边界词和规则在哪里有唯一含义:限界上下文代码边界模块、依赖规则、架构测试数据边界谁能修改哪些表事务边界哪些规则必须一次提交:聚合团队边界谁对线上结果负责部署边界哪些代码一起发布、一起扩缩容、一起失败报名平台:4 个上下文 → 1 个进程里的 4 个模块 → 只有通知有证据拆成独立服务
图 1 · 限界上下文为代码和数据边界提供依据,事务边界约束哪些东西不能拆开,部署边界最后由负载、故障隔离、发布节奏和团队所有权决定;每一层都可以与上一层不重合

三、第一阶段:模块化单体 ​

四个上下文先放在一个进程里,各自一个模块,数据各自一组表。通知上下文对外只暴露一个包:

text
notification.api        NotificationPort、ConfirmationNotice(发布语言)
notification.internal   模板、渠道、投递记录
registration            报名上下文:只能依赖 notification.api
deployment              同进程实现与 HTTP 实现,报名模块不知道自己被怎样部署

「报名只能依赖通知的 api 包」写成一条 ArchUnit 规则,在构建时执行:

java
noClasses().that().resideInAPackage("..registration..")
        .should().dependOnClassesThat().resideInAnyPackage("..notification.internal..", "..deployment..");

实测主代码满足规则;一个直接调用通知内部模板方法的夹具被拦下:

text
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 两种部署下都通过。

新的失败方式出现了:

text
通知服务停止: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 通过请求头传到了通知服务的日志里,出问题时可以把两边的日志串起来。单体里这件事不需要做。

模块化单体报名规则(3 个用例通过)通知模块:方法调用中位 11µs通知拆成 HTTP 服务报名规则(同样 3 个用例通过)停机20/20 失败超时重试15 条短信延迟中位 487µs提交后投递 + 事件 id 去重 + traceId报名在提交前同步调用通知时,拆分把通知的故障变成了报名的故障
图 2 · 同一组报名用例在两种部署下都通过;拆分之后新出现停机、超时后对方仍在处理、重复和额外延迟,每一种都要有对应的机制,同步调用加重试会让短信发了、报名却回滚

六、第三阶段:跨上下文的查询 ​

拆分之后,运营想看「每个场次的报名数和已收金额」,数据分别在报名和计费两个上下文。跨库 JOIN 做不到,逐个调用两边的 API 再在内存里拼,慢且脆弱。常见的做法是建立一个读模型:由两边发布的事件投影而来,专门服务这个查询,接受几秒的延迟。怎样判断需要读模型、怎样处理投影延迟和重放,见 CQRS 不是两套系统;更一般的跨库查询手段见 分库之后的关联查询。

七、共享能力放在哪里 ​

通知被多个业务共用,很容易被推广成「所有公共能力都沉淀成平台」。能力平台值得做的前提是:

  • 能力本身稳定,多个场景确实以相同的方式使用它;
  • 有明确的负责团队,对契约、版本和可用性负责;
  • 各业务的差异由适配层吸收,而不是不断往平台模型里加字段。

反例同样常见:为了复用,把不同上下文里的同名概念强行合并成一个「统一用户」「统一订单」;平台团队只维护代码、不对线上结果负责;用共享数据库代替稳定的契约。这些做法把多个上下文的变化重新绑在了一起,正是限界上下文要避免的事。

八、常见误区 ​

  • 「一个限界上下文就是一个微服务」:上下文是模型边界;部署方式要看负载、故障隔离、发布节奏和团队。
  • 「一个聚合就是一张表」:聚合是一致性边界,可能对应多张表;一张宽表也可能被几个上下文以不同模型读取。
  • 「每个服务一个库就解耦了」:如果一个服务还在直接读另一个服务的表,库分开了,数据边界仍然是混在一起的。
  • 「模块化单体是过渡状态,最终都要拆」:边界清晰、团队规模适中的单体可以长期运行;拆分要有证据。
  • 「拆开以后系统更可靠」:拆分让故障可以隔离,前提是调用方式改成能容忍对方失败;同步调用加重试反而放大了故障。

小结 ​

限界上下文给出的是模型边界,代码、数据、事务、团队和部署边界要分别决定。先在单体里用模块和架构测试让边界可执行,边界稳定、并且有运行上的证据时,再把个别上下文拆成独立的部署单元。拆分不改变业务规则,但会带来停机、超时、重复、延迟和追踪这些新问题,每一个都要有对应的机制。下一篇回到单个上下文内部,讨论 领域模型怎样落到数据库。


配套实验

  • codesphere-labs/ddd/deployment-boundaries:模块边界规则与两个违规夹具(含常量内联的盲区),同一组报名用例在同进程与 HTTP 拆分下运行,通知服务停机、超时后仍在处理、调用延迟与 traceId 传递(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权