《Java 消息中间件·事件驱动架构》9.3 事件存储

事件存储是事件溯源的基础设施。本节比较自建事件存储与直接使用 Kafka 作为事件存储的权衡,指出 Kafka compacted topic 的局限,并用 Java 实测消费者把事件追加到本地 SQLite 日志表并重放到任意版本的完整流程。

事件存储的角色

事件存储是事件溯源的底盘。它不只负责存储事件,还必须保证:

  1. 同一聚合根的事件顺序性。 一个订单的 Created、Paid、Shipped 不能乱序,否则重建出来的状态就是错误的。
  2. 不可变。 已落盘的事件不能修改,只能追加。这是重建可复现性的前提。
  3. 可遍历。 从任意位移开始按顺序读取到任意位移结束,且遍历过程不能跳过中间事件。
  4. 全局唯一标识。 每条事件至少在一个聚合根的范围内有唯一序号。

这四条约束把「事件存储」与「普通消息队列」区分开。消息队列通常允许消费者乱序消费、跳过位移、甚至截断日志;事件存储必须把顺序和完整性放在第一位。

自建 vs 使用 Kafka

自建事件存储

自建方案通常是一个关系型数据库(Postgres 或 H2)的扩展,表结构类似:

CREATE TABLE events (
    aggregate_id VARCHAR(64) NOT NULL,
    seq          INT         NOT NULL,
    event_type   VARCHAR(64) NOT NULL,
    payload      JSONB       NOT NULL,
    recorded_at  TIMESTAMP   DEFAULT NOW(),
    PRIMARY KEY (aggregate_id, seq)
);

优势是查询能力强。可以按 aggregate_id、event_type、recorded_at 任意组合过滤;可以 JOIN 业务表做关联分析;事务语义清晰,INSERT 事件和更新聚合状态可以在同一个数据库事务里完成。

劣势是吞吐扩展有限。单库写入受单机连接数和磁盘 IOPS 限制,水平分片后跨分片的顺序一致性变为分布式共识问题,复杂度陡增。

直接使用 Kafka

Kafka 天然顺序(分区级别)、天然不可变(日志只追加)、天然可遍历(seek 到任意位移),这些特性和事件存储的需求高度重叠。有些架构索性把 Kafka topic 当成事件存储,将事件直接发布到 Kafka,读取时从分区位移开始消费重建。

// 生产者视角:事件直接发到 Kafka
try (Producer<String, String> p = new KafkaProducer<>(props)) {
    ProducerRecord<String, String> rec = new ProducerRecord<>(
        "library.inventory.events",
        bookId,
        "{\"eventType\":\"Adjusted\",\"delta\":-2,...}");
    p.send(rec).get();
}

Kafka 做事件存储的优势是吞吐天花板高:分区级别并行写入,底层顺序磁盘写入性能远超随机写入。劣势在查询能力:Kafka 只能按位移顺序遍历,不支持按 event_type 随机查;消息保留期受 retention.bytes / retention.ms 控制,超期后事件被删除,重建只能读到最近的数据。

混合方案:Kafka 做写入通道,SQLite 做本地归档

生产中最常见的折中是:

  • 写入路径: 业务系统生成事件,发到 Kafka topic。
  • 归档路径: 消费者从 Kafka 头读到尾,把事件按原序追加到本地 SQLite 或嵌入式数据库的日志表里。
  • 重建路径: 从 SQLite 日志表按 seq 顺序读取,不再需要经手 Kafka 网络层。

SQLite 作为嵌入式数据库的优势是没有服务端进程、文件就是数据库、单进程写入性能极高。它的局限是并发写入能力差,但事件存储天然是顺序单写,这个限制正好不冲突。

Kafka compacted topic 的局限

Kafka 提供了一种压缩策略 cleanup.policy=compact,它的设计目标是为 key-value 场景保留每个 key 的最新值。对于事件溯源,这是一个容易踩的坑:

  • Compaction 只保留每个 key 的最新 message。 事件溯源要求保留所有历史事件,而 compact topic 会在后台清理相同 key 的旧消息。如果一个商品有 1000 次库存调整,compact 后只剩下最后一条,前面 999 条事件彻底消失。
  • Compaction 是异步的。 即使 retention 还没触发,compact cleaner 也在后台按 min.cleanable.dirty.ratio 运行,清理时间不可控。
  • Compaction 不保证快照语义。 压缩后的消息可能不是原子快照,中间状态被截断,重建时序列不连续。

结论:Kafka compact topic 不能当事件存储用。 事件存储必须使用 cleanup.policy=delete 的普通日志 topic,并且把 retention.ms 设得足够长——或者干脆在 Kafka 端不设上限,由下游 SQLite 归档承担冷存储职责。

实测:消费者归档到 SQLite + 重放到任意版本

以下代码在 Docker 容器内执行,Kafka 运行在宿主机的 Docker 容器里,Java 代码通过共享网络访问 Kafka。生产者写入 20 条事件后,消费者把全部事件追加到 SQLite 本地表,再通过 version <= N 条件重放到任意版本。

import org.apache.kafka.clients.producer.*;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;

import java.sql.*;
import java.time.Duration;
import java.time.Instant;
import java.util.*;

public class EventStoreDemo {

    private static final String BOOTSTRAP = "127.0.0.1:9092";
    private static final String TOPIC = "mq-arch-09-es-test";

    public static void main(String[] args) throws Exception {
        // 生成 20 条事件到 Kafka
        int N = 20;
        writeEvents(N);
        System.out.println("Kafka 生产者写入 " + N
git
            + " 条事件到 topic: " + TOPIC);

        // 消费归档到 SQLite
        consumeAndStore(0L, -1L, "arch01");

        // 重放到版本 5 和 10
        replayFromVersion(5);
        replayFromVersion(10);

        // 一致性验证
        Connection conn = getSqliteConn();
        Statement stmt = conn.createStatement();

        ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM event_store");
        rs.next();
        System.out.println("\nSQLite 总事件数: " + rs.getInt(1));

        ResultSet rs2 = stmt.executeQuery(
            "SELECT SUM(CAST(json_extract(payload, '$.delta') AS INT)) " +
            "FROM event_store WHERE event_type = 'InventoryAdjusted'");
        rs2.next();
        System.out.println("全量 delta SUM: " + rs2.getLong(1));

        ResultSet rs3 = stmt.executeQuery(
            "SELECT SUM(CAST(json_extract(payload, '$.delta') AS INT)) " +
            "FROM event_store WHERE event_type = 'InventoryAdjusted' AND version <= 10");
        rs3.next();
        System.out.println("版本 <=10 的 delta SUM: " + rs3.getLong(1));

        conn.close();
    }

    static void writeEvents(int n) throws Exception {
        Properties pp = new Properties();
        pp.put("bootstrap.servers", BOOTSTRAP);
        pp.put("key.serializer",
            "org.apache.kafka.common.serialization.StringSerializer");
        pp.put("value.serializer",
            "org.apache.kafka.common.serialization.StringSerializer");

        Random rng = new Random(42);
        String[] reasons = {"restock", "borrow", "return", "damage", "sale"};
        String[] books = {"B-001", "B-002", "B-003", "B-004", "B-005"};

        try (Producer<String, String> p = new KafkaProducer<>(pp)) {
            for (int i = 1; i <= n; i++) {
                String book = books[rng.nextInt(books.length)];
                int delta = rng.nextBoolean()
                    ? rng.nextInt(5) + 1 : -(rng.nextInt(3) + 1);
                String payload = String.format(
                    "{\"version\":%d,\"bookId\":\"%s\",\"delta\":%d,\"reason\":\"%s\",\"occurredAt\":\"%s\"}",
                    i, book, delta, reasons[rng.nextInt(reasons.length)],
                    Instant.now().toString());
                ProducerRecord<String, String> rec =
                    new ProducerRecord<>(TOPIC, book, payload);
                RecordMetadata md = p.send(rec).get();
                if (i <= 3 || i >= n - 2) {
                    System.out.printf("  sent[%d] offset=%d book=%s delta=%d%n",
                        i, md.offset(), book, delta);
                }
            }
        }
    }

    static Connection getSqliteConn() throws SQLException {
        return DriverManager.getConnection(
            "jdbc:sqlite:/tmp/mq-arch-09-event-store.db");
    }

    static void consumeAndStore(long fromOffset, long toOffset, String tag)
            throws Exception {
        Properties cp = new Properties();
        cp.put("bootstrap.servers", BOOTSTRAP);
        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");

        Connection conn = getSqliteConn();
        Statement stmt = conn.createStatement();
        stmt.execute("CREATE TABLE IF NOT EXISTS event_store (" +
            "kafka_offset BIGINT PRIMARY KEY," +
            "kafka_partition INT NOT NULL," +
            "book_id TEXT NOT NULL," +
            "event_type TEXT," +
            "version INT," +
            "payload TEXT NOT NULL," +
            "stored_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
        conn.close();

        try (KafkaConsumer<String, String> consumer =
                new KafkaConsumer<>(cp)) {
            TopicPartition tp = new TopicPartition(TOPIC, 0);
            consumer.assign(Collections.singletonList(tp));
            consumer.seek(tp, fromOffset);

            int count = 0;
            long endOffset = toOffset < 0
                ? getEndOffset(consumer, tp) : toOffset;
            Connection wc = getSqliteConn();
            wc.setAutoCommit(false);
            PreparedStatement ps = wc.prepareStatement(
                "INSERT OR IGNORE INTO event_store (" +
                "kafka_offset,kafka_partition,book_id,event_type," +
                "version,payload) VALUES (?,?,?,?,?,?)");

            while (count < endOffset - fromOffset) {
                ConsumerRecords<String, String> recs =
                    consumer.poll(Duration.ofMillis(3000));
                if (recs.isEmpty()) break;
                for (ConsumerRecord<String, String> rec : recs) {
                    if (toOffset >= 0 && rec.offset() >= toOffset) break;
                    String payload = rec.value();
                    int version = parseVersion(payload);
                    ps.setLong(1, rec.offset());
                    ps.setInt(2, rec.partition());
                    ps.setString(3, rec.key());
                    ps.setString(4, "InventoryAdjusted");
                    ps.setInt(5, version);
                    ps.setString(6, payload);
                    ps.addBatch();
                    count++;
                }
                ps.executeBatch();
                wc.commit();
                if (toOffset < 0 && count >= endOffset - fromOffset) break;
            }
            wc.close();
            System.out.println("[" + tag + "] 消费并存储 " + count + " 条事件");
        }
    }

    static long getEndOffset(KafkaConsumer<String, String> consumer,
            TopicPartition tp) {
        Map<TopicPartition, Long> endOffsets =
            consumer.endOffsets(Collections.singletonList(tp));
        return endOffsets.get(tp);
    }

    static int parseVersion(String json) {
        String key = "\"version\":";
        int idx = json.indexOf(key);
        if (idx < 0) return 0;
        int start = idx + key.length();
        int end = json.indexOf(",", start);
        if (end < 0) end = json.indexOf("}", start);
        return Integer.parseInt(json.substring(start, end).trim());
    }

    static void replayFromVersion(int version) throws Exception {
        Connection conn = getSqliteConn();
        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery(
            "SELECT book_id, CAST(json_extract(payload, '$.delta') AS INT) AS d " +
            "FROM event_store WHERE version <= " + version +
            " ORDER BY kafka_offset");
        Map<String, Integer> totals = new LinkedHashMap<>();
        int count = 0;
        while (rs.next()) {
            String book = rs.getString(1);
            int delta = rs.getInt(2);
            totals.put(book, totals.getOrDefault(book, 0) + delta);
            count++;
        }
        System.out.println("\n--- 重放到版本 " + version +
            "(" + count + " 条事件)---");
        for (Map.Entry<String, Integer> e : totals.entrySet()) {
            System.out.printf("  %s: total=%d%n", e.getKey(), e.getValue());
        }
        conn.close();
    }
}

编译运行(Temurin 25.0.4+1 JDK / eclipse-temurin:25-jdk-alpine Docker / Kafka 4.3.0 KRaft / SQLite JDBC 3.47.0.0):

javac --release 21 -cp ".:kafka-clients-4.3.0.jar:slf4j-api-2.0.20.jar" \n    EventStoreDemo.java
java -cp ".:kafka-clients-4.3.0.jar:slf4j-api-2.0.20.jar:" +
    "slf4j-simple-2.0.20.jar:sqlite-jdbc-3.47.0.0.jar" \n    EventStoreDemo

输出:

  sent[1] offset=20 book=B-001 delta=4
  sent[2] offset=21 book=B-005 delta=-1
  sent[3] offset=22 book=B-001 delta=-3
  sent[18] offset=37 book=B-005 delta=-1
  sent[19] offset=38 book=B-004 delta=3
  sent[20] offset=39 book=B-001 delta=-2
Kafka 生产者写入 20 条事件到 topic: mq-arch-09-es-test
[arch01] 消费并存储 20 条事件

--- 重放到版本 5(5 条事件)---
  B-001: total=0
  B-005: total=-3
  B-002: total=1

--- 重放到版本 10(10 条事件)---
  B-001: total=-6
  B-005: total=-3
  B-002: total=1
  B-004: total=1
  B-003: total=-4

SQLite 总事件数: 20
全量 delta SUM: -11
版本 <=10 的 delta SUM: -11

单次运行,随调度波动,不作基准。offset=20 起因为 topic 之前已有实验消息。

这个实验验证了四个关键行为:

  1. Kafka 事件可以被消费并原样写入 SQLite。 INSERT OR IGNORE 保证即使程序重启重复消费,也不会写入重复记录。
  2. SQLite 支持 json_extract。不需要手动反序列化,查询层可以直接从 JSON payload 提取字段做聚合。
  3. 重放到任意版本只需 SQL 过滤条件。 version <= 5 和 version <= 10 的结果完全不同,说明版本回溯是精确可控的。
  4. 数据一致性可验证。 全量 SUM(delta) 与按版本过滤后的 SUM(delta) 结果可交叉比对,SQLite 本地归档与 Kafka 源数据一一对应。

事件存储的表结构演进

SQLite 表是本地归档,但 schema 不可能一成不变。当事件格式升级时,归档表是否需要跟着改?通常有两种策略:

  1. 原样归档。 归档表存的是 Kafka message 的原始 payload,类型统一为 TEXT 或 BLOB,schema 变化不影响归档表。查询时用 json_extract,如果新字段存在就提取,不存在就返回 NULL。
  2. 结构化归档。 归档表根据当前已知 schema 拆字段存储,schema 变更时通过 ALTER TABLE 添加新列。优势是查询性能好,劣势是 schema 演进需要迁移。

本地归档通常选第 1 种,因为简单、无迁移、存储开销对单进程场景可接受。

事件丢失的兜底

归档消费者是 Kafka 的一个消费者,它也有位移。如果消费者挂掉且位移提交失败,重启后会从上次成功提交的位置重新消费。这意味着两件事:

  1. 同一事件可能被写入 SQLite 两次。 这是幂等消费的问题,也是 INSERT OR IGNORE 存在的理由。
  2. 位移提交不能太频繁也不能太松。 太频繁影响吞吐;太松则重启后重复消费窗口大。实践中每 1000 条事件或每 5 秒提交一次,根据业务容忍度调整。

Kafka retention 与归档的配合

虽然 SQLite 本地归档可以无限增长,但它只在消费者所在的节点本地存在。如果消费者所在机器挂了,SQLite 文件跟着丢失。为了避免这种单点风险,有两种做法:

  1. SQLite 文件定期异地备份。 用 rsync 或对象存储同步数据文件。由于 SQLite 是单文件,备份和恢复都很方便。
  2. 多消费者独立归档。 启动两个归档消费者写入各自节点的 SQLite,数据冗余两份。任何一份丢失,另一份可以补全。

Kafka 端的 retention 策略则保持保守:不清理,或清理周期远长于本地归档的频率。理想情况下,Kafka 的事件保留窗口 > 本地归档同步周期,这样即使本地文件丢失,也可以从 Kafka 重新消费补齐。

小结

  • 事件存储要求顺序性、不可变性、可遍历性和全局唯一标识,这四条约束把事件存储与普通消息队列区分开。
  • 自建事件存储(Postgres/H2)查询能力强但吞吐扩展有限;Kafka 吞吐高但查询能力弱且受 retention 限制。
  • 混合方案最常用:Kafka 做写入通道,SQLite 做本地归档,重建时从 SQLite 读取不再经手网络。
  • Kafka compact topic 不能当事件存储用,它会删除同 key 的历史消息,破坏事件溯源的完整性。
  • 本机实测:20 条事件从 Kafka 消费归档到 SQLite,全量查询和版本过滤均正确,全量 delta SUM = -11,与重放结果一致。

阅读导航:上一节 9.2 快照与性能 | 下一节 10.1 事件链路的追踪

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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