《Java 消息中间件·可靠消息与消费治理》附录 B 幂等与事务模板

四份可以直接复制的模板:带唯一约束的去重表、状态机迁移、同库同事务的 outbox 与其 relay、以及消费—处理—生产的事务生产者。每份都标注它解决哪一类重复、在什么位置仍会留下窗口、以及拼在一起时不能混用的边界条件,另附一张按场景选模板的决策表。

怎么用这篇附录

这四份模板不是四个「最佳实践」,而是四种不同前提下的解。选错模板比不选更糟:用状态机去处理「通知发送」这种没有实体状态的事件,会写出一个永远推进不了的假状态机;用唯一约束去处理「同一实体一天多条」的事件,会把合法消息当重复丢掉。

每份模板都标了三件事:

  • 解决哪一类重复——这个模板对哪种重复有效。
  • 窗口在哪——它没有关掉的那一段,什么时候会漏。
  • 接哪一节——正文里对应哪一章的展开。

代码用 H2 的语法,DataSource 与 KafkaProducer 的具体实现按你的技术栈替换。所有模板都只依赖 JDBC 与 kafka-clients,不引入连接池或 ORM。

模板一:去重表

适用:任意事件,尤其是没有实体状态、无法用状态机表达副作用的(发短信、调外部接口、写日志流水)。

解决的重复:所有来源的重复投递——生产端重试、再平衡、位移回退、上游重放。

窗口:去重记录与业务写入之间。两者必须同一个事务,否则会出现「记了去重但业务没做」或反过来。

CREATE TABLE consumed_event (
  event_id      VARCHAR(64) PRIMARY KEY,
  consumer      VARCHAR(64) NOT NULL,
  consumed_at   TIMESTAMP   NOT NULL,
  payload_hash  VARCHAR(64) NOT NULL
);

consumer 列不能省。同一份事件可能被多个消费组各处理一次,去重表的键必须是「事件 + 消费者」,否则第二个组的处理会被第一个组误判为重复。

boolean handleOnce(Connection conn, String eventId, String consumer,
                   Runnable businessAction) throws SQLException {
    try (PreparedStatement ps = conn.prepareStatement(
            "INSERT INTO consumed_event(event_id, consumer, consumed_at, payload_hash) " +
            "VALUES (?, ?, CURRENT_TIMESTAMP, ?)")) {
        ps.setString(1, eventId);
        ps.setString(2, consumer);
        ps.setString(3, hashOf(payload));
        ps.executeUpdate();
    } catch (SQLException e) {
        if (e.getSQLState() != null && e.getSQLState().startsWith("23")) {
            return false; // 唯一键冲突 = 已经处理过
        }
        throw e;
    }
    businessAction.run();   // 与上面的 INSERT 在同一个事务里
    return true;
}

三个要点:

  1. 靠 SQLState 判断重复,不靠异常类名。H2 抛 JdbcSQLIntegrityConstraintViolationException,PostgreSQL 抛 PSQLException,捕获具体类名会让代码绑死在一个数据库上。SQLState 以 23 开头是 SQL 标准的「完整性约束违反」。
  2. businessAction 必须用同一个 conn。换一个连接就是另一个事务,原子性消失。
  3. 外面的调用方管事务。这个方法只做「插入 + 业务」,commit 或 rollback 在上层,否则「插入成功、业务失败」时无法回滚。

TTL 与容量:去重表会随事件数线性增长。清理策略要么按 consumed_at 定期删,要么按分区归档。删除的时间窗必须大于「消息可能被重放的最大延迟」,否则重放时会重新消费。这个窗口的下界通常取消息保留期——如果 Agent 的重放可能来自 7 天前的数据,去重记录就得留 7 天以上。

模板二:状态机

适用:有明确生命周期的实体(订单、借阅、工单、账户)。

解决的重复:同一事件重复投递、不同事件的乱序到达。

窗口:状态读取与更新之间。必须用条件更新或行锁,否则两个并发消费者会各自读到旧状态、各自推进。

enum LoanState { CREATED, APPROVED, RETURNED, CANCELLED }

private static final Map<LoanState, Set<LoanState>> ALLOWED = Map.of(
    LoanState.CREATED,  EnumSet.of(LoanState.APPROVED, LoanState.CANCELLED),
    LoanState.APPROVED, EnumSet.of(LoanState.RETURNED)
);

迁移判断的关键在于区分「重复」与「非法」:

当前状态事件目标状态处理
CREATEDAPPROVED执行迁移
APPROVEDAPPROVED幂等成功,不迁移、不报错
APPROVEDCREATED抛异常,说明乱序或上游有 bug
RETURNED任何抛异常,终态不可迁移

第二行是最容易被写成 bug 的地方:把「目标状态等于当前状态」当作正常返回,而不是当作重复。它在重复投递时表现正确,但在「同样的两条合法事件」时也会静默吞掉第二条——如果业务允许同一实体多次进入同一状态(比如多次逾期),那就不能用状态机,回到模板一。

条件更新是防并发的手段:

UPDATE loan
   SET status = ?, updated_at = CURRENT_TIMESTAMP
 WHERE loan_id = ?
   AND status = ?   -- 期望的旧状态

检查返回的影响行数:0 行说明状态已被别人改过,重读后重新判断。这个「读—判断—条件更新」的循环最多重试两次,第三次还失败说明该实体的消息在乱序或重复投递,应当告警而不是无限重试。

模板三:outbox

适用:本地数据库要改,同时必须保证对应的领域事件一定发出去。

解决的重复:不是重复,是丢失。它保证「业务行提交了,事件就一定最终会发」。

窗口:relay 发送成功与标记 published 之间。这段窗口会产生重复投递,需要消费端幂等(模板一或模板二)兜住。outbox 从来不承诺「恰好一次」,只承诺「至少一次」。

表结构与 7.1 节一致,这里重复列出以便直接复制:

CREATE TABLE business_loan (
  loan_id     VARCHAR(32) PRIMARY KEY,
  book_id     VARCHAR(32) NOT NULL,
  member_id   VARCHAR(32) NOT NULL,
  status      VARCHAR(16) NOT NULL,
  created_at  TIMESTAMP   NOT NULL
);

CREATE TABLE outbox (
  id           BIGINT AUTO_INCREMENT PRIMARY KEY,
  event_id     VARCHAR(64) NOT NULL UNIQUE,
  aggregate_id VARCHAR(32) NOT NULL,
  event_type   VARCHAR(64) NOT NULL,
  payload      CLOB        NOT NULL,
  created_at   TIMESTAMP   NOT NULL,
  published    BOOLEAN     NOT NULL DEFAULT FALSE
);

业务写入与 outbox 写入在同一个事务里:

try (Connection conn = ds.getConnection()) {
    conn.setAutoCommit(false);
    try {
        insertLoan(conn, loan);
        insertOutbox(conn, eventId, loan.loanId(), "loan.created", payload);
        conn.commit();
    } catch (SQLException e) {
        conn.rollback();
        throw e;
    }
}

relay 用轮询,按 id 顺序取未发布的行:

List<OutboxRow> batch = selectUnpublished(conn, 100);
for (OutboxRow row : batch) {
    ProducerRecord<String, String> rec =
        new ProducerRecord<>(topicFor(row.eventType()), row.aggregateId(), row.payload());
    rec.headers().add(new RecordHeader("event-id",
        row.eventId().getBytes(StandardCharsets.UTF_8)));
    producer.send(rec, (meta, err) -> {
        if (err == null) markPublished(row.id());
        // 失败不标记,下一轮重试
    });
}
producer.flush();

四个必须注意的点:

  1. event_id 在建表时就生成并写进 outbox,不要每次 relay 重发时重新生成。重发时必须用同一个 eventId,否则消费端去重失效。
  2. 先 send 成功再标记 published。反过来会丢。顺序错了这一条是整个模式失效的唯一入口。
  3. 多个 relay 实例并发会重复发送。要么用单实例,要么用 SELECT ... FOR UPDATE SKIP LOCKED 抢占。无论哪种,消费端幂等都是必须的。
  4. 表会持续增长。已发布的旧行要定期归档,published 列上要有索引,否则轮询会扫全表。

模板四:事务生产者

适用:消费—处理—生产这条链路要求原子——要么「读了这批消息、产出了那批消息、位移推进」三件事一起成功,要么一起不发生。

解决的重复:消费端崩溃导致的「处理了但位移没提交」的重复,以及「产出了但位移没提交」导致的下一轮重复生产。

窗口:外部副作用。事务只能覆盖 Kafka 内部的写入(产出消息 + 位移),覆盖不了数据库写入、HTTP 调用。这是它最常被误解的边界。

props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "libraryd-loan-tx-1");
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, "60000");

producer.initTransactions();
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(cProps)) {
    consumer.subscribe(List.of("libraryd.loan"));
    while (running) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
        producer.beginTransaction();
        try {
            for (ConsumerRecord<String, String> r : records) {
                producer.send(new ProducerRecord<>("libraryd.audit", r.key(), r.value()));
            }
            Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
            for (TopicPartition tp : records.partitions()) {
                long next = records.records(tp).getLast().offset() + 1;
                offsets.put(tp, new OffsetAndMetadata(next));
            }
            producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
            producer.commitTransaction();
        } catch (Exception e) {
            producer.abortTransaction();
        }
    }
}

五个要点:

  1. TRANSACTION_TIMEOUT_CONFIG 不是 ..._MS_CONFIG。ProducerConfig 里这个常量就叫 TRANSACTION_TIMEOUT_CONFIG,写成 TRANSACTION_TIMEOUT_MS_CONFIG 编译不过。
  2. 位移要 +1。OffsetAndMetadata 存的是「下一条要读的位置」,不是「最后处理的位置」。写错会导致重复消费或无端跳过一条。
  3. transactional.id 必须跨实例稳定。同一个逻辑消费者重启后要用同一个 id,否则 fencing 失效、两个实例同时写。它是身份,不是实例名。
  4. 下游消费者必须 read_committed。read_uncommitted 会看到被 abort 的消息,事务白做。3.1 节实测过两种隔离级别看到的结果差异。
  5. 数据库写入不在事务内。要保证「写库 + 发消息」原子,用模板三(outbox),不要试图用 Kafka 事务覆盖数据库——两者是两个独立的事务管理器。

按场景选模板

场景模板理由
创建型事件、键天然唯一唯一约束(业务表主键)成本最低,无需额外表
无状态副作用(通知、调用外部接口)模板一 去重表状态机表达不了
有生命周期的实体模板二 状态机顺带解决乱序
本地库改动 + 必须发事件模板三 outbox消除「有业务无事件」
消费—处理—产出 要原子模板四 事务生产者位移与产出一起提交
跨库/跨服务的多步业务9.1 的 Saga补偿而非回滚

组合是常态而不是例外:一个真实的链路往往是「模板四做 Kafka 内的原子 + 模板一兜住 relay 的重复 + 模板三保证业务事件不丢」。三种叠加不矛盾,因为它们覆盖的是三个不同的窗口。

怎么测这四份模板

每个模板都有一个「最小可验证断言」,写完立刻能验,不要等到集成测试:

模板断言期望
去重表同一个 eventId 调两次 handleOnce第一次 true、第二次 false,业务表只多一行
状态机APPROVED → APPROVED 与 APPROVED → CREATED前者返回幂等成功,后者抛异常
outbox事务内抛异常后查 outbox业务行与 outbox 行都不存在
outbox relay发送成功后查 published变为 TRUE;发送失败则保持 FALSE 等下轮
事务生产者abort 后用两种隔离级别消费read_committed 看不到,read_uncommitted 看得到

最后一行同时验证了模板与配置两件事。如果 read_committed 也能看到被 abort 的消息,先查下游消费者的 isolation.level,而不是改事务代码。

四份模板共同的三个坑

幂等键必须在生产端生成。 四份模板全都依赖一个「重试时不变」的键。如果生产端每次重试都换一个 eventId,下游所有去重都失效。这是唯一一个无法在下游补救的问题。

TTL 不能短于重放窗口。 去重表、outbox 归档、状态机的终态清理,三者的时间窗都要覆盖「消息可能被重放的最大延迟」。这个延迟的上界通常就是消息保留期。

模板不解决「处理成功但副作用失败」。 去重表记了、业务写入回滚了,重试时会被判重复。所以 businessAction 里不能有不可回滚的外部调用——调用外部接口必须走「先记录意图、再异步执行、按结果标记」的路径,也就是 outbox 的思路。这一点在 6.3 节讨论毒消息时也出现过:不可回滚的副作用,是重试机制的天敌。


配置矩阵见附录 A,机制展开见第 2、3、7、9 章。这两篇附录不含新概念,只在抄代码时翻。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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