《Java 消息中间件·事件驱动架构》9.2 快照与性能

快照截断事件流以减少重建时的 apply 次数。本节讨论事件数阈值与时间窗口两种快照触发策略、快照与底层事件的版本一致性约束、快照失效时的降级回退方案与多版本保留策略,并用 H2 内存数据库实测 1000 条事件全量重建与快照恢复的性能对比与一致性验证。

为什么要做快照

事件溯源的核心特性是「状态从事件流计算而来」,但这个特性在事件量增大时会变成一个性能瓶颈。如果一个聚合根积累了 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 时提交消费位移并同步写入快照表。

快照失败的降级

快照创建不是一个必须成功的操作。如果快照写入失败——数据库挂了、磁盘满了、网络超时——系统必须能继续工作。降级策略有两种:

  1. 隐式降级: 不抛异常,下次读的时候发现没有快照就全量重建。代价是重建时间长了,但功能不受影响。
  2. 显式降级: 记录一个快照失败标记,触发告警但不阻塞写入。读服务看到失败标记后,使用上一个可用快照加增量事件重建。

显式降级更适合生产环境。它的关键是保留「上一个有效快照」,不要把失败快照覆盖掉已有的快照。如果每次快照都覆盖旧快照,那么一次失败就意味着彻底失去加速能力。

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 个快照,原因有两个:

  1. 快照写坏需要回退。 如果快照创建过程中进程崩溃,快照可能只写了一半,校验失败时需要上一个快照兜底。
  2. 事件重排需要双向回退。 如果审计发现某个历史事件写错了,需要把快照回退到更早版本、跳过错误事件重新重建。

快照表里可以增加 version 字段,每次创建快照时单调递增,旧版本保留到 TTL 过期或手动归档。9.3 会讨论事件存储的演进与归档策略。

快照的读取策略

读取聚合状态时,最稳妥的流程是:

  1. 读取最新快照(SELECT ... ORDER BY version DESC LIMIT 1)。
  2. 从 last_seq + 1 开始读取增量事件。
  3. Apply 增量事件到快照状态。
  4. 返回最终状态。

如果没有快照,回退到第 1 步变体:从头读取所有事件,构建一个临时「快照」,写入快照表后继续。第一个用户的等待时间会长一些,后续用户都能走快照加速路径。

小结

  • 快照截断事件流,将重建基数从全部事件降到增量事件,是事件溯源高性能读取的关键。
  • 触发条件有事件数阈值和时间窗口两种,前者增量上限固定,后者快照新鲜度固定;组合使用兼顾两者。
  • 快照必须与 last_seq 明确挂钩,重建时只重放 seq > last_seq 的事件。
  • 快照创建失败必须降级到上一个可用快照,不得用半成功快照覆盖旧快照。
  • 本机实测:1000 条事件 + 50 条追加,全量重建 20 ms,快照 + 增量 3 ms,提速约 6.7 倍,总量一致性验证 PASS。

阅读导航:上一节 9.1 事件溯源概述 | 下一节 9.3 事件存储

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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