为什么要做快照
事件溯源的核心特性是「状态从事件流计算而来」,但这个特性在事件量增大时会变成一个性能瓶颈。如果一个聚合根积累了 100 万条事件,每次读取当前状态都要从头 apply 到尾,即使单条 apply 只有几微秒,总和也会变成秒级甚至分钟级。
快照(snapshot)的作用就是在这里:在事件流的某个位置打上一个「加粗刻度」,记录聚合根在那个事件序号处的完整状态。后续重建时,只要加载快照、然后从快照对应的 seq 之后追加新事件即可。
快照不是事件的替代品,而是事件流的加速索引。快照可以丢——丢了就回退到全量重建;但事件绝不能丢,丢了就永远失去一段历史。
两种触发条件:事件数 vs 时间窗口
快照的创建通常由以下两种条件之一触发。
事件数阈值
当某个聚合根的新增事件数达到阈值时创建快照。例如每 500 条事件截断一次:
if ((latestSeq - snapshotSeq) >= SNAPSHOT_INTERVAL) {
createSnapshot(aggregateId, latestSeq);
}
这种策略的优势是行为可预测。第 500 条、第 1000 条、第 1500 条事件写入时都会稳定触发快照创建,重建时的增量事件数永远不超过 500 条。缺点是如果业务峰谷波动大,低谷期可能积累大量事件而快照迟迟没有更新——但由于它按事件数而非时间触发,低谷期的重建负担反而轻。
时间窗口
按固定时间间隔创建快照,例如每 15 分钟:
if (Duration.between(lastSnapshotAt, now).toMinutes() >= 15) {
createSnapshot(aggregateId, latestSeq);
}
这种策略的优势是快照的「新鲜度」可控。对于需要频繁读取当前状态的场景,每个聚合根的快照都不会超过 15 分钟,增量事件数在业务高峰期可能很多,但低谷期不会浪费计算。缺点是重建的增量上限不固定——如果 15 分钟内涌入了 50 万条事件,重建就要追 50 万条。
两种策略也可以组合使用:先检查时间窗口,再在窗口内按事件数触发一次截断。
| 策略 | 增量上限确定性 | 快照新鲜度 | 适合场景 |
|---|---|---|---|
| 事件数阈值 | 固定 | 取决于写入频率 | 流量稳定的聚合根 |
| 时间窗口 | 不固定 | 固定 | 需要快速启动的读服务 |
| 组合策略 | 窗口内固定 | 窗口内固定 | 核心业务聚合根 |
快照与事件的版本一致性
快照本身是聚合状态的快照,但它不是独立的——必须与某个特定的事件序号挂钩。快照表里必须有一个 last_seq 字段,重建时 events.seq > last_seq 的事件才需要重放。
class Snapshot {
String aggregateId;
int totalCopies;
long lastSeq; // 快照对应的事件序号
int version; // 快照第几次截断
Instant createdAt;
}
如果快照的 lastSeq 与实际落盘事件之间出现差距——比如快照创建后发送方崩溃,事件写入了 510 条但快照只记录了 500 条——那么重建就会跳过 10 条事件,状态不一致。
防止这个 bug 的方法是:快照创建与事件写入在同一个事务里完成,或者退而求其次,快照的 lastSeq 永远不大于已确认落盘的最大事件序号。在单进程内存实现中这很简单;在分布式环境下这通常要求快照服务作为事件消费者的下游,消费到 seq=500 时提交消费位移并同步写入快照表。
快照失败的降级
快照创建不是一个必须成功的操作。如果快照写入失败——数据库挂了、磁盘满了、网络超时——系统必须能继续工作。降级策略有两种:
- 隐式降级: 不抛异常,下次读的时候发现没有快照就全量重建。代价是重建时间长了,但功能不受影响。
- 显式降级: 记录一个快照失败标记,触发告警但不阻塞写入。读服务看到失败标记后,使用上一个可用快照加增量事件重建。
显式降级更适合生产环境。它的关键是保留「上一个有效快照」,不要把失败快照覆盖掉已有的快照。如果每次快照都覆盖旧快照,那么一次失败就意味着彻底失去加速能力。
Snapshot createNewSnapshot(String id, long newSeq) {
Snapshot old = loadLatest(id);
try {
Snapshot neu = buildSnapshot(id, newSeq);
saveSnapshot(neu); // 先写新快照
markOldAsArchive(old); // 再标记旧快照可归档
return neu;
} catch (IOException e) {
logSnapshotFailure(id, newSeq);
return old; // 回退到旧快照
}
}
本机实测:快照重建加速
下面用 H2 内存数据库对比「不取快照全量重建」和「取快照后增量重建」的耗时差异。实验条件是 1000 条库存调整事件,追加 50 条新事件后做两种重建。
import java.sql.*;
import java.time.Instant;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
public class SnapshotDemo {
private static final String DB_URL = "jdbc:h2:mem:snapshotdemo";
public static void main(String[] args) throws Exception {
Connection conn = DriverManager.getConnection(DB_URL);
Statement stmt = conn.createStatement();
stmt.execute("CREATE TABLE events (" +
"seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY," +
"book_id VARCHAR(32) NOT NULL, delta INT NOT NULL," +
"reason VARCHAR(32), occurred_at TIMESTAMP)");
stmt.execute("CREATE TABLE snapshots (" +
"book_id VARCHAR(32) PRIMARY KEY," +
"total_copies INT NOT NULL, last_seq BIGINT NOT NULL," +
"version INT NOT NULL DEFAULT 1, snapshot_at TIMESTAMP)");
stmt.execute("CREATE TABLE projection (" +
"book_id VARCHAR(32) PRIMARY KEY," +
"total_copies INT NOT NULL, last_seq BIGINT NOT NULL)");
String bookId = "BK-AV001";
int N = 1000;
// 写入 1000 条事件
PreparedStatement psWrite = conn.prepareStatement(
"INSERT INTO events (book_id, delta, reason, occurred_at) " +
"VALUES (?, ?, ?, ?)");
String[] reasons = {"restock", "borrow", "return", "damage", "sale", "transfer"};
Random rng = new Random(2025);
for (int i = 0; i < N; i++) {
int delta = ThreadLocalRandom.current().nextInt(-20, 21);
psWrite.setString(1, bookId);
psWrite.setInt(2, delta);
psWrite.setString(3, reasons[rng.nextInt(reasons.length)]);
psWrite.setTimestamp(4, Timestamp.from(Instant.now()));
psWrite.addBatch();
if (i % 100 == 99) psWrite.executeBatch();
}
psWrite.executeBatch();
// 方法1:不取快照,从 1000 条全量重建
long t1 = System.currentTimeMillis();
ResultSet rs1 = stmt.executeQuery(
"SELECT delta, seq FROM events WHERE book_id = '" + bookId + "' ORDER BY seq");
long total1 = 0; int count1 = 0;
while (rs1.next()) {
total1 += rs1.getLong(1); count1++;
}
long withoutSnapshot = System.currentTimeMillis() - t1;
// 保存快照
PreparedStatement snapPs = conn.prepareStatement(
"MERGE INTO snapshots (book_id, total_copies, last_seq, version, snapshot_at) " +
"VALUES (?, ?, ?, ?, ?)");
snapPs.setString(1, bookId);
snapPs.setLong(2, total1);
snapPs.setLong(3, count1);
snapPs.setInt(4, 1);
snapPs.setTimestamp(5, Timestamp.from(Instant.now()));
snapPs.executeUpdate();
// 追加 50 条新事件
PreparedStatement psExtra = conn.prepareStatement(
"INSERT INTO events (book_id, delta, reason, occurred_at) VALUES (?, ?, ?, ?)");
for (int i = 0; i < 50; i++) {
int d = ThreadLocalRandom.current().nextInt(-20, 21);
psExtra.setString(1, bookId);
psExtra.setInt(2, d);
psExtra.setString(3, "post-snap");
psExtra.setTimestamp(4, Timestamp.from(Instant.now()));
psExtra.addBatch();
}
psExtra.executeBatch();
// 方法2:从快照恢复 + 增量事件
long t2 = System.currentTimeMillis();
ResultSet snapRs = stmt.executeQuery(
"SELECT total_copies, last_seq FROM snapshots WHERE book_id = '" + bookId + "'");
snapRs.next();
long snapTotal = snapRs.getLong(1);
long snapSeq = snapRs.getLong(2);
ResultSet rs2 = stmt.executeQuery(
"SELECT delta, seq FROM events WHERE book_id = '" + bookId +
"' AND seq > " + snapSeq + " ORDER BY seq");
long total2 = snapTotal; int count2 = 0;
while (rs2.next()) {
total2 += rs2.getLong(1); count2++;
}
long withSnapshot = System.currentTimeMillis() - t2;
// 全量 SUM 验证
ResultSet rsFull = stmt.executeQuery(
"SELECT SUM(delta) FROM events WHERE book_id = '" + bookId + "'");
rsFull.next();
long fullTotal = rsFull.getLong(1);
System.out.println("=== 快照性能实验(" + N + " 条事件 + 50 条追加)===");
System.out.println("不取快照重建: " + count1 + " 条, 耗时 " + withoutSnapshot + " ms, 总量 " + total1);
System.out.println("快照 + 增量: " + count2 + " 条, 耗时 " + withSnapshot + " ms, 总量 " + total2);
System.out.println("全量 SUM 验证: " + fullTotal);
System.out.println("一致性: " + (fullTotal == total2 ? "PASS" : "FAIL"));
System.out.println("提速比: " + String.format("%.2fx", (double)withoutSnapshot / withSnapshot));
}
}
编译运行(Temurin 25.0.4+1 JDK / eclipse-temurin:25-jdk-alpine Docker / H2 2.3.232):
javac --release 21 SnapshotDemo.java
java -cp ".:h2-2.3.232.jar" SnapshotDemo
输出:
=== 快照性能实验(1000 条事件 + 50 条追加)===
不取快照重建: 1000 条, 耗时 20 ms, 总量 292
快照 + 增量: 50 条, 耗时 3 ms, 总量 289
全量 SUM 验证: 289
一致性: PASS
提速比: 6.67x
单次运行,随调度波动,不作基准。
这个结果是典型的数量级变化:虽然 H2 内存表的绝对时间都很短,但快照方案从追 1000 条降到追 50 条,耗时从 20 ms 降到 3 ms,提速比约 6.7 倍。放到生产环境,如果每条事件涉及磁盘 I/O 和网络 round-trip,这个差距会更大。
快照的版本管理
快照不是只存一份。生产环境里通常保留最近 2~3 个快照,原因有两个:
- 快照写坏需要回退。 如果快照创建过程中进程崩溃,快照可能只写了一半,校验失败时需要上一个快照兜底。
- 事件重排需要双向回退。 如果审计发现某个历史事件写错了,需要把快照回退到更早版本、跳过错误事件重新重建。
快照表里可以增加 version 字段,每次创建快照时单调递增,旧版本保留到 TTL 过期或手动归档。9.3 会讨论事件存储的演进与归档策略。
快照的读取策略
读取聚合状态时,最稳妥的流程是:
- 读取最新快照(
SELECT ... ORDER BY version DESC LIMIT 1)。 - 从
last_seq + 1开始读取增量事件。 - Apply 增量事件到快照状态。
- 返回最终状态。
如果没有快照,回退到第 1 步变体:从头读取所有事件,构建一个临时「快照」,写入快照表后继续。第一个用户的等待时间会长一些,后续用户都能走快照加速路径。
小结
- 快照截断事件流,将重建基数从全部事件降到增量事件,是事件溯源高性能读取的关键。
- 触发条件有事件数阈值和时间窗口两种,前者增量上限固定,后者快照新鲜度固定;组合使用兼顾两者。
- 快照必须与
last_seq明确挂钩,重建时只重放seq > last_seq的事件。 - 快照创建失败必须降级到上一个可用快照,不得用半成功快照覆盖旧快照。
- 本机实测:1000 条事件 + 50 条追加,全量重建 20 ms,快照 + 增量 3 ms,提速约 6.7 倍,总量一致性验证 PASS。
阅读导航:上一节 9.1 事件溯源概述 | 下一节 9.3 事件存储
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。