事件溯源模式:让系统拥有“时光机”

事件溯源(Event Sourcing),说白了就是——不存当前状态,只存发生过的事

我第一次接触这个模式是在一个金融交易系统里。当时有个需求:要能回溯任意时间点的账户余额。用传统方式?你得建一堆快照表,还得考虑数据一致性,麻烦得很。后来用了事件溯源,嗯,整个世界清静了。

核心思想:状态 = 事件的累积

传统CRUD模式下,我们直接保存对象的当前状态:

// 传统方式:直接更新余额
UPDATE account SET balance = 100 WHERE id = 1;

事件溯源的做法完全不同:

// 事件溯源:记录发生了什么
INSERT INTO events (aggregate_id, event_type, data, version) 
VALUES (1, 'AccountCreated', '{"owner":"张三"}', 1);

INSERT INTO events (aggregate_id, event_type, data, version) 
VALUES (1, 'MoneyDeposited', '{"amount":100}', 2);

你看,我们不直接改余额,而是记录「张三开了账户」和「存了100块」。要查余额?把事件从头到尾回放一遍就行。

核心公式: 当前状态 = 初始状态 + 所有已发生事件的累积效果

三个关键组件

事件溯源系统里,有三个东西你得搞明白:

1. 事件存储(Event Store)

这不是普通的数据库。它是个只追加(append-only)的存储。我习惯用专门的Event Store数据库,或者用PostgreSQL + 序列化JSON字段也能凑合。

事件存储要保证:

  • 事件一旦写入,就不能修改(不可变性)
  • 按聚合ID和版本号有序存储
  • 支持按聚合ID读取全部事件

2. 事件回放(Event Replay)

回放就是把存储的事件重新执行一遍。你想想看,这就像看电影——把录像带从头播到尾。

// 伪代码:事件回放
Account account = new Account();
for (Event event : eventStore.loadEvents(accountId)) {
    account.apply(event);  // 逐个应用事件
}
return account;

我在项目中遇到过一个问题:事件量太大,每次都从头回放太慢。后来加了快照(Snapshot)机制——每100个事件存一次中间状态,回放时从最近的快照开始。

3. 聚合重建(Aggregate Reconstruction)

聚合(Aggregate)是DDD里的概念,你可以理解为一个业务实体。重建聚合就是通过事件回放,把对象恢复到某个时间点的状态。

我的建议: 聚合重建时,一定要保证事件的顺序正确。我曾经因为并发问题导致事件乱序,结果余额算出来是负数……排查了两天才找到原因。

SVG:事件溯源核心流程

事件溯源核心流程 用户命令 聚合(Aggregate) 事件存储 命令处理 生成事件 事件回放 读取事件 重建状态 快照存储 定期快照 查询模型 事件投影 命令流 事件流 回放流

Axon框架实践

理论说完了,咱们看看实战。Axon是Java生态里最成熟的事件溯源框架。我用了好几年,确实省了不少事。

定义聚合

@Aggregate
public class AccountAggregate {

    @AggregateIdentifier
    private String accountId;
    private int balance;

    // 命令处理器:处理"创建账户"命令
    @CommandHandler
    public AccountAggregate(CreateAccountCommand cmd) {
        // 验证业务规则
        if (cmd.getInitialBalance() < 0) {
            throw new IllegalArgumentException("初始余额不能为负");
        }
        // 发布事件
        apply(new AccountCreatedEvent(cmd.getAccountId(), cmd.getOwner(), cmd.getInitialBalance()));
    }

    // 事件源处理器:处理"账户创建"事件
    @EventSourcingHandler
    public void on(AccountCreatedEvent event) {
        this.accountId = event.getAccountId();
        this.balance = event.getInitialBalance();
    }

    // 命令处理器:处理"存款"命令
    @CommandHandler
    public void handle(DepositMoneyCommand cmd) {
        apply(new MoneyDepositedEvent(cmd.getAccountId(), cmd.getAmount()));
    }

    // 事件源处理器:处理"存款"事件
    @EventSourcingHandler
    public void on(MoneyDepositedEvent event) {
        this.balance += event.getAmount();
    }
}

你看,Axon用注解就把聚合、命令、事件串起来了。@CommandHandler处理命令,@EventSourcingHandler更新状态——职责分明。

事件存储配置

// Spring Boot配置
@Configuration
public class AxonConfig {

    @Bean
    public EventStorageEngine eventStorageEngine(EntityManager entityManager) {
        // 使用JPA作为事件存储
        return JpaEventStorageEngine.builder()
            .entityManagerProvider(new SimpleEntityManagerProvider(entityManager))
            .build();
    }

    @Bean
    public SnapshotTriggerDefinition snapshotTriggerDefinition(
            Snapshotter snapshotter) {
        // 每100个事件生成一次快照
        return new EventCountSnapshotTriggerDefinition(snapshotter, 100);
    }
}
注意: 快照频率不是越高越好。我见过有人每10个事件就做一次快照,结果存储开销比事件本身还大。一般100-500个事件做一次比较合理,具体要看你的业务场景。

事件溯源的优缺点

优势 劣势
完整的审计日志——谁在什么时候做了什么,一清二楚 学习曲线陡峭——团队需要转变思维方式
时间旅行——可以回溯到任意历史状态 事件版本管理复杂——事件结构变了怎么办?
读写分离天然支持——CQRS最佳搭档 查询性能依赖投影——不能直接查当前状态
高并发下无锁——只追加写入,没有更新冲突 存储量增长快——事件只增不减

避坑指南

我曾经踩过几个坑,分享给你:

  • 事件结构变更: 事件一旦发布就不能改。如果要加字段,用新版本事件类,老版本事件保留原样。Axon的@Revision注解可以帮你做版本映射。
  • 性能问题: 别把所有事件都放一个表里。按聚合类型分表,或者用专门的Event Store数据库(比如EventStoreDB)。
  • 分布式事务: 事件溯源和分布式事务天生不对付。我建议用最终一致性 + 幂等处理,别强求强一致性。
我的习惯: 事件命名用过去时态,比如AccountCreated、MoneyDeposited。这样读起来就像在讲一个故事——「账户被创建了,钱被存入了」。语义清晰,不容易混淆。

什么时候用事件溯源?

不是所有系统都适合。我个人觉得,以下场景可以考虑:

  • 需要完整审计日志的金融系统
  • 需要时间旅行调试的复杂业务
  • 事件驱动架构中的核心聚合
  • 需要高并发写入且无锁的场景

但如果你的系统只是简单的CRUD,用户管理、文章发布这种,别用事件溯源——杀鸡焉用牛刀。

事件溯源这个模式,说白了就是「用空间换时间,用事件换状态」。刚开始可能觉得别扭,但一旦用顺手了,你会发现它带来的审计能力和灵活性,是传统方式给不了的。

公众号:蓝海资料掘金营,微信 deep3321