[TOC] #### 1. 前言 --- 用户下单了但没付款,过了 30 分钟,订单自动取消,库存恢复 淘宝、京东、拼多多,所有电商平台都有这个功能,但有没有想过:它是怎么实现的 ? #### 2. 业务为什么难做 --- 这个需求看起来简单:30 分钟没付款就取消 但实现起来有几个难点: 难点一:怎么知道「30 分钟到了」 ? 我们需要一个计时机制,用户下单的那一瞬间开始计时,30 分钟后触发取消 但用户可能同时下了 1000 个单,每个都要独立计时 难点二:取消的时候要做什么 ? + 把订单状态改成「已取消」 + 恢复库存 + 如果用了优惠券,还要退回优惠券 + 通知用户「您的订单已超时取消」 这些操作要么全做,要么全不做,不能做一半 难点三:不能影响正常流程 用户在 29 分钟的时候付款了,你不能 30 分钟又把订单取消了 #### 3. 不用 RabbitMQ 会怎样 --- 最常见的方式:定时任务扫表 ```php // 每分钟执行一次 $orders = DB::table('orders') ->where('status', 'unpaid') ->where('created_at', '<', now()->subMinutes(30)) ->get(); foreach ($orders as $order) { cancelOrder($order->id); } ``` 能用,但有几个问题: + 延迟:最坏情况延迟 1 分钟,用户付了款结果被取消了 + 数据库压力:每分钟扫一次表,订单量大了查询很慢 + 并发问题:定时任务可能和用户付款同时操作同一个订单,需要加锁处理 + 扩展困难:不同商品的超时时间不一样怎么办?有的 15 分钟,有的 30 分钟,有的 1 小时 #### 4. 用 RabbitMQ 怎么解决 --- 用 TTL + DLX 的组合 还记得第 22、23 讲讲的吗 ? 用户下单时,往队列里丢一条消息,设好 30 分钟过期 30 分钟后消息过期,自动进入死信队列,消费者从死信队列里取消息,执行取消逻辑 ```plaintext 用户下单 → 消息进入 order_queue(TTL 30分钟) ↓ 30分钟后消息过期 ↓ 自动转发到 DLX → timeout_queue ↓ 消费者取消订单 ``` #### 5. 代码实现 --- 下单时发消息 ```php public function createOrder($userId, $goodsId) { // 扣库存 $this->reduceStock($goodsId); // 创建订单 $order = $this->insertOrder($userId, $goodsId); // 发一条 30 分钟过期的消息,单位毫秒 $message = new AMQPMessage(json_encode(['order_id' => $order->id]), [ 'expiration' => '1800000' // 30分钟,单位毫秒 ]); $channel->basic_publish($message, '', 'order_queue'); return $order; } ``` 声明队列(绑定 DLX) ```php $args = new AMQPTable([ 'x-dead-letter-exchange' => 'dlx_exchange', 'x-dead-letter-routing-key' => 'order_timeout', ]); $channel->queue_declare('order_queue', false, true, false, false, false, $args); $channel->exchange_declare('dlx_exchange', 'direct', false, true, false); $channel->queue_declare('timeout_queue', false, true, false, false); $channel->queue_bind('timeout_queue', 'dlx_exchange', 'order_timeout'); ``` 消费者处理超时 ```php $callback = function ($msg) { $data = json_decode($msg->body, true); $order = DB::table('orders')->find($data['order_id']); if ($order && $order->status === 'unpaid') { // 恢复库存和取消订单放在事务里处理 // 避免恢复库存成功但订单状态更新失败 DB::transaction(function () use ($order) { DB::table('goods')->where('id', $order->goods_id)->increment('stock'); DB::table('orders')->where('id', $order->id)->update(['status' => 'cancelled']); }); echo "订单 {$order->id} 超时取消\n"; } $msg->ack(); }; $channel->basic_consume('timeout_queue', '', false, false, false, false, $callback); ``` #### 6. 如果用户付款了呢 --- 消费者收到消息后,会先查订单状态 如果用户已经付款了(status 不是 unpaid),直接 ack 跳过,什么都不做 不需要额外的逻辑,一个状态判断就够了 #### 7. 本文小结 --- | 方案 | 定时任务扫表 | RabbitMQ TTL + DLX | | ------------ | ------------ | ------------ | | 延迟 | 最多 1 分钟 | 不需要频繁扫描数据库 | | 数据库压力 | 每分钟查一次 | 不查数据库 | | 扩展性 | 改代码 | 改消息 TTL 就行 | | 可靠性 | 依赖定时任务存活 | 消息持久化,不丢 | 这个案例是 TTL + DLX 最经典的实战场景,这里是为了帮助理解延迟队列的思路 实际生产环境中,还需要结合 RabbitMQ 版本、消息量、业务规模选择合适的方案,下一讲,我们来做秒杀系统的削峰填谷