一、环境前置条件
ThinkPHP 6.x/8.x 框架(TP5.x 仅需少量语法调整)
Redis 服务(已配置到 ThinkPHP 缓存驱动)
PHP 扩展:redis、pcntl(守护进程需要)
Supervisor(用于管理守护进程,可选但推荐)
二、完整代码实现
- 订单延迟队列核心逻辑(app/service/OrderDelayService.php)
php
<?php
declare (strict_types=1);
namespace app\service;
use think\facade\Cache;
use think\facade\Db;
use think\facade\Log;
/**
* 订单延迟队列服务
* 处理订单超时取消逻辑
*/
class OrderDelayService
{
// Redis 延迟队列 KEY
const DELAY_QUEUE_KEY = 'order:delay:queue';
// 已处理订单标记 KEY
const PROCESSED_KEY = 'order:delay:processed:';
// 分布式锁 KEY
const CONSUMER_LOCK_KEY = 'order:delay:consumer:lock';
/**
* 下单成功后,将订单加入延迟队列
* @param string $orderId 订单号
* @param string $expireTime 订单过期时间(格式:Y-m-d H:i:s)
* @return bool
*/
public function addToDelayQueue(string $orderId, string $expireTime): bool
{
try {
$redis = Cache::store('redis')->handler();
// 将过期时间转为时间戳作为 zset 的 score
$expireTimestamp = strtotime($expireTime);
if ($expireTimestamp <= time()) {
Log::warning("订单{$orderId}过期时间已失效,无需加入延迟队列");
return false;
}
// 加入延迟队列(zadd:key score member)
$redis->zAdd(self::DELAY_QUEUE_KEY, $expireTimestamp, $orderId);
Log::info("订单{$orderId}已加入延迟队列,过期时间:{$expireTime}");
return true;
} catch (\Exception $e) {
Log::error("订单{$orderId}加入延迟队列失败:{$e->getMessage()}");
return false;
}
}
/**
* 消费延迟队列(守护进程模式)
*/
public function consumeDelayQueue()
{
// 1. 获取分布式锁,防止多进程重复消费
$redis = Cache::store('redis')->handler();
$lock = $redis->setNx(self::CONSUMER_LOCK_KEY, 1);
if (!$lock) {
Log::info("延迟队列消费进程已在运行,本次启动失败");
return;
}
// 设置锁有效期(60秒),防止进程异常退出导致锁无法释放
$redis->expire(self::CONSUMER_LOCK_KEY, 60);
Log::info("延迟队列消费进程已启动");
// 2. 循环消费
while (true) {
try {
// 重置锁有效期
$redis->expire(self::CONSUMER_LOCK_KEY, 60);
// 查询当前时间前的过期订单(每次取100条,避免单次处理过多)
$orderIds = $redis->zRangeByScore(
self::DELAY_QUEUE_KEY,
0,
time(),
['limit' => [0, 100]]
);
if (empty($orderIds)) {
// 无数据时休眠100ms,降低CPU占用
usleep(100000);
continue;
}
// 批量处理过期订单
$this->handleExpiredOrders($orderIds);
} catch (\Exception $e) {
Log::error("延迟队列消费异常:{$e->getMessage()}");
// 异常时休眠1秒,避免死循环报错
sleep(1);
}
}
// 释放锁(理论上不会执行到这里,守护进程需手动停止)
$redis->del(self::CONSUMER_LOCK_KEY);
}
/**
* 处理过期订单(核心取消逻辑)
* @param array $orderIds
*/
private function handleExpiredOrders(array $orderIds): void
{
$redis = Cache::store('redis')->handler();
foreach ($orderIds as $orderId) {
// 1. 幂等性校验:防止重复处理
if ($redis->exists(self::PROCESSED_KEY . $orderId)) {
$redis->zRem(self::DELAY_QUEUE_KEY, $orderId);
continue;
}
// 2. 加单订单锁,防止并发处理
$orderLockKey = "order:lock:{$orderId}";
if (!$redis->setNx($orderLockKey, 1, 30)) {
continue;
}
try {
// 3. 查询订单状态,确认是否未支付
$order = Db::name('order')
->where('order_id', $orderId)
->find();
if (!$order || $order['status'] != 0) {
// 订单不存在或已支付/取消,直接移除队列
$redis->zRem(self::DELAY_QUEUE_KEY, $orderId);
$redis->set(self::PROCESSED_KEY . $orderId, 1, 86400); // 标记已处理,保留24小时
continue;
}
// 4. 执行订单取消逻辑
Db::startTrans();
// 更新订单状态为"自动取消"
$updateRes = Db::name('order')
->where('order_id', $orderId)
->where('status', 0) // 再次校验状态,确保幂等
->update([
'status' => 2,
'cancel_type' => 0,
'update_time' => date('Y-m-d H:i:s')
]);
if ($updateRes === false) {
throw new \Exception("订单状态更新失败");
}
// 5. 执行关联业务(释放库存、返还优惠券等,根据业务调整)
$this->releaseStock($orderId);
$this->returnCoupon($orderId);
Db::commit();
Log::info("订单{$orderId}已自动取消");
// 6. 标记已处理并移除队列
$redis->set(self::PROCESSED_KEY . $orderId, 1, 86400);
$redis->zRem(self::DELAY_QUEUE_KEY, $orderId);
} catch (\Exception $e) {
Db::rollback();
Log::error("订单{$orderId}取消失败:{$e->getMessage()}");
} finally {
// 释放订单锁
$redis->del($orderLockKey);
}
}
}
/**
* 定时任务兜底:扫描遗漏的超时订单(每天/每小时执行)
*/
public function scanTimeoutOrders(): void
{
$batchSize = 1000; // 每批次处理1000条
$lastOrderId = '';
Log::info("开始执行定时兜底扫描超时订单");
do {
// 查询超时未支付且未被处理的订单
$orderList = Db::name('order')
->where('status', 0)
->where('expire_time', '<', date('Y-m-d H:i:s'))
->where('order_id', '>', $lastOrderId)
->order('order_id')
->limit($batchSize)
->select()
->toArray();
if (empty($orderList)) {
break;
}
// 批量取消
$orderIds = array_column($orderList, 'order_id');
$this->batchCancelOrders($orderIds);
// 更新游标
$lastOrderId = end($orderList)['order_id'];
unset($orderList);
} while (true);
Log::info("定时兜底扫描完成");
}
/**
* 批量取消订单
* @param array $orderIds
*/
private function batchCancelOrders(array $orderIds): void
{
try {
Db::startTrans();
// 批量更新订单状态
$updateRes = Db::name('order')
->where('status', 0)
->whereIn('order_id', $orderIds)
->update([
'status' => 2,
'cancel_type' => 0,
'update_time' => date('Y-m-d H:i:s')
]);
if ($updateRes === false) {
throw new \Exception("批量更新订单状态失败");
}
// 批量处理关联业务
$this->batchReleaseStock($orderIds);
$this->batchReturnCoupon($orderIds);
Db::commit();
Log::info("批量取消{$updateRes}个订单成功");
} catch (\Exception $e) {
Db::rollback();
Log::error("批量取消订单失败:{$e->getMessage()},订单ID:" . implode(',', $orderIds));
}
}
// -------------------- 以下为业务关联方法(需根据实际业务实现) --------------------
private function releaseStock(string $orderId): void
{
// 释放订单占用的库存逻辑
}
private function returnCoupon(string $orderId): void
{
// 返还订单使用的优惠券逻辑
}
private function batchReleaseStock(array $orderIds): void
{
// 批量释放库存逻辑
}
private function batchReturnCoupon(array $orderIds): void
{
// 批量返还优惠券逻辑
}
}
- 命令行入口(app/command/OrderDelayCommand.php)
用于启动延迟队列消费进程和执行定时兜底任务:
bash
<?php
declare (strict_types=1);
namespace app\command;
use app\service\OrderDelayService;
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\Output;
class OrderDelayCommand extends Command
{
protected function configure()
{
// 指令配置
$this->setName('order:delay')
->addArgument('action', Argument::REQUIRED, '操作:consume(消费队列)|scan(兜底扫描)')
->setDescription('订单延迟队列处理命令');
}
protected function execute(Input $input, Output $output)
{
$action = $input->getArgument('action');
$service = new OrderDelayService();
switch ($action) {
case 'consume':
$output->writeln('启动订单延迟队列消费进程...');
$service->consumeDelayQueue();
break;
case 'scan':
$output->writeln('执行订单超时兜底扫描...');
$service->scanTimeoutOrders();
$output->writeln('兜底扫描完成');
break;
default:
$output->writeln('无效操作!支持:consume/scan');
break;
}
}
}
- 注册命令(config/console.php)
php
return [
'commands' => [
'order:delay' => \app\command\OrderDelayCommand::class
]
];
- 下单时加入延迟队列(示例)
php
// 下单控制器中调用
public function placeOrder()
{
// 1. 生成订单逻辑(省略)
$orderId = 'ORDER202601140001';
$expireTime = date('Y-m-d H:i:s', time() + 1800); // 30分钟后过期
// 2. 加入延迟队列
$delayService = new \app\service\OrderDelayService();
$delayService->addToDelayQueue($orderId, $expireTime);
return json(['code' => 0, 'msg' => '下单成功']);
}
三、部署与运行
- 启动延迟队列消费进程(守护进程)
php
# 方式1:直接启动(终端关闭后进程终止)
php think order:delay consume
# 方式2:使用nohup后台运行
nohup php think order:delay consume > runtime/log/order_delay_consumer.log 2>&1 &
# 方式3:推荐使用Supervisor管理(进程崩溃自动重启)
# 1. 创建Supervisor配置文件 /etc/supervisor/conf.d/order_delay.conf
[program:order_delay_consumer]
command=php /www/wwwroot/your_project/think order:delay consume
autostart=true
autorestart=true
user=www
redirect_stderr=true
stdout_logfile=/www/wwwroot/your_project/runtime/log/order_delay_consumer.log
stopasgroup=true
killasgroup=true
# 2. 重新加载Supervisor
supervisorctl reload
# 3. 启动进程
supervisorctl start order_delay_consumer
- 配置定时兜底任务(crontab)
php
# 编辑crontab
crontab -e
# 添加定时任务(每小时执行一次兜底扫描)
0 * * * * cd /www/wwwroot/your_project && php think order:delay scan >> runtime/log/order_delay_scan.log 2>&1
四、关键配置说明
- Redis 缓存配置:确保 config/cache.php 中 Redis 驱动配置正确:
php
return [
'default' => env('cache.driver', 'redis'),
'stores' => [
'redis' => [
'type' => 'redis',
'host' => env('redis.host', '127.0.0.1'),
'port' => env('redis.port', 6379),
'password' => env('redis.password', ''),
'select' => env('redis.select', 0),
'timeout' => 3600,
],
],
];
- 批次大小调整:batchSize(1000)可根据服务器性能调整,建议从 500 开始逐步测试。
- 日志配置:确保 config/log.php 开启文件日志,方便排查问题。
五、运行验证 - 测试延迟队列:
手动创建一个测试订单(status=0,expire_time 设置为 1 分钟后)。
调用 addToDelayQueue 方法将订单加入队列。
观察日志,1 分钟后订单是否被自动取消。 - 测试定时兜底:
手动创建一个已过期但未被处理的订单。
执行 php think order:delay scan。
检查订单状态是否被取消。 - 总结
核心架构:延迟队列(实时处理)+ 定时扫描(兜底),兼顾精准性和可靠性。
关键保障:分布式锁、幂等性校验、事务控制,避免重复取消和数据不一致。
部署要点:消费进程用 Supervisor 守护,定时任务用 crontab 配置,日志需持续监控。
这套脚本可直接部署使用,仅需根据你的业务场景补充releaseStock、returnCoupon等关联方法的具体逻辑即可。如果需要针对分库分表场景调整脚本,或者优化性能,都可以告诉我。