ThinkPHP6.0 Redis 延迟队列 + 定时任务 实现超时取消订单完整部署脚本

一、环境前置条件

ThinkPHP 6.x/8.x 框架(TP5.x 仅需少量语法调整)

Redis 服务(已配置到 ThinkPHP 缓存驱动)

PHP 扩展:redis、pcntl(守护进程需要)

Supervisor(用于管理守护进程,可选但推荐)

二、完整代码实现

  1. 订单延迟队列核心逻辑(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
    {
        // 批量返还优惠券逻辑
    }
}
  1. 命令行入口(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;
        }
    }
}
  1. 注册命令(config/console.php)
php 复制代码
return [
    'commands' => [
        'order:delay' => \app\command\OrderDelayCommand::class
    ]
];
  1. 下单时加入延迟队列(示例)
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' => '下单成功']);
}

三、部署与运行

  1. 启动延迟队列消费进程(守护进程)
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
  1. 配置定时兜底任务(crontab)
php 复制代码
# 编辑crontab
crontab -e
# 添加定时任务(每小时执行一次兜底扫描)
0 * * * * cd /www/wwwroot/your_project && php think order:delay scan >> runtime/log/order_delay_scan.log 2>&1

四、关键配置说明

  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,
        ],
    ],
];
  1. 批次大小调整:batchSize(1000)可根据服务器性能调整,建议从 500 开始逐步测试。
  2. 日志配置:确保 config/log.php 开启文件日志,方便排查问题。
    五、运行验证
  3. 测试延迟队列:
    手动创建一个测试订单(status=0,expire_time 设置为 1 分钟后)。
    调用 addToDelayQueue 方法将订单加入队列。
    观察日志,1 分钟后订单是否被自动取消。
  4. 测试定时兜底:
    手动创建一个已过期但未被处理的订单。
    执行 php think order:delay scan。
    检查订单状态是否被取消。
  5. 总结
    核心架构:延迟队列(实时处理)+ 定时扫描(兜底),兼顾精准性和可靠性。
    关键保障:分布式锁、幂等性校验、事务控制,避免重复取消和数据不一致。
    部署要点:消费进程用 Supervisor 守护,定时任务用 crontab 配置,日志需持续监控。
    这套脚本可直接部署使用,仅需根据你的业务场景补充releaseStock、returnCoupon等关联方法的具体逻辑即可。如果需要针对分库分表场景调整脚本,或者优化性能,都可以告诉我。
相关推荐
2501_944521002 小时前
rn_for_openharmony商城项目app实战-主题设置实现
javascript·数据库·react native·react.js·ecmascript
heartbeat..2 小时前
SQL 常用函数大全:聚合、字符串、数值、日期、窗口函数解析
java·数据库·sql·函数
chuxinweihui2 小时前
MySQL数据库基础
数据库·mysql
无敌的牛3 小时前
MySQL基础
数据库·mysql
进阶的小名3 小时前
[超轻量级延时队列(MQ)] Redis 不只是缓存:我用 Redis Stream 实现了一个延时MQ(自定义注解方式)
java·数据库·spring boot·redis·缓存·消息队列·个人开发
短剑重铸之日3 小时前
《7天学会Redis》Day 6 - 内存&性能调优
java·数据库·redis·缓存·7天学会redis
鱼跃鹰飞4 小时前
面试题:解释一下什么是全字段排序和rowid排序
数据结构·数据库·mysql
Aloudata技术团队4 小时前
完美应对千亿级明细数据计算:Aloudata CAN 双引擎架构详解
数据库·数据分析·数据可视化
Dxy12393102164 小时前
MySQL连表查询讲解:从基础到实战
数据库·mysql