跳至正文
来两杯美式
返回

DDD 复杂业务系统设计(六):领域事件,让“作业提交”自动触发批改进度

By 来两杯美式
发布于

前言

上一篇我们讲了聚合。一个核心原则是“一次事务只改一个聚合”。但现实里很多业务天然就涉及多个聚合

比如“作业提交”:提交动作改了作业聚合,但批改完成、通知学员、更新成绩……这些动作又涉及学习聚合、通知聚合、成绩聚合。

怎么让这些跨聚合的动作不破坏一致性原则,同时还能顺畅协作?

答案就是领域事件

一、什么是领域事件?

领域事件(Domain Event):领域中已经发生的事实,通常会导致进一步的业务操作。

关键点:领域事件是“已发生”的事实,不是“将要发生”的命令

举例:

它们都是过去时,表示某事已经发生

二、怎么识别领域事件?

一个简单的方法:在用户旅程和场景分析时,留意这些关键词

满足这些模式的事件,几乎都是领域事件

三、领域事件的关键特征

1. 不可变

事件已经发生,业务数据不再修改

class HomeworkSubmittedEvent {
    final HomeworkId homeworkId;
    final StudentId studentId;
    final LocalDateTime submittedAt;
    final String content;
    // 全部 final
}

2. 带业务属性

事件不仅要说明“发生了什么”,还要带上发生那一刻的业务数据,订阅方才能基于这些数据做下一步处理。

class HomeworkSubmittedEvent {
    final HomeworkId homeworkId;
    final StudentId studentId;
    final String submittedFileUrl;  // 提交的文件
    final LocalDateTime submittedAt; // 提交时间
}

3. 全局唯一标识

事件应该有全局唯一 ID,方便追踪和对账。

class HomeworkSubmittedEvent {
    final EventId eventId;  // 全局唯一
    // ...
}

四、为什么用事件,而不是直接调用?

直接调用的问题:

// ❌ 错误:直接调用导致强耦合
class HomeworkService {
    void submit(Homework hw) {
        homeworkRepo.save(hw);
        gradingService.autoGrade(hw);    // 同步调用
        notificationService.notify(hw);  // 同步调用
        achievementService.update(hw);  // 同步调用
    }
}

问题

用事件的写法:

// ✅ 正确:发布事件
class HomeworkService {
    void submit(Homework hw) {
        homeworkRepo.save(hw);
        eventBus.publish(new HomeworkSubmittedEvent(hw));  // 发布事件
    }
}

// 批改服务订阅
class AutoGradingEventHandler {
    @Subscribe
    void on(HomeworkSubmittedEvent e) {
        gradingService.autoGrade(e.getHomeworkId());
    }
}

// 通知服务订阅
class NotificationEventHandler {
    @Subscribe
    void on(HomeworkSubmittedEvent e) {
        notificationService.notify(e.getStudentId());
    }
}

好处

五、聚合内事件 vs 跨服务事件

领域事件发生的位置不同,处理方式也不同。

1. 聚合内事件(不太常见)

如果一个事件的发布方和订阅方在同一个进程、同一个微服务内,可以用进程内事件总线(如 Spring Event、Guava EventBus):

// 发布
@Service
class HomeworkService {
    @Autowired
    ApplicationEventPublisher publisher;

    void submit(Homework hw) {
        homeworkRepo.save(hw);
        publisher.publishEvent(new HomeworkSubmittedEvent(hw));
    }
}

// 订阅
@Component
class AutoGradingHandler {
    @EventListener
    @Async
    void on(HomeworkSubmittedEvent e) {
        gradingService.autoGrade(e.getHomeworkId());
    }
}

特点

2. 跨服务事件(最常见)

当事件需要跨微服务传递,必须用消息中间件(Kafka、RabbitMQ、RocketMQ 等):

Homework Service                          Grading Service
  │                                            │
  ├── save(hw)                                 │
  ├── publish(HomeworkSubmittedEvent)         │
  │     │                                      │
  │     └─→ [Message Broker] ──→  consume ──→ autoGrade
  │                                            │
  └── return success                  (异步处理)

特点

六、6 步事件运行机制

我用一个“作业提交”案例走一遍:

业务场景:学员提交作业后,自动触发批改 + 通知。

[步骤 1] 学员提交作业

[步骤 2] 作业服务保存作业(聚合内事务)

[步骤 3] 构建 HomeworkSubmittedEvent

[步骤 4] 持久化事件到本地事件表(与作业保存同一事务)

[步骤 5] 定时程序/CDC 把事件发到消息中间件

[步骤 6] 订阅方消费事件,各自处理

关键点事件持久化和业务持久化在同一个事务里

@Transactional
public void submit(Homework hw) {
    // 1. 保存作业
    homeworkRepo.save(hw);
    // 2. 构建事件
    HomeworkSubmittedEvent event = new HomeworkSubmittedEvent(hw);
    // 3. 持久化事件(同一事务)
    eventStore.save(event);
    // 4. 发送到消息中间件(不同事务,异步)
    messagePublisher.publish(event);
}

如果发送消息失败,事件已经持久化了,可以由定时程序扫描事件表重试。

七、事件持久化的两种方案

方案 1:本地事件表(推荐)

事件表和业务表在同一个数据库,事务保证一致性。

CREATE TABLE domain_event (
    id BIGINT PRIMARY KEY,
    event_type VARCHAR,
    payload JSON,
    created_at TIMESTAMP,
    published_at TIMESTAMP NULL  -- NULL = 未发布
);

定时程序扫描 published_at IS NULL 的记录,发到消息中间件后置为已发布。

优点:事务简单。

缺点:定时扫描有延迟(通常秒级)。

方案 2:基于事务日志的 CDC

通过 binlog 监听(如 Canal、Debezium)实时同步事件。

优点:实时性高。

缺点:架构复杂,需要额外的中间件。

八、领域事件 vs 应用事件 vs 系统事件

事件类型含义例子
领域事件业务领域中发生的事实作业已提交、订单已支付
应用事件应用层的状态变化用户登录失败、定时任务完成
系统事件技术层面的事件服务健康检查、API 限流

只有领域事件是 DDD 关心的。其他事件通常用监控/日志系统处理,不进领域模型。

九、消息中间件选型

不同 MQ 的特点:

MQ适用场景
Kafka高吞吐、日志流式处理、事件溯源
RabbitMQ传统消息队列、复杂路由
RocketMQ金融级可靠性、事务消息
Redis Stream轻量级、性能要求高

经验法则

十、一致性怎么保证?

事件机制是最终一致性,不是强一致。怎么确保不出问题?

1. 发送方 + 接收方都落库

发送方在发消息前把事件落库(业务事务内),接收方在处理前把事件落库(接收方事务内)。两边都有据可查

2. 定期对账

定时比对发送方和接收方的事件表,找出未送达或未处理的事件,重新发送。

3. 幂等消费

订阅方必须实现幂等——同一个事件可能被投递多次,处理结果不能因为重复处理而出错

@EventListener
void on(HomeworkSubmittedEvent e) {
    // 幂等检查:是否已经处理过
    if (processedEventRepo.exists(e.getEventId())) {
        return;  // 跳过
    }
    gradingService.autoGrade(e.getHomeworkId());
    processedEventRepo.save(e.getEventId());
}

十一、领域事件 vs 直接调用

什么时候用事件,什么时候用直接调用?

场景推荐方式
强一致、必须同步直接调用
解耦、异步、最终一致领域事件
跨服务、性能要求高领域事件
同一个微服务内的简单流程直接调用

经验法则能用事件就别直接调用。直接调用是“过去 SOA 时代”的思路,微服务时代默认是事件

十二、常见误区

误区 1:所有跨服务调用都用事件

事件机制虽然好,但不适用于所有场景

误区 2:事件总线过度使用

微服务内的事件总线要谨慎。如果聚合之间需要强一致,直接调应用服务即可。引入事件总线会增加开发复杂度。

误区 3:忘记幂等

幂等是事件机制的生命线。没有幂等,系统在重试、消息堆积恢复时一定出问题。

误区 4:事件粒度太细

// ❌ 错误:粒度太细
class StudentNameChangedEvent { }
class StudentEmailChangedEvent { }

// ✅ 正确:业务级粒度
class StudentInfoUpdatedEvent {
    final StudentId id;
    final Map<String, Object> changedFields;  // 包含所有变更
}

粒度太细会让事件数量爆炸,增加复杂度。

总结

这一篇我们讲了:

下一篇我们讲DDD 分层架构——一个完整的 DDD 项目,代码应该怎么分层。


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.DDD 复杂业务系统设计(一):为什么从“领域”开始,比从“数据库”开始更靠谱
  2. 02.DDD 复杂业务系统设计(二):领域与子域,把“学校/课程/学员”拆成三件事再说
  3. 03.DDD 复杂业务系统设计(三):限界上下文,为什么“课程”在教务和市场是两种东西
  4. 04.DDD 复杂业务系统设计(四):实体与值对象,把“学员”和“地址”放进代码的两种姿势
  5. 05.DDD 复杂业务系统设计(五):聚合与聚合根,一个“选课单”应该装多少东西
  6. 06.DDD 复杂业务系统设计(六):领域事件,让“作业提交”自动触发批改进度
  7. 07.DDD 复杂业务系统设计(七):分层架构,你的“业务代码”应该待在哪一层
  8. 08.DDD 复杂业务系统设计(八):整洁架构 vs 六边形架构 vs DDD 分层,我选哪一种
  9. 09.DDD 复杂业务系统设计(九):中台,在线教育平台到底应该共享什么
  10. 10.DDD 复杂业务系统设计(十):从战略到微服务,一张图跑通 DDD+中台+微服务
  11. 11.DDD 复杂业务系统设计(总结):从领域到微服务,一张图看懂 DDD 落地全流程

上一篇
设计模式之适配器模式:让不兼容的接口协同工作
下一篇
TCP 与 UDP:三次握手、四次挥手、SYN 攻击与选型