限界上下文怎样集成:契约、防腐层与可靠事件
边界画出来以后,如果下游仍然直接读上游的表、直接反序列化上游的内部对象,边界就只存在于图上。集成要落到三件事上:一份双方约定的契约,一层把对方语言翻译成自己语言的代码,以及一条在崩溃、重复和乱序下仍然正确的投递链路。
报名确认以后,计费上下文要生成一笔应收。最直接的写法是报名事务提交后调用计费。实测每 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 的边界不止一个 实测过反面:同步调用通知服务时,通知服务一停,报名就全部失败。
三、防腐层与契约
报名上下文发布的集成事件:
{"schema": "registration.confirmed/v1", "eventId": "<uuid>", "registrationId": "<uuid>",
"attendeeId": "p0", "feeCents": 19900, "currency": "CNY", "registrationVersion": 1}计费上下文不认识「参会人」和「场次」,它的模型是「应收」:谁付钱、付多少。防腐层(Anti-Corruption Layer)负责翻译:
/** 只读取契约里约定的字段;缺字段或类型不对时明确失败,多出来的字段忽略。 */
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,上游改了字段名、调整了编码,只需要改这一处。
契约变化用同一个翻译函数来检查:
生产者当前的 v1 消息:计费侧翻译通过,应收 19900 分,付款人 payer:alice
生产者新增字段 channel:计费侧翻译通过
生产者把 feeCents 改名为 amountCents:契约测试失败:缺少字段 feeCents新增字段是兼容的变化,前提是消费方只读取自己需要的字段(宽容读取);改名和删除是不兼容的变化。把消费方的翻译函数和一份样例消息放进生产者的构建里,改名就会在发布前失败,而不是在线上变成一批解析错误。确实需要不兼容的变化时,发布 v2,与 v1 并行一段时间,等消费方迁移后再下线旧版本。
四、领域事件与集成事件
| 领域事件 | 集成事件 | |
|---|---|---|
| 描述 | 聚合内部发生的业务事实 | 对外承诺的、稳定的消息 |
| 类型 | 内部类型,可以带值对象、枚举 | 与语言无关的格式(JSON、Avro 等),带 schema 版本 |
| 谁使用 | 同一个上下文内的处理逻辑 | 其他上下文 |
| 能否随意修改 | 可以,随模型重构 | 不能,修改需要兼容或升级版本 |
| 是否一定跨进程 | 否 | 通常是 |
一个领域事件不应该默认直接变成 Kafka 消息。把聚合或领域事件直接序列化发出去,等于把内部模型变成了公共 API,之后每一次重构都可能破坏下游。常见的做法是在应用层或专门的发布模块里,把领域事件翻译成集成事件,写入 Outbox。
五、从聚合到下游:三段可靠性
一个事实从报名聚合走到计费的应收,要经过三段,每一段的失败方式不同:
- 聚合内登记:状态改变时登记领域事件,事务回滚时事件不对外生效。聚合边界 实测过在领域方法里直接发布的后果。
- 事务内记录:业务行和待发送的集成事件在同一个事务里提交。
- 事务外投递:转发器把事件投递给下游,下游幂等处理,并能应对重复、乱序和失败。
5.1 双写与 Outbox
提交后直接调用计费,每 10 次在调用前崩溃 1 次:报名 100 条,应收 90 条
报名与 outbox 同一事务,另有 1 笔在提交前失败(已回滚):报名 100 条,outbox 待发送 100 条;
转发器处理后应收 100 条双写的问题在于两次写入之间没有原子性:报名提交了,计费调用还没发出,进程就崩溃了,这笔应收永远不会生成。@TransactionalEventListener(AFTER_COMMIT) 让副作用等到提交之后,避免回滚后误发,但它也在同一个进程的内存里,进程崩溃同样会丢,见 观察者、事件与消息。
Outbox 把待发送的事件作为一行数据,和报名写在同一个事务里:两者要么都提交,要么都回滚。提交前失败的那一笔,报名和 outbox 都没有留下。
5.2 转发器崩溃与重复投递
转发器每批读取最多 10 条待发送事件,投递整批之后,再一次性标记为已发送。在第 3 批投递之后、标记之前让它崩溃,然后重启:
50 个事件,重启后继续:计费处理 60 次
不去重:应收 60 条
按 eventId 写 inbox 去重(与应收同一事务):应收 50 条,跳过 10 次投递和标记之间永远有一个窗口,所以 Outbox 给出的是至少一次,不是「只发一次」。消费方用一张 inbox 表记录处理过的事件 id,插入 inbox 和写入应收在同一个事务里:重复的事件插入 inbox 失败,整笔跳过。
5.3 乱序
20 笔报名先确认后取消,投递顺序与产生顺序相反(多个转发器实例、重试或分区都可能造成这种情况):
只去重、不判断版本:应收 OPEN 20 条、VOID 0 条
按聚合版本判断: 应收 OPEN 0 条、VOID 20 条,忽略过期事件 20 次不判断版本时,取消事件先到,找不到应收,被丢弃;随后确认事件到达,生成了一笔本该作废的应收。按版本判断时,取消事件先记下「已作废,版本 2」,版本 1 的确认事件到达时被识别为过期事件,忽略。
这要求集成事件携带聚合版本号,且同一个聚合的版本单调递增。跨聚合的顺序没有这种保证,也通常不需要。
5.4 毒消息
31 个事件中有 1 个的金额字段被写成了字符串 "19.9 元"。转发器每次运行,对这条事件记一次失败;第 3 次失败后,它被写入计费侧的死信表,outbox 标记为 FAILED:
应收 30 条,死信 1 条(字段 feeCents 不是整数:"19.9 元"),outbox FAILED 1 条、仍待发送 0 条其他 30 条事件在它失败期间照常处理。如果转发器遇到失败就停下来重试同一条,一条坏消息就能堵住整条链路。进入死信的事件需要告警和人工处理:修正数据后重新投递,或者确认可以丢弃。
六、与 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 不是两套系统。
配套实验
- codesphere-labs/ddd/context-integration-outbox:双写与 Outbox、转发器崩溃后的重复投递与 inbox 去重、乱序与版本判断、毒消息与死信、契约的新增与改名,MySQL 8.4.11 容器(验证记录)
参考资料
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 14 章(防腐层、开放主机服务、发布语言)
- Chris Richardson,Pattern: Transactional outbox
- Martin Fowler,TolerantReader
- Pat Helland,Life beyond Distributed Transactions: an Apostate's Opinion(CIDR 2007)