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等关联方法的具体逻辑即可。如果需要针对分库分表场景调整脚本,或者优化性能,都可以告诉我。
相关推荐
科技小花2 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸2 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain2 小时前
linux个人心得22 (mysql)
数据库·mysql
阿里小阿希3 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神3 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员3 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java3 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿3 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb
不知名的老吴4 小时前
Redis的延迟瓶颈:TCP栈开销无法避免
数据库·redis·缓存
YOU OU4 小时前
三大范式和E-R图
数据库