Skip to content

限界上下文怎样集成:契约、防腐层与可靠事件 ​

边界画出来以后,如果下游仍然直接读上游的表、直接反序列化上游的内部对象,边界就只存在于图上。集成要落到三件事上:一份双方约定的契约,一层把对方语言翻译成自己语言的代码,以及一条在崩溃、重复和乱序下仍然正确的投递链路。

报名确认以后,计费上下文要生成一笔应收。最直接的写法是报名事务提交后调用计费。实测每 10 次在「提交之后、调用之前」模拟一次进程崩溃:100 条报名,90 笔应收。改用 Outbox,把「要发一个事件」和报名写在同一个事务里,100 条报名对应 100 笔应收;但转发器崩溃重启后会重复投递,计费侧不去重就会多出 10 笔;取消事件先于确认事件到达时,不判断版本的消费者会让 20 笔应收错误地保持未作废。

本文用报名 → 计费这条链路,在 MySQL 8.4.11 上把这些情况逐一复现。两个上下文各用一个库,传输用进程内调用模拟,只关注两端各自的事务、重试和去重语义。

一、先说结论 ​

  • 上下文之间通过契约说话:发布方对外只给稳定、带版本的集成事件,不序列化自己的聚合;消费方用防腐层翻译成自己的模型。
  • 双写会丢,Outbox 让「业务写入」和「要发事件」原子化:实测双写 100 条报名只有 90 笔应收,Outbox 全部送达。
  • Outbox 之后仍然是至少一次:转发器在投递后、标记前崩溃,重启后重复投递 10 条;按事件 id 去重后应收 50 条,不去重是 60 条。
  • 顺序不能假设:取消先于确认到达时,只去重不判断版本,20 笔应收全部错误;按聚合版本判断,全部正确作废。
  • 毒消息要隔离:一条格式错误的事件 3 次失败后进入死信,其余 30 条正常处理,没有阻塞。
  • 契约变化要在发布前被发现:新增字段不影响宽容读取的消费方;改名字段让契约测试失败,指出缺少的字段。

二、三种集成方式 ​

方式耦合延迟对方不可用时一致性适合
同步 API 调用时间上耦合:双方必须同时在线一次网络往返调用方失败或降级调用方立即知道结果需要立即拿到结果才能继续,例如下单前查库存
异步事件只耦合契约投递延迟,秒级常见事件积压,恢复后处理最终一致通知对方「某件事发生了」,对方自行决定怎么处理
数据复制耦合数据结构取决于同步机制读到旧数据最终一致下游需要大量读取上游数据,例如读模型

报名确认后生成应收,计费不需要在报名那一刻就完成,报名也不应该因为计费故障而失败,所以用异步事件。DDD 的边界不止一个 实测过反面:同步调用通知服务时,通知服务一停,报名就全部失败。

三、防腐层与契约 ​

报名上下文发布的集成事件:

json
{"schema": "registration.confirmed/v1", "eventId": "<uuid>", "registrationId": "<uuid>",
 "attendeeId": "p0", "feeCents": 19900, "currency": "CNY", "registrationVersion": 1}

计费上下文不认识「参会人」和「场次」,它的模型是「应收」:谁付钱、付多少。防腐层(Anti-Corruption Layer)负责翻译:

java
/** 只读取契约里约定的字段;缺字段或类型不对时明确失败,多出来的字段忽略。 */
static Receivable translateConfirmed(JsonObject o) {
    return new Receivable(
            required(o, "registrationId").getAsString(),
            "payer:" + required(o, "attendeeId").getAsString(),   // 参会人 → 付款人
            requiredInt(o, "feeCents"),
            required(o, "currency").getAsString(),
            requiredInt(o, "registrationVersion"));
}

防腐层不只是封装一个 HTTP 客户端。它把上游的语言挡在边界之外:计费上下文的其他代码只认识 Receivable,上游改了字段名、调整了编码,只需要改这一处。

契约变化用同一个翻译函数来检查:

text
生产者当前的 v1 消息:计费侧翻译通过,应收 19900 分,付款人 payer:alice
生产者新增字段 channel:计费侧翻译通过
生产者把 feeCents 改名为 amountCents:契约测试失败:缺少字段 feeCents

新增字段是兼容的变化,前提是消费方只读取自己需要的字段(宽容读取);改名和删除是不兼容的变化。把消费方的翻译函数和一份样例消息放进生产者的构建里,改名就会在发布前失败,而不是在线上变成一批解析错误。确实需要不兼容的变化时,发布 v2,与 v1 并行一段时间,等消费方迁移后再下线旧版本。

报名上下文参会人 · 场次 · 报名registration.confirmed/v1feeCents · version防腐层只读约定字段计费上下文付款人 · 应收新增 channel翻译通过feeCents 改名 amountCents契约测试失败:缺少字段 feeCents
图 1 · 报名上下文只对外承诺带版本的集成事件,计费上下文的防腐层只读取约定的字段并翻译成应收;新增字段被忽略,改名字段让契约测试在发布前失败

四、领域事件与集成事件 ​

领域事件集成事件
描述聚合内部发生的业务事实对外承诺的、稳定的消息
类型内部类型,可以带值对象、枚举与语言无关的格式(JSON、Avro 等),带 schema 版本
谁使用同一个上下文内的处理逻辑其他上下文
能否随意修改可以,随模型重构不能,修改需要兼容或升级版本
是否一定跨进程否通常是

一个领域事件不应该默认直接变成 Kafka 消息。把聚合或领域事件直接序列化发出去,等于把内部模型变成了公共 API,之后每一次重构都可能破坏下游。常见的做法是在应用层或专门的发布模块里,把领域事件翻译成集成事件,写入 Outbox。

五、从聚合到下游:三段可靠性 ​

一个事实从报名聚合走到计费的应收,要经过三段,每一段的失败方式不同:

  1. 聚合内登记:状态改变时登记领域事件,事务回滚时事件不对外生效。聚合边界 实测过在领域方法里直接发布的后果。
  2. 事务内记录:业务行和待发送的集成事件在同一个事务里提交。
  3. 事务外投递:转发器把事件投递给下游,下游幂等处理,并能应对重复、乱序和失败。

5.1 双写与 Outbox ​

text
提交后直接调用计费,每 10 次在调用前崩溃 1 次:报名 100 条,应收 90 条
报名与 outbox 同一事务,另有 1 笔在提交前失败(已回滚):报名 100 条,outbox 待发送 100 条;
  转发器处理后应收 100 条

双写的问题在于两次写入之间没有原子性:报名提交了,计费调用还没发出,进程就崩溃了,这笔应收永远不会生成。@TransactionalEventListener(AFTER_COMMIT) 让副作用等到提交之后,避免回滚后误发,但它也在同一个进程的内存里,进程崩溃同样会丢,见 观察者、事件与消息。

Outbox 把待发送的事件作为一行数据,和报名写在同一个事务里:两者要么都提交,要么都回滚。提交前失败的那一笔,报名和 outbox 都没有留下。

5.2 转发器崩溃与重复投递 ​

转发器每批读取最多 10 条待发送事件,投递整批之后,再一次性标记为已发送。在第 3 批投递之后、标记之前让它崩溃,然后重启:

text
50 个事件,重启后继续:计费处理 60 次
  不去重:应收 60 条
  按 eventId 写 inbox 去重(与应收同一事务):应收 50 条,跳过 10 次

投递和标记之间永远有一个窗口,所以 Outbox 给出的是至少一次,不是「只发一次」。消费方用一张 inbox 表记录处理过的事件 id,插入 inbox 和写入应收在同一个事务里:重复的事件插入 inbox 失败,整笔跳过。

5.3 乱序 ​

20 笔报名先确认后取消,投递顺序与产生顺序相反(多个转发器实例、重试或分区都可能造成这种情况):

text
只去重、不判断版本:应收 OPEN 20 条、VOID 0 条
按聚合版本判断:    应收 OPEN 0 条、VOID 20 条,忽略过期事件 20 次

不判断版本时,取消事件先到,找不到应收,被丢弃;随后确认事件到达,生成了一笔本该作废的应收。按版本判断时,取消事件先记下「已作废,版本 2」,版本 1 的确认事件到达时被识别为过期事件,忽略。

这要求集成事件携带聚合版本号,且同一个聚合的版本单调递增。跨聚合的顺序没有这种保证,也通常不需要。

5.4 毒消息 ​

31 个事件中有 1 个的金额字段被写成了字符串 "19.9 元"。转发器每次运行,对这条事件记一次失败;第 3 次失败后,它被写入计费侧的死信表,outbox 标记为 FAILED:

text
应收 30 条,死信 1 条(字段 feeCents 不是整数:"19.9 元"),outbox FAILED 1 条、仍待发送 0 条

其他 30 条事件在它失败期间照常处理。如果转发器遇到失败就停下来重试同一条,一条坏消息就能堵住整条链路。进入死信的事件需要告警和人工处理:修正数据后重新投递,或者确认可以丢弃。

报名聚合登记领域事件同一事务报名行 + outbox 行转发器投递 → 标记计费:inbox + 版本判断同一事务写应收1双写:100 条剩 90Outbox:100 条2重复:应收 60inbox:503乱序:20 条 OPEN版本:20 条 VOID4毒消息3 次后进死信投递是至少一次:Outbox 保证不丢,不保证不重领域事件只在上下文内部使用,对外发布的是翻译后的集成事件
图 2 · 业务行与 outbox 同一事务提交,解决双写丢失;投递与标记之间的窗口带来重复,由 inbox 去重;投递顺序不保证,由聚合版本判断;持续失败的事件进入死信,不阻塞其他事件

六、与 Domain Driven Kit 的对照 ​

DDK 的对应关系:

本文的一段DDK 的做法
回滚不误发聚合根登记领域事件,仓储写入成功后交给 Spring,订阅方用 @TransactionalEventListener(AFTER_COMMIT) 在提交后处理
双写与 Outbox领域事件标上 @IntegrationEvent,由 Spring Modulith 的 JDBC 事件发布记录在业务事务里登记,提交后投递到 Kafka、RocketMQ、AMQP、JMS 等;与 MyBatis-Plus 共用同一个事务
重复投递事件用 @IntegrationEvent(id = ...) 声明自带的标识,随消息头 ddk-event-id 发出;消费方用 IdempotentConsumer 按(消费者,消息 ID)去重
毒消息投递失败的发布记录停在 FAILED,不阻塞其他事件,可以通过 Spring Modulith 的 API 重新提交
乱序用 @IntegrationEvent(key = ...) 让同一个聚合的事件落在同一个分区;按聚合版本丢弃过期事件仍需消费方自己判断

用法与取舍见 DDK 领域事件。

七、先在单体里验证,再决定是否拆开 ​

本文的两个上下文在同一个 MySQL 实例的两个库里,传输是进程内调用。即便如此,重复、乱序、毒消息全都出现了:它们来自「两个独立的事务」和「至少一次投递」,而不是来自网络。所以这些机制可以先在模块化单体里建立和验证,等到真正需要把计费拆成独立服务时,换掉的只是传输方式。消息中间件自身的投递语义,例如生产者重试、分区内顺序、消费位点提交,见 Kafka 不丢、不重与 Exactly Once。

八、常见误区 ​

  • 「下游直接读上游的表最快」:上游的每一次表结构调整都会破坏下游,数据边界形同虚设。
  • 「领域事件直接序列化发出去」:内部模型变成了公共契约,重构会破坏下游。
  • 「用了 Outbox 就不会重复」:Outbox 保证不丢,投递仍然是至少一次,消费方必须幂等。
  • 「消息是按顺序到的」:重试、多实例、分区都会打乱顺序;需要顺序的地方用版本号判断。
  • 「失败就一直重试」:永远失败的消息会堵住后面的消息,要有次数上限和死信。
  • 「上下文集成就是拆微服务」:契约、防腐层和可靠事件在单体里同样需要,也可以先在单体里验证。

小结 ​

上下文集成的核心是三件事:发布方只对外承诺带版本的集成事件,消费方用防腐层翻译成自己的模型;业务写入和待发送事件在同一个事务里提交;投递按至少一次设计,消费方用事件 id 去重、用聚合版本处理乱序、用次数上限和死信隔离毒消息。下一篇讨论另一个跨边界的需求:读取。CQRS 不是两套系统。


配套实验

参考资料

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