支付中台 RocketMQ 消息零丢失:4 个真实踩坑场景复盘

7 阅读1分钟

支付中台 RocketMQ 消息零丢失:4 个真实踩坑场景复盘

前置说明:本文直接复盘 4 个生产事故,告诉你消息是怎么丢的、怎么发现的、怎么修的。每个场景都是真实案例,参数精确到可落地。


一、消息丢失的代价有多大

先看一个真实代价:

20253月,某支付平台
事件:用户充值成功,但账户余额未到账
根因:RocketMQ 消息发送失败,但业务已返回"成功"
影响:237笔充值未到账,单笔最高5000元
处理:人工逐笔核对 + 补发消息 + 人工补偿
耗时:4小时

支付系统消息丢失的后果不是「丢一条日志」,而是钱不对账。消息可靠性不是可选项,是必选项。


二、生产事故场景复盘

场景 1:Producer 异步发送未等结果,消息消失

事故链路

14:23 支付回调处理成功
14:25 财务对账发现少了 3 笔交易
14:30 开发排查,消息队列消息量对不上
14:50 定位:Producer 使用异步发送,失败后只打日志
15:10 修复:切换为同步发送 + 回调确认

问题代码

// ❌ 问题:异步发送,不等待结果,失败无补救
@Service
public class PaymentMessageService {

    public void sendPaymentNotify(PaymentTransaction tx) {
        rocketMQTemplate.asyncSend(
            "payment-topic:tx-notify",
            tx,
            new SendCallback() {
                @Override
                public void onSuccess(SendResult sendResult) {
                    log.info("发送成功: {}", sendResult.getMsgId());
                }

                @Override
                public void onException(Throwable e) {
                    // ⚠️ 只打日志,没有任何补救
                    log.error("发送失败: {}", tx.getOrderId(), e);
                }
            }
        );
        // ⚠️ 方法立即返回,消息可能在异步队列中丢失
    }
}

根因:异步发送时,消息先进入 Producer 内存队列,如果应用在消息落盘前崩溃,消息就没了。

修复方案

// ✅ 同步发送 + 结果校验 + 失败补偿
@Service
public class PaymentMessageService {

    public void sendPaymentNotifySync(PaymentTransaction tx) {
        try {
            SendResult result = rocketMQTemplate.syncSend(
                "payment-topic:tx-notify",  // Topic:Tag
                tx,                         // 消息体
                3000,                       // 超时 3s
                2                           // 重试 2 次
            );

            if (result == null
                    || result.getSendStatus() != SendStatus.SEND_OK) {
                log.error("消息发送失败,需要补偿: orderId={}", tx.getOrderId());
                // 放入补偿队列
                paymentCompensationService.enqueue(tx, "SEND_FAILED");
            }

        } catch (Exception e) {
            log.error("同步发送异常: orderId={}", tx.getOrderId(), e);
            paymentCompensationService.enqueue(tx, "SEND_EXCEPTION");
        }
    }
}

场景 2:Broker 刷盘配置错误导致消息丢失

事故链路

09:00 系统上线新版本
09:15 发现消息消费延迟 2 小时
09:30 排查发现 Broker 配置为 ASYNC_FLUSH
09:45 改为 SYNC_FLUSH,重启 Broker
10:00 延迟追上,但期间有 17 笔回调重复发送

根因:RocketMQ 刷盘策略分两种:

策略说明风险
ASYNC_FLUSH消息异步刷盘,崩溃时内存消息丢失⚠️ 支付系统禁用
SYNC_FLUSH消息同步刷盘后才响应 Producer安全

Broker 配置

# ❌ 危险配置(支付系统禁用)
flushDiskType=ASYNC_FLUSH

# ✅ 正确配置
flushDiskType=SYNC_FLUSH

但仅 SYNC_FLUSH 不够,需要同步复制

# 完整安全配置
flushDiskType=SYNC_FLUSH
brokerRole=SYNC_MASTER
waitForSlavesAck=true
同步刷盘 + 同步复制 = 最高可靠性:
1. Producer → Master 写消息
2. Master 刷盘
3. Master 同步复制到 Slave
4. Slave 刷盘成功后 Master 回复 Producer
任意节点崩溃,消息不丢。

场景 3:本地事务成功后消息发送失败,未补偿

事故链路

11:20 支付回调处理成功,账户余额已更新
11:21 数据库事务已提交
11:22 MQ 消息发送失败(网络抖动)
11:25 用户查询余额,钱已到账,下游系统未收到通知
11:40 人工介入补发消息

问题流程

❌ 先执行业务,后发消息,消息失败无法回滚:
1. 执行本地 SQL(已提交)
2. 发送 MQ 消息(失败)
结果:余额加了,消息没发,钱对不上

✅ 用事务消息,本地和消息原子:
1. 发送半消息(Half Message)试探
2. 执行本地 SQL
3. 本地提交 → 消息投递
4. 本地回滚 → 消息丢弃

修复方案:事务消息

@Transactional
public void handlePaymentCallback(PaymentCallback callback) {
    TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(
        "payment-topic:account-update",
        MessageBuilder.withPayload(callback)
            .setHeader("orderId", callback.getOrderId())
            .build(),
        new TransactionListener() {
            @Override
            public LocalTransactionState executeLocalTransaction(
                    Message msg, Object arg) {
                try {
                    // 执行本地业务(加余额)
                    accountService.credit(callback.getUserId(), callback.getAmount());
                    // 记录事务日志(用于回查)
                    transactionLogService.log(msg.getMsgId(), callback.getOrderId(), "COMMITTED");
                    return LocalTransactionState.COMMIT_MESSAGE;  // 提交消息

                } catch (Exception e) {
                    log.error("本地事务失败,回滚: orderId={}", callback.getOrderId(), e);
                    transactionLogService.log(msg.getMsgId(), callback.getOrderId(), "ROLLBACK");
                    return LocalTransactionState.ROLLBACK_MESSAGE;  // 丢弃消息
                }
            }

            @Override
            public LocalTransactionState checkLocalTransaction(MessageExt msg) {
                // MQ 未收到提交时,主动回查本地状态(最多 30 次,约 15 分钟)
                String orderId = msg.getHeaders().get("orderId", String.class);
                TransactionLog log = transactionLogService.findByOrderId(orderId);
                if (log == null) {
                    return LocalTransactionState.UNKNOWN;  // 再等等
                }
                return "COMMITTED".equals(log.getStatus())
                    ? LocalTransactionState.COMMIT_MESSAGE
                    : LocalTransactionState.ROLLBACK_MESSAGE;
            }
        },
        callback
    );
}

事务消息三大保障

1. 本地事务和消息发送原子 → 本地失败,消息不投递
2. 消息发送失败可回滚本地事务 → Half 失败,直接回滚数据库
3. 本地成功后 MQ 崩溃,消息不丢 → Broker 启动时主动回查

场景 4:Consumer 消费超时导致消息被重复投递

事故链路

13:00 支付回调消费中,部分订单处理超时
13:05 重复消费:同一订单被处理了 2 次
13:06 账户余额被加了 2 次
13:10 定位:Consumer 返回成功前网络抖动,ACK 丢失
13:20 修复:增加幂等校验

根因

RocketMQ 消费流程:
1. Consumer 拉取消息
2. Consumer 处理业务(耗时 30s)
3. Consumer 发送 ACK
4. ⚠️ ACK 消息丢失(网络抖动)
5. Broker 认为消费失败
6. 重新投递消息给其他 Consumer
7. 同一消息被处理 2 次 → 余额被加 2 次

问题代码

// ❌ 问题:没有幂等校验,重复投递 = 重复业务
@RocketMQMessageListener(
    topic = "payment-topic",
    consumerGroup = "account-consumer-group"
)
public class AccountConsumer implements RocketMQListener<PaymentNotifyMessage> {
    @Override
    public void onMessage(PaymentNotifyMessage message) {
        // ⚠️ 没有幂等,直接执行业务
        // 消息重复 → 余额重复增加
        accountService.credit(message.getUserId(), message.getAmount());
    }
}

修复方案:幂等 + 手动确认

// ✅ 幂等表防重
@Service
public class IdempotentService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    // 原子操作:已处理则返回 false,不重复处理
    public boolean tryLock(String msgId, String bizType) {
        try {
            jdbcTemplate.update(
                "INSERT IGNORE INTO idempotent_msg (msg_id, biz_type, create_time) VALUES (?, ?, NOW())",
                msgId, bizType
            );
            // INSERT IGNORE:已存在则返回 0,插入成功返回 1
            return true;
        } catch (DuplicateKeyException e) {
            return false;  // 已处理过,跳过
        }
    }
}

// ✅ Consumer 幂等消费
@RocketMQMessageListener(
    topic = "payment-topic",
    consumerGroup = "account-consumer-group",
    maxReconsumeTimes = 3   // 最多重试 3 次
)
public class AccountConsumer implements RocketMQListener<PaymentNotifyMessage> {

    @Override
    public void onMessage(PaymentNotifyMessage message) {
        String msgId = message.getMsgId();

        // 1. 幂等校验:已处理过则抛异常跳过
        if (!idempotentService.tryLock(msgId, "ACCOUNT_CREDIT")) {
            log.warn("消息已处理,跳过: msgId={}", msgId);
            return;
        }

        try {
            // 2. 执行业务
            accountService.credit(message.getUserId(), message.getAmount());
            log.info("消费成功: msgId={}, orderId={}", msgId, message.getOrderId());
            // 无异常 = 消费成功,自动 ACK

        } catch (Exception e) {
            log.error("消费异常,准备重试: msgId={}", msgId, e);
            throw new RuntimeException("消费失败,等待重试");  // 抛异常触发重试
        }
    }
}

幂等表设计

CREATE TABLE idempotent_msg (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    msg_id VARCHAR(64) NOT NULL UNIQUE,  -- 消息唯一ID
    biz_type VARCHAR(32) NOT NULL,        -- 业务类型
    order_id VARCHAR(64),                 -- 关联订单号(便于排查)
    create_time DATETIME NOT NULL,
    INDEX idx_msg_id (msg_id),
    INDEX idx_order_id (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

三、支付中台消息可靠性完整方案

3.1 生产者端:三级保障

第一级:事务消息
├─ 本地事务和消息发送原子
├─ 本地失败 → 消息不投递
└─ 适用:余额变更、订单状态变更

第二级:同步发送 + 结果校验
├─ syncSend + 重试 2 次
├─ 发送失败 → 补偿队列
└─ 适用:非核心通知

第三级:消息补偿定时任务
├─ 定时扫描:本地有记录但 MQ 无投递
├─ 对账扫描:消费端对账,发现缺失则告警
└─ 兜底:每日凌晨批量对账

3.2 Broker 端:安全配置清单

# nameserver.conf
namesrvAddr=192.168.1.101:9876;192.168.1.102:9876

# broker.conf(Master 节点)
flushDiskType=SYNC_FLUSH
brokerRole=SYNC_MASTER
waitForSlavesAck=true
# Slave 节点配置
brokerRole=SLAVE
# 推荐最小集群:2 主 2 从

3.3 消费者端:幂等 + 顺序保障

// 幂等设计原则:
// 1. 每条消息必须有一个全局唯一 ID(msgId)
// 2. 消费前先查幂等表,已处理则跳过
// 3. 处理和标记幂等在同一事务中
// 4. ACK 只在业务成功处理后

// 顺序保障(如果业务需要):
// 消息按 orderId 哈希到同一队列,消费端按序处理
@RocketMQMessageListener(
    topic = "payment-topic",
    consumerGroup = "account-consumer-group",
    messageModel = MessageModel.CLUSTERING,
    consumeThreadNumber = 1  // 单线程消费保证顺序(注意吞吐量)
)

四、完整故障自愈机制

@Component
public class PaymentMessageHealthCheck {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Autowired
    private TransactionLogService transactionLogService;

    // 定时检查:消息是否已投递
    @Scheduled(fixedDelay = 60000)  // 每 1 分钟检查一次
    public void checkUnsentMessages() {
        // 查找本地已提交但 MQ 未确认的消息
        List<TransactionLog> unconfirmed =
            transactionLogService.findUnconfirmed(30);  // 超过 30 分钟未确认

        for (TransactionLog log : unconfirmed) {
            log.warn("发现未确认消息,准备补偿: orderId={}, elapsed={}min",
                log.getOrderId(), log.getElapsedMinutes());

            // 重新查找原消息体,重新发送
            try {
                PaymentTransaction tx = paymentTransactionService
                    .findByOrderId(log.getOrderId());
                if (tx != null) {
                    SendResult result = rocketMQTemplate.syncSend(
                        "payment-topic:tx-notify",
                        tx, 3000, 3
                    );
                    if (result.getSendStatus() == SendStatus.SEND_OK) {
                        log.updateStatus("COMPENSATED");
                        log.info("补偿成功: orderId={}", log.getOrderId());
                    }
                }
            } catch (Exception e) {
                log.error("补偿失败: orderId={}", log.getOrderId(), e);
            }
        }
    }
}

五、总结:消息零丢失检查清单

上线前必检项:
✅ Producer 使用事务消息(余额/状态变更场景)
✅ Producer 使用同步发送 + 补偿队列(非核心通知场景)
✅ Broker 配置 flushDiskType=SYNC_FLUSH
✅ Broker 配置 brokerRole=SYNC_MASTER + waitForSlavesAck=true
✅ Consumer 实现幂等(每条消息必须去重)
✅ Consumer 手动 ACK 在业务处理完成后
✅ 消息补偿定时任务(每分钟扫描未确认消息)
✅ 每日凌晨批量对账(兜底方案)
✅ 告警:消息发送失败率 > 1% → 立即告警
✅ 告警:消费延迟 > 5 分钟 → 立即告警

记忆口诀:
发送端,用事务;刷盘用同步;消费端,做幂等;
补偿要定时跑,对账要每日跑。

关联阅读