事件溯源实战——Event Sourcing 的真实收益和隐藏成本
事件溯源实战——Event Sourcing 的真实收益和隐藏成本
适读人群:对 Event Sourcing 感兴趣、或者正在评估是否引入的工程师 | 阅读时长:约16分钟 | 核心价值:Event Sourcing 有真实收益,但隐藏成本更多——我把两面都说清楚
我为什么写这篇文章
上一篇写了领域事件,很多读者在评论区问:你说事件溯源有坑,但我们公司的系统确实有强审计需求,是不是一定要用 Event Sourcing?
这个问题问得很好,说明大家没有简单地被我"劝退",而是在具体思考自己的场景。
我的答案是:不是一定要用,但有些场景它确实是最优解。这篇文章我把 Event Sourcing 的真实收益和隐藏成本都摆出来,让你自己判断。
Event Sourcing 的真实收益
收益一:完整的审计历史,零成本
传统系统要记录审计日志,通常需要专门开发:在关键操作前后记日志,维护日志表,处理日志的增删改查。
Event Sourcing 里,审计日志是系统的自然产物——所有改变状态的操作都以事件的形式存下来了,不需要额外开发。
// 查看某个订单的完整操作历史
List<OrderEvent> history = eventStore.loadEvents(orderId);
// 输出可能是:
// 2024-01-01 10:00:00 - OrderCreatedEvent: user=123, amount=500
// 2024-01-01 10:05:00 - PaymentConfirmedEvent: paymentId=pay-456, method=ALIPAY
// 2024-01-01 10:30:00 - ShipmentStartedEvent: trackingNo=SF123456789
// 2024-01-02 15:00:00 - OrderDeliveredEvent: signedBy=user123这个历史是完整且不可篡改的(事件一旦写入就不能修改),对于需要合规审计的业务(金融、医疗、合同)这是极其宝贵的能力。
收益二:时间旅行查询
可以重放到任意一个历史时刻,查看那个时刻的系统状态。
// 查看订单在某个时间点的状态(用于纠纷处理)
Order orderAtDispute = eventStore.replayUntil(orderId, disputeTime);
System.out.println("纠纷发生时,订单状态是:" + orderAtDispute.getStatus());
System.out.println("纠纷发生时,订单金额是:" + orderAtDispute.getAmount());在传统系统里,这需要你事先记录了所有历史状态变更,而且很难完整。
收益三:事件天然成为集成点
Event Sourcing 里,每个状态变化都是一个事件,这些事件可以被其他系统订阅,天然地支持了 EDA(事件驱动架构)。
不需要额外做"变更数据捕获"(CDC),事件本身就是变更记录。
收益四:可以重建任何投影
如果你发现某个查询视图的数据有问题,或者需要新增一种数据聚合方式,可以从头重放所有历史事件,重新构建新的投影(Read Model)。
// 新需求:统计每个用户的累计消费金额(之前没有这个统计)
// 不需要迁移数据,只需要从头重放事件,建一个新的投影
public class UserConsumptionProjection {
public void rebuild() {
// 遍历所有 OrderPaidEvent,累计到用户消费统计表里
eventStore.getAllEvents(OrderPaidEvent.class)
.forEach(event -> {
userConsumptionMapper.addAmount(event.getUserId(), event.getAmount());
});
}
}这个能力在传统系统里是不可能的——历史数据已经被 UPDATE 覆盖了,回不去了。
Event Sourcing 的隐藏成本
成本一:查询当前状态需要重放事件(性能问题)
这是最直接的成本。
传统系统查询当前状态:SELECT * FROM orders WHERE id = 123,一次 O(1) 的查询。
Event Sourcing 查询当前状态:加载这个聚合的所有历史事件,逐个重放,O(n) 的操作,n 是事件数量。
解决方案是快照(Snapshot):每 N 个事件打一个快照,查询时从最近快照出发重放剩余事件。
public Order load(OrderId orderId) {
// 加载最近的快照
Optional<OrderSnapshot> snapshot = snapshotStore.loadLatest(orderId);
Order order;
long fromVersion;
if (snapshot.isPresent()) {
order = snapshot.get().reconstruct(); // 从快照恢复
fromVersion = snapshot.get().getVersion() + 1;
} else {
order = new Order();
fromVersion = 0;
}
// 重放快照之后的增量事件
List<OrderEvent> events = eventStore.loadEvents(orderId, fromVersion);
events.forEach(order::apply);
return order;
}但快照引入了额外的存储和管理成本:什么时候打快照?快照过期了怎么办?快照和事件流不一致怎么处理?
成本二:事件 Schema 演进极其痛苦
业务变化时,老版本的事件和新版本的代码需要兼容。
// v1 事件
public class OrderCreatedEvent {
private String userId;
private BigDecimal amount;
}
// v2 事件(新增了 currency 字段)
public class OrderCreatedEvent {
private String userId;
private BigDecimal amount;
private String currency; // 新字段
}历史上存储的 v1 事件没有 currency 字段,重放时需要兼容:
// 聚合的 apply 方法需要处理版本兼容
private void apply(OrderCreatedEvent event) {
this.userId = event.getUserId();
this.amount = event.getAmount();
// 兼容 v1 事件
this.currency = event.getCurrency() != null ? event.getCurrency() : "CNY";
}这看起来简单,但真实业务里事件版本会演进很多次,每次都要写兼容代码。几年后,apply 方法里全是版本检查,可读性极差,没有人敢动。
这是 Event Sourcing 长期维护成本最高的地方。
成本三:删除数据几乎不可能
如前所述,GDPR/个人信息保护法 要求用户有权要求删除数据。
Event Sourcing 的设计原则是"事件不可变",这和"删除数据"是根本矛盾的。
解决方案:遗忘令牌(Forget-me Token)/ 加密抹除。
// 为每个用户生成独立的加密密钥
// 事件里的用户敏感数据用这个密钥加密存储
// 删除用户时,删除加密密钥
// 历史事件里的数据变成不可解密的密文,等同于删除
public class EventEncryptionService {
public String encryptUserData(String userId, String plainData) {
byte[] key = keyStore.getOrCreateKey(userId);
return AesGcm.encrypt(plainData, key);
}
public void forgetUser(String userId) {
keyStore.deleteKey(userId); // 删除密钥,历史事件数据变为乱码
}
}这个方案能解决问题,但引入了加密基础设施,复杂度大幅提升。
成本四:调试难度剧增
传统系统:看数据库里的当前状态,直观清晰。
Event Sourcing:要看当前状态,需要在脑子里(或者工具里)重放所有历史事件,推演出当前状态。
线上出了 Bug,数据状态异常,定位原因时,你需要找到哪个历史事件导致了异常,可能需要翻几百条事件记录。
Event Sourcing 适合什么场景
我的建议是,只有同时满足以下条件时,才考虑 Event Sourcing:
- 业务上有强合规审计需求(不只是"想留个日志")
- 需要时间旅行查询
- 团队有足够的技术能力和意愿承担长期维护成本
大多数系统,用 Outbox 模式 + 专门的审计日志表,就能满足需求,不需要 Event Sourcing。
如果必须用,有哪些实践建议
建议一:只对部分聚合用 Event Sourcing
不要全系统 Event Sourcing,只对真正需要完整历史的核心聚合(比如账户、合同、订单)使用,其他模块用普通方式。
建议二:事件版本化要从第一天开始
// 每个事件类必须带版本信息
@EventVersion(1) // 自定义注解
public class OrderCreatedEvent {
private final String schemaVersion = "1";
private String userId;
// ...
}从第一天开始建立版本化机制,否则后续演进会非常痛苦。
建议三:快照策略要根据业务定制
不是所有聚合都需要同样频率的快照。高频变更的聚合(比如账户余额)需要更频繁的快照;低频变更的聚合(比如合同)可以很少打快照。
最后
Event Sourcing 是一个有真实价值的模式,但它的价值只在特定场景下才能体现,而它的成本是全时间段都要承担的。
做技术决策前,问自己一个问题:三年后,这个系统的事件数量会是多少?届时维护它的工程师是否还能理解所有的事件版本兼容代码?
如果答案让你有点不确定,先不要引入 Event Sourcing。
