Appearance
事务消息与延迟消息
看在前面
事务消息和延迟消息是 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 marker | half 消息 + 本地事务 + 定时回查 |
| 外部 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 补全)。