概述
系列定位说明
本文是“分布式事务工程实践”系列的第五篇。在透彻拆解 XA 强一致(第二篇 Seata AT)、TCC 业务补偿(第三篇)、Saga 长事务编排(第四篇)之后,我们进入分布式事务领域最广泛使用的异步最终一致性方案——基于消息队列的可靠消息最终一致性。理解“如何保证业务操作与消息发送的原子性”以及“如何保证消息可靠投递与幂等消费”,是构建高并发异步解耦架构的基石。本篇将从前文已建立的补偿逻辑与幂等控制体系出发,剖析消息驱动一致性的内核。
总结性引言
在电商大促场景中,订单服务创建订单后需要通知库存服务扣减库存、积分服务增加积分、短信服务发送通知。若将这三件事置于一个分布式事务中协调(XA 或 Seata AT),吞吐量将受限于最慢服务的响应时间,全局锁竞争急剧拉低并发度;若采用 Saga 编排,则需为“增加积分失败”设计补偿逻辑(扣减已加的积分),补偿链长度随参与服务数量线性膨胀,维护噩梦。可靠消息最终一致性提供了第三条路径:订单服务只需保证“创建订单”与“写入一条待发送消息”的原子性(本地消息表或 RocketMQ 事务消息),然后立即返回成功;库存服务、积分服务、短信服务各自订阅消息,独立幂等消费。没有全局锁、没有补偿链、没有 Try 预留资源——只有消息的可靠投递和消费者的幂等处理。
当 OutboxScheduler 每 3 秒扫描 outbox 表将 PENDING 状态的消息发送到 RocketMQ,当 TransactionMQProducer 的半消息等待 executeLocalTransaction 返回 COMMIT 或 ROLLBACK,当消费者通过 INSERT...ON DUPLICATE KEY UPDATE 防止重复消费——这些机制背后,是可靠消息最终一致性对“如何在异步解耦的同时保证最终一致”的工程回答。本文将从本地消息表的 outbox 表设计出发,到 RocketMQ 事务消息的半消息与回查机制,再到消费者的三种幂等去重方案,完整拆解可靠消息最终一致性的工程内核。
核心要点
- 本地消息表:
outbox表与业务同事务写入 +OutboxScheduler定时扫描发送 +PENDING→SENDING→SENT→DEAD状态机。 - RocketMQ 事务消息:半消息(消费者不可见)+
executeLocalTransaction+ MQ Broker 回查checkLocalTransaction。 - 幂等消费:三种去重方案——数据库唯一约束、Redis
SET NX PX、业务状态机乐观锁。 - 最大努力通知:
WAITING→SENT→CONSUMED状态机 + 定时重试 + 消费者确认回调。 - 与 Saga 对比:可靠消息最终一致性无逆序补偿要求,仅保证正向消息可靠投递。
文章组织架构图
flowchart LR
A["可靠消息最终一致性"] --> B["1. 本地消息表模式"]
A --> C["2. RocketMQ 事务消息模式"]
A --> D["3. 下游消费者幂等消费"]
A --> E["4. 最大努力通知模式"]
A --> F["5. 方案对比: 本地消息表 vs RocketMQ 事务消息 vs Saga"]
A --> G["6. 面试高频专题"]
B --> B1["outbox 表设计"]
B --> B2["OutboxScheduler 定时扫描"]
B --> B3["状态机: PENDING→SENDING→SENT/DEAD"]
C --> C1["半消息机制: RMQ_SYS_TRANS_HALF_TOPIC"]
C --> C2["executeLocalTransaction"]
C --> C3["回查: checkLocalTransaction"]
D --> D1["DB 唯一约束"]
D --> D2["Redis SET NX"]
D --> D3["业务状态机防重"]
E --> E1["状态机: WAITING→SENT→CONSUMED"]
E --> E2["消费者确认回调"]
F --> F1["延迟对比"]
F --> F2["可靠性对比"]
F --> F3["适用场景对比"]
classDef default fill:#f1f5f9,stroke:#334155,stroke-width:2px,color:#0f172a
架构图说明
- 总览说明:全文 6 个模块从本地消息表的基础方案出发,逐步深入到 RocketMQ 事务消息的进阶方案、消费者幂等消费、最大努力通知,最后以方案对比和面试题收尾。
- 逐模块说明:模块 1-2 是两种核心实现路径,分别代表应用层原子性保证(本地消息表)和 MQ 层原子性保证(RocketMQ 事务消息);模块 3 是消费端的通用保障,是最终一致性的必要条件;模块 4 是面向通知场景的扩展模式;模块 5 进行跨方案对比,并关联第 4 篇 Saga;模块 6 面试巩固。
- 关键结论:可靠消息最终一致性的核心是业务操作与消息发送的原子性——本地消息表通过在同一个本地事务中操作业务表和
outbox表实现,RocketMQ 事务消息通过半消息与回查在 MQ 侧实现。消息消费的幂等性是保证最终一致性的必要条件,数据库唯一约束是最可靠的去重方案。该模式是当前高并发异步解耦场景中最广泛使用的分布式事务方案。
二、本地消息表模式:outbox 表设计 + OutboxScheduler 定时扫描 + 状态机
2.1 架构总览
本地消息表模式在业务数据库中增加一张 outbox 表,将消息发送转化为数据库写入,再通过独立的调度器完成投递,完全解耦业务与消息队列。其架构组件与交互路径如下:
flowchart LR
subgraph Application ["业务服务"]
direction LR
Biz["订单服务"]
end
subgraph Database ["业务数据库"]
direction LR
BizTable[("业务表: orders")]
OutboxTable[("outbox 表")]
end
subgraph Scheduler ["调度器集群"]
direction LR
S1["OutboxScheduler 实例1"]
S2["OutboxScheduler 实例2"]
end
subgraph MQ ["RocketMQ Broker"]
Queue["OrderTopic 队列"]
end
subgraph Consumers ["消费者"]
direction LR
C1["库存服务"]
C2["积分服务"]
C3["短信服务"]
end
Biz -->|"同本地事务: INSERT order + INSERT outbox"| Database
S1 & S2 -->|"定时扫描: SELECT ... WHERE status='PENDING'"| OutboxTable
S1 & S2 -->|"CAS抢占: UPDATE status='SENDING'"| OutboxTable
S1 & S2 -->|"send()"| Queue
S1 & S2 -->|"成功: UPDATE status='SENT' / 失败: 递增重试"| OutboxTable
Queue -->|"推送消息"| C1 & C2 & C3
C1 & C2 & C3 -->|"幂等去重 + 业务处理"| C1 & C2 & C3
classDef app fill:#f1f5f9,stroke:#334155,color:#0f172a
classDef db fill:#e2e8f0,stroke:#475569,color:#1e293b
classDef sched fill:#ede9fe,stroke:#8b5cf6,color:#3b2f4b
classDef mq fill:#fef3c7,stroke:#d97706,color:#78350f
classDef consumer fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
class Biz app
class BizTable,OutboxTable db
class S1,S2 sched
class Queue mq
class C1,C2,C3 consumer
图 2-1 — 本地消息表模式架构图
- 业务服务:负责在同一个本地数据库事务中完成业务数据写入和
outbox消息插入。业务服务不直接操作消息队列,完全依赖outbox表作为消息的持久化日志。 - 数据库:核心数据包括业务表(如
orders)和新增的outbox表。数据库实例的本地事务能力是保证业务与消息原子性的关键。 - 调度器集群:由多个
OutboxScheduler实例组成,通过定时轮询outbox表获取待发送消息,并利用状态机PENDING → SENDING → SENT/DEAD以及乐观锁UPDATE ... WHERE status = 'PENDING'保证多实例下的发送幂等。 - 消息队列:任何消息中间件均可(RocketMQ、Kafka、RabbitMQ),只需在调度器中适配相应发送 API。
- 消费者:订阅对应主题,按业务唯一键
message_key进行幂等去重后执行本地业务,并确认消费。
2.2 核心原理:同本地事务写入 outbox 表
在单体架构下,事务内同时操作两张表是理所当然的:BEGIN TRANSACTION → UPDATE account SET balance = balance - 100 WHERE id = 1 → INSERT INTO order_log … → COMMIT。本地消息表模式正是将这种思路平移到分布式场景:在业务操作所在的同一个数据库本地事务中,向一张专门的 outbox 表插入一条待发送消息记录。本地事务的原子性天然保证了“业务操作成功 ↔ 消息写入成功”。事务提交后,由独立的定时任务进程将 outbox 表中的消息投递到消息队列,消费者最终处理。这就消除了经典的两难问题:“先写数据库还是先发消息?先发消息,消息发出但数据库事务失败;先写数据库,数据库写入但消息发送失败”。
如图 2-2 所示,本地事务的边界包含订单业务 SQL 和 INSERT INTO outbox:
sequenceDiagram
participant BizSvc as 业务服务
participant DB as 数据库
participant Scheduler as OutboxScheduler
participant MQ as RocketMQ Broker
participant Consumer as 消费者
BizSvc->>DB: BEGIN LOCAL TRANSACTION
BizSvc->>DB: INSERT INTO orders (id, status...) VALUES (123, 'CREATED')
BizSvc->>DB: INSERT INTO outbox (message_key, topic, message_body, status) VALUES (123, 'OrderCreated', '{"orderId":123}', 'PENDING')
BizSvc->>DB: COMMIT
Note over BizSvc,DB: 业务操作与消息写入原子成功
loop 每N秒扫描
Scheduler->>DB: SELECT * FROM outbox WHERE status='PENDING' AND next_retry_time<=NOW() LIMIT 100
Scheduler->>DB: UPDATE outbox SET status='SENDING' WHERE status='PENDING' AND id IN (...)
Scheduler->>MQ: rocketMQTemplate.send(topic, tag, messageKey, body)
alt 发送成功
Scheduler->>DB: UPDATE outbox SET status='SENT' WHERE id=?
MQ->>Consumer: 推送消息
Consumer->>Consumer: 幂等去重 + 执行业务逻辑
Consumer->>MQ: ACK
else 发送失败
Scheduler->>DB: UPDATE outbox SET retry_count++, next_retry_time=NOW()+INTERVAL ... WHERE id=?
end
end
图 2-2 — 本地消息表完整序列图
- 阶段 1:业务事务:业务服务将订单插入和
outbox消息插入封装在同一本地事务中提交。即使此时没有 MQ 连接,消息也不会丢失,它已持久化在业务库中。 - 阶段 2:定时扫描:
OutboxScheduler独立于业务请求线程,周期性扫描PENDING消息,通过条件更新status='SENDING'抢占发送权,避免多实例并发重复发送。 - 阶段 3:消息投递:调度器通过
RocketMQTemplate发送消息,成功则标记为SENT,失败则递增重试次数并设置指数退避的next_retry_time。 - 阶段 4:幂等消费:消费者接收到消息后,必须执行去重检查再处理,然后 ACK。即使同一条消息被重复投递,业务效果也只会发生一次。
2.3 outbox 表结构设计
一张健壮的 outbox 表需要承载消息元数据、投递状态和重试策略。标准 DDL 如下:
CREATE TABLE outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
topic VARCHAR(100) NOT NULL COMMENT '消息主题',
tag VARCHAR(100) DEFAULT NULL COMMENT '消息标签,用于消费过滤',
message_key VARCHAR(128) NOT NULL COMMENT '业务唯一键,如订单ID,消费者幂等去重依据',
message_body TEXT NOT NULL COMMENT '消息体JSON,包含完整业务数据',
status ENUM('PENDING','SENDING','SENT','DEAD') NOT NULL DEFAULT 'PENDING'
COMMENT '消息状态:PENDING待发送,SENDING发送中,SENT已发送,DEAD死信',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
COMMENT '下次重试时间,实现指数退避',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_message_key (message_key) -- 业务幂等辅助,可选但强烈建议
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
字段语义解读:
message_key:承载业务全局唯一标识,是消费者去重的核心依据。下游消费者可依据它查询去重表或执行INSERT ... ON DUPLICATE KEY UPDATE。唯一约束uk_message_key也防止业务重复插入同一消息。status:引入SENDING中间状态解决定时任务多实例并发发送问题。发送前 CAS 更新为SENDING,成功则升级为SENT,失败则回退为PENDING等待下次重试。DEAD状态用于超过最大重试次数的消息,需人工介入。retry_count与next_retry_time:实现指数退避重试策略,避免对 MQ 和下游服务的密集压力。通常退避公式为next_retry_time = now + 2^retry_count * 基础间隔或固定倍数。message_body:存储完整的业务事件快照。消费者无需回调生产者就能获得所有必要信息,降低服务间耦合。
2.4 OutboxScheduler 定时扫描与发送幂等性
定时任务的核心职责:可靠地将 PENDING 消息转换为 SENT。在 Spring Boot 中可使用 @Scheduled 注解驱动,伪代码如下:
@Component
public class OutboxScheduler {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
private static final int MAX_RETRY = 10;
private static final long BASE_DELAY_SECONDS = 60; // 基础退避60秒
@Scheduled(fixedDelay = 3000) // 每3秒执行
public void sendPendingMessages() {
// 1. 分页拉取 PENDING 消息,注意条件 next_retry_time <= NOW()
List<OutboxMessage> messages = jdbcTemplate.query(
"SELECT id, topic, tag, message_key, message_body, retry_count FROM outbox " +
"WHERE status = 'PENDING' AND next_retry_time <= NOW() " +
"ORDER BY id LIMIT 100",
new OutboxMessageRowMapper()
);
for (OutboxMessage msg : messages) {
// 2. 乐观锁抢占 SENDING 状态,防止多实例并发
int updated = jdbcTemplate.update(
"UPDATE outbox SET status = 'SENDING' WHERE id = ? AND status = 'PENDING'",
msg.getId()
);
if (updated == 0) {
continue; // 已被其他实例抢占
}
try {
// 3. 发送消息
rocketMQTemplate.send(msg.getTopic() + ":" + msg.getTag(),
MessageBuilder.withPayload(msg.getMessageBody())
.setHeader(RocketMQHeaders.KEYS, msg.getMessageKey())
.build());
// 4. 成功则标记为 SENT
jdbcTemplate.update("UPDATE outbox SET status = 'SENT' WHERE id = ?", msg.getId());
} catch (Exception e) {
// 5. 失败:增加重试计数并计算下次重试时间,回退到 PENDING
int newRetry = msg.getRetryCount() + 1;
long delay = (long) Math.pow(2, newRetry) * BASE_DELAY_SECONDS;
if (newRetry > MAX_RETRY) {
jdbcTemplate.update(
"UPDATE outbox SET status = 'DEAD', retry_count = ? WHERE id = ?",
newRetry, msg.getId());
// 触发告警...
} else {
jdbcTemplate.update(
"UPDATE outbox SET status = 'PENDING', retry_count = ?, " +
"next_retry_time = DATE_ADD(NOW(), INTERVAL ? SECOND) WHERE id = ?",
newRetry, delay, msg.getId());
}
}
}
}
}
设计意图与可靠性语义:
- 分页 + 条件查询:
WHERE status = 'PENDING' AND next_retry_time <= NOW()确保只处理到期重试的消息,避免对刚失败的消息立即重试。 - 乐观锁抢占:
UPDATE ... SET status='SENDING' WHERE status='PENDING'是一个 CAS 操作。即使多个调度器实例同时扫描到同一条消息,只有一个能将状态改为SENDING,其余实例的影响行数为 0 并跳过。这从根本上消除了发送阶段的重复投递问题。 - 发送异常处理:任何异常(网络超时、MQ 不可用)都会被捕获,消息状态回退到
PENDING,重试计数增加并计算指数退避下次重试时间。这保证了消息最终一定会被送达(at-least-once)。 - 死信机制:当
retry_count超过MAX_RETRY(例如 10 次)时,消息进入DEAD状态。此时必须人工介入排查原因(如消息格式错误、下游服务长期不可用),避免无限重试浪费资源。
2.5 outbox 表状态机
outbox 表的状态流转需严格定义,以确保任何异常都能被正确处理。
stateDiagram-v2
[*] --> PENDING : 业务事务INSERT
PENDING --> SENDING : 定时任务CAS抢占
SENDING --> SENT : 发送MQ成功
SENDING --> PENDING : 发送失败且未超最大重试
SENDING --> DEAD : 发送失败且达到最大重试次数
DEAD --> [*] : 人工修复后手动重发或删除
SENT --> [*] : 定期归档清理
图 2-3 — outbox 表状态机转换图
- PENDING→SENDING:定时任务通过
UPDATE ... SET status='SENDING' WHERE status='PENDING'抢占。若更新失败说明已被其他实例处理,保证幂等。 - SENDING→SENT:消息成功投递到 MQ Broker。此时消息对消费者可见,可靠性由 MQ 的持久化和消费者的 ACK 机制保障。
- SENDING→PENDING:发送过程中发生临时性错误(如网络超时)。状态回滚为
PENDING,根据重试计数设定next_retry_time,稍后重新拉取。 - SENDING→DEAD:连续重试
MAX_RETRY次仍失败,判定为不可恢复错误,移入死信并触发运维告警。
2.6 优点与局限
优点:
- 零外部依赖:仅依赖业务数据库和任何消息队列,无需特殊事务协调器。
- 实现简单:核心逻辑仅仅是本地事务加定时任务。
- 与 MQ 解耦:更换 RabbitMQ、Kafka 只需修改
send实现,业务代码无感。
局限:
- 秒级延迟:定时扫描间隔通常设置为 3~5 秒,加上消息投递和消费时间,端到端延迟通常在秒级,不适用于 100ms 以内的强实时场景。
- outbox 表膨胀:高频业务会产生大量消息记录,
SENT状态的消息需定期清理,否则影响查询性能并占用存储。通常使用定时任务批量删除 7 天前的SENT记录。 - 顺序无法严格保证:由于重试、并发抢占等因素,消息发送到 MQ 的顺序可能与
outbox表写入顺序不完全一致。如果业务强依赖顺序,需要在下游做额外排序或使用顺序消息机制。
三、RocketMQ 事务消息模式:半消息 + executeLocalTransaction + 回查机制
3.1 架构总览
RocketMQ 事务消息将消息原子性保证下沉到 Broker 层,通过半消息暂存和两阶段提交(准备与确认)消除了 outbox 表和定时扫描延迟。其架构组件与交互路径如下:
flowchart TD
subgraph Producer[生产者服务]
BizSvc[订单服务]
TXListener[TransactionListener]
end
subgraph Broker[RocketMQ Broker]
HalfTopic[(RMQ_SYS_TRANS_HALF_TOPIC)]
RealTopic[(OrderTopic)]
end
subgraph BizDB[业务数据库]
Orders[(orders 表)]
end
subgraph Consumer[消费者]
InventorySvc[库存服务]
end
BizSvc -->|1. sendMessageInTransaction| HalfTopic
HalfTopic -->|2. 回调| TXListener
TXListener -->|3. executeLocalTransaction| Orders
TXListener -->|4. COMMIT / ROLLBACK| Broker
HalfTopic -->|COMMIT 时复制| RealTopic
RealTopic -->|5. 推送| InventorySvc
Broker -.->|超时/UNKNOWN 时回查| TXListener
TXListener -.->|checkLocalTransaction 查询| Orders
TXListener -.->|返回 COMMIT / ROLLBACK| Broker
图 3-1 — RocketMQ 事务消息模式架构图
- 生产者服务:包含业务逻辑和
TransactionListener实现。业务入口通过RocketMQTemplate.sendMessageInTransaction发起事务消息,TransactionListener负责执行本地事务和回查。 - Broker 内部主题:
RMQ_SYS_TRANS_HALF_TOPIC是半消息的暂存区,对任何消费者不可见。只有当生产者提交后,消息才会被复制到OrderTopic等业务主题。 - 业务数据库:只负责业务数据的持久化,不再维护
outbox表。本地事务的执行结果(订单是否创建)通过回调或回查告知 Broker。 - 消费者:与普通消息消费完全相同,从业务主题拉取消息并进行幂等处理。
3.2 核心原理与半消息实现
RocketMQ 事务消息借鉴了两阶段提交(2PC)的思想,将其轻量化为消息层面的提交/回滚。生产者发送半消息(Prepare 阶段),Broker 暂存但不投递;然后生产者执行本地事务,根据事务结果通知 Broker 提交或回滚(Commit/Rollback 阶段)。如图 3-2 所示:
sequenceDiagram
participant Producer as 生产者
participant Broker as RocketMQ Broker
participant BizDB as 业务数据库
participant Consumer as 消费者
Producer->>Broker: 发送半消息(Half Message)
Broker-->>Producer: 半消息写入 RMQ_SYS_TRANS_HALF_TOPIC,返回成功
Broker->>Producer: 回调 executeLocalTransaction()
Producer->>BizDB: 执行本地事务(如创建订单)
alt 本地事务成功
Producer-->>Broker: 返回 COMMIT_MESSAGE
Broker->>Broker: 半消息复制到真实主题,消费者可见
Broker->>Consumer: 推送消息
else 本地事务失败
Producer-->>Broker: 返回 ROLLBACK_MESSAGE
Broker->>Broker: 丢弃半消息
else 无响应/超时
Note over Broker: 等待 transactionTimeout (默认60s)
loop 定时回查
Broker->>Producer: checkLocalTransaction(messageExt)
Producer->>BizDB: 查询本地事务状态(根据messageKey)
alt 事务已提交
Producer-->>Broker: COMMIT_MESSAGE
Broker->>Consumer: 推送消息
else 事务已回滚
Producer-->>Broker: ROLLBACK_MESSAGE
else 事务状态未知
Producer-->>Broker: UNKNOW,继续回查
end
end
end
图 3-2 — RocketMQ 事务消息完整时序图
- 半消息阶段:生产者调用
sendMessageInTransaction后,Broker 将消息存储到内置主题RMQ_SYS_TRANS_HALF_TOPIC。此时该主题对任何消费者不可见,因此下游不会看到未提交的事务消息。 - 执行本地事务:Broker 收到半消息后,回调生产者注册的
TransactionListener.executeLocalTransaction()。生产者在回调中执行真正的业务逻辑(如INSERT INTO orders),并返回三种状态之一:COMMIT_MESSAGE(提交)、ROLLBACK_MESSAGE(回滚)或UNKNOW(状态未知,触发回查)。 - 提交/回滚:若为
COMMIT,Broker 将半消息复制到原目标主题,消费者可见;若为ROLLBACK,则直接丢弃半消息。整个过程没有outbox表,消息的“暂存”由 Broker 负责。 - 回查机制:若
executeLocalTransaction超时、网络断开或返回UNKNOW,Broker 将定时向生产者发起回查请求,调用checkLocalTransaction。生产者必须根据半消息中的业务键(messageKey)查询本地事务的真实状态,并返回明确的COMMIT或ROLLBACK。回查次数由transactionCheckMax配置(默认 15 次),超过后消息被丢弃并触发告警。
3.3 executeLocalTransaction 与 checkLocalTransaction 实现
在 Spring Boot 中集成 RocketMQ 事务消息,可以使用 RocketMQTemplate 和 @RocketMQTransactionListener 注解,简化回调注册。
@Service
@RocketMQTransactionListener(txProducerGroup = "order-producer-group")
public class OrderTransactionListener implements RocketMQLocalTransactionListener {
@Autowired
private OrderService orderService;
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
String orderId = msg.getHeaders().get(RocketMQHeaders.KEYS, String.class);
byte[] body = (byte[]) msg.getPayload();
// 解析业务参数
OrderCreateRequest request = JSON.parseObject(new String(body, StandardCharsets.UTF_8), OrderCreateRequest.class);
try {
// 执行本地事务:创建订单
orderService.createOrder(request);
return RocketMQLocalTransactionState.COMMIT;
} catch (Exception e) {
log.error("Order creation failed, orderId={}", orderId, e);
return RocketMQLocalTransactionState.ROLLBACK;
}
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
String orderId = msg.getHeaders().get(RocketMQHeaders.KEYS, String.class);
// 回查本地事务状态:订单是否存在且状态正确
Order order = orderService.getOrder(orderId);
if (order != null && "CREATED".equals(order.getStatus())) {
return RocketMQLocalTransactionState.COMMIT;
} else if (order == null) {
return RocketMQLocalTransactionState.ROLLBACK;
} else {
// 状态不明确,继续回查
return RocketMQLocalTransactionState.UNKNOWN;
}
}
}
// 业务服务入口
@Service
public class OrderMessageService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void sendOrderMessage(OrderCreateRequest request) {
String orderId = request.getOrderId();
Message message = MessageBuilder.withPayload(JSON.toJSONBytes(request))
.setHeader(RocketMQHeaders.KEYS, orderId)
.build();
// 发送事务消息,第三个参数 businessParam 会传给 executeLocalTransaction 的 arg
rocketMQTemplate.sendMessageInTransaction(
"order-producer-group", // 事务生产者组
"OrderTopic:create", // 主题:Tag
message,
null);
}
}
可靠性语义:
executeLocalTransaction:必须保证其内部的业务逻辑是幂等的,因为极端情况下 Broker 可能因超时发起重试。通常的做法是使用messageKey作为业务唯一键防重。checkLocalTransaction:回查时生产者需可靠判断事务的真实状态。如果业务库已提交但回查接口查询不到(如主从延迟),返回UNKNOWN将导致持续回查直至超限;若返回ROLLBACK则消息丢失,造成业务不一致。因此回查逻辑必须可靠,且数据库查询要能立即反映最新提交状态(例如强制走主库查询)。- 回查超限:当回查次数达到
transactionCheckMax后,Broker 会丢弃消息并记录日志。为应对这种极端情况,业务系统应监控回查失败数量,并设置相应的告警和补偿任务。
3.4 与本地消息表的本质差异
| 维度 | 本地消息表 | RocketMQ 事务消息 |
|---|---|---|
| 消息暂存位置 | 业务数据库 outbox 表 | MQ Broker 内部主题 (RMQ_SYS_TRANS_HALF_TOPIC) |
| 原子性保证方式 | 本地事务同时操作业务表 + outbox 表 | 半消息先于本地事务发送,后续 commit/rollback 绑定事务结果 |
| 投递延迟 | 秒级(定时扫描间隔) | 准实时(ms 级,仅受回查影响) |
| 外部依赖 | 仅依赖业务 DB,任何 MQ | 强依赖 RocketMQ,无法迁移至 RabbitMQ/Kafka |
| 运维复杂度 | 需管理 outbox 表膨胀、定时任务集群 | 需关注回查超限、半消息堆积 |
| 发送幂等性保障 | SENDING 状态乐观锁 | Broker 端半消息去重(生产端重试不产生重复消息) |
RocketMQ 事务消息凭借 Broker 端的半消息暂存和回查机制,天然避免了定时扫描的延迟和 outbox 表管理成本,适合已有 RocketMQ 基础设施且对延迟敏感的场景。但其技术栈绑定限制使得在 RabbitMQ 或 Kafka 环境下仍必须采用本地消息表(或下文将提到的 CDC 方案)。
四、下游消费者幂等消费:三种去重方案
4.1 架构总览
消费者幂等消费是可靠消息最终一致性的最后一道防线。由于 MQ 只能保证 at-least-once 投递,消费者必须设计去重逻辑,使同一条消息多次消费的效果与一次相同。幂等消费的核心是基于 message_key 的唯一性检查,可通过数据库、Redis 或业务状态机实现。架构如下:
flowchart LR
subgraph MQ[RocketMQ Broker]
Queue[OrderTopic 队列]
end
subgraph Consumer[消费者服务]
Listener[MessageListener]
Dedup[去重检查]
BizLogic[业务逻辑]
end
subgraph DedupBackends[去重存储]
DB[(去重表 consumer_record)]
Redis[(Redis)]
BizDB[(业务状态字段)]
end
Queue -->|拉取消息| Listener
Listener --> Dedup
Dedup -->|DB方案: INSERT consumer_record| DB
Dedup -->|Redis方案: SET NX PX| Redis
Dedup -->|状态机方案: UPDATE ... WHERE status='old'| BizDB
Dedup -->|未重复| BizLogic
Dedup -->|已重复| Listener
BizLogic -->|执行成功| Listener
Listener -->|ACK| Queue
图 4-1 — 消费者幂等消费架构图
- 消费者监听器:从 MQ 拉取消息,提取
message_key,交由去重组件判断。 - 去重组件:根据选型策略,访问相应的去重存储。若判定重复,直接返回 ACK 跳过业务逻辑;若未重复,则执行业务逻辑并记录去重标识。
- 去重存储:数据库去重表提供强一致保障;Redis 提供高性能去重;业务表自身状态字段提供免额外表的去重方式。
4.2 消费流程
消费者幂等消费的标准流程如下:
- 从 MQ 拉取消息,解析
messageKey(通常设置在 RocketMQ 消息的KEYS属性中)。 - 查询去重表(或执行
SET NX/UPDATE WHERE条件),判断是否已消费。 - 若未消费,执行业务逻辑,并将
messageKey写入去重表,ACK 确认消费。 - 若已消费,直接 ACK,丢弃重复消息。
4.3 数据库唯一约束去重
最可靠的方式是利用关系数据库的主键唯一约束。消费者在接收到消息后,将 messageKey 插入一张专门的消息去重表,该操作与业务 SQL 包裹在同一个本地事务中。
@Transactional
public void handleMessage(String messageKey, String messageBody) {
// 去重表插入
try {
jdbcTemplate.update(
"INSERT INTO consumer_record (message_key, create_time) VALUES (?, NOW())",
messageKey);
} catch (DuplicateKeyException e) {
// 主键冲突,说明消息已消费,直接确认
log.info("Duplicate message ignored: {}", messageKey);
return;
}
// 业务逻辑,如更新库存
inventoryService.deduct(messageBody);
}
去重表 DDL:
CREATE TABLE consumer_record (
message_key VARCHAR(128) PRIMARY KEY,
create_time DATETIME NOT NULL
) ENGINE=InnoDB;
优势:与业务同事务,强一致;即使消费者宕机重启,去重记录不会丢失。局限:引入一次 INSERT,轻微影响业务吞吐;去重表需定期清理(保留时间窗口内的记录即可)。
4.4 Redis SET NX 去重
高吞吐场景下,DB 的额外写入可能成为瓶颈。Redis 的 SET key value NX PX milliseconds 命令提供了一种高性能的分布式去重方案:
public void handleMessage(String messageKey, String messageBody) {
String redisKey = "msg:dedup:" + messageKey;
// SET NX PX:如果key不存在则设置,并设定过期时间(如24小时)
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", Duration.ofHours(24));
if (Boolean.TRUE.equals(success)) {
// 成功获取去重锁,执行业务
businessService.process(messageBody);
} else {
log.info("Duplicate message ignored: {}", messageKey);
}
}
过期时间考量:必须大于消息重试的最大窗口。例如消息重试持续最长 2 小时,则 TTL 应设为 24 小时以上,避免去重键过期后重复消息再次被执行。劣势:Redis 宕机或数据丢失会导致去重失效,可能引发业务重复。在金融等强一致场景慎用。
4.5 业务状态机防重
当业务本身具备明确的状态流转时,可利用数据库的乐观锁实现天然幂等,无需额外去重表。例如订单支付通知:
public void processPayment(String orderId) {
int rows = jdbcTemplate.update(
"UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'UNPAID'",
orderId);
if (rows == 0) {
// 状态已为 PAID 或不存在,重复消息,忽略
return;
}
// 后续积分发放等
}
这里 WHERE status = 'UNPAID' 就是去重条件,依赖数据库行锁保证并发安全。适用场景:业务实体有清晰的状态枚举,且状态转换不可逆。局限:无法用于无状态的操作,比如纯粹的通知记录。
4.6 三种方案对比图
flowchart TD
subgraph DB[数据库唯一约束]
A1[收到消息] --> A2[INSERT consumer_record]
A2 -- 成功 --> A3[执行业务]
A2 -- 主键冲突 --> A4[直接ACK]
end
subgraph Redis[Redis SET NX]
B1[收到消息] --> B2[SET key NX PX]
B2 -- OK --> B3[执行业务]
B2 -- nil --> B4[直接ACK]
end
subgraph StateMachine[业务状态机]
C1[收到消息] --> C2[UPDATE ... WHERE status='old']
C2 -- rows>0 --> C3[执行业务]
C2 -- rows=0 --> C4[直接ACK]
end
图 4-2 — 消费者幂等消费三种去重方案对比图
- DB 方案:去重记录与业务强一致,安全最高,适用于对一致性要求严格的金融、交易场景。
- Redis 方案:性能最佳,延迟低,适合高并发但能容忍极小概率重复的互联网场景,需合理设置过期时间并监控 Redis 可用性。
- 状态机方案:零额外存储,实现精简,但仅适用于有明确状态字段的业务实体,普适性较弱。
选择策略通常是:核心链路用 DB 唯一约束,旁路日志通知用 Redis,状态明确的实体优先用状态机。
五、最大努力通知模式:WAITING→SENT→CONSUMED + 消费者确认回调
5.1 架构总览
最大努力通知是本地消息表的扩展,在原有消息投递保证之上,增加了消费者处理确认环节,确保通知被完整处理。发送方不仅负责消息发送,还要等待消费者的确认回调,未确认的消息会定期重发。架构如下:
flowchart TB
subgraph Sender[发送方服务]
Biz[业务服务]
Outbox[outbox 表]
Scheduler[OutboxScheduler]
ConfirmAPI[确认回调接口]
end
subgraph MQ[RocketMQ Broker]
Queue[通知主题]
end
subgraph Receiver[消费者服务]
Listener[消息监听器]
BizLogic[业务处理]
ConfirmClient[确认回调客户端]
end
Biz -->|同事务写入| Outbox
Scheduler -->|扫描 WAITING 状态| Outbox
Scheduler -->|发送消息| Queue
Scheduler -->|更新为 SENT| Outbox
Queue -->|推送| Listener
Listener --> BizLogic
BizLogic -->|处理成功| ConfirmClient
ConfirmClient -->|回调 confirm 接口| ConfirmAPI
ConfirmAPI -->|更新为 CONSUMED| Outbox
Scheduler -->|超时未确认: 重置为 WAITING 重发| Outbox
图 5-1 — 最大努力通知模式架构图
- 发送方:依然使用
outbox表,但状态扩展为WAITING、SENT、CONSUMED,并新增确认回调 API 供消费者调用。 - 调度器:除常规发送逻辑外,增加对
SENT状态超过确认窗口的消息进行重发的能力(回退为WAITING)。 - 消费者:处理完业务后,必须回调发送方的确认接口,传递
message_key,完成闭环。 - 去重保障:消费者仍然需要幂等处理,因为重发可能导致重复消息。
5.2 核心原理与状态机
最大努力通知将消息生命周期延长到消费者确认。其状态机如图 5-2:
stateDiagram-v2
[*] --> WAITING : 业务事务INSERT
WAITING --> SENT : 发送MQ成功
SENT --> CONSUMED : 收到消费者确认回调
SENT --> WAITING : 超时未确认,回退重发
WAITING --> DEAD : 重试超限
图 5-2 — 最大努力通知状态机
- WAITING → SENT:调度器成功将消息投递到 MQ。
- SENT → CONSUMED:消费者处理完毕后回调
/confirm接口,发送方更新状态。 - SENT → WAITING:若在指定窗口内未收到确认,调度器将状态重置为
WAITING,触发重发。 - WAITING → DEAD:重发次数达到上限后移入死信。
5.3 消费者确认回调接口设计
发送方需提供幂等的回调接口:
@PostMapping("/confirm")
public void confirm(@RequestParam String messageKey) {
jdbcTemplate.update(
"UPDATE outbox SET status='CONSUMED' WHERE message_key=? AND status='SENT'",
messageKey);
}
消费者在业务成功后调用此接口。该接口需保证幂等,通常配合 message_key 唯一性实现。
5.4 与本地消息表的关系
最大努力通知是本地消息表的上层扩展:本地消息表只保证消息到达 MQ,而最大努力通知进一步保证消费者成功处理。它适用于支付结果通知、跨系统状态同步等需要闭环确认的场景。
六、方案对比:本地消息表 vs RocketMQ 事务消息 vs Saga
6.1 架构总览对比
三种方案在消息持久化位置、协调机制和补偿复杂性上存在本质差异,其架构对比如下:
flowchart TD
subgraph LocalDB[本地消息表]
L1[业务DB + outbox] --> L2[定时调度器] --> L3[MQ] --> L4[消费者]
end
subgraph RocketMQ[RocketMQ事务消息]
R1[Producer] --> R2[Broker 半消息] --> R3[executeLocalTransaction] --> R4[Commit/Rollback] --> R5[消费者]
end
subgraph Saga[Saga编排]
S1[Saga协调器] --> S2[正向操作1] --> S3[正向操作2] --> S4[正向操作N]
S2 -.-> S5[补偿操作1]
S3 -.-> S6[补偿操作2]
end
图 6-1 — 三种方案架构对比
- 本地消息表:依赖业务数据库做消息暂存,调度器承担投递职责,架构简单但延迟较高。
- RocketMQ 事务消息:Broker 接管消息暂存与两阶段提交流程,减少应用侧负担,延迟极低。
- Saga:需要独立的协调器(或事件编排)来管理正向操作序列和逆序补偿链,组件最多、设计最复杂。
6.2 三维对比图
flowchart LR
subgraph 对比维度
direction LR
Latency[延迟]
Reliability[可靠性]
Complexity[实现复杂度]
end
Latency -->|秒级| LocalDB[(本地消息表)]
Latency -->|准实时| RocketMQ[(RocketMQ事务消息)]
Latency -->|分钟级| Saga[(Saga编排)]
Reliability -->|at-least-once + 重试| LocalDB
Reliability -->|at-least-once + 回查| RocketMQ
Reliability -->|补偿链重试 + 人工介入| Saga
Complexity -->|低| LocalDB
Complexity -->|中| RocketMQ
Complexity -->|高| Saga
图 6-2 — 本地消息表 vs RocketMQ 事务消息 vs Saga 三维对比图
- 延迟:本地消息表受定时轮询影响,通常在秒级;RocketMQ 事务消息在正常提交路径下基本是实时推送,延迟接近同步调用;Saga 由于涉及多个参与方的串行或并行事务以及补偿,端到端延迟可能达到分钟级。
- 可靠性:三者都依赖重试。本地消息表靠调度器反复重试直到成功或死信;RocketMQ 靠回查和 Broker 持久化;Saga 靠补偿链重试和人工修复。Saga 的补偿逻辑最为复杂,也最容易出现设计缺陷。
- 实现复杂度:本地消息表只需数据库表+定时任务,实施门槛最低;RocketMQ 事务消息需要理解半消息和回查,稍复杂;Saga 需要定义正向操作、补偿操作和状态机编排,复杂度最高,常配合专门框架(如 Seata Saga、Camunda)。
- 适用场景:本地消息表适用于大多数异步解耦、秒级延迟可接受的场景;RocketMQ 事务消息适合延迟敏感、技术栈统一的系统;Saga 只适用于需要复杂补偿逻辑的长事务,比如跨多个微服务的订单履行。
回顾前文:可靠消息最终一致性可视为 Saga 的“异步退化版”——它去掉了逆序补偿要求,仅保证正向消息可靠投递与下游幂等处理。当一个业务操作只有正向通知而没有失败补偿义务时,显然不应该引入 Saga 的复杂性,可靠消息最终一致性就是最轻盈的方案。而 TCC 的幂等控制(bizId + actionType 唯一键)与消息消费的幂等去重是同构的,它们都依赖业务唯一键和状态机来防止重复执行,这体现了分布式事务中“幂等”这一基础能力的复用。
七、面试高频专题
(本模块严格分离正文,共 12 题,含 1 道系统设计题)
1. 本地消息表的核心原理是什么?为什么能保证业务操作与消息发送的原子性?
- 一句话回答:通过在同一本地数据库事务中将业务数据和待发送消息记录一起提交,利用数据库 ACID 实现原子性。
- 详细解释:本地消息表模式将“发送消息”转化为“在业务数据库里插入一行记录”。业务操作(如订单创建)和
INSERT INTO outbox在同一个BEGIN/COMMIT事务中,要么全部成功,要么全部回滚。事务提交后,即使应用进程崩溃,消息记录已经持久化。随后由独立的定时任务负责读取outbox表并把消息投递到 MQ,投递成功后再标记记录为已发送。这样完全避免了“先写库还是先发消息”的分布式原子性问题。 - 追问方向:
- 如果定时任务在标记消息为
SENT前宕机,重启后会重复发送吗?如何解决? outbox表如果无限膨胀怎么办?- 定时任务扫描性能如何保证?
- 如果定时任务在标记消息为
- 加分回答:引入
SENDING状态和乐观锁更新可防止重复发送;通过消息重试指数退避和死信机制保证可靠交付;使用message_key唯一约束可从源头防止业务重复插入。
2. outbox 表的 SENDING 状态起什么作用?不用 SENDING 会有什么并发问题?
- 一句话回答:
SENDING是定时任务的并发互斥锁,防止多个调度实例重复发送同一消息。 - 详细解释:在多实例部署的定时任务集群中,假如没有
SENDING,两个实例可能同时查出PENDING消息并同时调用rocketMQTemplate.send(),导致消息重复投递。引入SENDING后,发送前先执行UPDATE outbox SET status='SENDING' WHERE status='PENDING',利用数据库的行锁和影响行数判断,只有第一个执行的实例会成功,其他实例影响行数为 0 并跳过,从而保证投递幂等。 - 追问方向:
- 如果实例更新为
SENDING后自己宕机,消息会丢失吗? - 为什么不用分布式锁?
- 更新
SENDING和send不在同一事务,如何保证一致性?
- 如果实例更新为
- 加分回答:宕机后消息仍停留在
SENDING状态,可以增加后台修复任务定期将超时的SENDING记录重置为PENDING;分布式锁会引入外部依赖和网络延迟,数据库乐观锁直接在数据层面解决并发,成本更低;SENDING本质是投递阶段的“预占”标记,配合重试和死信达成最终一致。
3. RocketMQ 事务消息的半消息是如何实现的?为什么消费者看不到半消息?
- 一句话回答:半消息先写入内部主题
RMQ_SYS_TRANS_HALF_TOPIC,该主题未被任何消费者订阅。 - 详细解释:当生产者调用事务发送接口后,Broker 先把消息存入一个特殊的内部主题(
RMQ_SYS_TRANS_HALF_TOPIC),该主题不会分配给任何消费者队列。只有收到生产者的COMMIT请求后,Broker 才会将消息原封不动地移动到原始目标主题的队列中,此时才对消费者可见。若收到ROLLBACK,则直接删除半消息。这种设计相当于 Broker 代替了业务库outbox表,实现消息的暂存和“提交/回滚”语义。 - 追问方向:
- 半消息如果堆积会不会影响 Broker 性能?
COMMIT丢失怎么办?- 与 Kafka 事务消息区别?
- 加分回答:半消息堆积属于正常的事务未决消息,Broker 会通过回查推动决议,避免无限堆积;如果
COMMIT请求网络丢失,Broker 触发回查,生产者根据业务状态决定最终提交或回滚;Kafka 的事务消息基于幂等生产者和事务协调器,实现“原子多分区写”,但理念类似。
4. RocketMQ 事务消息的回查机制是如何工作的?checkLocalTransaction 应该返回什么?
- 一句话回答:当生产者执行本地事务超时或无响应时,Broker 定期回调生产者的
checkLocalTransaction接口,要求其查询本地事务状态并返回COMMIT、ROLLBACK或UNKNOWN。 - 详细解释:Broker 维护事务消息的状态,若在一定时间(默认 60 秒)未收到
COMMIT/ROLLBACK,则发起回查。生产者的回查实现必须根据半消息中的业务键(如订单 ID)查询业务数据库,确认事务最终结果。返回COMMIT则消息投递;ROLLBACK则丢弃;UNKNOWN表示仍无法确定(如从库延迟),Broker 会再次回查直到最大次数。最大次数后 Broker 丢弃消息,运维人员需介入。 - 追问方向:
- 如何避免回查的并发问题?
- 生产者在回查时应该查从库还是主库?
- 可以返回
UNKNOWN永久循环吗?
- 加分回答:回查接口应无副作用且幂等,通常走主库查询以保证实时一致性;最大回查次数限制使其不会永久循环。
5. 消息消费的幂等性为什么是必要的?三种去重方案各自的优劣是什么?
- 一句话回答:MQ 只能保证 at-least-once 投递,消息可能重复,消费者必须通过业务唯一键去重以保证最终一致性。
- 详细解释:重复消息可能来自生产者重试、MQ 重平衡、网络超时等。数据库唯一约束方案将去重记录与业务写入同一事务,最强一致但增加数据库写压力;Redis SET NX 方案性能高,但有数据丢失风险,适合非金融核心链路;业务状态机方案利用现有状态字段,实现简单但仅适用于状态流转的业务。实际选型需权衡一致性、吞吐量和运维成本。
- 追问方向:
- Redis 去重键的过期时间如何设定?
- 如果去重表因故无法写入,业务能继续吗?
- 有没有更通用的去重中间件?
- 加分回答:过期时间应超过消息重试窗口加冗余;去重表写入失败应阻止业务执行,避免重复;更通用的方案可以是结合布隆过滤器和数据库精确去重。
6. 本地消息表与 RocketMQ 事务消息在延迟、可靠性、运维复杂度上有何本质区别?
- 一句话回答:本地消息表依赖定时扫描,秒级延迟,运维需关注表膨胀;RocketMQ 事务消息近乎实时,但强依赖 RocketMQ 且需处理回查逻辑。
- 详细解释:本地消息表的延迟主要由
@Scheduled间隔决定,通常是 3~5 秒,无法满足 100ms 内响应;RocketMQ 事务消息在正常提交路径上是立即投递的,延迟极小。可靠性方面,两者都通过重试保障,本地消息表依靠数据库持久化,RocketMQ 依靠 Broker 持久化半消息。运维上,本地消息表要管理 SQL 清理脚本,RocketMQ 要关注事务消息堆积和回查超限。 - 追问方向:
- 如果业务 DB 和 MQ 跨机房,哪种方案更可靠?
- RocketMQ 事务消息能不能完全替代本地消息表?
- Kafka 有没有对应的方案?
- 加分回答:跨机房时,本地消息表只依赖本地 DB 提交,更健壮;RocketMQ 事务消息替代本地消息表需要整个基础设施统一;Kafka 提供事务消息 API 可类似实现,但理念稍有不同(详见 Kafka 系列第 7 篇)。
7. 最大努力通知模式与本地消息表有什么关系?消费者确认回调起什么作用?
- 一句话回答:最大努力通知是本地消息表的扩展,通过增加消费者确认回调,将消息终态从“已发送”变为“已消费”,实现对消费者处理结果的闭环。
- 详细解释:本地消息表只保证消息成功投递到 MQ,但不对消费者处理结果负责。最大努力通知在
outbox中增加了CONSUMED状态,消费者业务执行完毕后回调发送方的确认接口,标记消息已消费。若超时未确认,定时任务会将该消息重新投递。这实现了跨服务的“投递+处理”双重保障。 - 追问方向:
- 如果消费者回调失败,会不会无限重发?
- 确认回调需要保证幂等吗?
- 与同步 RPC 调用相比优势在哪?
- 加分回答:无限重发通过最大重试次数和死信来阻止;确认回调本身也需要幂等,通常由
messageKey唯一约束保证;相比于同步 RPC,最大努力通知解耦了服务,提升了整体吞吐量和容错性。
8. 可靠消息最终一致性与 Saga 的核心区别是什么?为什么可靠消息最终一致性不需要补偿链?
- 一句话回答:Saga 包含正向操作序列和逆序补偿,适用于需要业务回滚的长事务;可靠消息最终一致性假定每个正向操作都是终局性的,没有补偿义务,仅靠消息驱动异步协同。
- 详细解释:在 Saga 中,任何一个步骤失败都需要触发之前已成功步骤的补偿操作(如退款、恢复库存),因此必须定义补偿链。而在可靠消息最终一致性的典型场景(如订单通知、数据同步)中,消息消费者的操作自身是幂等、无状态依赖的,且不需要因为某个消费者失败而回滚生产者,因为生产者的业务已经最终落地。消息的可靠投递和幂等消费保证了各系统最终与生产者状态一致,所以补偿是不必要的。
- 追问方向:
- 什么情况下需要在可靠消息最终一致性中加入补偿逻辑?
- Saga 是否也可以和消息队列结合?
- TCC 与可靠消息最终一致性在幂等上的异同?
- 加分回答:当消费者业务有严格的前置依赖且失败需要反向通知时,可能需要引入补偿消息,但仍比完整的 Saga 轻量;Saga 的 Choreography 方式本身就大量使用事件驱动消息;TCC 与消息消费的幂等均基于业务唯一键 + 状态机,是同构的(详见第 3 篇)。
9. 如果 outbox 表定时任务发送消息成功,但更新 status = 'SENT' 前宕机,重启后会发生什么?
- 一句话回答:消息会因状态仍为
SENDING(或未更新)而被重新处理,但依靠下游幂等可以保证业务不重复,最终消息状态会被修正为SENT。 - 详细解释:假设宕机前
send()已成功,而UPDATE status='SENT'未执行,重启后该记录可能仍为SENDING。需要有一个修复任务定期将超过一定时间的SENDING记录重置为PENDING,从而触发重发。由于消费者具备幂等,重复消费不会造成业务异常,重新投递成功后状态更新为SENT。outbox的最终一致靠重试和幂等来收敛。 - 追问方向:
- 如何设计这个修复任务的频率和超时阈值?
- 如果消费者不支持幂等怎么办?
- 为什么不把
send和update放在同一个事务?
- 加分回答:修复阈值通常设为最大重试间隔的若干倍;消费者不支持幂等则必须改造消费端,因为 MQ 的 at-least-once 语义无法避免重复;
send是外部资源调用,无法与 DB 事务组合,这正是分布式事务要解决的核心矛盾。
10. RocketMQ 事务消息的 executeLocalTransaction 超时未返回,MQ Broker 如何处理?
- 一句话回答:Broker 将发起回查,调用
checkLocalTransaction确定消息最终提交或回滚。 - 详细解释:
executeLocalTransaction可能因为 GC 停顿、网络闪断或代码 bug 超时。Broker 在配置的transactionTimeout后尚未收到确认,则会按照一定间隔(默认 60s)调用生产者的checkLocalTransaction接口。生产者必须通过查询业务状态返回确定结果。如果回查也一直返回UNKNOWN,达到最大回查次数后 Broker 会丢弃消息并记录日志,需要人工介入补偿。 - 追问方向:
- 如果
checkLocalTransaction的实现有 bug,返回了错误结果怎么办? - 回查过程中生产者的业务状态刚好变更,怎么处理?
- 可以调整回查间隔和最大次数吗?
- 如果
- 加分回答:错误结果会导致不一致,需要完善的监控和测试;回查时应查询事务的终态(如订单创建状态),一般不会变更;参数
transactionCheckInterval和transactionCheckMax可配置。
11. Redis 去重键方案中,如果 Redis 宕机导致去重键丢失,会发生什么?如何补救?
- 一句话回答:丢失期间收到的重复消息可能被再次执行,造成业务问题;补救需降级到数据库去重或事后补偿。
- 详细解释:Redis 宕机或数据淘汰后,
SET NX不再能阻挡重复消息,如果恰好有重发消息到达,业务就会重复执行。要降低风险,一是设置足够长的过期时间,覆盖所有可能的重试窗口;二是实现熔断降级——当 Redis 不可用时切换到数据库去重表(性能下降但保证一致性);三是事后通过业务流水对账发现重复并冲正。 - 追问方向:
- 能否采用 Redis 持久化和主从保证不丢?
- 熔断降级的触发条件如何设定?
- 为什么不全用 DB 方案?
- 加分回答:Redis AOF 和主从复制仍存在丢失窗口,不保证绝对持久化;熔断通常在连接池耗尽或超时率上升时触发;全用 DB 方案在极高 TPS 下可能成为瓶颈,因此需要分层选择。
12. (系统设计题)一个电商订单系统需要在下单后:扣库存、发积分、发短信。TPS 10000+,允许 1-3 秒的最终一致性延迟。请给出:(1)为什么 XA/AT/TCC 不适合此场景?(2)本地消息表方案设计(outbox 表 DDL + 定时任务 + 幂等消费);(3)如果要求延迟 < 100ms,迁移到 RocketMQ 事务消息的改造方案;(4)三种消费服务的幂等去重方案选型及理由。
- 一句话回答:高 TPS 异步解耦场景宜采用可靠消息最终一致性,XA/AT 会因锁冲突降低并发,TCC 复杂度过高。
- 详细解释:
(1)XA 或 Seata AT 在扣库存等热点数据上会持有全局锁,大促 TPS 下锁竞争急剧恶化,导致整体吞吐量下降;TCC 需要为每个参与方实现 Try/Confirm/Cancel,对于发短信这样无补偿的场景过度设计。
(2)本地消息表设计:订单表创建订单同时
INSERT INTO outbox,status=PENDING。定时任务 1 秒间隔扫描并发送到 MQ,消息体包含订单 ID 和商品 ID。库存消费者收到消息扣减库存,积分消费者增加积分,短信消费者发短信。三者在消费端均使用数据库唯一约束去重表(message_key为主键)。 (3)迁移到 RocketMQ 事务消息:使用RocketMQTemplate.sendMessageInTransaction发送半消息,executeLocalTransaction中创建订单,返回COMMIT。Broker 立即投递消息,消费者端几乎实时扣库存,端到端延迟可控制在 100ms 内。需为下单服务配置@RocketMQTransactionListener并实现回查。 (4)去重选型:扣库存是核心交易,必须使用 DB 唯一约束强一致;发积分也涉及资产,建议 DB 唯一约束或状态机(用户积分表余额 + 流水);发短信可接受极小概率重复,使用 Redis SET NX 降低数据库压力,并设置 24 小时过期覆盖重试窗口。 - 追问方向:
- 如果定时任务来不及处理 10000 TPS 生成的消息,如何优化?
- 消息顺序性如何保障?
- 如何监控整个链路的最终一致性?
- 加分回答:可通过分库分表 + 多实例调度器每个处理一个分片来提高扫描并发;顺序消息通过 RocketMQ 的顺序队列实现,但会降低并行度;监控可采用消息生命周期表记录状态时间戳,定时对账订单与库存积分。
八、Demo 代码汇总
8.1 本地消息表完整示例(Spring Boot)
application.properties 配置 RocketMQ 连接:
rocketmq.name-server=127.0.0.1:9876
rocketmq.producer.group=order-outbox-producer
outbox 表 DDL 如前文。定时任务 OutboxScheduler 如前文。业务服务 示例:
@Service
public class OrderService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Transactional
public void createOrder(OrderCreateRequest req) {
// 1. 业务操作
jdbcTemplate.update("INSERT INTO orders (id, status, amount) VALUES (?, 'CREATED', ?)",
req.getOrderId(), req.getAmount());
// 2. 写入 outbox
String messageBody = JSON.toJSONString(new OrderCreatedEvent(req.getOrderId(), req.getAmount()));
jdbcTemplate.update(
"INSERT INTO outbox (topic, tag, message_key, message_body, status, next_retry_time) " +
"VALUES ('OrderTopic', 'created', ?, ?, 'PENDING', NOW())",
req.getOrderId(), messageBody);
}
}
消费者幂等消费(DB 去重):
@RocketMQMessageListener(topic = "OrderTopic", selectorExpression = "created", consumerGroup = "inventory-consumer")
@Component
public class InventoryDeductionConsumer implements RocketMQListener<MessageExt> {
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
@Transactional
public void onMessage(MessageExt msg) {
String messageKey = msg.getKeys();
try {
jdbcTemplate.update("INSERT INTO consumer_record (message_key, create_time) VALUES (?, NOW())", messageKey);
} catch (DuplicateKeyException e) {
return; // 幂等
}
// 解析消息并扣库存
OrderCreatedEvent event = JSON.parseObject(new String(msg.getBody()), OrderCreatedEvent.class);
int rows = jdbcTemplate.update(
"UPDATE inventory SET stock = stock - ? WHERE product_id = ? AND stock >= ?",
event.getQuantity(), event.getProductId(), event.getQuantity());
if (rows == 0) throw new RuntimeException("库存不足");
}
}
8.2 RocketMQ 事务消息完整示例
事务监听器及发送代码已在 3.3 节给出。消费者代码与本地消息表方案完全相同,仅需订阅正确的 topic。
8.3 三种幂等去重完整代码
- DB 唯一约束:见上文
consumer_record示例。 - Redis SET NX:见 4.4 节代码,需引入
spring-boot-starter-data-redis。 - 状态机防重:见 4.5 节
UPDATE ... WHERE status='UNPAID'。
8.4 outbox 表清理任务
-- 每天清理7天前已发送的记录(可配置定时任务或事件调度)
DELETE FROM outbox WHERE status = 'SENT' AND create_time < DATE_SUB(NOW(), INTERVAL 7 DAY) LIMIT 1000;
在 Spring Boot 中可通过 @Scheduled(cron = "0 0 3 * * ?") 执行。
九、可靠消息最终一致性核心机制速查表
| 方案 | 核心机制 | 消息原子性保证 | 幂等去重方案 | 典型延迟 | 可靠性 | 适用场景 | 关键配置参数 |
|---|---|---|---|---|---|---|---|
| 本地消息表 | 业务+outbox 同事务,定时扫描投递 | 本地事务 ACID | DB 唯一约束/Redis/状态机 | 秒级 | at-least-once + 重试 | 异步解耦,允许秒级延迟 | 扫描间隔、最大重试次数、退避策略 |
| RocketMQ 事务消息 | 半消息 + executeLocalTransaction + 回查 | MQ 侧半消息暂存 | 同上 | 准实时 | at-least-once + 回查 | 准实时异步解耦,RocketMQ 环境 | transactionTimeout, transactionCheckMax |
| Saga (参考) | 本地事务序列 + 逆序补偿链 | 无统一原子性,靠补偿 | 状态机+幂等 | 分钟级 | 补偿链重试+人工 | 长事务,需业务回滚 | 编排状态机定义 |
十、延伸阅读
- RocketMQ 官方文档:事务消息原理与样例
- 《Designing Data-Intensive Applications》第 11 章:Stream Processing 与消息语义
- 《企业集成模式》第 5 章:消息路由与保证投递模式
- Spring Cloud Stream 官方文档:绑定器与消费者组幂等配置
本文完整解构了可靠消息最终一致性的两大工程路径,从数据库本地事务的原子性到 MQ 半消息的黑盒魔力,再到幂等的最后防线,为高并发异步架构提供了可靠的理论和实操基础。下一篇文章将探讨基于 CDC 的 Outbox 实现,进一步消除定时扫描延迟,实现实时事件驱动。