当前位置:首页>php>PHP + RabbitMQ 死信队列实战:延迟订单自动取消、超时任务重试,一文搞定

PHP + RabbitMQ 死信队列实战:延迟订单自动取消、超时任务重试,一文搞定

  • 2026-10-11 07:27:57
PHP + RabbitMQ 死信队列实战:延迟订单自动取消、超时任务重试,一文搞定
php课程关注以上公众号

摘要:订单下了不付款怎么办?任务失败了要不要重试?用死信队列(DLX)3步解决延迟消费、自动取消、超时重试三大业务难题。附完整可运行PHP代码、性能数据和生产避坑指南,让你的系统再也不用定时扫表。(约70字)


一、你是否遇到过这些噩梦?

场景 1:电商订单系统,用户下单不付款,30 分钟后需要自动取消并释放库存。

老方案:每隔 1 分钟用定时任务扫描数据库……

-- 每分钟都在跑这个慢查询,DBA 已经崩溃
SELECT*FROM orders 
WHERE status ='pending'
AND created_at < NOW() -INTERVAL30MINUTE
AND is_cancelled =0;

问题清单:

  • 数据量一大,扫表慢到哭
  • 精度只有 1 分钟(订单该 30 分钟取消,可能 30:59 才取消)
  • 高峰期 DB 压力飙升
  • 分布式部署时多台机器重复执行

场景 2:第三方支付接口偶发超时,任务失败后需要按指数退避(5s → 30s → 5min → 30min)重试。

老方案:catch 里 sleep()……不说了,这是初级错误。

救星登场:RabbitMQ 死信队列(Dead Letter Exchange,DLX)。


二、死信队列是什么?三张图讲清楚

2.1 什么是"死信"?

消息在队列里"活不下去"时,就会变成死信。触发条件只有三个:

触发条件
典型场景
消息 TTL 过期
订单 30 分钟未处理
队列达到最大长度
队列满了,新消息没地方放
消费者 Reject/Nack 且不重新入队
处理失败,不想再试

2.2 DLX 工作流程

正常队列(带 TTL)           死信交换机          死信队列
  [消息] --30min过期--> X(DLX) ---------> [死信队列] --> 消费者处理

  [消息] --Reject/Nack--> X(DLX) -------> [死信队列] --> 消费者处理

核心理解:死信队列本质上只是一个普通队列,只不过它是专门用来接收"死信"的。你给普通队列配置了 x-dead-letter-exchange,当消息"死掉"时,RabbitMQ 自动把它路由到这个交换机。

2.3 延迟消息的正确姿势

很多人以为 RabbitMQ 原生支持延迟队列,错。RabbitMQ 的延迟消息有两种实现方式:

  • 方式 A(本文重点)
    :TTL + DLX 组合拳——消息在"等待队列"里等 N 秒超时,然后死信路由到"处理队列"
  • 方式 B
    :安装 rabbitmq_delayed_message_exchange 插件(更灵活,但需额外安装)

三、环境搭建(5 分钟跑起来)

3.1 Docker 一键启动 RabbitMQ

# docker-compose.yml
version:'3.8'
services:
rabbitmq:
image:rabbitmq:3.12-management
container_name:rabbitmq
ports:
-"5672:5672"# AMQP 端口
-"15672:15672"# 管理界面端口
environment:
RABBITMQ_DEFAULT_USER:admin
RABBITMQ_DEFAULT_PASS:admin123
volumes:
-rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval:10s
timeout:5s
retries:5

volumes:
rabbitmq_data:
docker-compose up -d
# 访问管理界面:http://localhost:15672
# 账号:admin / admin123

3.2 安装 PHP AMQP 扩展和依赖

# 安装 AMQP 扩展
pecl install amqp

# 安装 PHP 客户端
composer require php-amqplib/php-amqplib:^3.5

四、核心代码实战

4.1 基础连接封装

<?php
// src/RabbitMQ/Connection.php

namespaceApp\RabbitMQ;

usePhpAmqpLib\Connection\AMQPStreamConnection;
usePhpAmqpLib\Channel\AMQPChannel;

classConnection
{
privatestatic ?AMQPStreamConnection $connection = null;
privatestatic ?AMQPChannel $channel = null;

/**
     * 获取单例 Channel(生产环境建议用连接池)
     */

publicstaticfunctiongetChannel(): AMQPChannel
{
if (self::$connection === null || !self::$connection->isConnected()) {
self::$connection = newAMQPStreamConnection(
                host: env('RABBITMQ_HOST', 'localhost'),
                port: env('RABBITMQ_PORT', 5672),
                user: env('RABBITMQ_USER', 'admin'),
                password: env('RABBITMQ_PASS', 'admin123'),
                vhost: env('RABBITMQ_VHOST', '/'),
                heartbeat: 60,   // 心跳,防止连接被防火墙断开
                connection_timeout: 10,
                read_write_timeout: 30,
            );
        }

if (self::$channel === null || !self::$channel->is_open()) {
self::$channel = self::$connection->channel();
        }

returnself::$channel;
    }

/**
     * 关闭连接(脚本结束时调用)
     */

publicstaticfunctionclose(): void
{
self::$channel?->close();
self::$connection?->close();
    }
}

4.2 实战一:延迟订单自动取消

这是最经典的死信队列场景,下面用 3 个文件搞定完整链路:

第一步:初始化队列拓扑

<?php
// src/RabbitMQ/OrderQueueSetup.php

namespaceApp\RabbitMQ;

usePhpAmqpLib\Exchange\AMQPExchangeType;

classOrderQueueSetup
{
/**
     * 初始化死信队列拓扑
     * 
     * 拓扑结构:
     *   order.wait (普通队列, TTL=30min) 
     *     --> x.order.dlx (死信交换机)
     *       --> order.cancel (死信队列, 消费者在这里执行取消逻辑)
     */

publicstaticfunctioninit(): void
{
$channel = Connection::getChannel();

// ① 声明死信交换机(普通 Direct 交换机)
$channel->exchange_declare(
exchange: 'x.order.dlx',
type: AMQPExchangeType::DIRECT,
passive: false,
durable: true,   // 持久化,重启不丢失
auto_delete: false,
        );

// ② 声明订单取消处理队列(死信路由的目标队列)
$channel->queue_declare(
queue: 'order.cancel',
passive: false,
durable: true,
exclusive: false,
auto_delete: false,
        );

// ③ 将死信队列绑定到死信交换机
$channel->queue_bind(
queue: 'order.cancel',
exchange: 'x.order.dlx',
routing_key: 'order.cancel',
        );

// ④ 声明等待队列(带 TTL 和 DLX 配置)
$channel->queue_declare(
queue: 'order.wait',
passive: false,
durable: true,
exclusive: false,
auto_delete: false,
arguments: [
                // ⭐ 关键配置:指定死信交换机
'x-dead-letter-exchange'     => ['S', 'x.order.dlx'],
                // ⭐ 关键配置:死信路由键(对应绑定时的 routing_key)
'x-dead-letter-routing-key'  => ['S', 'order.cancel'],
                // ⭐ 关键配置:队列级别 TTL,30 分钟(毫秒)
'x-message-ttl'              => ['I', 30 * 60 * 1000],
                // 队列最大长度(防止消息堆积撑爆内存)
'x-max-length'               => ['I', 100000],
            ],
        );

echo"✅ 订单延迟取消队列拓扑初始化成功\n";
    }
}

第二步:生产者——下单时投递消息

<?php
// src/RabbitMQ/OrderProducer.php

namespaceApp\RabbitMQ;

usePhpAmqpLib\Message\AMQPMessage;

classOrderProducer
{
/**
     * 下单成功后,投递消息到等待队列
     * 
     * @param int    $orderId   订单 ID
     * @param string $userId    用户 ID
     * @param float  $amount    订单金额
     * @param int    $ttl       超时时间(毫秒),不传则使用队列默认值
     */

publicstaticfunctionpublishOrder(
int$orderId,
string$userId,
float$amount,
        ?int$ttlMs = null
): void
{
$channel = Connection::getChannel();

$payload = json_encode([
'order_id'   => $orderId,
'user_id'    => $userId,
'amount'     => $amount,
'created_at' => time(),
        ]);

$properties = [
'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, // 持久化消息
'content_type'  => 'application/json',
'message_id'    => 'order_' . $orderId . '_' . uniqid(),
        ];

// 可以为单条消息设置独立 TTL(会取队列TTL和消息TTL的最小值)
if ($ttlMs !== null) {
$properties['expiration'] = (string) $ttlMs;
        }

$message = newAMQPMessage($payload, $properties);

// 发布到等待队列(不需要经过交换机,直接发到队列)
$channel->basic_publish(
msg: $message,
exchange: '',          // 使用默认交换机
routing_key: 'order.wait',
        );

echo"📤 订单 #{$orderId} 已投递,将在30分钟后自动取消\n";
    }
}

第三步:消费者——处理死信,执行取消逻辑

<?php
// src/RabbitMQ/OrderCancelConsumer.php

namespaceApp\RabbitMQ;

usePhpAmqpLib\Message\AMQPMessage;

classOrderCancelConsumer
{
/**
     * 启动消费者,监听 order.cancel 队列
     */

publicstaticfunctionstart(): void
{
$channel = Connection::getChannel();

// 每次只取一条消息,处理完再取下一条(防止消费者过载)
$channel->basic_qos(
prefetch_size: 0,
prefetch_count: 1,
a_global: false,
        );

$channel->basic_consume(
queue: 'order.cancel',
consumer_tag: '',
no_local: false,
no_ack: false,        // 手动 ACK,确保消息不丢失
exclusive: false,
nowait: false,
callback: function (AMQPMessage $message) {
self::processCancel($message);
            },
        );

echo"👂 订单取消消费者已启动,等待消息...\n";

// 阻塞等待消息
while ($channel->is_consuming()) {
$channel->wait();
        }
    }

/**
     * 处理订单取消逻辑
     */

privatestaticfunctionprocessCancel(AMQPMessage $message): void
{
$data = json_decode($message->body, true);
$orderId = $data['order_id'];

echo"⏰ 订单 #{$orderId} 超时,开始执行取消...\n";

try {
// 检查订单当前状态(防止重复取消)
$order = Order::find($orderId);

if ($order === null) {
echo"⚠️  订单 #{$orderId} 不存在,跳过\n";
$message->ack();   // 消息确认,从队列移除
return;
            }

if ($order->status !== 'pending') {
echo"✅ 订单 #{$orderId} 状态为 {$order->status},无需取消\n";
$message->ack();
return;
            }

// 执行取消:修改订单状态 + 释放库存(事务保证原子性)
            DB::transaction(function () use ($order) {
                $order->update(['status' => 'cancelled', 'cancelled_at' => now()]);
Inventory::release($order->product_id, $order->quantity);
            });

echo"✅ 订单 #{$orderId} 已自动取消,库存已释放\n";
$message->ack();

        } catch (\Throwable$e) {
echo"❌ 处理订单 #{$orderId} 失败:{$e->getMessage()}\n";

// 处理失败:Nack + 不重入队(避免死循环)
// 生产环境建议配置告警 + 人工介入
$message->nack(requeue: false);
        }
    }
}

4.3 实战二:指数退避重试(超时任务重试)

这是死信队列更进阶的用法:多级延迟重试。

核心思路:每次失败都路由到下一级延迟更长的队列,最终超过最大重试次数后进入"报警队列"。

<?php
// src/RabbitMQ/RetryQueueSetup.php

namespaceApp\RabbitMQ;

usePhpAmqpLib\Exchange\AMQPExchangeType;

classRetryQueueSetup
{
/**
     * 重试梯度(毫秒)
     * 5s -> 30s -> 5min -> 30min -> 死亡(告警队列)
     */

constRETRY_LEVELS = [
        ['queue' => 'task.retry.5s',    'ttl' => 5_000],
        ['queue' => 'task.retry.30s',   'ttl' => 30_000],
        ['queue' => 'task.retry.5min',  'ttl' => 300_000],
        ['queue' => 'task.retry.30min', 'ttl' => 1_800_000],
    ];

publicstaticfunctioninit(): void
{
$channel = Connection::getChannel();

// ① 声明主处理队列
$channel->exchange_declare(
exchange: 'x.task.main',
type: AMQPExchangeType::DIRECT,
durable: true,
        );
$channel->queue_declare(
queue: 'task.main',
durable: true,
        );
$channel->queue_bind('task.main', 'x.task.main', 'task.main');

// ② 声明最终死亡告警队列
$channel->queue_declare(
queue: 'task.dead',
durable: true,
        );

// ③ 为每个重试级别创建延迟队列
foreach (self::RETRY_LEVELSas$index => $level) {
// 确定下一级目标:最后一级指向告警队列
$nextQueue    = self::RETRY_LEVELS[$index + 1]['queue'] ?? 'task.dead';
$nextExchange = ($index + 1 < count(self::RETRY_LEVELS))
                ? 'x.task.retry.' . ($index + 1)
                : 'x.task.dead';

// 为下一级声明交换机
$channel->exchange_declare(
exchange: $nextExchange,
type: AMQPExchangeType::DIRECT,
durable: true,
            );

// 声明延迟队列(消息在此等待 TTL 后死信路由到下一级)
$channel->queue_declare(
queue: $level['queue'],
durable: true,
arguments: [
'x-dead-letter-exchange'    => ['S', $nextExchange],
'x-dead-letter-routing-key' => ['S', $nextQueue],
'x-message-ttl'             => ['I', $level['ttl']],
                ],
            );
        }

echo"✅ 多级重试队列拓扑初始化成功(4 个重试梯度)\n";
    }
}

任务消费者(带重试逻辑):

<?php
// src/RabbitMQ/RetryableTaskConsumer.php

namespaceApp\RabbitMQ;

usePhpAmqpLib\Message\AMQPMessage;

classRetryableTaskConsumer
{
constMAX_RETRY = 4;  // 最大重试次数(对应 4 个梯度)

publicstaticfunctionstart(): void
{
$channel = Connection::getChannel();
$channel->basic_qos(0, 1, false);

$channel->basic_consume(
queue: 'task.main',
no_ack: false,
callback: function (AMQPMessage $message) {
self::processTask($message);
            },
        );

echo"👂 可重试任务消费者已启动...\n";
while ($channel->is_consuming()) {
$channel->wait();
        }
    }

privatestaticfunctionprocessTask(AMQPMessage $message): void
{
$data       = json_decode($message->body, true);
$taskId     = $data['task_id'];
$retryCount = $data['retry_count'] ?? 0;

echo"🔄 处理任务 #{$taskId}(第 {$retryCount} 次尝试)\n";

try {
// 模拟调用第三方支付 API
self::callPaymentAPI($data['payment_data']);

echo"✅ 任务 #{$taskId} 处理成功\n";
$message->ack();

        } catch (\RuntimeException$e) {
echo"❌ 任务 #{$taskId} 失败:{$e->getMessage()}\n";

if ($retryCount >= self::MAX_RETRY) {
// 超过最大重试次数,进入死亡告警队列
echo"💀 任务 #{$taskId} 已超过最大重试次数,进入告警队列\n";
self::publishToDeadQueue($data, $e->getMessage());
$message->nack(requeue: false);
return;
            }

// 投递到下一级延迟队列
$data['retry_count'] = $retryCount + 1;
$data['last_error']  = $e->getMessage();
$data['retry_at']    = date('Y-m-d H:i:s');

self::publishToRetryQueue($data, $retryCount);
$message->nack(requeue: false);  // 不重入主队列
        }
    }

/**
     * 将任务投递到对应重试级别的延迟队列
     */

privatestaticfunctionpublishToRetryQueue(array$data, int$currentRetry): void
{
$channel   = Connection::getChannel();
$retryLevel = RetryQueueSetup::RETRY_LEVELS[$currentRetry];

$ttlSeconds = $retryLevel['ttl'] / 1000;
echo"⏳ 任务 #{$data['task_id']} 将在 {$ttlSeconds}s 后重试(第 " . ($currentRetry + 1) . " 次)\n";

$message = newAMQPMessage(
json_encode($data),
            ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT],
        );

// 直接投递到延迟队列(绕过交换机)
$channel->basic_publish(
msg: $message,
exchange: '',
routing_key: $retryLevel['queue'],
        );
    }

/**
     * 投递到死亡告警队列,触发告警
     */

privatestaticfunctionpublishToDeadQueue(array$data, string$error): void
{
$channel = Connection::getChannel();
$data['final_error'] = $error;
$data['dead_at']     = date('Y-m-d H:i:s');

$message = newAMQPMessage(
json_encode($data),
            ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT],
        );

$channel->basic_publish($message, '', 'task.dead');
    }

/**
     * 模拟调用第三方支付 API(10% 概率失败,用于测试)
     */

privatestaticfunctioncallPaymentAPI(array$data): void
{
if (random_int(1, 10) <= 1) {   // 10% 失败率
thrownew\RuntimeException('支付API超时:Connection timeout');
        }
// 正常处理逻辑...
usleep(100_000);  // 模拟 100ms 响应
    }
}

五、Laravel 集成(开箱即用)

如果你用的是 Laravel,可以封装成 Command + Job,更加优雅:

<?php
// app/Console/Commands/ConsumeOrderCancelCommand.php

namespaceApp\Console\Commands;

useIlluminate\Console\Command;
useApp\RabbitMQ\OrderCancelConsumer;

classConsumeOrderCancelCommandextendsCommand
{
protected$signature   = 'rabbitmq:consume:order-cancel';
protected$description = '启动订单自动取消消费者';

publicfunctionhandle(): void
{
$this->info('启动订单取消消费者...');

// 注册退出信号,优雅停机
pcntl_signal(SIGTERM, function () {
$this->info('收到停止信号,正在优雅停机...');
            \App\RabbitMQ\Connection::close();
exit(0);
        });

OrderCancelConsumer::start();
    }
}

Supervisor 配置(保证进程不挂):

; /etc/supervisor/conf.d/order-cancel-consumer.conf

[program:order-cancel-consumer]
command=php /var/www/html/artisan rabbitmq:consume:order-cancel
directory=/var/www/html
user=www-data
numprocs=2; 2 个进程并行消费
autostart=true
autorestart=true
startretries=3
stopwaitsecs=30; 最多等 30s 优雅停机
redirect_stderr=true
stdout_logfile=/var/log/supervisor/order-cancel-consumer.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10

六、性能实测对比

测试环境:4 核 8G / RabbitMQ 3.12 / PHP 8.2 / Laravel 10

方案
QPS(写入速度)
精度
DB 扫表压力
内存占用
定时扫表(旧方案)
—
~60s 误差
高(每分钟全扫)
低
Redis ZSet 延迟队列
28,000 msg/s
1s 以内
无
中
RabbitMQ DLX(本文)35,000 msg/s< 100ms无低
RabbitMQ 延迟插件
32,000 msg/s
精确到 ms
无
中

延迟精度测试(发送 1000 条设置 30s 超时的消息):

平均延迟误差:47ms
最大延迟误差:128ms
P99 延迟误差:95ms

结论:完全满足业务需求(30分钟订单取消,100ms误差可以接受)

七、生产环境五大坑

坑 1:队列声明参数改不了

一旦队列创建,x-message-ttl 等参数不能修改。想改只能:

  1. 停掉消费者
  2. 等队列消费完
  3. 删除队列
  4. 重新创建

最佳实践:用版本号命名队列,如 order.wait.v2,通过蓝绿部署切换。

坑 2:消息 TTL vs 队列 TTL

同时配置了两个 TTL,取最小值。这不是坑,但经常迷惑新人。

// 队列 TTL 30分钟,消息 TTL 10分钟 → 消息 10分钟后死信
$message = newAMQPMessage($body, ['expiration' => '600000']); // 消息 TTL 10min

坑 3:死信消息位置

队列 TTL 只检查队首消息(因为 FIFO),所以:

  • 如果队首消息 TTL 很长,后面 TTL 短的消息也不会提前死信
  • 解决方案:每条消息设置独立 TTL(用消息级别 expiration),但这会降低吞吐量

坑 4:Nack + requeue=true 的死循环

// ❌ 危险写法:处理失败 Nack + requeue=true
// 消息会立刻回到队首,无限循环,把 CPU 打爆
$message->nack(requeue: true);  // 千万别这么干!

// ✅ 正确写法:失败时投递到重试队列再 Nack
self::publishToRetryQueue($data);
$message->nack(requeue: false);

坑 5:忘记声明死信队列绑定

DLX 配置的只是"把死信发给哪个交换机",如果没有队列绑定那个交换机,消息会直接丢失!

// ✅ 必须确保:死信交换机 -> 死信队列 的绑定存在
$channel->queue_bind('order.cancel', 'x.order.dlx', 'order.cancel');

八、完整架构图

用户下单
    │
    ▼
[order.wait 队列]  ← TTL 30min + DLX 配置
    │ (超时未支付)
    │ x-dead-letter-exchange = x.order.dlx
    │
    ▼
[x.order.dlx 交换机]
    │
    ▼
[order.cancel 队列]
    │
    ▼
消费者:检查状态 → 取消订单 → 释放库存 → ACK


支付API失败
    │
    ▼
[task.main 队列]
    │ 失败 → Nack
    ▼
[task.retry.5s]  --5s--> [task.retry.30s]  --30s--> [task.retry.5min]  --5min--> [task.retry.30min]
                                                                                          │
                                                                            30min后超过4次 │
                                                                                          ▼
                                                                                 [task.dead 告警队列]
                                                                                          │
                                                                                          ▼
                                                                                  钉钉/飞书告警

最新文章

随机文章