事件溯源(Event Sourcing)详解

银行转账系统里,账户余额是 10000 元。但这个数字是怎么来的?是昨天存了 5000,前天取了 2000,上周转入了 7000?光看当前余额,你无法知道资金的流动轨迹。传统的数据库存储方式是"记录当前状态"——只保存余额这个结果,丢失了过程。事件溯源(Event Sourcing)反其道而行之:它不存储账户余额,只存储交易流水;想知道余额,就把所有流水从头到尾累加一遍。听起来效率很低,但在某些场景下,这种"审计日志即数据"的思路,能解决传统方案无法解决的问题。

事件溯源完整流程

事件溯源的核心思想是把"状态变化"作为一等公民来存储。每次业务状态发生变化,就产生一个领域事件(Domain Event),这些事件被追加(Append)到事件存储(Event Store)中。查询状态时,通过"重放"(Replay)这些事件来重建当前状态。

flowchart TB
    subgraph 命令["命令处理"]
        CMD["CreateOrder\n下订单命令"]
        CH["命令处理器\nCommand Handler"]
    end

    subgraph 验证["业务规则验证"]
        AR["聚合根\nAggregate Root"]
        Rules["业务规则"]
    end

    subgraph 事件["事件生成与存储"]
        EVT["领域事件\nDomain Event"]
        ES["Event Store\nAppend-Only"]
    end

    subgraph 投影["查询模型重建"]
        PRJ["投影处理器\nProjection"]
        RM["读取模型\nRead Model"]
    end

    CMD --> CH
    CH --> AR
    AR --> Rules
    Rules --> EVT
    EVT --> ES
    ES --> PRJ
    PRJ --> RM

    ES -.->|"重放事件\n重建状态"| AR

完整流程是这样的:用户发起一个命令(如"创建订单"),命令处理器验证命令的合法性,如果验证通过,聚合根生成领域事件(如 OrderCreatedEvent),事件被追加到 Event Store。事件存储完成后,触发投影处理器异步构建各种查询视图。查询时,直接从查询视图读取,不需要重放事件。

Event Store 设计

Event Store 是事件溯源的核心组件,它本质上是一个只追加(Append-Only)的日志存储。与普通数据库的区别在于:它不是存储"当前状态",而是存储"状态变更序列"。

classDiagram
    class DomainEvent {
        <<interface>>
        +String eventId
        +String aggregateId
        +String eventType
        +LocalDateTime occurredOn
        +Object payload
    }

    class EventStream {
        +String streamId
        +int version
        +List~DomainEvent~ events
    }

    class EventStore {
        +append(streamId, events)
        +getEvents(streamId, fromVersion)
        +getEventsBetween(streamId, startTime, endTime)
    }

    EventStore --> EventStream
    EventStream --> DomainEvent

Event Store 的设计要点包括:

事件不可变性:事件一旦写入就不能修改。这是审计日志的本质要求,也是事件溯源的基石。如果业务规则发生变化,需要通过"事件升级"(Upcasting)来处理旧事件,而不是直接修改历史数据。

乐观并发控制:每个聚合根有一个版本号(Version),每次状态变更后版本号递增。当重放事件时,如果发现版本号不连续,说明中间有事件丢失,数据不一致。聚合根的版本号同时用于解决并发冲突——两个命令同时修改同一个聚合根,先提交的获胜,后提交的因版本号不匹配被拒绝。

public class EventStore {
    private final JdbcTemplate jdbcTemplate;

    public void append(String streamId, int expectedVersion, List<DomainEvent> events) {
        String sql = """
            INSERT INTO events (stream_id, version, event_type, payload, occurred_on)
            VALUES (?, ?, ?, ?, ?)
        """;

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                DomainEvent event = events.get(i);
                ps.setString(1, streamId);
                ps.setInt(2, expectedVersion + i + 1);
                ps.setString(3, event.getClass().getSimpleName());
                ps.setString(4, objectMapper.writeValueAsString(event));
                ps.setTimestamp(5, Timestamp.valueOf(event.getOccurredOn()));
            }

            @Override
            public int getBatchSize() {
                return events.size();
            }
        });
    }
}

public class AggregateRoot {
    protected String id;
    protected int version;

    protected void apply(DomainEvent event) {
        this.version++;
        // 状态更新逻辑
    }

    public void replay(List<DomainEvent> events) {
        for (DomainEvent event : events) {
            apply(event);
        }
    }
}

事件分块(Snapshots):如果聚合根的历史事件很多,每次重建状态都要从头重放所有事件,性能会急剧下降。解决方案是定期创建快照(Snapshot),保存聚合根在某个时间点的状态。恢复时先加载最新的快照,再从快照之后的第一个事件开始重放。

投影重建状态

投影(Projection)是从事件流中构建查询视图的过程。每个投影定义了如何将事件转换为查询模型中的一份数据。投影可以随时重新运行(Re-projection),从零构建出全新的查询视图,且不需要修改任何业务逻辑。

public class OrderProjection {
    private final JdbcTemplate jdbcTemplate;

    @EventHandler
    public void on(OrderCreatedEvent event) {
        String sql = """
            INSERT INTO order_read_model (order_id, customer_id, total_amount, status, created_at)
            VALUES (?, ?, ?, ?, ?)
        """;
        jdbcTemplate.update(sql,
            event.getOrderId(),
            event.getCustomerId(),
            event.getTotalAmount(),
            "CREATED",
            event.getOccurredOn()
        );
    }

    @EventHandler
    public void on(OrderPaidEvent event) {
        String sql = """
            UPDATE order_read_model SET status = 'PAID', paid_at = ?
            WHERE order_id = ?
        """;
        jdbcTemplate.update(sql, event.getOccurredOn(), event.getOrderId());
    }

    @EventHandler
    public void on(OrderCancelledEvent event) {
        String sql = """
            UPDATE order_read_model SET status = 'CANCELLED', cancelled_at = ?
            WHERE order_id = ?
        """;
        jdbcTemplate.update(sql, event.getOccurredOn(), event.getOrderId());
    }
}

即时投影 vs 快照:即时投影(Eager Projection)是指事件处理完成后立即更新查询模型;快照投影(Snapshot Projection)是指先缓存中间结果,积累到一定量后再批量更新。两者各有优劣:即时投影延迟低,但可能产生"部分更新"的中间状态;快照投影可以减少数据库写入次数,但增加了投影处理的复杂度。

零 downtime 迁移:当查询模型需要变更(如增加字段、调整结构)时,可以在不停服的情况下重建投影。具体做法是:先部署新投影处理器,同时运行新旧两个投影;旧投影继续处理新事件,新投影从头重放所有历史事件;新投影追上进度后,切换读取来源到新模型;旧投影下线。

CQRS 组合

事件溯源与 CQRS 是天然搭档。在 CQRS 架构中,命令端写入领域事件,事件存储作为单一真相来源;查询端通过投影构建各种读取视图。两者结合形成了完整的数据架构闭环:命令写入事件,事件驱动投影,投影支撑查询。

这种组合的优势包括:完整的审计追踪(每个状态变更都有记录)、灵活的多视图支持(同一份事件可以投影出不同的查询模型)、轻松实现时间旅行查询(查询任意时间点的状态)。代价是架构复杂度高,事件 Schema 变更需要谨慎处理。

关于 CQRS 的详细内容,可参考CQRS 数据读写分离

事件版本升级

随着业务演进,事件的 Payload 结构可能需要变更。例如早期的 OrderCreatedEvent 只有 orderIdamount,后来需要增加 customerIdshippingAddress。直接修改历史事件的 Payload 违反了事件不可变性原则——历史事件是不可变的。解决方案是使用 Upcasting(事件升级器)。

// 旧版本事件(已存储)
public class OrderCreatedEventV1 {
    private String orderId;
    private BigDecimal amount;
}

// 新版本事件(业务逻辑使用)
public class OrderCreatedEvent {
    private String orderId;
    private BigDecimal amount;
    private String customerId;
    private ShippingAddress shippingAddress;
}

// 事件升级器:将 V1 升级为当前版本
public class OrderCreatedEventUpcaster extends AbstractUpcaster {
    @Override
    public boolean canUpcast(String typeName, int version) {
        return "OrderCreatedEvent".equals(typeName) && version == 1;
    }

    @Override
    public DomainEvent doUpcast(DomainEvent event) {
        OrderCreatedEventV1 v1 = objectMapper.readValue(event.getPayload(), OrderCreatedEventV1.class);

        OrderCreatedEvent current = new OrderCreatedEvent();
        current.setOrderId(v1.getOrderId());
        current.setAmount(v1.getAmount());
        current.setCustomerId("UNKNOWN"); // 旧数据缺失,设为默认值
        current.setShippingAddress(null);

        return new UpgradedEvent(event, current, 2);
    }
}

事件升级器的注册顺序很重要:从旧版本逐步升级到当前版本。如果有 V1、V2、V3 三个版本,升级器链就是 V1→V2→V3。升级器链设计不当可能导致数据丢失或格式错误。

事件溯源的优缺点

优势:完整的审计追踪,每个状态变更都有记录,支持任意时间点回放和重投影;简化了领域模型,状态变更通过事件表达,聚合根不需要关心如何持久化;天然支持 CQRS,事件直接驱动投影;支持时间旅行查询,可以查看"三个月前的余额是多少"这类历史状态查询;松耦合,事件是接口,发布者和订阅者通过事件通信,互相不直接依赖。

缺点:学习曲线陡峭,团队需要理解事件溯源思维方式;事件 Schema 变更需要 Upcasting,增加了维护成本;查询灵活性受限,如果查询需求是临时性的,从事件重放可能很慢;事件存储的容量随时间线性增长,需要归档策略;调试复杂,重放事件来复现问题比直接查看当前状态更困难。

适用场景:审计日志要求极高的系统(如金融、订单、财务)、需要完整历史追踪的系统、需要支持时间旅行和状态回放的场景、需要灵活多视图的 CQRS 系统。

不适用场景:简单的 CRUD 应用、业务模型不稳定的早期项目、性能要求极高且无法接受事件重放延迟的场景。