深度解析:RocketMQ 在支付场景下的消息可靠性保障体系

深度解析: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 核心场景

支付场景有两个典型定时任务需求:

  1. 订单超时未支付 → 自动关单:用户下单后 30 分钟未支付,系统自动取消
  2. 支付结果延迟通知 → 定时补偿:第三方支付回调偶发丢失,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 任意时间精度延迟消息的时间轮原理感兴趣,欢迎评论留言,后续会单独出文章深入解析。