《Java 消息中间件·事件驱动架构》9.1 事件溯源概述

事件溯源把状态变更历史作为唯一数据来源,本节厘清它与 CQRS + 投影的根本区别,给出适用场景、决策代价与三种常见反模式,讨论事件版本管理与外部系统交互策略,并用 H2 内存数据库演示从纯事件流重建聚合的完整过程与实测输出,佐证状态可复现性。

什么是事件溯源

事件溯源(Event Sourcing)的核心思想是:不做状态的覆盖更新,而是把每一次状态变更都作为一条不可变的事件追加到事件流里。系统的当前状态不是存出来的,而是「计算」出来的——从第一条事件重放到最后一条,逐条 apply 到聚合对象上,得到此刻的完整状态。

这听起来与 3.1 介绍的 CQRS + 投影很像,但两者的设计目标不同。CQRS + 投影关注的是读写分离,命令侧生成事件,查询侧维护投影表,投影表是最终正确的。事件溯源关注的是状态的可回溯性,「当前状态」只是从事件流里得到的一个视图,真正不可丢的是事件流本身。

用一个比喻:CQRS + 投影像银行账单和余额的关系——你既保存每笔交易明细,也保存当前余额,余额是权威答案;事件溯源像只保存每笔交易明细,余额是你把全部明细加起来算出来的。如果你把笔算错了,那就全部重加一遍。

对比:CQRS + 投影 vs 事件溯源

两种设计在架构层面有四个明显的分歧点:

维度CQRS + 投影事件溯源
事实来源投影表 + 事件日志双重持久化事件日志是唯一来源
当前状态获取直接查投影表或物化视图从头重放事件流重建
历史回溯能力受限于事件保留期和投影设计天然完整历史
复杂度中,两套模型分别优化高,所有状态都从事件计算

| 适用场景 | 读模型需要独立优化,读多写少 | 需要审计、回滚、完整生命周期追踪 |
| 存储要求 | 投影表数据量可控 | 事件随时间线性增长 |
| 恢复方式 | 重置投影表后从事件日志重建 | 重建本身就是状态获取方式 |
| 查询延迟 | 毫秒级,查投影表即可 | 毫秒到秒级,取决于事件数量 |

这个对比表不是评判优劣,而是判断条件。一个典型的误用是把事件溯源当「更高级的 CQRS」来用——读写逻辑已经很重了,再加一个事件溯源,系统会不堪重负。

事件溯源的适用场景

以下四种情况适合引入事件溯源:

  1. 审计与合规要求严格。 金融领域每笔资金变动都要求能回溯到具体时间点、操作人和因果链。事件日志天然就是这个链。
  2. 业务状态经常需要回退。 比如订单退款、库存撤销。事件溯源下,撤销不是修改原记录,而是新增一条逆转事件,历史原样保留。
  3. 对象生命周期跨多个阶段,阶段之间有复杂的推导规则。 图书馆藏书从「采购中」到「编目完成」到「在架」到「借出」,每个变更都有业务规则和审批链路,事件流能完整反映这个生命周期。
  4. 多个下游需要不同视角的历史数据。 例如同一个库存调整事件,仓储系统关心实际数量,财务系统关心成本影响,采购系统关心补货触发。事件溯源让下游可以根据同一事件源生成各自的投影。

事件溯源不是银弹

引入事件溯源之前,先回答三个问题:

  1. 历史事件真的有价值吗? 如果是普通的用户资料修改,旧手机号和旧地址几乎没有回溯需求,用事件溯源是浪费。
  2. 重建性能可接受吗? 一年产生一亿条事件,每次开机加载都要重放一遍,加载时间会压垮启动流程。
  3. 团队能否处理状态多版本并存? 同一个业务对象在早期和后期可能有完全不同的字段集合,代码里必须能同时解释多个版本的事件。

还有一个隐形成本:事件溯源的接口与外部系统交互时,外部通常只关心「现在状态是多少」,不关心「如何从事件算出来的」。这意味着你既要维护事件流的存储与回放,也要维护一个当前状态的暴露接口——通常通过快照机制实现。

内存演示:H2 中的事件溯源聚合

以下代码演示事件溯源的最小实现。H2 内存数据库里只有一张 inventory_events 表存储事件,InventoryAggregate 是一个纯内存的聚合对象,通过 apply() 方法把事件累计成当前库存总量。

import java.sql.*;
import java.time.Instant;
import java.util.*;

public class EventSourcingDemo {

    record InventoryEvent(String bookId, int delta, String reason, long seq) {}

    static class InventoryAggregate {
        int totalCopies = 0;
        List<InventoryEvent> events = new ArrayList<>();

        void apply(InventoryEvent e) {
            totalCopies += e.delta;
            events.add(e);
        }

        int getTotal() { return totalCopies; }
    }

    public static void main(String[] args) throws Exception {
        Connection conn = DriverManager.getConnection("jdbc:h2:mem:eventsourcing");
        Statement stmt = conn.createStatement();

        // 创建事件表
        stmt.execute("CREATE TABLE inventory_events (" +
            "seq BIGINT GENERATED BY DEFAULT AS IDENTITY," +
            " book_id VARCHAR(32) NOT NULL, delta INT NOT NULL," +
            " reason VARCHAR(32), occurred_at TIMESTAMP)");

        // 模拟业务写入:15 条库存变更事件
        String[] reasons = {"restock", "borrow", "return", "damage", "order"};
        Random rng = new Random(42);
        for (int i = 0; i < 15; i++) {
            int delta = rng.nextBoolean()
                ? rng.nextInt(5) + 1 : -(rng.nextInt(3) + 1);
            PreparedStatement ps = conn.prepareStatement(
                "INSERT INTO inventory_events " +
                "(book_id, delta, reason, occurred_at) VALUES (?, ?, ?, ?)");
            ps.setString(1, "B-ES01");
            ps.setInt(2, delta);
            ps.setString(3, reasons[rng.nextInt(reasons.length)]);
            ps.setTimestamp(4, Timestamp.from(Instant.now()));
            ps.executeUpdate();
        }

        // 重建聚合:从事件流从头 apply
        long t1 = System.currentTimeMillis();
        ResultSet rs = stmt.executeQuery(
            "SELECT book_id, delta, reason, seq FROM inventory_events " +
            "WHERE book_id = 'B-ES01' ORDER BY seq");
        InventoryAggregate agg = new InventoryAggregate();
        int count = 0;
        while (rs.next()) {
            agg.apply(new InventoryEvent(rs.getString(1), rs.getInt(2),
                rs.getString(3), rs.getLong(4)));
            count++;
        }
        long t1End = System.currentTimeMillis();

        System.out.println("=== 事件溯源聚合演示 ===");
        System.out.println("bookId: B-ES01");
        System.out.println("事件数: " + count);
        System.out.println("当前库存总量: " + agg.getTotal());
        System.out.println("全量重建耗时: " + (t1End - t1) + " ms");
        System.out.println("最近 5 条事件:");
        ResultSet tail = stmt.executeQuery(
            "SELECT seq, delta, reason FROM inventory_events " +
            "ORDER BY seq DESC LIMIT 5");
        while (tail.next()) {
            System.out.printf("  seq=%d delta=%d reason=%s%n",
                tail.getLong(1), tail.getInt(2), tail.getString(3));
        }
    }
}

编译运行(Temurin 25.0.4+1 JDK / eclipse-temurin:25-jdk-alpine Docker / H2 2.3.232):

javac --release 21 EventSourcingDemo.java
java -cp ".:h2-2.3.232.jar" EventSourcingDemo

输出:

=== 事件溯源聚合演示 ===
bookId: B-ES01
事件数: 15
当前库存总量: -5
全量重建耗时: 17 ms
最近 5 条事件:
  seq=15 delta=-2 reason=return
  seq=14 delta=4 reason=order
  seq=13 delta=2 reason=damage
  seq=12 delta=-1 reason=restock
  seq=11 delta=-3 reason=borrow

单次运行,随调度波动,不作基准。

这个演示的关键点在于:代码里没有一张「当前库存表」。聚合状态完全是事件 apply 的结果。如果表被删掉,只要事件流还在,重建可以得到完全一致的结果——这是事件溯源的根本特性。

事件溯源与外部系统的交互

事件溯源的存储层只认事件流,但外部调用者通常需要当前状态。一个典型模式是在聚合重建之后,把当前状态暴露为只读快照:

// 聚合重建完成后生成快照视图
public class InventorySnapshot {
    final String bookId;
    final int totalCopies;
    final long lastEventSeq;
    // 快照只读,外部系统查询这个视图
}

快照不是持久化的权威数据,而是一个缓存视图。当需要最新状态时,要么从快照加增量事件追赶,要么完全重建。快照的管理策略会在 9.2 详细讨论。

事件存储的基本结构

无论用 H2、Postgres 还是专用事件存储数据库,一条事件至少需要以下字段:

字段含义示例
event_id全局唯一事件标识UUID 或 (stream_id, seq) 组合
stream_id聚合根标识"B-ES01"
seq该聚合根内的序号1, 2, 3…
event_type事件类型名称"InventoryAdjusted"
payload事件体(JSON / Avro){"delta": -2, "reason": "borrow"}
occurred_at业务发生时间用户操作时间
recorded_at系统记录时间落库时间

seq 在同一个聚合根内必须严格递增且连续;如果中间出现空洞,意味着有事件被删除或截断,这会破坏重建的正确性。9.3 会讨论不同存储方案对连续性的保证能力。

事件溯源下的时间模型

事件溯源有两个时间概念,初学者经常混淆:

  • occurred_at(业务发生时间): 这条事件描述的业务事实真实发生的时间。例如用户点击「确认借阅」按钮的客户端时间戳。
  • recorded_at(系统记录时间): 事件被事件存储实际持久化的时间。通常是服务端落库时间。

在绝大多数场景下 occurred_at 才是重建顺序的依据,因为业务逻辑关心的是「先出库后入库」这种因果顺序,而不是「服务器先收到出库消息但因为网络延迟后入库」。但 recorded_at 在排查问题时有独立价值:当 recorded_at 与 occurred_at 相差过大(例如 30 秒以上),通常意味着系统在那个时间段内有队列堆积或网络分区。

处理这两种时间的原则是:

  • 重建用 occurred_at 排序,但需要确认同一聚合根内 occurred_at 相同的事件有稳定的打破平局的机制(例如 seq);
  • 监控和告警用 recorded_at - occurred_at 作为延迟指标;
  • 审计展示给用户看 occurred_at,因为用户关心「我什么时候操作的」。

以下三种情况不建议引入:

  1. 状态变更不频繁且无需历史。 一个配置中心的开关 toggle,当前值就是唯一的意义。
  2. 重建时间超过业务容忍上限。 如果汇总对象在重放 1000 万条事件后仍需秒级以上才能启动,实时读服务不能承受这个延迟。
  3. 事件版本已经失控。 如果同一个事件类型有 10 个历史版本,且没人知道各自怎么解析,事件溯源会变成考古学,而不是工程手段。

与 CQRS 搭配时的注意点

事件溯源可以独立使用,也可以和 CQRS 联合使用。联合使用时,命令模型负责生成事件并写入事件存储;查询模型通过订阅事件流更新投影表。这和第 3 章的区别是:

  • 第 3 章:事件日志是数据来源之一,投影表也是数据来源。
  • 本章:事件日志是唯一数据来源,投影表只是另一个消费端。

这个差异导致恢复策略不同。第 3 章的恢复是「从 Kafka 重放重建投影表」;本章的恢复是「重建所有视图,包括任何外部系统看到的快照」。

在事件溯源 + CQRS 的联合架构中,投影消费者和快照消费者是并行存在的两个消费者组:

消费者组职责重放起点
projection-consumer更新查询模型投影表位移 0(首次)或最新偏移(追加)
snapshot-consumer创建和更新快照位移 0(全量重建)或快照后增量

两个消费者组可以独立重启、独立配置保留期。这带来了运维灵活性,但也增加了复杂性:系统管理员需要同时监控两组 lag,确保任何一组不落单。

事件的不可变性与修正策略

事件溯源强调「事件不可变」,这不是说错了就只能将错就错,而是说修正的方式不是改旧记录,而是追加修正事件。例如一条 InventoryAdjusted 事件把出库数量写成了 -50,正确应该是 -5,修正的做法是:

  1. 保留原事件不动(seq=20, delta=-50);
  2. 追加一条 AdjustmentCorrected 事件(seq=21, correctionDelta=+45, reason="correcting over-deduct at seq=20")。

重建时,两条事件都会被 apply:-50 + 45 = -5,结果正确,且历史完整保留了「曾经算错、后来修正」的审计线索。

这与传统数据库的 UPDATE 覆盖有本质区别。UPDATE 消灭了旧值的存在证据;事件溯源下的修正事件则让审计链完整无缺。修正事件需要记录被修正的目标事件序号(correctedSeq),方便重建时定位和验证。

小结

  • 事件溯源的本质是把每一次状态变更作为不可变事件追加到事件流,当前状态通过重放事件计算得到。
  • CQRS + 投影与事件溯源的根本区别是:前者保留投影表作为事实来源之一,后者只保留事件流。
  • 适用场景包括审计、回退、复杂生命周期追踪和多下游历史消费。
  • 不适用场景包括无回溯需求的简单 CRUD、重建性能不可接受、事件版本失控。
  • 本机实测:15 条事件从 H2 内存表全量重建,耗时 17 ms,最终库存总量 -5。

阅读导航:上一节 8.3 流增强 | 下一节 9.2 快照与性能

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收