事件溯源不是存日志——你写的伪 Event Sourcing 迟早会把系统拖垮

0 阅读1分钟

事件溯源不是存日志——你写的"伪 Event Sourcing"迟早会把系统拖垮

很多团队号称用了 Event Sourcing,实际做的就是把操作日志存了一张表,然后查询还是走状态库。这不叫事件溯源,这叫写了个审计日志。

真正的 Event Sourcing 是把状态库彻底去掉,所有查询都从事件流里重建状态。听起来很酷,但落地的时候,每个环节都能踩坑。

从 CRUD 到 Event Sourcing 的本质转变

CRUD 的数据模型是"当前状态"。你有一张 orders 表,里面有 order_status、total_amount、updated_at——这是某个时刻的快照,历史状态被覆盖了。

Event Sourcing 的数据模型是"事件序列"。同一笔订单,你存的是:

OrderCreated {orderId: "A001", items: [...], total: 500}
PaymentReceived {orderId: "A001", amount: 500}
ItemShipped {orderId: "A001", trackingNo: "SF123"}

没有 status 字段。想查订单当前状态?从第一个事件开始,逐个应用,重建出当前状态。这就是"溯源"的含义——状态不是存出来的,是算出来的。

这个转变不是换了个存储方式,是换了整个数据哲学。CRUD 的查询天然高效(直接读库),Event Sourcing 的查询天然低效(要回放事件流)。你选择的是用写性能换读性能,还是反过来——这取决于你的业务特征。

四个工程化坑,每个都是生产事故级别

坑一:事件版本升级——你以为加个字段很简单

第一个版本的事件叫 OrderCreated,有 orderId 和 items 两个字段。半年后业务要加 shippingAddress。

CRUD 里你加个列就完了。Event Sourcing 里你不能改老事件——因为老事件已经存了成千上万条,改结构意味着要么全量迁移要么写兼容层。

工程上的做法是"事件版本化":

@JsonTypeInfo(use = Id.NAME, include = Property.PROPERTY, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = OrderCreatedV1.class, name = "OrderCreatedV1"),
    @JsonSubTypes.Type(value = OrderCreatedV2.class, name = "OrderCreatedV2")
})
public interface OrderEvent {}

V1 和 V2 是两个类,反序列化的时候按 type 字段区分。重建状态时,V1 事件用 V1 的 handler 处理,V2 用 V2 的。这意味着每个事件类型的每个版本都要有一个 handler,你的事件处理逻辑会随时间膨胀。

这不是理论问题。我见过一个项目,两年下来有 17 个事件类型,每个类型平均 3 个版本,handler 类写了 51 个——光是维护版本兼容就占了一个开发的人力。

坑二:快照策略——不做快照,回放 10 万条事件要 30 秒

事件溯源的查询性能依赖于"回放速度"。一笔订单从创建到完成,事件流可能有 5-10 条,回放毫秒级。但一个用户账户,5 年下来可能攒了 10 万条事件,回放要几十秒。

工程上的解法是"定期快照":每隔 N 条事件,把当前状态存一份快照。查询时先加载最近一份快照,再从快照之后的事件开始回放。

public AccountState reconstruct(String accountId) {
    Snapshot snapshot = snapshotRepo.findLatest(accountId);
    long fromVersion = snapshot != null ? snapshot.getVersion() : 0;
    AccountState state = snapshot != null ? snapshot.getState() : AccountState.initial();
    
    List<AccountEvent> events = eventStore.loadFrom(accountId, fromVersion);
    for (AccountEvent event : events) {
        state = state.apply(event);
    }
    return state;
}

快照的坑在于频率选择。太频繁(每 10 条事件一次)会浪费存储;太稀疏(每 1 万条一次)回放还是慢。而且快照本身也占存储,一个账户可能存了 50 份快照,每份几十 KB——存储成本是纯 CRUD 的 5-10 倍。

还有一个坑:快照跟事件版本升级的冲突。你存了一份 V1 时期的快照,后来事件升级到 V2 了,这份快照还能用吗?如果 V2 的状态类结构变了,快照得重新生成。所以快照版本号和事件版本号要联动管理。

坑三:查询——不是所有查询都能回放

"查某笔订单的当前状态"没问题,回放 10 条事件就行。"查所有未付款订单"怎么办?回放所有订单的事件流,过滤出 status=UNPAID 的?一万个订单要回放一万个事件流,这查询跑完得几分钟。

工程上的做法是 CQRS——写侧走事件溯源,读侧走专门的查询视图。事件写入后异步投影到读模型:

@EventHandler
public void on(OrderCreated event) {
    readModelRepo.save(new OrderView(
        event.getOrderId(),
        "CREATED",
        event.getTotal(),
        event.getCreatedAt()
    ));
}

读模型就是一张普通的关系表,查询直接走 SQL。写模型还是事件流,保证写侧的完整溯源能力。

但 CQRS 带来了数据一致性问题。读模型的更新是异步的,写入事件后读模型可能有几百毫秒的延迟。用户刚提交了订单,立刻查,可能查不到——"我刚下的订单怎么不见了?"

这个延迟在业务上能不能接受,决定了你能不能用 Event Sourcing + CQRS。如果业务要求"写完立刻读",要么用同步投影(写事件和投影在同一事务里),要么直接放弃 Event Sourcing 用 CRUD。

坑四:调试——事件流让问题追踪变成了拼图游戏

CRUD 的调试很直接:查状态库,看当前值,看 updated_at 时间线,问题一目了然。

Event Sourcing 的调试要"拼图"。一笔订单的状态异常,你得从事件流里逐条回放,看哪个事件把状态推到了错误方向。如果事件流有 20 条,你得读 20 条事件的完整 payload,在脑子里(或日志里)逐步重建状态变化,定位到第 14 条事件是问题根源。

我遇到过一次生产事故:一笔退款操作把订单状态推成了负数余额。CRUD 场景下查一下余额字段和最近一条 update 记录就能定位;Event Sourcing 场景下,我得回放 47 条事件,发现第 31 条 RefundApplied 事件的 amount 字段是负数——但这个负数是上游系统传入的,事件溯源本身只是忠实记录了错误输入。

这不是 Event Sourcing 的 bug,但它让调试成本翻了数倍。工程上的缓解手段是给关键事件加审计日志和校验规则,但这又引入了新的复杂度。

什么时候不该用 Event Sourcing

不是所有场景都适合事件溯源。三个明确的反信号:

  1. 查询为主、写操作简单。如果你的系统 90% 是查询,10% 是写入,而且写入逻辑很简单(改个状态字段),Event Sourcing 的收益为零,代价翻倍。CRUD 直接读库就是最优解。

  2. 没有合规溯源需求。金融、保险、医疗这些领域,监管要求你必须保留完整操作历史,这是 Event Sourcing 的天然场景。但一个内容管理系统、一个配置管理后台——没有合规溯源需求,用 Event Sourcing 是过度工程化。

  3. 团队没有 CQRS 和异步投影的经验。Event Sourcing 必须搭配 CQRS,CQRS 必须处理读模型延迟。如果你的团队对异步一致性没有经验,上线后"用户查不到刚写入的数据"会变成日常投诉。

Event Sourcing vs Outbox——别搞混了

Outbox 模式解决的是"写库和发消息的一致性"问题。你在同一个事务里写业务数据和 outbox 表,然后由独立进程把 outbox 表里的记录发到 MQ。这是分布式消息可靠投递的方案。

Event Sourcing 解决的是"状态的完整溯源能力"问题。你的状态不是存出来的,是从事件流里算出来的。这是数据哲学层面的转变。

两者经常被混在一起讨论,因为它们都涉及"事件/消息表"。但 Outbox 的表是投递队列,消费完就删或标记已处理;Event Sourcing 的事件表是永久存储,不能删,因为删了就丢失了溯源能力。

如果你只是想让消息投递可靠,用 Outbox。如果你需要完整的状态溯源,用 Event Sourcing。两者可以组合使用,但各有各的职责边界。

落地三条底线

  1. 事件不可变原则:一旦写入事件流,就不能修改或删除。哪怕事件内容有错误,也是通过追加补偿事件来修正,不是原地修改。这保证了溯源链的完整性。

  2. 快照必须版本化:快照的版本号要和事件版本号联动,升级事件结构时要能正确处理旧版快照。

  3. 读模型投影必须是幂等的:同一个事件被投影两次,读模型的结果必须一致。否则异步重试会导致数据不一致。

我在做一个用卡皮巴拉讲设计模式的微信小程序「爪爪代码冒险记」,23 个设计模式用漫画加答题的方式讲,目前正在开发中。如果你觉得这类内容有意思,搜一下「爪爪代码冒险记」,或者等我后面的文章。

最后一句

Event Sourcing 是一种数据哲学选择,不是一种存储技术升级。你选择的是"状态可溯源"的收益,代价是查询复杂度、调试成本、存储膨胀和版本兼容的持续维护。如果你的业务确实需要完整溯源,这笔代价值得付;如果不是,CRUD 就是最优解——别为了酷用 Event Sourcing。