《Java 消息中间件·可靠消息与消费治理》7.3 事件溯源入门

只存当前状态,你能回答「现在是什么」,回答不了「为什么变成这样」。事件溯源把状态变更本身作为唯一事实来源,状态由重放推导。本篇用纯 JDK 实现最小事件存储,追加 8 个库存事件后从 0 重放得到 stock=9、reserved=12,再用 version=4 的快照加 4 条增量重放,两条路径结果逐字段相等;并实测乐观并发与重复 eventId 两种拒绝。

当前状态不是事实,变更才是

一张 book_stock 表里写着 stock=9, reserved=12。这个数字回答了「现在是多少」,但回答不了下面这些问题:

  • 9 是怎么来的?是入库 15 本、借出 6 本,还是入库 10 本、借出 1 本?
  • 三天前是多少?那次盘点调整有没有生效?
  • 某个消费者把状态算错了,能不能重算一遍?

关系型数据库的更新是「覆盖」:UPDATE book_stock SET stock = 9。旧值被覆盖的那一刻,历史就没了。你能看到的只有当前行,以及可能存在的审计表;审计表往往是事后补的,字段和语义都不完整。

事件溯源(Event Sourcing)换了一个存储模型:不存当前状态,存导致状态变化的每一个事件。当前状态是这些事件按顺序折叠出来的结果。事件是唯一事实来源;状态是派生视图,可以随时丢掉重算。

这个定义有两个直接推论:

  1. 写入是追加,不是覆盖。事件一旦写入不可修改,只能追加新事件来纠正。这天然适合只追加的日志,也天然可审计。
  2. 读取要么重放,要么查投影。重放保证正确性,投影保证性能;两者的一致性由事件流的顺序保证。

最小事件存储

实验用纯 JDK 实现,聚合是 BookStock,事件只有三种:入库、预留、释放。

record Event(String eventId, long version, String type, int qty) { }

record State(int stock, int reserved, long version) { }

事件存储的核心不是容器,而是两条约束:

synchronized long append(String streamId, long expectedVersion, Event event) {
    List<Event> events = streams.computeIfAbsent(streamId, k -> new ArrayList<>());
    long actual = events.isEmpty() ? 0 : events.get(events.size() - 1).version();
    if (actual != expectedVersion) {
        throw new OptimisticConcurrencyException(
                "expected version " + expectedVersion + " but actual " + actual);
    }
    if (!eventIds.add(event.eventId())) {
        throw new IllegalStateException("duplicate eventId " + event.eventId());
    }
    events.add(event);
    return event.version();
}
  • expectedVersion 是乐观并发控制。写者必须声明「我基于哪个版本做的决策」,存储端比对当前版本;不一致就拒绝,写者重新加载、重算、再试。数据库实现里这对应唯一约束 UNIQUE(stream_id, version),冲突时插入失败。
  • eventId 全局唯一。同一个事件被重复提交(网络重试、上游重放)时,第二次直接拒绝,不会写进两条。

追加 8 个事件,每一步都打印折叠后的状态:

[append] v=1 STOCK_ADDED qty=10 -> stock=10 reserved=0
[append] v=2 STOCK_RESERVED qty=3 -> stock=7 reserved=3
[append] v=3 STOCK_ADDED qty=5 -> stock=12 reserved=3
[append] v=4 STOCK_RESERVED qty=4 -> stock=8 reserved=7
[append] v=5 STOCK_RELEASED qty=2 -> stock=10 reserved=5
[append] v=6 STOCK_RESERVED qty=1 -> stock=9 reserved=6
[append] v=7 STOCK_ADDED qty=6 -> stock=15 reserved=6
[append] v=8 STOCK_RESERVED qty=6 -> stock=9 reserved=12

注意这里没有一条 UPDATE。库存从 10 变到 7、再变到 12,全部是追加事件后重新折叠出来的结果。

重放:把事件折叠成状态

折叠函数是事件溯源里唯一需要严格测试的纯函数:

static State apply(State state, Event event) {
    return switch (event.type()) {
        case "STOCK_ADDED" -> new State(state.stock() + event.qty(),
                state.reserved(), event.version());
        case "STOCK_RESERVED" -> new State(state.stock() - event.qty(),
                state.reserved() + event.qty(), event.version());
        case "STOCK_RELEASED" -> new State(state.stock() + event.qty(),
                state.reserved() - event.qty(), event.version());
        default -> throw new IllegalArgumentException("unknown type " + event.type());
    };
}

从 0 重放全部 8 条:

[replay-all] events=8 stock=9 reserved=12 lastVersion=8

也可以只重放到某个版本,回答「三天前是多少」:

[replay-to] version=3 stock=12 reserved=3

这两行输出就是事件溯源相对状态存储的核心优势:任意历史时点的状态都可以通过重放得到,不需要额外的审计表。对账、排查、监管取证、灰度回滚,用的都是同一条路径。

代价也很直接:事件越多,重放越慢。一个跑了三年的聚合可能有几十万个事件,每次加载都从头折叠不可接受。于是有了快照。

快照:加速,不是新的真相

快照是「某个版本之后的状态」的缓存。加载时先读快照,再从快照版本之后重放增量:

record Snapshot(long version, int stock, int reserved) { }

static State replayFrom(Snapshot snapshot, List<Event> tail) {
    State state = new State(snapshot.stock(), snapshot.reserved(), snapshot.version());
    for (Event e : tail) state = apply(state, e);
    return state;
}

在 version 4 处做一个快照(stock=8, reserved=7),再重放 5 到 8 这 4 条:

[snapshot] version=4 stock=8 reserved=7
[snapshot+tail] events=4 stock=9 reserved=12 lastVersion=8
[compare] replay-all equals snapshot+tail: true

两条路径的结果逐字段相等。这不是巧合,而是快照的正确性判据:快照只能记录事件边界上的状态,重放快照加尾部必须等价于从头重放。

快照的三个工程要点:

要点做错会怎样正确做法
快照必须带版本号不知道从哪条事件继续,重复或跳过快照与 lastVersion 一起原子写入
快照不能替代事件快照损坏后无法恢复快照可删除、可重建,事件永久保留
快照要与事件一致快照包含未提交事件的状态在事件追加成功之后生成,或从事务边界读

第三条最容易被忽略。如果快照线程在事件 A 提交前读到了 A 之后的状态,快照版本却记成 A 之前,重启后重放 A 会把 A 的效果算两次。安全做法是快照只读「已提交且版本号明确」的状态,或者干脆异步生成、生成失败就丢掉重来。

并发与幂等:追加是唯一的写入口

事件溯源的写路径只有一个动作:追加。所有并发控制都集中在这个动作上。本机测了两个拒绝场景。

两个写者基于同一版本

[concurrency] first append expected=8 -> v=9 OK
[concurrency] second append expected=8 -> REJECTED expected version 8 but actual 9

第一个写者成功追加 v9;第二个写者仍以为当前是 v8,被拒绝。它应该重新加载聚合并重放,得到最新状态,再决定这次业务是否还成立。这个「加载—决策—追加」的循环就是命令处理器的标准形态。

拒绝不是失败,而是并发冲突的正常处理。真正危险的是「先查再写」不加版本条件:两个写者都读到 v8,都判断库存足够,都追加 v9,其中一个必然覆盖或产生两个 v9。

同一个事件重复提交

[idempotent] replay same eventId es-3 -> duplicate eventId es-3
[idempotent] rejected=true events-now=8

eventId 唯一约束挡住了重复追加。这一点和消费端去重是同一个思路:重试可以发生,副作用只能一次。

在数据库里,这两条约束可以合成一个索引:

CREATE TABLE domain_event (
  stream_id  VARCHAR(64) NOT NULL,
  version    BIGINT      NOT NULL,
  event_id   VARCHAR(64) NOT NULL,
  type       VARCHAR(64) NOT NULL,
  payload    CLOB        NOT NULL,
  PRIMARY KEY (stream_id, version),
  UNIQUE (event_id)
);

追加就是一次 INSERT。成功即提交,冲突即重试。没有 UPDATE,也没有行锁竞争。

与 outbox 的区别

这两个模式经常被混为一谈,因为都涉及「事件」和「追加」。它们的定位完全不同:

维度Outbox事件溯源
解决的问题业务提交与消息发送的原子性状态如何存储与重建
事实来源业务表事件日志
事件的角色副作用,发给下游主存储,状态由它推导
读路径直接查业务表重放或查投影
写路径业务行 + outbox 行同事务只追加事件
失败模式重复发送并发冲突、事件 schema 演进
能否回答历史状态不能能

一个常见的误解是「用了事件溯源就不需要 outbox」。恰恰相反:事件溯源把事件存进了自己的库,但把事件发布到 Kafka 仍然是另一个系统,仍然有「追加成功、发送失败」的窗口。所以事件溯源的生产实现通常也要 outbox:在同一个事务里追加领域事件、写一条 outbox 记录,relay 再发到 Kafka。

事务 {
  INSERT domain_event(stream_id, version, event_id, type, payload)
  INSERT outbox(event_id, aggregate_id, event_type, payload)
}

这样既保留了事件日志作为事实来源,又保证了对外发布至少一次。7.1 的 relay 和 2.2 的消费端去重可以直接复用。

反过来,outbox 不要求事件溯源。大多数系统用「业务表 + outbox」就够了,不需要把状态存储改成事件日志。事件溯源的成本在下一节。

代价与适用边界

事件溯源不是免费的,下面这些成本在选型时必须摊开:

成本具体表现缓解手段
存储增长事件只追加,数据量随变更次数增长快照、冷热分层、按聚合归档
重放时间聚合事件多时加载慢定期快照 + 尾部重放
schema 演进事件结构改了,老事件还要能读事件版本号、向上转换(upcasting),见 8.2
删除需求用户要求删除个人数据,事件不能改加密擦除(crypto-shredding)、墓碑事件
最终一致投影落后于事件流版本化投影、重建、监控延迟
团队门槛调试从「查一行」变成「看一条流」事件目录、重放工具、可观测性

有三类聚合特别适合事件溯源:

  • 状态机复杂、审计要求高:借阅、订单、账户。每一次状态迁移都有业务含义,值得留痕。
  • 需要时间旅行:对账、风控回溯、按历史时点重算费用。
  • 天然由事件驱动:库存预留与释放、预约排队,本身就是事件序列。

反过来,下面这些场景不建议上事件溯源:

  • 简单的 CRUD 配置表,改了就改了,没有审计需求。
  • 读多写少、查询模式复杂,投影维护成本高于收益。
  • 团队没有事件建模经验,事件契约会迅速腐化,最后变成「把数据库行序列化进事件」。

事件溯源也不是「必须全站使用」。常见的落地方式是按聚合选择:借阅聚合用事件溯源,图书元数据用普通表。同一个系统里两种模型共存完全正常,边界画在聚合上,而不是画在服务上。

libraryd 的借阅聚合

把实验里的库存聚合换成借阅聚合,事件与状态迁移大致是:

事件关键字段状态变化
LoanCreatedloanId, bookId, memberId, dueAtCREATED
LoanReturnedloanId, returnedAtCREATED -> RETURNED
LoanOverdueloanId, overdueDate, feeCents状态不变,累计费用
LoanCancelledloanId, reasonCREATED -> CANCELLED

重放这些事件,可以得到任意一本图书在任意时点的借阅状态、逾期费用和操作历史。这比在 loan 表上堆 status、returned_at、overdue_fee 几个字段更能表达业务,也更容易回答「这笔费用是哪天产生的」。

但请记住:事件日志是事实,状态是缓存。任何投影、缓存、搜索索引坏了,都可以丢掉重建;事件日志本身必须持久、有序、不可变。这是事件溯源可靠性的全部基础,也是它与「加一张审计表」的根本区别。

本节判据

  1. 事件溯源里什么是事实来源? 答:事件日志。当前状态是事件折叠出来的派生结果,可以丢掉重算。
  2. 快照会不会破坏「事件是唯一事实」? 答:不会。快照只是带版本号的缓存,删掉后仍可从事件重建;判据是快照加尾部重放等于从头重放。
  3. 乐观并发控制在事件存储里怎么实现? 答:追加时校验 expectedVersion,数据库用 UNIQUE(stream_id, version),冲突则重新加载重试。
  4. 事件溯源能替代 outbox 吗? 答:不能。事件写入本地库与发布到 Kafka 仍是两个系统,仍需 outbox 或 CDC 保证发布可靠。
  5. 什么情况下不该用事件溯源? 答:简单 CRUD、无审计需求、读模型复杂且团队缺少事件建模经验时,普通表加 outbox 更划算。

阅读导航:上一节 7.2 CDC:增量快照与变更捕获 | 下一节 8.1 领域事件建模

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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