事件总线下多模块业务原子性问题

事件总线下多模块业务原子性问题

#tech / dev / pattern #type / howto #status / growing

[!info] related notes

事件总线下多模块业务原子性问题


我有一个问题,在一个项目中,使用了 DDD 架构 然后有 reminder 模块 和 schedule 、notification模块。他们之间通过事件总线连接, 我要怎么测试 比如 启用一个 reminder 模块,此时应该要 创建一个 对应的 scheduleTask 实体对象(根据时间配置执行trrigger,触发 notification)。 所以 我要测试 reminder 提醒的 业务流 需要 跨越三个模块, 禁用 reminder 时,也要确保 scheduleTask 实体进入 disabled 状态 。 我在业务中要怎么确保业务完整性,或者怎么测试


这是一个非常经典的 DDD(领域驱动设计)与 EDA(事件驱动架构)相结合的场景。你的核心挑战在于:如何在解耦的模块之间,既保证业务流程的最终一致性,又能编写可靠的自动化测试。

由于模块间通过“事件总线”通信,这意味着业务流程是异步最终一致的。你不能像测试单体应用那样在一个事务里断言所有状态。

以下我将从 “架构设计保障”“测试策略实施” 两个维度为你解答。


一、 架构设计层面:如何确保业务完整性?

在测试之前,必须先确保架构本身能处理分布式/解耦带来的问题。

1. 事务性发件箱模式 (Transactional Outbox Pattern)

这是确保“Reminder 保存成功”与“事件发送成功”原子性的关键。

  • 问题: 如果 Reminder 存入数据库后,消息队列挂了,事件没发出去,Schedule 就永远不会创建任务。
  • 解决: 在 Reminder 的同一个数据库事务中,既保存 Reminder 实体,也将要发送的事件保存到一张 Outbox 表中。然后由一个独立的后台进程(Relay)读取 Outbox 表并推送到事件总线。

2. 幂等性 (Idempotency)

  • 问题: 假如消息中间件因为网络抖动重发了 ReminderEnabled 事件,Schedule 模块不能创建两个重复的任务。
  • 解决: Schedule 模块在消费消息时,必须检查唯一性。通常利用 reminder_id 作为幂等键(Idempotency Key)。如果数据库中已经存在对应 reminder_id 的 ScheduleTask,则忽略或更新,而不是新建。

3. 关联 ID (Correlation ID)

为了跨越三个模块追踪业务流,每个事件都必须携带一个 TraceIdCorrelationId。当 Reminder 触发时生成 ID,Schedule 和 Notification 处理时都透传这个 ID。这对于排查问题和链路追踪至关重要。


二、 测试策略:分层测试

针对你的跨模块流程,建议采用 “测试金字塔” 策略,重 Unit Test,轻 E2E。

1. 单元测试 (Unit Test) - 聚焦领域逻辑

不要在单元测试中测试跨模块交互。只测试当前上下文的职责。

  • Reminder Context:
    • 测试点: 调用 reminder.enable() 后,断言该实体不仅状态变为 Enabled,而且在其 domainEvents() 列表中包含了一个 ReminderEnabled 事件。
    • 不涉及: 此时不关心事件是否发出去,只关心实体是否产生了正确的事件数据。
  • Schedule Context:
    • 测试点: 编写一个 Handler 测试。输入一个构造好的 ReminderEnabled 事件对象,调用 Schedule 的 Application Service,断言 Schedule 数据库(或 Mock 的 Repository)中生成了对应的 ScheduleTask
    • 重点: 测试幂等性(输入两次同样的事件,只产生一条数据)。

2. 模块集成测试 (Module Integration Test) - 聚焦组件协作

这里使用内存级组件来模拟整个流程,速度快且稳定。如果你使用的是 Java (Spring Boot),可以使用 Spring Modulith 或简单的 ApplicationEventPublisher

  • 场景: 启用 Reminder 触发 Schedule。
  • 测试手段:
    1. 启动 Spring 上下文(包含 Reminder 和 Schedule 模块,使用 H2 或 TestContainers 数据库)。
    2. 使用内存事件总线(Mock 掉真实的 Kafka/RabbitMQ,改用内存传递)。
    3. Action: 调用 Reminder Service 的 enable(id) 方法。
    4. Wait: 因为是异步的,使用 Awaitility 库进行等待。
    5. Assert: 查询 Schedule 的 Repository,确认 Task 已创建。

代码示例 (伪代码/Java风格):

@Test
void shouldCreateScheduleTask_WhenReminderIsEnabled() {
    // 1. 准备数据
    var reminderId = createDisabledReminder();

    // 2. 执行动作 (在 Reminder 模块)
    reminderService.enable(reminderId);

    // 3. 异步断言 (跨越到 Schedule 模块)
    // 使用 Awaitility 等待,直到条件满足或超时
    await().atMost(5, SECONDS).untilAsserted(() -> {
        // 直接查询 Schedule 的数据库
        var tasks = scheduleRepository.findByReminderId(reminderId);
        assertThat(tasks).hasSize(1);
        assertThat(tasks.get(0).getStatus()).isEqualTo(TaskStatus.PENDING);
    });
}

3. 契约测试 (Consumer-Driven Contract Testing) - 防止接口破坏

  • 风险: Reminder 修改了事件结构(比如把 userId 改成了 operatorId),但 Schedule 没改,集成测试可能没覆盖到所有字段,上线后 Schedule 解析失败。
  • 解决: 使用 Pact 或 Spring Cloud Contract。Schedule 定义它需要什么样的事件格式(契约),Reminder 每次构建时都会验证它发出的事件是否符合这个契约。

更系统的整理可继续看 契约测试与 CDC

4. 端到端测试 (E2E) - 少量,覆盖核心路径

在类似生产的环境(连接真实的 MQ 和 DB)中跑通全流程。

  • 场景: 完整的“启用 -> 调度 -> 通知”流程。
  • 测试点: 用户 API 启用 Reminder,等待几秒,断言 Notification 模块记录了一条发送记录。
  • 注意: 这种测试维护成本高,仅覆盖“快乐路径”(Happy Path)。

三、 针对“禁用 Reminder”的具体测试设计

你提到的需求:“禁用 reminder 时,也要确保 scheduleTask 实体进入 disabled 状态”。

这是一个典型的最终一致性场景。

测试步骤设计:

  1. Given (前置条件):
    • 系统中已存在一个 Enabled 的 Reminder。
    • 系统中已存在一个对应的 Pending 状态的 ScheduleTask。
  2. When (动作):
    • 调用 reminderService.disable(reminderId)
  3. Then (断言):
    • 即时断言: Reminder 状态变为 Disabled。
    • 即时断言: Reminder 产生了一个 ReminderDisabled 事件。
    • 异步断言 (使用 Awaitility):
      • 查询 Schedule 模块:找到对应 reminderId 的 ScheduleTask。
      • 验证该 Task 的状态已变为 DISABLED (或者 CANCELLED)。

四、 总结建议

在 DDD 架构下,为了确保你描述的业务完整性,建议采取以下优先级的措施:

  1. 代码实现: 严格遵循 “发布-订阅” 模式,确保 Schedule 模块消费逻辑的幂等性
  2. 基础设施: 如果对可靠性要求极高,实现 Outbox Pattern
  3. 自动化测试: 重点编写 “模块间集成测试”(Integration Test)。不要过分依赖 E2E 测试。
    • 使用内存数据库 + 内存事件总线。
    • 使用 Awaitility 解决异步断言问题。
    • 直接验证数据库状态,而不是通过 API 查询(因为 API 可能也是最终一致的)。

你可以为我做的下一步: 如果你的项目是基于 Java/Spring 的,我可以为你提供一个具体的 Awaitility 结合 Spring Events 的集成测试代码模版,或者为你解释如何使用 Spring Modulith 自动验证模块依赖。你需要吗?

创建于 2025/1/1 更新于 2026/5/27