"老板,我的订单不见了!"
早上九点半,一个用户发来截图:他昨晚下的订单,已经支付了,但在订单列表里找不到。金额不大,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,就足够让几十条订单静默消失。
这次事故教会我三件事:
-
消息中间件的每一项配置都要懂为什么这样配。
durable、persistent、mandatory、ack不是"默认就行"的参数,是生死攸关的参数。 -
分布式系统必须有补偿机制。 不管你的主链路设计得多健壮,总有消息会丢。对账脚本不是可有可无的"best practice",是必须有的兜底方案。
-
用户的信任是用每一次不出差错积累的,但一次差错就会摧毁它。 补了单、退了钱、发了优惠券——但如果那个用户再也不来了,我失去的不只是一次订单,是一个愿意为我付费的人。
做微平台,技术可以日臻完善,但信任不能日臻——它必须从第一天开始就牢不可破。