Skip to content

事务消息与延迟消息 ​

看在前面 ​

事务消息和延迟消息是 MQ 面试的「区分度题」。不丢不重(消息不丢失与不重复)人人会背。而「本地事务和发消息怎么原子」「订单 30 分钟未支付自动取消怎么实现」直接暴露有没有做过交易系统。

两个问题本质都是把 MQ 当状态机用:事务消息解决「写库和发消息的原子性」,延迟消息解决「未来某时刻的触发」。

问题一:本地事务与发消息的原子性 ​

经典场景:订单服务写完订单表,要发「订单已创建」消息通知下游。先发后写库:库写失败,下游收到幽灵消息。先写库后发消息:发消息失败,下游永远不知道。本地消息表/事务消息就是解这个的两条路。

text
方案A 先发后写:  send ✅ → db ✘   → 下游消费了不存在的订单(脏数据)
方案B 先写后发:  db ✅ → send ✘  → 下游状态永远滞后(数据不一致)
目标:           db ✅ + send ✅ 原子,或失败可回滚/可恢复

Kafka 事务:两阶段提交 + transactional.id ​

Kafka 事务的定位先说清楚:它做的是 「多个分区写入 + 消费位移提交」的原子性(consume-transform-produce 循环)。不是「写本地数据库 + 发消息」的原子——后者它管不了,这是和 RocketMQ 事务消息的根本区别。

text
 Producer                    Transaction Coordinator (broker)
    │── initTransactions ──────►│  按 transactional.id 取 epoch
    │                          │  (新会话 epoch+1,旧会话僵尸写入被拒)
    │── addPartitionsToTxn ────►│  声明事务涉及的分区
    │── produce(写数据) ────────►│  数据落盘,但对消费者「不可见」(未提交)
    │   sendOffsetsToTxn ──────►│  把 __consumer_offset 的提交也纳入事务
    │── commitTransaction ─────►│  ① 写 PREPARE_COMMIT marker 到日志
    │◄── ack ──────────────────│  ② 异步向所有分区写 COMMIT marker
    │                          │  消费者(read_committed)看到 marker 才放行
    │   崩溃恢复:              │  coordinator 扫描未完成事务,按 marker 决定
    │                          │  重放提交或写 ABORT(两阶段的恢复阶段)
java
props.put("transactional.id", "order-tx-1");      // 稳定业务 ID,跨重启
props.put("transaction.timeout.ms", "60000");
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();
try {
    producer.beginTransaction();
    producer.send(new ProducerRecord<>("topic-a", k, v));
    producer.send(new ProducerRecord<>("topic-b", k, v));   // 跨 topic 原子
    producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException e) {
    producer.close();          // 僵尸/序号错,必须换 producer
} catch (KafkaException e) {
    producer.abortTransaction();
}
// 消费端 isolation.level=read_committed 才只读已提交数据

要点:transactional.id 的 epoch 机制防僵尸 producer(旧实例网络分区恢复后写会被 fence 拒绝)。消费者要用 read_committed 隔离级别,否则事务白做。

RocketMQ 事务消息:half 消息 + 本地事务 + 回查 ​

RocketMQ 的定位正相反:它解决**「本地事务 + 发消息」的最终一致性**,思路是二阶段消息:

text
 Producer                      Broker                        DB
    │── ① send(half消息) ──────►│  写入系统 topic
    │                          │  RMQ_SYS_TRANS_HALF_TOPIC
    │◄─ half发送成功 ───────────│  (对消费者不可见)
    │── ② 执行本地事务 ─────────────────────────────────────►│  写订单表
    │◄─ 本地事务返回 COMMIT / ROLLBACK / UNKNOWN ─────────────│
    │── ③ commitOrRollback ───►│  COMMIT: half 恢复出真实 topic,可见
    │                          │  ROLLBACK: 删除 half
    │   ③ 没来(producer 挂了) │
    │                          │── ④ 定时回查 checkLocalTx ─►│ 查订单表状态
    │◄────────── 回查结果 ──────│  按结果 commit/rollback(默认回查15次)
java
TransactionMQProducer producer = new TransactionMQProducer("order-group");
producer.setTransactionListener(new TransactionListener() {
    @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        orderService.create(order);                     // 本地事务
        return LocalTransactionState.COMMIT_MESSAGE;   // 失败则 ROLLBACK_MESSAGE
    }
    @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // 回查:以 DB 为准。查得到 -> COMMIT;查不到 -> ROLLBACK
        return orderMapper.exists(msg.getKeys())
                ? LocalTransactionState.COMMIT_MESSAGE
                : LocalTransactionState.UNKNOW;        // UNKNOW 等下一轮回查
    }
});
Message msg = new Message("order-topic", "create", orderId, body);
producer.sendMessageInTransaction(msg, null);

回查的容错语义是面试关键:producer 宕机不会丢事务。broker 会按 transactionCheckInterval 反复回查,直到超过最大次数(默认 15 次,间隔 60s)默认丢弃并告警。所以必须保证本地事务落库可查,回查以 DB 为事实源。

两者原理对比 ​

维度Kafka 事务RocketMQ 事务消息
目标多分区写入+位移提交的原子(流处理 EOS)本地事务与发消息的最终一致
实现coordinator + 事务日志 + PREPARE/COMMIT markerhalf 消息 + 本地事务 + 定时回查
外部 DB不参与、不覆盖以 DB 为事实源做回查
隔离性read_committed 强隔离消息在 commit 前不可见(弱隔离)
典型场景Kafka Streams exactly-once下单后发积分/通知等跨系统一致

一句话总结:Kafka 事务保「消息与消息」原子,RocketMQ 事务消息保「DB 与消息」最终一致,选型时先问自己事务的边界在哪一侧。

延迟消息:几种实现 ​

场景:订单 15 分钟未支付自动取消、定时提醒。轮询 DB 扫表有延迟且压 DB,用延迟消息把「定时」变成 MQ 的调度。

实现一:延迟级别(RocketMQ 4.x) ​

broker 内置 18 个级别,消息先扔进 SCHEDULE_TOPIC_XXXX,由定时服务按级别扫描到期消息投回原 topic:

java
message.setDelayTimeLevel(3);   // 级别3 = 10s
// 级别表:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h

优点:简单、broker 高效(每级别一个队列,队头必然最早到期,只需看队头)。缺点:级别固定不能任意时间,最长 2h。RocketMQ 5.x 已支持任意时刻(setDeliverTimeMs,基于时间轮改造的定时消息)。

实现二:时间轮(HashedWheelTimer 思路) ​

Kafka 自己的延迟操作(延迟拉取、延迟加入组)就是用时间轮实现的。这是它没做延迟消息却必须讲时间轮的原因:

text
轮盘(bucket 数=20,tick=1s,一轮=20s):
      ┌────┬────┬────┬────┬─ ... ─┬────┐
      │ b0 │ b1 │ b2 │ b3 │       │b19 │
      ├────┼────┼────┼────┼─ ... ─┼────┤
      │task│    │task│    │       │task│   每个槽挂到期时间相同的任务链表
      └────┴────┴────┴────┴─ ... ┴────┘
         ▲ currentTime 指针每 tick 前进一格,走到哪格就执行哪格的任务
  延迟 > 一轮:任务留在槽里并记录 remainingRounds,指针每轮经过时轮数-1

插入/删除 O(1),只消耗一个推进线程。它比优先级队列(堆,O(log n) 且全量竞争一把锁)适合海量定时任务。

实现三:Kafka 没有原生延迟消息怎么办 ​

text
方案A:延迟 topic 梯队
  order-delay-5s / order-delay-30s / order-delay-5m ... 每个配一个中转消费者:
  到期检查 → 未到期重新投递回延迟 topic → 到期投回业务 topic
  优点:纯生产端组合即可实现;缺点:层级越多中转流量越大,精度受消费间隔影响

方案B:时间轮服务(自建/TimerWheel + Redis ZSET 兜底)
  发消息先落时间轮,到期再投 Kafka;轮子重启需从存储恢复任务

追问链 ​

Q1:RocketMQ 事务消息的 half 消息为什么要先对消费者不可见?

先占住「消息一定发得出去」的位置,再执行本地事务,避免「事务成功了但消息发不出去」。不可见期是两阶段的第一阶段,回查机制补齐了第二阶段失联的恢复路径。

Q2:回查时本地事务还在执行中怎么办?

返回 UNKNOW,broker 下轮再查。所以本地事务要设计成幂等且可查状态,且别在事务里做长耗时动作。否则回查次数耗尽默认回滚。

Q3:Kafka 事务为什么不能保证写 MySQL + 发消息原子?

事务日志和 marker 只管理 Kafka 自身分区的可见性,外部系统不在 coordinator 的参与者名单里。要 DB+消息原子,要么 RocketMQ 事务消息,要么本地消息表 + 对账兜底。

Q4:时间轮和延迟队列(优先级堆)怎么选?

任务量百万级以上、且删除/取消频繁选时间轮(O(1))。任务量小、到期精度要求高且要全局严格按时间顺序选堆(O(log n) 但取最小值 O(1))。Netty 用时间轮,JDK DelayQueue 用堆。

Q5:延迟消息的时钟问题?

broker 主备时钟漂移会导致到期判断偏差。5.x 任意时刻延迟依赖 broker 统一时钟。这也是自建延迟层时要用 NTP 校准的原因。

面试官视角小结 ​

  • 事务消息先划清两种事务边界(消息间原子 vs DB 与消息一致),再分别讲 Kafka 两阶段 marker 与 RocketMQ half+回查。边界感是这题的最大加分项,混着讲的候选人基本没实践过。
  • RocketMQ 回查必须答出「以 DB 为事实源」「UNKNOW 等待下一轮」「超过次数默认丢弃要告警」三个细节。
  • 延迟消息至少给出「延迟级别 / 时间轮 / 延迟 topic 梯队」三条路,并指出Kafka 无原生支持这个事实——很多人在这里露馅。
  • 时间轮能把 O(1) 插入、tick 推进、remainingRounds 三点说清,直接对齐 Kafka 内部实现层级。
  • 主动对比「本地消息表」方案(最终一致、实现简单、有对账)与事务消息(中间件托管、绑定 RocketMQ)。这体现选型而非背书能力,可与 MQ选型对比 串联。

2026-10 新增(P0 补全)。