独立开发中·📚18 篇日记·📝最近更新:2026年07月12日
🐰

RabbitMQ丢了58块钱:消息中间件的正确打开方式

一个durable:false的配置导致订单消息静默丢失。重建消息可靠性体系。

水哥
2026-07-029 分钟阅读

"老板,我的订单不见了!"

早上九点半,一个用户发来截图:他昨晚下的订单,已经支付了,但在订单列表里找不到。金额不大,58 元的一条宠物牵引绳。但这不是钱的问题——是用户对平台的信任

我开始排查。支付回调日志正常,支付平台确认已付款。但订单表里没有这条记录。也就是说:

支付平台 → 通知我的服务器:"用户付钱了!"
           → 服务器收到了通知
           → 服务器发了一条消息到 RabbitMQ
           → 但订单服务没有消费到这条消息
           → 钱收了,订单没创建

消息丢了。

RabbitMQ 是怎么丢消息的

我在 RabbitMQ 的控制台里发现了问题。order.created 这个队列有几百条消息堆积,而且队列的 x-message-ttl 设了 24 小时。一部分消息因为 TTL 过期被自动删除了。

消息流动:
  Publisher → Exchange → Queue → Consumer
  
消息丢失的三个环节:
  1. Publisher 发了,Exchange 没收到(网络问题)
  2. Exchange 发了,Queue 没持久化(重启丢失)
  3. Queue 有消息,Consumer 消费失败但没正确处理(消息确认)

我这个案例属于 2 和 3 的组合暴击:

  • Queue 没有设置持久化(durable: false
  • Consumer 处理失败后直接 nack 且没有 requeue

更致命的是,生产者收到支付回调后,没有判断消息是否真的被 RabbitMQ 接收了。

// ❌ 我原来的代码:fire and forget
async function handlePaymentCallback(orderId: string) {
  channel.sendToQueue('order.created', Buffer.from(orderId))
  // 不管 RabbitMQ 有没有收到,直接返回成功
  return { success: true }
}

// ✅ 正确的代码:confirm 模式
async function handlePaymentCallback(orderId: string) {
  return new Promise((resolve, reject) => {
    channel.sendToQueue('order.created', Buffer.from(orderId), {
      persistent: true,   // 消息持久化到磁盘
      mandatory: true,    // 如果没有 queue 绑定,返回错误
    }, (err) => {
      if (err) reject(err)
      else resolve({ success: true })
    })
  })
}

修复四步走

第一步:立即止血——手动补单

先手动给那个用户创建订单,退款 + 道歉。然后扫了所有支付记录和订单记录,找到了另外 5 条漏网之鱼——都是凌晨时候的订单,QPS 高的时候丢的消息。

给每个受影响的用户发了道歉消息 + 5 元无门槛优惠券。成本 30 元,但信任成本远不止这些。

第二步:消息持久化

// RabbitMQ 配置
const queueConfig = {
  durable: true,         // 队列本身持久化(重启后队列还在)
  autoDelete: false,     // 不要自动删除
  arguments: {
    'x-dead-letter-exchange': 'order.dlx',  // 死信队列
    'x-dead-letter-routing-key': 'order.dlq',
    'x-message-ttl': 86400000, // 24小时 TTL
  }
}

// 消息也持久化
channel.sendToQueue('order.created', buffer, {
  persistent: true,      // 消息写入磁盘
  mandatory: true,       // 不可路由时返回错误
})

第三步:消费者端重试 + 死信队列

// 消费消息时
channel.consume('order.created', async (msg) => {
  if (!msg) return

  const retryCount = (msg.properties.headers?.['x-retry-count'] || 0) as number

  try {
    await processOrder(msg)
    channel.ack(msg) // 成功就确认
  } catch (error) {
    if (retryCount < 3) {
      // 重试最多 3 次
      channel.nack(msg, false, false)
      channel.sendToQueue('order.created', msg.content, {
        persistent: true,
        headers: { 'x-retry-count': retryCount + 1 },
        expiration: '30000', // 30  秒后重试
      })
    } else {
      // 超过 3 次进入死信队列,人工处理
      channel.nack(msg, false, false)
      // 死信队列会自动接收
    }
  }
})

第四步:补偿机制——定时对账

// 每 10 分钟跑一次对账脚本
setInterval(async () => {
  // 查出所有已支付但没有订单的记录
  const orphanPayments = await db.query(`
    SELECT p.order_id, p.amount, p.paid_at
    FROM payments p
    LEFT JOIN orders o ON p.order_id = o.id
    WHERE p.status = 'paid'
      AND o.id IS NULL
      AND p.paid_at > NOW() - INTERVAL '24 hours'
  `)

  for (const payment of orphanPayments) {
    logger.error(`Orphan payment found: ${payment.order_id}`)
    // 自动补单
    await createOrderFromPayment(payment)
    // 发钉钉告警通知我
    await alertDingTalk(`自动补单: ${payment.order_id} ${payment.amount}元`)
  }
}, 10 * 60 * 1000)

整个修复的逻辑架构

Producer(支付回调)
  │
  ├── [confirm 确认消息到达 exchange]
  │
  └── Exchange(订单交换器)
        │
        ├── [mandatory: 检查 queue 绑定]
        │
        └── Queue(持久化,durable=true)
              │
              ├── [消息持久化,persistent=true]
              │
              └── Consumer(订单服务)
                    │
                    ├── [处理成功 → ack]
                    ├── [处理失败 → retry 最多3次]
                    └── [retry 失败 → dead letter queue → 人工 + 自动补偿]

写在最后

消息丢失这件事,比你以为的容易发生得多。它不需要服务器宕机,不需要硬盘损坏——只需要一个配置错误,一个 durable: false,就足够让几十条订单静默消失。

这次事故教会我三件事:

  1. 消息中间件的每一项配置都要懂为什么这样配。 durablepersistentmandatoryack 不是"默认就行"的参数,是生死攸关的参数。

  2. 分布式系统必须有补偿机制。 不管你的主链路设计得多健壮,总有消息会丢。对账脚本不是可有可无的"best practice",是必须有的兜底方案。

  3. 用户的信任是用每一次不出差错积累的,但一次差错就会摧毁它。 补了单、退了钱、发了优惠券——但如果那个用户再也不来了,我失去的不只是一次订单,是一个愿意为我付费的人。

做微平台,技术可以日臻完善,但信任不能日臻——它必须从第一天开始就牢不可破。

相关文章