什么是事件溯源
事件溯源(Event Sourcing)的核心思想是:不做状态的覆盖更新,而是把每一次状态变更都作为一条不可变的事件追加到事件流里。系统的当前状态不是存出来的,而是「计算」出来的——从第一条事件重放到最后一条,逐条 apply 到聚合对象上,得到此刻的完整状态。
这听起来与 3.1 介绍的 CQRS + 投影很像,但两者的设计目标不同。CQRS + 投影关注的是读写分离,命令侧生成事件,查询侧维护投影表,投影表是最终正确的。事件溯源关注的是状态的可回溯性,「当前状态」只是从事件流里得到的一个视图,真正不可丢的是事件流本身。
用一个比喻:CQRS + 投影像银行账单和余额的关系——你既保存每笔交易明细,也保存当前余额,余额是权威答案;事件溯源像只保存每笔交易明细,余额是你把全部明细加起来算出来的。如果你把笔算错了,那就全部重加一遍。
对比:CQRS + 投影 vs 事件溯源
两种设计在架构层面有四个明显的分歧点:
| 维度 | CQRS + 投影 | 事件溯源 |
|---|---|---|
| 事实来源 | 投影表 + 事件日志双重持久化 | 事件日志是唯一来源 |
| 当前状态获取 | 直接查投影表或物化视图 | 从头重放事件流重建 |
| 历史回溯能力 | 受限于事件保留期和投影设计 | 天然完整历史 |
| 复杂度 | 中,两套模型分别优化 | 高,所有状态都从事件计算 |
| 适用场景 | 读模型需要独立优化,读多写少 | 需要审计、回滚、完整生命周期追踪 |
| 存储要求 | 投影表数据量可控 | 事件随时间线性增长 |
| 恢复方式 | 重置投影表后从事件日志重建 | 重建本身就是状态获取方式 |
| 查询延迟 | 毫秒级,查投影表即可 | 毫秒到秒级,取决于事件数量 |
这个对比表不是评判优劣,而是判断条件。一个典型的误用是把事件溯源当「更高级的 CQRS」来用——读写逻辑已经很重了,再加一个事件溯源,系统会不堪重负。
事件溯源的适用场景
以下四种情况适合引入事件溯源:
- 审计与合规要求严格。 金融领域每笔资金变动都要求能回溯到具体时间点、操作人和因果链。事件日志天然就是这个链。
- 业务状态经常需要回退。 比如订单退款、库存撤销。事件溯源下,撤销不是修改原记录,而是新增一条逆转事件,历史原样保留。
- 对象生命周期跨多个阶段,阶段之间有复杂的推导规则。 图书馆藏书从「采购中」到「编目完成」到「在架」到「借出」,每个变更都有业务规则和审批链路,事件流能完整反映这个生命周期。
- 多个下游需要不同视角的历史数据。 例如同一个库存调整事件,仓储系统关心实际数量,财务系统关心成本影响,采购系统关心补货触发。事件溯源让下游可以根据同一事件源生成各自的投影。
事件溯源不是银弹
引入事件溯源之前,先回答三个问题:
- 历史事件真的有价值吗? 如果是普通的用户资料修改,旧手机号和旧地址几乎没有回溯需求,用事件溯源是浪费。
- 重建性能可接受吗? 一年产生一亿条事件,每次开机加载都要重放一遍,加载时间会压垮启动流程。
- 团队能否处理状态多版本并存? 同一个业务对象在早期和后期可能有完全不同的字段集合,代码里必须能同时解释多个版本的事件。
还有一个隐形成本:事件溯源的接口与外部系统交互时,外部通常只关心「现在状态是多少」,不关心「如何从事件算出来的」。这意味着你既要维护事件流的存储与回放,也要维护一个当前状态的暴露接口——通常通过快照机制实现。
内存演示: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,因为用户关心「我什么时候操作的」。
以下三种情况不建议引入:
- 状态变更不频繁且无需历史。 一个配置中心的开关 toggle,当前值就是唯一的意义。
- 重建时间超过业务容忍上限。 如果汇总对象在重放 1000 万条事件后仍需秒级以上才能启动,实时读服务不能承受这个延迟。
- 事件版本已经失控。 如果同一个事件类型有 10 个历史版本,且没人知道各自怎么解析,事件溯源会变成考古学,而不是工程手段。
与 CQRS 搭配时的注意点
事件溯源可以独立使用,也可以和 CQRS 联合使用。联合使用时,命令模型负责生成事件并写入事件存储;查询模型通过订阅事件流更新投影表。这和第 3 章的区别是:
- 第 3 章:事件日志是数据来源之一,投影表也是数据来源。
- 本章:事件日志是唯一数据来源,投影表只是另一个消费端。
这个差异导致恢复策略不同。第 3 章的恢复是「从 Kafka 重放重建投影表」;本章的恢复是「重建所有视图,包括任何外部系统看到的快照」。
在事件溯源 + CQRS 的联合架构中,投影消费者和快照消费者是并行存在的两个消费者组:
| 消费者组 | 职责 | 重放起点 |
|---|---|---|
| projection-consumer | 更新查询模型投影表 | 位移 0(首次)或最新偏移(追加) |
| snapshot-consumer | 创建和更新快照 | 位移 0(全量重建)或快照后增量 |
两个消费者组可以独立重启、独立配置保留期。这带来了运维灵活性,但也增加了复杂性:系统管理员需要同时监控两组 lag,确保任何一组不落单。
事件的不可变性与修正策略
事件溯源强调「事件不可变」,这不是说错了就只能将错就错,而是说修正的方式不是改旧记录,而是追加修正事件。例如一条 InventoryAdjusted 事件把出库数量写成了 -50,正确应该是 -5,修正的做法是:
- 保留原事件不动(
seq=20, delta=-50); - 追加一条
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 快照与性能
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。