前几天排查对账单,发现有十几笔第三方支付回调的订单状态死活对不上。翻了半天消费端日志,直接让人高血压:原来是负责推送订单的同学在消费端写了个极为粗暴的 try-catch。只要消费失败,他在 catch 块里打印了一条无关痛痒的 error 日志,接着就直接给 RabbitMQ 返回了 Ack

这种直接丢弃(Discard)数据的处理方式,在分布式高并发系统里简直是灾难。数据库事务失败、第三方 API 抖动、网络瞬时故障,任何一个因素都能让你的异步任务死机。如果直接扔掉,排查困难不说,数据一致性还彻底崩盘。

如果不丢弃,直接 Nack(requeue=true) 回滚到原队列?那更恐怖。如果是一个由于代码 Bug 导致 100% 报错的毒丸消息(Poison Message),队列会以每秒上万次的频率死循环重试,瞬间把 CPU 烧干,整个队列的正常消费全部被堵死。

为了优雅地搞定这个问题,我们利用 RabbitMQ 的**死信交换机(DLX,Dead Letter Exchange)**构建了一套渐进式延迟重试与补偿机制。

其原理非常巧妙:

  1. 工作队列(Work Queue):接收业务消息,消费者在这里消费。
  2. 死信退避队列(Retry Queue):当工作队列消费失败时,消费者记录已重试次数,如果未超限,则将消息发送至退避队列,并根据重试次数设置不同的过期时间(TTL)。该队列不设消费者。
  3. 退避队列的 DLX:退避队列配置了 x-dead-letter-exchange 属性指向工作队列的交换机。当 TTL 到期后,消息会自动变成“死信”,通过绑定的路由键重新投递回工作队列重新消费。
  4. 最终死信队列(DLQ):重试超过最大次数(如 5 次)的消息,直接路由到人工干预的 DLQ,发钉钉/企业微信告警,等待手动补偿。

以下是基于 Node.js amqplib 完整落地的队列配置与延迟重试核心代码:

import * as amqp from 'amqplib';

const RABBITMQ_URL = 'amqp://admin:admin123@localhost:5672';
const MAIN_EXCHANGE = 'order.exchange';
const WORK_QUEUE = 'order.work.queue';
const RETRY_EXCHANGE = 'order.retry.exchange';
const RETRY_QUEUE = 'order.retry.10s.queue'; // 10秒延迟重试队列
const DLQ_QUEUE = 'order.dlq.queue';

async function setupRabbitMQ() {
  const connection = await amqp.connect(RABBITMQ_URL);
  const channel = await connection.createChannel();

  // 1. 声明主业务交换机与队列
  await channel.assertExchange(MAIN_EXCHANGE, 'topic', { durable: true });
  await channel.assertQueue(WORK_QUEUE, {
    durable: true,
    arguments: {
      // 队列溢出或被拒的死信直接进入 DLQ
      'x-dead-letter-exchange': MAIN_EXCHANGE,
      'x-dead-letter-routing-key': 'order.dlq',
    }
  });
  await channel.bindQueue(WORK_QUEUE, MAIN_EXCHANGE, 'order.payment.#');

  // 2. 声明延迟重试队列(核心:利用 TTL 和 DLX 实现延迟重发)
  await channel.assertExchange(RETRY_EXCHANGE, 'direct', { durable: true });
  await channel.assertQueue(RETRY_QUEUE, {
    durable: true,
    arguments: {
      // 消息 TTL 到期后,自动作为死信发回主交换机
      'x-dead-letter-exchange': MAIN_EXCHANGE,
      'x-dead-letter-routing-key': 'order.payment.retry',
      // 设置固定过期时间 10000 毫秒(10秒)
      'x-message-ttl': 10000,
    }
  });
  await channel.bindQueue(RETRY_QUEUE, RETRY_EXCHANGE, 'retry.10s');

  // 3. 声明最终死信队列 (DLQ) 用于人工排查
  await channel.assertQueue(DLQ_QUEUE, { durable: true });
  await channel.bindQueue(DLQ_QUEUE, MAIN_EXCHANGE, 'order.dlq');

  // 4. 启动消费者
  channel.consume(WORK_QUEUE, async (msg) => {
    if (!msg) return;

    const content = JSON.parse(msg.content.toString());
    const headers = msg.properties.headers || {};
    const retryCount = (headers['x-retry-count'] as number) || 0;

    try {
      console.log(`[Consumer] 正在处理支付回调消息: ${content.orderId}, 当前重试次数: ${retryCount}`);
      
      // 模拟偶尔发生的外部接口调用失败
      if (Math.random() > 0.1) {
        throw new Error('Connect gateway timeout');
      }

      console.log(`[Consumer] 订单 ${content.orderId} 推送成功!`);
      channel.ack(msg);
    } catch (err: any) {
      console.error(`[Consumer] 处理订单 ${content.orderId} 出错: ${err.message}`);

      if (retryCount < 3) {
        // 未超限,发布到延迟重试队列
        const nextRetryCount = retryCount + 1;
        channel.ack(msg); // 确认原消息,防止堆积
        
        channel.publish(RETRY_EXCHANGE, 'retry.10s', msg.content, {
          persistent: true,
          headers: {
            'x-retry-count': nextRetryCount,
          }
        });
        console.log(`[Retry] 已将订单 ${content.orderId} 投递至重试队列,等待 10s 后重试`);
      } else {
        // 超过重试上限,直接送入 DLQ
        console.error(`[Alert] 订单 ${content.orderId} 重试次数超限,直接丢入 DLQ 进行排查监控!`);
        channel.reject(msg, false); // false 表示不放入原队列,直接走绑定的 DLX
      }
    }
  });
}

setupRabbitMQ().catch(console.error);

有了这套架构,支付微服务再也不怕上游偶发性的网络抖动。出错的消息会在退避队列挂机 10 秒钟,然后神不知鬼不觉地被吐回工作队列,大部分偶发性故障都在第二轮重试中被完美搞定。

你们在 RabbitMQ 里做延迟队列时,是利用我们这种原生的 TTL + DLX 双向循环路由,还是干脆给 RabbitMQ 安装官方的 rabbitmq_delayed_message_exchange 插件来实现?