DDD 领域事件实战——事件溯源不是银弹,这是我的真实经历
DDD 领域事件实战——事件溯源不是银弹,这是我的真实经历
适读人群:在项目中使用或考虑使用领域事件的工程师 | 阅读时长:约16分钟 | 核心价值:领域事件是解耦利器,但事件溯源是复杂度杀手——我用真实项目经历说清楚这两件事的边界
一次"完美方案"引发的噩梦
三年前,我在一个新项目上看到了事件溯源(Event Sourcing)的概念,当时觉得这是一个绝妙的设计:不存储当前状态,存储所有产生状态变化的事件,随时可以重放事件恢复状态,审计日志天然完备,时间旅行查询(查看某个时间点的状态)也成为可能。
我们的业务是一个财务对账系统,确实有强审计需求,所以我力主引入了 Event Sourcing。
六个月后,这个决策成了团队的技术债噩梦。
领域事件 vs 事件溯源
在讲我的经历之前,先把两个概念区分清楚,因为很多人把它们混淆了。
领域事件(Domain Event):业务上发生了某件事,其他部分对这件事感兴趣,通过事件通知来解耦。
// 一个典型的领域事件
public class OrderPaidEvent implements DomainEvent {
private final OrderId orderId;
private final Money amount;
private final LocalDateTime paidAt;
// 当订单支付成功时,发布这个事件
// 库存服务、物流服务、营销服务等可以独立订阅这个事件
}事件溯源(Event Sourcing):不存储当前状态,只存储事件序列,通过重放事件来得到当前状态。
// 事件溯源:Account 的状态通过重放所有事件计算得出
public class Account {
private Money balance;
private List<AccountEvent> uncommittedEvents = new ArrayList<>();
// 不直接修改状态,而是创建一个事件
public void credit(Money amount) {
apply(new MoneyDepositedEvent(this.id, amount));
}
private void apply(AccountEvent event) {
// 更新内存状态
if (event instanceof MoneyDepositedEvent e) {
this.balance = this.balance.add(e.getAmount());
}
// 记录待持久化的事件
uncommittedEvents.add(event);
}
}领域事件是普遍适用的模式,几乎所有复杂系统都应该用。
事件溯源是一个特殊的存储模式,适用场景非常有限。
领域事件怎么用
用领域事件解耦跨聚合、跨服务的业务逻辑:
场景:用户支付成功后,多个模块需要联动
不用领域事件:
// 应用服务里堆满了各种"支付后操作"
public void payOrder(OrderId orderId, PaymentMethod method) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.pay(method);
orderRepository.save(order);
// 以下这些代码应该在哪里?它们是订单服务的职责吗?
inventoryService.confirmDeduction(orderId);
loyaltyService.addPoints(order.getUserId(), order.getTotalAmount());
notificationService.sendPaymentConfirmation(order.getUserId());
promotionService.recordUsage(order.getCouponId());
}用领域事件:
// 应用服务只负责订单本身
public void payOrder(OrderId orderId, PaymentMethod method) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.pay(method); // 内部会发布 OrderPaidEvent
orderRepository.save(order);
// 领域事件随 save 一起发布(transactional outbox 模式)
}
// 各自订阅,各自处理
@DomainEventListener
public class InventoryEventHandler {
@EventHandler
public void on(OrderPaidEvent event) {
inventoryService.confirmDeduction(event.getOrderId());
}
}@DomainEventListener
public class LoyaltyEventHandler {
@EventHandler
public void on(OrderPaidEvent event) {
loyaltyService.addPoints(event.getUserId(), event.getAmount());
}
}这样,各个模块职责清晰,互不依赖,新增一个"支付后操作"只需要新增一个 EventHandler,不需要修改订单服务。
事件溯源:我踩过的坑
回到那个财务对账系统的故事。
坑一:简单查询变成了扫表重放
"查询某个账户的当前余额"这个操作,在事件溯源里需要把这个账户从成立到现在的所有事件全部重放一遍,才能得到当前余额。
账户存在 3 年,有 10 万条事件,查询一次要几百毫秒。
用快照(Snapshot)可以缓解,每 N 条事件打一个快照,查询时从最近的快照出发重放剩余事件。但这增加了额外的复杂度。
坑二:事件 Schema 演进是噩梦
你发布了一个事件 v1,6 个月后业务变了,这个事件需要增加一个字段 v2。
但历史上已经存储的 v1 事件怎么处理?它们没有新字段。你必须在代码里写兼容逻辑:
private void apply(MoneyDepositedEvent event) {
this.balance = this.balance.add(event.getAmount());
// v2 新增字段,历史事件没有这个字段,需要兼容
if (event.getCurrency() != null) {
this.currency = event.getCurrency();
} else {
this.currency = Currency.CNY; // 历史事件默认人民币
}
}听起来不难,但真实业务里,事件版本会演进很多次,兼容逻辑会越积越多,最终变成每个 apply 方法里都是版本检查,可读性极差。
坑三:删除数据成了 impossible
事件溯源的语义是"事件不可变",你只能追加新事件,不能修改或删除历史事件。
但 GDPR 合规要求:用户有权要求删除他的数据。
我们有用户发来了删除请求,法务说我们必须执行,但技术上我们没有办法从事件流里"删除"用户相关的事件——整个审计链会断掉。
最终用了一个变通方案:给用户数据加密,删除用户时删除加密密钥,让历史事件里的数据变得不可读。但这增加了额外的加密基础设施。
坑四:测试难度剧增
单元测试一个聚合,需要先构造一系列历史事件来建立初始状态,然后触发操作,再验证新产生的事件序列是否正确。
// 测试"取款不能透支"
@Test
void testWithdrawalCannotOverdraft() {
// 需要先重放历史事件建立初始状态
Account account = new Account();
account.apply(new AccountCreatedEvent(accountId, userId));
account.apply(new MoneyDepositedEvent(accountId, Money.of(100)));
// 然后测试操作
assertThrows(InsufficientFundsException.class, () -> {
account.withdraw(Money.of(200));
});
}而传统模型里,直接 account.setBalance(100) 就可以建立测试初始状态。
事件溯源适合什么场景
基于踩坑经验,我认为事件溯源适合的场景非常有限:
- 业务对完整审计历史有极强需求(金融监管、医疗记录)
- 需要时间旅行查询("这笔交易发生时,账户余额是多少")
- 事件本身有业务价值,不只是用来更新状态(CQRS + 实时数据流分析)
如果你的系统只是"想要审计日志",用 Outbox 模式把每次状态变更发到 Kafka 存一份就够了,不需要引入事件溯源。
领域事件的实现方案
不搞事件溯源,普通的领域事件应该怎么实现?推荐 Transactional Outbox 模式:
// Outbox 表结构
CREATE TABLE domain_event_outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(64) UNIQUE NOT NULL,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(200) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP,
status ENUM('PENDING', 'PUBLISHED') DEFAULT 'PENDING'
);这个方案确保领域事件和业务数据在同一个事务里,要么一起成功,要么一起失败,不会出现"业务成功但事件没发出去"的情况。
总结
- 领域事件:强烈推荐,是 DDD 里解耦的核心机制
- 事件溯源:谨慎评估,99% 的系统不需要,1% 的场景下它是最优解
不要因为看了几篇文章觉得 Event Sourcing 很酷,就在系统里全面推广。技术选型的代价往往要6个月后才能完全感受到。
