《Java 消息中间件·事件驱动架构》3.3 模型重建与重放

投影表是可丢弃的派生视图。本节真跑一次完整重建实验:先投影 20 条库存事件,清空表后从位移 0 重放重建并对比两次耗时与一致性,验证事件日志作为唯一事实来源的可复现性,深入讨论重建触发条件、快照优化策略、业务低峰期执行建议与常见注意事项。

为什么投影表可以丢

投影表不是业务数据库,它随时可以从事件日志再生。这个特性是 CQRS 架构的底气:查询端出了问题——索引坏了、数据漂移、逻辑 bug 导致统计错误——最快的修复方式不是修补丁,而是删掉投影表,从位移 0 开始重放,让事件重新流过全新的处理器。

这种操作叫重建(rebuild),核心是重放(replay)——把 Kafka 主题从头读一遍,逐条事件重新计算投影。

重建的前提只有一个:Kafka 保留期必须覆盖重建窗口。 如果主题只保留 7 天,而你要重建的数据跨度是 30 天,重建到一半事件就消失了。保留期是 CQRS 的生命线,也是一项需要在建主题时就定好的运维决策。

什么时候需要重建

重建不是日常操作,但以下三种情况逃不掉:

  1. 投影逻辑改了。 比如之前按 book_id 聚合,现在要按 book_id + branch_id 二级聚合。老投影表不支持新维度,只能从头重建。
  2. 数据漂移确认。 怀疑投影表和事件日志不一致,用重建做一次对账。
  3. 新消费者上线。 新建一个消费组从零开始消费,它的投影表天然是空的,需要完整重放。

第 3 种情况最常见:加一个新查询维度,就加一个新投影消费者,这个新消费者必须从位移 0 开始追赶。

重建期间的读服务

重建期间,旧的投影表不能立刻删掉——否则读服务会白屏。标准做法是双实例并行:

  • 实例 A 继续服务读请求,数据来自旧投影表。
  • 实例 B 从零开始重放,在新表里重建投影。
  • 重建完成后切流量到实例 B,再下线实例 A。

双实例切换的判定条件有两个:新投影表的数据和旧表对得上(抽样对比),且新消费者的位移追上了日志末端(lag ≈ 0)。

实验:两次重建对比

下面用 library.inventory.adjusted 主题跑 20 条库存调整事件(随机正负增量),先做第一次投影,清空表,再做第二次重放,比较结果和耗时。

数据准备

Random rng = new Random(42);
String[] books = {"B-A01", "B-A02", "B-A03", "B-A04", "B-A05"};
String[] reasons = {"restock", "damage", "lost", "return"};

try (Producer<String, String> producer = new KafkaProducer<>(pp)) {
    for (int i = 0; i < 20; i++) {
        String book = books[rng.nextInt(books.length)];
        int delta = rng.nextBoolean()
            ? rng.nextInt(5) + 1
            : -(rng.nextInt(3) + 1);
        String v = String.format(
            "{\"bookId\":\"%s\",\"deltaCopies\":%d,"
            + "\"reason\":\"%s\",\"occurredAt\":\"%s\"}",
            book, delta, reasons[rng.nextInt(reasons.length)],
            Instant.now().toString());
        RecordMetadata md = producer.send(
            new ProducerRecord<>(
                "library.inventory.adjusted", book, v)).get();
    }
}

随机生成保证了覆盖四种原因和正负增量,20 条足够验证聚合逻辑的正确性。

重建方法

重建函数只做三件事:seek 到起始位移,逐条消费事件,解析增量并更新投影表。

static int rebuildFromOffset(long fromOffset, long toOffset)
        throws Exception {
    int count = 0;
    Properties cp = new Properties();
    cp.put("bootstrap.servers", "127.0.0.1:19102");
    cp.put("key.deserializer",
        "org.apache.kafka.common.serialization.StringDeserializer");
    cp.put("value.deserializer",
        "org.apache.kafka.common.serialization.StringDeserializer");
    cp.put("auto.offset.reset", "earliest");

    try (Connection conn = DriverManager.getConnection(
            DB_URL, "sa", "");
         KafkaConsumer<String, String> consumer =
            new KafkaConsumer<>(cp)) {

        TopicPartition tp = new TopicPartition(
            "library.inventory.adjusted", 0);
        consumer.assign(Collections.singletonList(tp));
        consumer.seek(tp, fromOffset);
        conn.setAutoCommit(false);

        while (count < toOffset - fromOffset) {
            ConsumerRecords<String, String> recs =
                consumer.poll(Duration.ofMillis(3000));
            if (recs.isEmpty()) break;

            for (ConsumerRecord<String, String> rec : recs) {
                long off = rec.offset();
                if (off >= toOffset) break;

                String val = rec.value();
                int delta = parseDelta(val);
                String bookId = rec.key();

                // MERGE 初始行
                PreparedStatement ups = conn.prepareStatement(
                    "MERGE INTO inventory_projection "
                    + "KEY(book_id) VALUES (?, ?, ?)");
                ups.setString(1, bookId);
                ups.setInt(2, delta);
                ups.setLong(3, off);
                ups.executeUpdate();

                // 累加增量
                PreparedStatement inc = conn.prepareStatement(
                    "UPDATE inventory_projection "
                    + "SET total_copies = total_copies + ?,"
                    + " last_offset = ? WHERE book_id = ?"
                    + " AND last_offset = ?");
                inc.setInt(1, delta);
                inc.setLong(2, off);
                inc.setString(3, bookId);
                inc.setLong(4, off);
                inc.executeUpdate();

                count++;
            }
            conn.commit();
        }
    }
    return count;
}

执行与结果

long targetOffset = ...;  // 主题末端位移

// 第一次投影
long t1Start = System.currentTimeMillis();
int firstCount = rebuildFromOffset(0L, targetOffset);
long t1End = System.currentTimeMillis();

// 清空表
conn.createStatement().executeUpdate(
    "DELETE FROM inventory_projection");

// 第二次重放
long t2Start = System.currentTimeMillis();
int secondCount = rebuildFromOffset(0L, targetOffset);
long t2End = System.currentTimeMillis();

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29):
javac --release 25 -cp ... RebuildDemo.java && java -cp ... RebuildDemo

输出截取:

Seed[0]: book=B-A01 delta=-1 offset=0
Seed[19]: book=B-A02 delta=1 offset=19
Topic end offset=20
First build: records=20 elapsed=543 ms
Projection table wiped
Replay build: records=20 elapsed=537 ms
--- Comparison ---
B-A01: first=-4 replay=-4 OK
B-A02: first=2 replay=2 OK
B-A03: first=6 replay=6 OK
B-A04: first=-4 replay=-4 OK
B-A05: first=8 replay=8 OK
All match=true
Speedup ratio=1.01

单次运行,随调度波动,不作基准。

两次重建结果完全一致:5 本书的库存总量都是相同的。B-A05 最终 total_copies=8,是增量最多的书;B-A01 和 B-A04 都是 -4,说明出库多于入库。

重放的性能原罪

重放虽然结果可复现,但性能本身是一个问题。

本实验中 20 条事件两次 rebuild 耗时都在 500 ms 左右,比例接近 1:1。这是因为 H2 内存表 + 本地 Kafka ≈ 零网络开销。放到生产环境,重放 100 万条事件的典型问题有三:

  1. I/O 放大。 每条事件至少一次 poll + 一次 SQL 写入,投影表如果在外部 Postgres,网络 RTT 会把单条处理时间拉到人毫秒级。
  2. 消费组滞后追赶。 如果一个新消费者从零开始消费 100 万条,每秒处理 5000 条也需要 200 秒。这期间读服务要么读旧投影,要么承受高延迟。
  3. 事件本身可能过大了。 携带全量状态的「胖事件」会让网络和反序列化双双承压,事件应该只携带增量(delta)。

应对策略没有银弹,只有权衡:

策略效果代价
增大 max.poll.records减少 poll 次数单批次内存增大
批量写入投影表SQL 往返减少批次内失败要整体重试
保留 snapshot从 snapshot + 增量追赶snapshot 管理复杂度
并行消费多分区吞吐量线性增长投影逻辑必须按 key 可分区

重建的性能影响与优化

重建的成本取决于三个因素:

  1. 事件总量:1000 条事件约需 1 秒,1000 万条事件可能需要数小时。
  2. 投影复杂度:简单计数投影很快,涉及多表 join 和复杂计算的投影很慢。
  3. 网络带宽:从 Kafka 读取事件的网络延迟。

缓解方案包括增量快照(定期生成投影表快照,重建时从快照开始)、并行重建(多个消费者并行重建不同分片)、事件压缩(在 changelog topic 上启用压缩减少重放量)。

重建的触发条件

触发条件说明
投影表损坏磁盘故障、数据错误、bug 导致的数据污染
投影逻辑变更新业务需求需要不同的投影计算方式
数据库迁移从 H2 迁移到 PostgreSQL,需要重新投影
消费者组重置seekToBeginning() 或变更消费者组名
架构升级事件 schema 演进后,旧投影无法理解新事件

重建是运维操作,不应在业务高峰期执行。建议在低峰期通过脚本触发,并监控重建进度和系统负载。

重建与快照的关系

快照是重建的加速器。没有快照时,重建必须从位移 0 开始,逐条重放所有历史事件。有了快照后,重建可以从快照对应的时间点开始,只重放增量事件。

快照的实现方式:

  1. 投影表快照:定期导出投影表到文件(如 CSV 或 Parquet),重建时先导入快照,再重放增量事件。
  2. Kafka Streams 状态存储快照:KafkaStreams#allMetadata() 可导出本地状态存储的快照。
  3. 数据库备份:如果使用 PostgreSQL 等持久化数据库,直接用 pg_dump 做快照。

快照频率取决于事件产生速率和业务容忍度。高事件速率场景建议每小时快照一次,低事件速率场景可每天一次。

小结

  • 投影表是可丢弃的,唯一不可丢的是 Kafka 中的事件日志。保留期决定了重建窗口的上限。
  • 重建的典型触发条件:投影逻辑变更、数据漂移对账、新消费者从零追赶。
  • 重建期间读服务不能挂,双实例并行是最稳妥的切换方式。
  • 实测:20 条库存调整事件两次重建,5 本书的总量 100% 一致,耗时分别 543 ms 和 537 ms。
  • 生产级重放的性能瓶颈在 I/O 放大和滞后追赶,策略是批量消费、批量写入、并行分区,或引入 snapshot 减少重放基数。
  • 重建完成后应进行数据对账,验证新建投影与原有查询结果一致。
  • 重建过程应记录日志,包括开始时间、结束时间、处理事件数和最终状态。
  • 重建失败时应有回滚机制,保留旧投影表直到新投影验证通过。
  • 重建期间读服务不应中断,建议采用双实例并行切换策略。
  • 重建操作应由运维脚本自动化执行,避免手工操作导致的人为错误。

阅读导航:上一节 3.2 投影与视图构建 | 下一节 4.1 分布式事务概述

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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