前言
上一篇我们讲了聚合。一个核心原则是“一次事务只改一个聚合”。但现实里很多业务天然就涉及多个聚合。
比如“作业提交”:提交动作改了作业聚合,但批改完成、通知学员、更新成绩……这些动作又涉及学习聚合、通知聚合、成绩聚合。
怎么让这些跨聚合的动作不破坏一致性原则,同时还能顺畅协作?
答案就是领域事件。
一、什么是领域事件?
领域事件(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); // 同步调用
}
}
问题:
- 强耦合:HomeworkService 知道所有下游服务。
- 同步阻塞:任意下游慢,整个提交都慢。
- 事务问题:下游调用失败,整个事务回滚,作业反而没提交成功。
- 难以扩展:新增一个下游就要改 HomeworkService。
用事件的写法:
// ✅ 正确:发布事件
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());
}
}
好处:
- 解耦:HomeworkService 只发布事件,不知道谁会处理。
- 异步:批改可以异步进行,作业提交秒级响应。
- 可扩展:新增订阅方不用改 HomeworkService。
- 故障隔离:批改服务挂了,作业提交照常成功。
五、聚合内事件 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 | 轻量级、性能要求高 |
经验法则:
- 普通业务用 Kafka 即可。
- 强事务需求(金融)用 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:所有跨服务调用都用事件
事件机制虽然好,但不适用于所有场景。
- 查询类操作:用同步 API 调用,不要用事件。
- 强事务场景:用分布式事务(Seata 等),不要强行事件。
- 实时性要求极高的场景:事件可能延迟 ms~s 级,不适合。
误区 2:事件总线过度使用
微服务内的事件总线要谨慎。如果聚合之间需要强一致,直接调应用服务即可。引入事件总线会增加开发复杂度。
误区 3:忘记幂等
幂等是事件机制的生命线。没有幂等,系统在重试、消息堆积恢复时一定出问题。
误区 4:事件粒度太细
// ❌ 错误:粒度太细
class StudentNameChangedEvent { }
class StudentEmailChangedEvent { }
// ✅ 正确:业务级粒度
class StudentInfoUpdatedEvent {
final StudentId id;
final Map<String, Object> changedFields; // 包含所有变更
}
粒度太细会让事件数量爆炸,增加复杂度。
总结
这一篇我们讲了:
- 领域事件是已发生的事实,用过去时命名。
- 事件机制的核心是解耦、异步、最终一致。
- 事件持久化在同一事务内,发布可以异步。
- 幂等消费是必须的。
- 跨服务用消息中间件,聚合内用进程内总线。
下一篇我们讲DDD 分层架构——一个完整的 DDD 项目,代码应该怎么分层。