深入探讨如何在限界上下文之间设计稳定的集成事件契约、确保消息可靠传递,并妥善处理重复、乱序等分布式系统难题,帮助团队构建可独立演进的业务架构。
在领域驱动设计的实践中,限界上下文是保持模型清晰与团队自治的重要边界。每个上下文拥有自己的语言、规则与职责,并独立地演进。然而,真实的业务系统很少孤立运行。一个上下文中发生的关键事实,往往需要被另一个上下文知晓,进而触发相应的业务动作。集成事件正是为跨越这一边界而设计的契约,它既要让信息稳定地流动,又不能让内部模型的细节随之泄漏。理解集成事件的设计原则与交付机制,是构建可演进分布式系统的核心能力。
从内部事件到外部事件 在领域驱动设计中,领域事件用于在同一个限界上下文内部记录已经发生的业务事实。它的命名、类型与负载都属于该上下文的领域模型,并随着模型自由演化。我们可以将这类事件视为内部事件,它们服务于本地反应,例如更新读模型、发送本地通知或触发同上下文内的业务规则。 当一个事实必须跨越限界上下文,进入另一个拥有独立语言与职责的模型时,情形便发生了变化。接收方并不需要了解生产方的内部结构,它需要的是一份稳定的公共契约,其中包含它在业务上真正关心的信息。这便是集成事件,也可以理解为外部事件。
它与领域事件描述同一个事实,却不必共享同一个名称或结构。 集成事件通常借助持久的异步消息基础设施进行传递,例如 Apache Kafka、RabbitMQ 或 Azure Service Bus。这种传递方式带来了显著的好处:生产方不必等待消费方处理完毕即可完成自身工作,两个上下文可以按各自节奏处理任务,短时的不可用不必回传给生产方,消息在消费方准备就绪之前都可以持续保留。然而,这些好处伴随着新的挑战:任何一方都可能暂时不可用,消息可能被延迟或重复,两个上下文也无法共享数据库事务。于是我们需要解决两个核心问题:设计契约,以及可靠地交付契约。 设计边界上的契约 集成事件最常见的误区,是直接将领域事件发布出去。
这看似一种复用 - - 事件已经存在,何必再造一个?然而,一旦将内部模型直接暴露,每一个消费方都将依赖于生产方的内部结构。生产方为了自身需求进行的每一次重构,都可能破坏其他团队的代码。更糟糕的是,负载中往往包含消费方并不需要的信息,同时使用了生产方内部的类型标识。 正确的做法是在边界处进行翻译。生产方将同一个业务事实翻译为针对特定消费方设计的集成事件。这个事件携带的是稳定的、可序列化的值,而非生产方的值对象类型,并只包含消费方真正需要的数据。
例如,当订单被下达后,履行上下文需要准备订单时,集成事件可以只包含订单编号与履行所需的商品信息,而无需暴露客户标识或内部商品标识。 在事件结构上,将传输与契约的元数据与业务负载分离,是一种良好的实践。元数据用于标识事件契约、路由、去重与解读消息,负载则承载消费方关心的业务事实。具体的 JSON 形态并非关键,重要的在于这种分离在语义上保持一致,并贯穿所有生产方与消费方。统一的信封使得消息基础设施能够在不理解业务内容的情况下处理追踪与去重等公共关注点。 集成事件是一份已发布的接口。
一旦其他上下文依赖于它,它的演进便如同版本化的 REST 或 gRPC 接口一般,需要谨慎对待。模式、字段含义、保证与版本策略都成为接口契约的一部分。 业务事实而非内部状态变化 契约的设计不仅关乎负载,也关乎命名。如果消费方接收的是一个状态变更事件,其中包含旧状态与新状态的枚举值,那么消费方就必须理解生产方的内部状态机,并复现其部分逻辑。当生产方调整状态枚举时,即使业务事实并未变化,所有消费方也可能受到影响。这种契约将其他上下文耦合到了生产方的内部模型之上。
更恰当的方式是表达业务事实本身。例如,与其发布履行状态变更事件,不如发布订单已发货事件,其中仅包含订单编号与发货时间。消费方无需理解履行的完整生命周期,只需知道对其业务有意义的事实已经发生。当状态变更本身即是业务语言时,状态变更事件并无不妥;但当它仅仅是为了同步字段或暴露实现细节时,便需要重新审视。 数据流不等于集成契约 事件溯源与变更数据捕获都会产生各自的事件流,很容易被视为现成的集成事件。然而,它们并非如此。
事件溯源中的事件是用于重建内部状态的持久化记录,其命名、模式与演化为生产方的模型与持久化需求服务。变更数据捕获流则更为底层,它描述的是数据库行级别的变更,通常并不以业务术语表达变更的原因。两者都是内部契约,而非公共集成契约。 将变更数据捕获记录直接暴露给消费方,迫使其理解某个数值代表何种业务含义,并从数据库变更中推断业务事实。将事件溯源流直接暴露,则使消费方依赖于生产方可能需要拆分、合并或重构的记录。两者都可以作为内部来源,从中派生出真正的集成事件。
当消费方需要更多数据 消费方在响应事实时,往往需要生产方拥有的数据。常见的做法有三种,各自在自治性与耦合度之间有不同的权衡。 事件携带状态转移是一种方式,每个事件包含消费方响应事实所需的生产方数据。消费方无需调用生产方即可采取行动,代价是负载较大且数据存在冗余。例如订单准备履行的事件携带商品信息,使得履行上下文无需再行查询。 轻量通知加回调则是另一种方式,事件仅携带标识符,消费方通过生产方的公共接口查询细节。
这保持了较小的负载,但引入了运行时依赖,且返回的数据可能与事件发生时已有所不同。它适用于多个消费方各自需要不同数据的场景,避免单一负载因取悦所有方而膨胀。 本地副本是第三种方式,消费方通过处理来自生产方的公共集成事件流构建读模型,例如维护本地的商品表,以便在处理订单时查询重量与尺寸而无需调用商品上下文。这使得消费方能够查询数据而不依赖生产方的可用性,但需要运维额外的存储并容忍复制延迟。 无论选择哪种方式,通知并不转移数据的所有权。生产方仍然是其数据的权威来源。
边缘处的翻译 从内部事实到公共契约的翻译,应当发生在边界处,而非聚合内部。应用层或基础设施层负责将领域事件映射为集成事件,聚合本身对集成契约保持无知,仅发布领域事件。某些领域事件可能不会产生任何集成事件,因为该事实无需离开限界上下文。单个领域事件也可能派生出多个集成事件,以满足不同消费方的需求。 这一翻译过程本身是朴素的:映射、扁平化、分配序列号,然后交由发布机制处理。无需在此处进行业务决策,因为事实已在聚合发布领域事件时被决定。
这种简洁正是保持领域模型免于传输关注点污染的关键。 在保存与发布之间幸存 设计好集成事件后,仍需解决交付问题。在订单保存之后、消息发布之前的瞬间,进程崩溃,事件便永远无法离开系统。生产方认为订单已下达,消费方却无从知晓,客户等待着无人准备的包裹。 若改为先发布后保存,则可能发布一个并不存在的订单事件。两个系统无法被包裹在同一个原子事务中,这是问题的根源。
可行的方案是将领域变更与发布意图提交至同一个数据库事务,这便是事务性发件箱模式的核心思想。 发件箱模式不再直接发送消息,而是将消息序列化至与订单同库的表中。订单与其发布意图随之一起提交或回滚。一个独立的发布器轮询待发消息,将其发送,并在消息代理确认后标记为已发布。 消息到达两次 发件箱确保事件不被丢失,却无法阻止其到达两次。发布器可能在标记已发布之前崩溃,重启后再次发送同一条消息。
消费方一侧也存在类似窗口:处理器提交业务变更后丢失确认,导致代理重新投递。这被称为至少一次投递。 因此消费方需要幂等处理,即重复处理同一条消息与处理一次具有相同的业务效果。某些操作天然幂等,例如将发货地址设为特定值。但增加计数、预留库存或扣款通常并不如此。 对于不幂等的操作,显然需要记录已处理的消息。
关键在于这一记忆位于何处,以及在检查之后崩溃会如何。消费方可以使用事件的消息标识作为去重键,维护一个收件箱或简单的已处理标识表,并在与业务变更同一个事务中记录该标识。 若处理失败,事务回滚,消息可被重试。若持续失败,则应转移至死信队列或等价的失败存储,并附带足够的诊断与受控重放所需的上下文。若处理成功但确认丢失,下次投递将发现已记录的标识而不再执行。 一个容易出错的细节是:业务变更与已处理消息记录必须原子提交。
若置于不同事务中,两者之间的崩溃将重现生产方刚刚用发件箱解决的双写问题。在已处理消息标识上建立唯一约束,也可防止两个消费实例并发接收重复消息。 恰好一次地向客户收费 收件箱技巧之所以有效,是因为业务变更与已处理消息记录是针对同一数据库的两次写入,单个事务即可覆盖两者。一旦消费方的事件处理器在数据库之外执行动作,这一保证便消失。例如,准备订单的同时调用支付提供商扣款。该调用不属于事务,我们再次面临协调两个独立系统的问题。
收件箱去重保护的是消费方自身的数据库,无法阻止重新投递的消息再次调用支付提供商。处理器看到未处理的消息标识,扣款后崩溃,且未记录为已处理。重新投递后,再次扣款,设计缺口变成了退款工单。 支付提供商需要自身的保护。消费方随调用发送幂等键,即一个稳定的标识符,可以简单地使用消息标识,使提供商能够识别重复请求并仅扣款一次。一道关卡守护入口,另一道关卡守护出口。
为使调用决策同样持久,消费方将外部调用记录为自身发件箱中的命令,并与业务变更在同一事务中提交。之后独立步骤排空发件箱并执行调用,延续生产方对代理的边界处理。 发件箱确保调用永不丢失,幂等键确保调用永不重复。这自然要求支付提供商自身支持幂等键。 取消先于下单到达 消息也可能乱序到达。履行上下文并不关心不同订单的消息顺序,但对于同一订单的事件而言,顺序至关重要。
例如,序列号为五的订单取消事件可能早于序列号为四的延迟订单准备履行事件到达。处理较早的事件将令仓库工作人员拣货打包一个客户几分钟前已取消的订单。 将订单编号用作代理的分区键或会话键,有助于保持同一订单的投递顺序。生产方分配的序列号则提供了第二道保障:记录序列号五之后,履行上下文可忽略序列号四,将其视为陈旧消息。去重防止单条消息被执行两次,序列检查则防止较早的消息撤销较新的事实。 当集成事件并不合适 集成事件适用于上下文能够发布事实并在不等待响应的情况下完成工作的场景。
当上下文需要即时答复才能继续时,直接的接口调用通常使依赖关系更为清晰。 通过代理发送请求并不会将其变为事件。指示特定接收方执行动作的消息是命令,请求信息的消息是查询。若发送方期待回复,则交互仍是请求响应。前述的回调便是这样的查询:它由事件触发,却不属于事件契约。 消息机制虽具吸引力,却并不能自动解耦系统,且引入了大量需要思考的情形:发件箱、收件箱、序列检查。
当上下文确实需要独立运作,或多个消费方需要同一事实时,消息机制物有所值。否则,直接调用或许更为简洁。 集成事件使限界上下文能够共享业务事实,而无需暴露其内部模型。这种独立性依赖于边界两侧的共同努力:生产方必须可靠地发布契约,消费方必须容忍重复、延迟与乱序。当这些权衡被合理接受时,集成事件为上下文的独立演进与运行提供了持久而坚实的基础。 。