深度解析:RocketMQ 在支付场景下的消息可靠性保障体系
支付是互联网业务中对一致性要求最高的场景之一。一笔支付请求,从用户点击「确认支付」到最终账务结算,中间经过了支付网关、风控、资金账户、通知回调等多个系统。任何一个环节丢消息、重复消费,都可能造成资金损失或用户投诉。
本文结合生产实践,系统梳理 RocketMQ 在支付场景下如何通过事务消息、延迟消息、幂等消费三大核心机制,构建完整的消息可靠性保障体系。
一、为什么支付场景需要可靠消息?
传统同步调用在支付链路中面临以下三大挑战:
| 挑战 | 同步调用缺陷 | MQ 解法 |
|---|---|---|
| 跨系统一致性 | 强依赖,任一下游超时导致整体失败 | 事务消息解耦,最终一致 |
| 超时重试风暴 | 链路雪崩风险高 | 异步削峰,消费端自主重试 |
| 对账 / 补偿 | 无法感知下游实际处理结果 | 延迟消息实现定时对账与超时补偿 |
二、事务消息:保障支付主链路一致性
2.1 核心问题
用户下单 → 扣减库存 → 发 MQ 通知支付系统
若扣减库存成功,但 MQ 发送失败(网络抖动、Broker 宕机),会导致订单与支付状态不一致。
2.2 RocketMQ 事务消息原理
RocketMQ 事务消息采用两阶段提交 + 反查机制,整体流程如下:
sql
Producer Broker 本地事务
│ │ │
│── 1. 发送半消息(PREPARE) ──▶│ │
│◀─ 2. 发送成功 ────────────│ │
│── 3. 执行本地事务 ─────────────────────────────▶│
│◀─ 4. 本地事务结果 ─────────────────────────────│
│── 5. COMMIT/ROLLBACK ──▶│ │
│ │── 6. 投递消费者 ──▶ │
若步骤5未收到:
│◀─ 7. 反查本地事务状态 ────│
│── 8. 返回 COMMIT/ROLLBACK ▶│
2.3 支付场景代码实现
java
@Component
public class PayOrderTransactionListener implements TransactionListener {
@Autowired
private OrderService orderService;
/**
* 执行本地事务(扣减库存、创建订单)
*/
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
PayOrderDTO orderDTO = JSON.parseObject(new String(msg.getBody()), PayOrderDTO.class);
// 执行本地业务:扣库存 + 创建订单
orderService.createOrderWithDeduction(orderDTO);
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
log.error("本地事务执行失败, msgId={}", msg.getMsgId(), e);
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
/**
* 事务反查:Broker 未收到 COMMIT/ROLLBACK 时主动回查
*/
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
String orderId = msg.getUserProperty("orderId");
// 查询本地订单状态
OrderStatus status = orderService.getOrderStatus(orderId);
return switch (status) {
case CREATED -> LocalTransactionState.COMMIT_MESSAGE;
case CANCELLED -> LocalTransactionState.ROLLBACK_MESSAGE;
default -> LocalTransactionState.UNKNOW; // 继续等待
};
}
}
java
// 发送事务消息
@Service
public class PaymentService {
@Autowired
private TransactionMQProducer transactionProducer;
public void sendPayNotify(PayOrderDTO orderDTO) {
Message msg = new Message(
"TOPIC_PAY_NOTIFY",
"TAG_ORDER_CREATED",
orderDTO.getOrderId(),
JSON.toJSONBytes(orderDTO)
);
msg.putUserProperty("orderId", orderDTO.getOrderId());
SendResult result = transactionProducer.sendMessageInTransaction(msg, null);
log.info("事务消息发送结果: {}, msgId={}", result.getSendStatus(), result.getMsgId());
}
}
2.4 关键配置
yaml
rocketmq:
producer:
group: pay-order-producer-group
# 事务回查最大次数,超过后消息会被丢弃,需结合业务做兜底
check-thread-pool-min-size: 2
check-thread-pool-max-size: 5
check-request-hold-max: 2000
生产注意:反查接口必须幂等,且要考虑超时场景(返回 UNKNOW 让 Broker 继续等待),不要轻易 ROLLBACK,否则会丢失已执行的本地事务。
三、延迟消息:支付超时关单与定时对账
3.1 核心场景
支付场景有两个典型定时任务需求:
- 订单超时未支付 → 自动关单:用户下单后 30 分钟未支付,系统自动取消
- 支付结果延迟通知 → 定时补偿:第三方支付回调偶发丢失,15 分钟后主动查询补偿
3.2 RocketMQ 延迟消息原理
RocketMQ 5.x 前使用18 个固定延迟级别 ,5.x 版本开始支持任意时间精度延迟(基于时间轮)。
makefile
延迟级别(4.x):
1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
Level: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
3.3 订单超时关单实现
java
// 下单时发送延迟关单消息
@Service
public class OrderCreateService {
@Autowired
private DefaultMQProducer producer;
public void createOrder(OrderDTO orderDTO) {
// 1. 创建订单
orderRepository.save(orderDTO);
// 2. 发送延迟消息(Level 14 = 10分钟,按需选择)
Message closeMsg = new Message(
"TOPIC_ORDER_CLOSE",
"TAG_TIMEOUT",
orderDTO.getOrderId(),
JSON.toJSONBytes(orderDTO)
);
// RocketMQ 4.x 使用延迟级别,级别16=30分钟
closeMsg.setDelayTimeLevel(16);
producer.send(closeMsg);
log.info("订单{}已创建,延迟关单消息已发送", orderDTO.getOrderId());
}
}
// 消费延迟消息,执行关单逻辑
@Component
@RocketMQMessageListener(
topic = "TOPIC_ORDER_CLOSE",
consumerGroup = "order-close-consumer-group",
selectorExpression = "TAG_TIMEOUT"
)
public class OrderCloseConsumer implements RocketMQListener<OrderDTO> {
@Autowired
private OrderService orderService;
@Override
public void onMessage(OrderDTO orderDTO) {
// 幂等检查:已支付的订单不做关单操作
OrderStatus status = orderService.getOrderStatus(orderDTO.getOrderId());
if (OrderStatus.PAID.equals(status)) {
log.info("订单{}已支付,跳过关单", orderDTO.getOrderId());
return;
}
if (OrderStatus.CREATED.equals(status)) {
orderService.closeOrder(orderDTO.getOrderId(), CloseReason.TIMEOUT);
log.info("订单{}超时关单完成", orderDTO.getOrderId());
}
}
}
3.4 支付结果对账补偿
java
// 支付成功后,发送15分钟延迟对账消息
public void onPaymentCallback(PayCallbackDTO callback) {
// 1. 处理支付结果
orderService.updatePayStatus(callback);
// 2. 发送延迟对账消息(15分钟后校验)
Message reconcileMsg = new Message("TOPIC_PAY_RECONCILE", ...);
reconcileMsg.setDelayTimeLevel(14); // Level 14 = 10分钟,按需调整
producer.send(reconcileMsg);
}
四、幂等消费:防止重复消费导致资金异常
4.1 为什么会重复消费?
RocketMQ 保证至少一次投递(At Least Once),以下场景必然触发重复消费:
markdown
1. 消费者处理成功,但 ACK 前宕机 → Broker 超时重投
2. 消费者处理超时(consumeTimeout 默认15分钟)→ Broker 重投
3. 消费者主动 RECONSUME_LATER → N次重试后最终进死信队列
4.2 幂等设计方案
支付场景推荐数据库唯一索引 + 状态机的组合方案:
sql
-- 消费幂等记录表
CREATE TABLE mq_consume_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
msg_id VARCHAR(64) NOT NULL COMMENT 'RocketMQ msgId',
topic VARCHAR(128) NOT NULL,
tag VARCHAR(64),
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-处理中 1-成功 2-失败',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_msg_id (msg_id) -- 唯一索引防重
);
java
@Component
@RocketMQMessageListener(
topic = "TOPIC_PAY_NOTIFY",
consumerGroup = "pay-notify-consumer-group"
)
public class PayNotifyConsumer implements RocketMQListener<MessageExt> {
@Autowired
private ConsumeRecordService consumeRecordService;
@Autowired
private AccountService accountService;
@Override
public void onMessage(MessageExt msg) {
String msgId = msg.getMsgId();
// 1. 幂等检查:查询消费记录
ConsumeRecord record = consumeRecordService.getByMsgId(msgId);
if (record != null && record.getStatus() == ConsumeStatus.SUCCESS) {
log.info("消息{}已成功消费,跳过", msgId);
return;
}
try {
// 2. 插入处理中记录(唯一索引,并发场景下只有一个能成功)
consumeRecordService.insertProcessing(msgId, msg.getTopic(), msg.getTags());
// 3. 执行业务:资金入账
PayNotifyDTO dto = JSON.parseObject(msg.getBody(), PayNotifyDTO.class);
accountService.creditAccount(dto);
// 4. 更新为成功
consumeRecordService.updateSuccess(msgId);
} catch (DuplicateKeyException e) {
// 并发重复消费,直接忽略
log.warn("消息{}正在被其他实例处理,忽略", msgId);
} catch (Exception e) {
// 业务异常,更新失败状态,抛出让 Broker 重试
consumeRecordService.updateFailed(msgId, e.getMessage());
throw new RuntimeException("消费失败,触发重试", e);
}
}
}
4.3 死信队列兜底处理
java
// 监听死信队列,告警 + 人工处理
@Component
@RocketMQMessageListener(
topic = "%DLQ%pay-notify-consumer-group", // 死信队列命名规则
consumerGroup = "pay-dlq-consumer-group"
)
public class PayDLQConsumer implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt msg) {
// 告警通知(钉钉/企微/邮件)
alertService.sendCriticalAlert(
"支付消息进入死信队列",
"msgId=" + msg.getMsgId() + ", body=" + new String(msg.getBody())
);
// 持久化到人工处理表
deadLetterService.saveForManualProcess(msg);
}
}
五、完整架构图
scss
用户支付
│
▼
支付网关
│── 事务消息(PREPARE) ──▶ RocketMQ Broker
│ │
│── 执行本地事务(扣款) │
│ │─ 延迟消息(30min) ──▶ [超时关单消费者]
│── COMMIT ────────────▶ │
│
│─ 投递 ──▶ [支付通知消费者]
│
│── 幂等检查
│── 资金入账
│── 发送回调通知
│
│─ 失败重试(×16) ──▶ 死信队列 ──▶ 人工处理
六、生产配置最佳实践
yaml
rocketmq:
name-server: 192.168.1.100:9876
producer:
group: pay-producer-group
send-message-timeout: 3000 # 发送超时3秒
retry-times-when-send-failed: 2 # 同步发送失败重试次数
retry-times-when-send-async-failed: 2
consumer:
group: pay-consumer-group
consume-message-batch-max-size: 1 # 支付场景建议单条消费,方便事务控制
max-reconsume-times: 16 # 最大重试16次(约4小时)
consume-timeout: 15 # 消费超时15分钟
pull-batch-size: 32
七、总结
| 保障机制 | 解决问题 | 核心原理 |
|---|---|---|
| 事务消息 | 本地事务与 MQ 发送的原子性 | 两阶段提交 + Broker 反查 |
| 延迟消息 | 超时关单 / 定时对账补偿 | 时间轮调度,精确延迟投递 |
| 幂等消费 | 防止重复消费导致资金异常 | 数据库唯一索引 + 状态机 |
| 死信队列 | 消费最终失败的兜底处理 | 超过重试次数后转入 DLQ |
支付场景的消息可靠性没有银弹,需要**发送端保障(事务消息)+ 消费端保障(幂等)+ 补偿机制(延迟消息 + 死信队列)**三层防护协同工作,才能在高并发场景下真正做到"一分钱都不能差"。
如果你对 RocketMQ 事务消息的底层 TwoPhaseCommit 实现、或者 5.x 任意时间精度延迟消息的时间轮原理感兴趣,欢迎评论留言,后续会单独出文章深入解析。