当前状态不是事实,变更才是
一张 book_stock 表里写着 stock=9, reserved=12。这个数字回答了「现在是多少」,但回答不了下面这些问题:
- 9 是怎么来的?是入库 15 本、借出 6 本,还是入库 10 本、借出 1 本?
- 三天前是多少?那次盘点调整有没有生效?
- 某个消费者把状态算错了,能不能重算一遍?
关系型数据库的更新是「覆盖」:UPDATE book_stock SET stock = 9。旧值被覆盖的那一刻,历史就没了。你能看到的只有当前行,以及可能存在的审计表;审计表往往是事后补的,字段和语义都不完整。
事件溯源(Event Sourcing)换了一个存储模型:不存当前状态,存导致状态变化的每一个事件。当前状态是这些事件按顺序折叠出来的结果。事件是唯一事实来源;状态是派生视图,可以随时丢掉重算。
这个定义有两个直接推论:
- 写入是追加,不是覆盖。事件一旦写入不可修改,只能追加新事件来纠正。这天然适合只追加的日志,也天然可审计。
- 读取要么重放,要么查投影。重放保证正确性,投影保证性能;两者的一致性由事件流的顺序保证。
最小事件存储
实验用纯 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 的借阅聚合
把实验里的库存聚合换成借阅聚合,事件与状态迁移大致是:
| 事件 | 关键字段 | 状态变化 |
|---|---|---|
LoanCreated | loanId, bookId, memberId, dueAt | CREATED |
LoanReturned | loanId, returnedAt | CREATED -> RETURNED |
LoanOverdue | loanId, overdueDate, feeCents | 状态不变,累计费用 |
LoanCancelled | loanId, reason | CREATED -> CANCELLED |
重放这些事件,可以得到任意一本图书在任意时点的借阅状态、逾期费用和操作历史。这比在 loan 表上堆 status、returned_at、overdue_fee 几个字段更能表达业务,也更容易回答「这笔费用是哪天产生的」。
但请记住:事件日志是事实,状态是缓存。任何投影、缓存、搜索索引坏了,都可以丢掉重建;事件日志本身必须持久、有序、不可变。这是事件溯源可靠性的全部基础,也是它与「加一张审计表」的根本区别。
本节判据
- 事件溯源里什么是事实来源? 答:事件日志。当前状态是事件折叠出来的派生结果,可以丢掉重算。
- 快照会不会破坏「事件是唯一事实」? 答:不会。快照只是带版本号的缓存,删掉后仍可从事件重建;判据是快照加尾部重放等于从头重放。
- 乐观并发控制在事件存储里怎么实现? 答:追加时校验
expectedVersion,数据库用UNIQUE(stream_id, version),冲突则重新加载重试。 - 事件溯源能替代 outbox 吗? 答:不能。事件写入本地库与发布到 Kafka 仍是两个系统,仍需 outbox 或 CDC 保证发布可靠。
- 什么情况下不该用事件溯源? 答:简单 CRUD、无审计需求、读模型复杂且团队缺少事件建模经验时,普通表加 outbox 更划算。
阅读导航:上一节 7.2 CDC:增量快照与变更捕获 | 下一节 8.1 领域事件建模
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。