ThinkPHP8实战:用模型事件+Redis延迟队列搞定订单超时关单

2026-08-12 0 165

做电商项目,关单是个绕不过去的标准功能。用户下单后如果一直不支付,总得有个定时任务把订单状态改成已取消,顺便回滚库存。以前我是写一个cron,每分钟扫一次订单表,把超时未支付的订单全找出来关掉。但表大了以后,sql越写越重,扫全表扫得心里发慌。

后来我换成了ThinkPHP8,用它的模型事件配合Redis的zset做了一套轻量的延迟队列,效果不错,代码量也少。这做法不算复杂,但比每分钟扫全表靠谱多了。今天我把这套实现拆开来讲,顺便说说设计思路。

为什么不用cron扫表

大部分系统里,订单表少说几十万行,多的几千万。每次都跑一条SELECT id FROM order WHERE status='paid' AND created_at < '2025-06-15 10:00',随着时间推移越来越慢。而且cron的粒度太粗,一分钟扫一次,意味着订单最坏要等待59秒才能被关闭,体验上不痛不痒,但数据库压力却是实打实的。

延迟队列的意义在于,定时器是精确到秒级的,并且是不需要轮询的,到了时间点才触发。有很多方案,比如RabbitMQ的TTL、RabbitMQ的延迟插件、RocketMQ的定时消息。但很多PHP项目用不上重MQ,Redis却是标配。用Redis的zset实现延迟队列非常简单,而且和ThinkPHP8配合起来非常顺手。

延迟队列的核心原理

Redis的zset有一种天然适合做延迟队列的结构。我们给每个任务绑定一个分数(score),分数是任务应该执行的时间戳(毫秒级)。然后用一个消费者线程/进程轮询,每次取出score小于当前时间的任务,处理掉并移除。因为zset是按照score排序的,取出最早到期的任务非常高效。

用ThinkPHP8实现这个消费者也很简单,就是写一个命令行(指令)丢到后台跑,或者用系统cron每隔几秒调用一次。命令行的代码放到`appcommand`目录里,然后在`config/console.php`里注册即可。

具体实现步骤

假设我们有一个订单表,主要字段是`id`、`order_sn`、`status`、`created_at`。下单时status=0(未支付),支付后status=1(已支付),超时关单要把它变成-1。

我们使用ThinkPHP8的模型事件,在订单模型身上挂一个`afterInsert`事件。这样每当新订单创建成功后,就自动把“关闭订单”的任务推进Redis延迟队列里,不需要控制器额外写代码。

1. 安装Redis扩展

在ThinkPHP8项目里,确保`topthink/think-redis`已经安装,或者你直接用`thinkfacadeCache`也行。但为了直接用zset,建议用Redis原生方法。我在`composer.json`里加了`”topthink/think-redis”: “^3.0″`。

2. 创建订单模型文件

文件位置:`appmodelOrder.php`

namespace appmodel;

use thinkModel;
use thinkfacadeCache;

class Order extends Model
{
    protected $name = 'order';
    protected $autoWriteTimestamp = true;
    protected $createTime = 'created_at';
    protected $updateTime = 'updated_at';

    // 订单创建成功后,自动将一条“超时关闭任务”推入延迟队列
    public static function onAfterInsert(Order $order)
    {
        $expireTime = time() + 3600; // 一小时后关闭
        $jobId = 'close_order_' . $order->id;
        $score = $expireTime * 1000; // 转成毫秒

        Cache::store('redis')->handler()->zAdd('delay:order_close', $score, $jobId);

        // 额外存一份订单id与任务的关系,方便调试
        Cache::store('redis')->set($jobId, $order->id, 86400);
    }
}

注意这里用了`onAfterInsert`,是ThinkPHP8模型事件的标准写法。新订单插入数据库后,这个静态方法会被自动调用。由于TP8的事件机制,已经替我们处理好了参数传递,直接用`$order`就能拿到当前订单对象。

3. 编写延迟队列消费命令

我们创建一个命令行任务,让它负责扫描符合到期的任务并处理。命令行文件放在`appcommandConsumeCloseOrder.php`:

namespace appcommand;

use thinkconsoleCommand;
use thinkconsoleInput;
use thinkconsoleOutput;
use thinkfacadeCache;
use thinkfacadeDb;
use thinkfacadeLog;

class ConsumeCloseOrder extends Command
{
    protected function configure()
    {
        $this->setName('consume:close_order')
            ->setDescription('消费延迟队列,关闭超时订单');
    }

    protected function execute(Input $input, Output $output)
    {
        $redis = Cache::store('redis')->handler();
        $output->writeln('消费队列启动,等待中...');

        while (true) {
            $nowMs = (int)(microtime(true) * 1000);

            // 从zset中取出所有score小于当前时间戳的任务,最多取100条
            $tasks = $redis->zRangeByScore('delay:order_close', 0, $nowMs, ['limit' => [0, 100]]);

            if (empty($tasks)) {
                sleep(1); // 没有任务就睡1秒再查
                continue;
            }

            // 逐个处理
            foreach ($tasks as $jobId) {
                $orderId = $redis->get($jobId);
                if ($orderId) {
                    // 调用业务方法关闭订单
                    $this->closeOrder((int)$orderId);
                    // 任务处理完后,从zset中移除
                    $redis->zRem('delay:order_close', $jobId);
                    $redis->del($jobId);
                } else {
                    // 如果任务里的订单id不存在,说明订单已经被删除了,直接移出队列即可
                    $redis->zRem('delay:order_close', $jobId);
                }
            }

            // 每处理完一批,小睡0.2秒,降低CPU占用
            usleep(200000);
        }
    }

    private function closeOrder($orderId)
    {
        // 防止重复关闭,用原子操作
        $result = Db::name('order')
            ->where('id', $orderId)
            ->where('status', 0)  // 只关闭未支付订单
            ->update(['status' => -1, 'closed_at' => date('Y-m-d H:i:s')]);

        if ($result) {
            // 可以在这里做库存回滚、日志记录等等
            Log::info('订单超时关闭', ['order_id' => $orderId]);
        }
    }
}

这里有个需要注意的细节:我是用`while (true)`让这个命令一直常驻后台跑。你可以用`php think consume:close_order`启动它,配合supervisor守护。如果你不想常驻,也可以写一个cron每分钟执行一次,但这样延迟没那么精准。我推荐用supervisor跑这个命令。

4. 注册命令

把命令注册到TP8里,在`config/console.php`中加一行:

return [
    'commands' => [
        appcommandConsumeCloseOrder::class,
    ],
];

5. 测试一下

启动消费者:

php think consume:close_order

然后我们写一个测试路由,模拟创建订单(或者直接在数据库里手动插入一条,也会自动触发模型事件):

public function testCreateOrder()
{
    $order = new appmodelOrder();
    $order->order_sn = '20250615' . rand(1000, 9999);
    $order->status = 0;
    $order->save();

    return json(['code' => 1, 'msg' => '下单成功']);
}

访问这个接口,然后观察Redis里的zset:

> ZRANGE delay:order_close 0 -1 WITHSCORES
1) "close_order_1"
2) "1784563200000"

等到时间到达score对应的毫秒时间后,消费者进程会自动取出任务,把订单状态更新为-1。你去命令行看到日志输出:`订单超时关闭`,然后数据库里这个订单status变成了-1。

你可以把`expireTime`改短一点,比如30秒,来测试效果。

这套方案的几个优势

第一,代码侵入性很低。订单模型只多了一个`onAfterInsert`,其余业务逻辑都不用改。不管是控制器创建的订单,还是后台手动创建的订单,只要走Order模型,都会自动进入超时队列。不会出现漏触发的情况。

第二,不会有扫表的压力。消费者的每次查询只取到期的任务,数据库只更新真正需要关闭的订单,性能开销小很多。

第三,延迟精度高。因为用的是Redis zset的score,精确到毫秒,所以订单的实际关闭时间和预设的到期时间误差非常小,不会出现cron扫表那样最长延迟59秒的情况。

一些细节优化

有很多朋友会问:如果订单支付了,那延迟队列里的任务怎么办?可以不用管,因为`closeOrder`方法里面用了`where(‘status’, 0)`,如果订单已经支付,状态是1,更新就不会命中。但任务还是在队列里白白占用空间。有洁癖的话,可以在支付成功的时候顺手把任务删掉,这样更干净。

在支付接口里,调用订单模型的支付方法后,加一行:

$redis = thinkfacadeCache::store('redis')->handler();
$jobId = 'close_order_' . $order->id;
$redis->zRem('delay:order_close', $jobId);
$redis->del($jobId);

这样未支付的订单保留超时任务,已支付的订单不再有任务遗留。

另外,这个延迟队列不只能做订单超时。比如评论后多少天通知用户、优惠券过期提醒、定时发布文章,都可以用同一个原理。你可以自己封装一个更通用的延迟队列类,把任务类型和执行的业务逻辑分离,但本文这个版本已经足够清晰。

踩过的坑

我在调试的时候,第一版消费者里用了`while(true)`,但PHP的mysql连接是会超时的,所以如果订单关闭方法里用了Db查询,长时间运行后经常报告“MySQL server has gone away”。后来我在循环里定期用`Db::connect()->getPdo()`重置一下连接,但这样太麻烦。更好的做法是在启动命令时设置一个超时时间,或者每次执行完任务后短暂sleep,并在实际生产环境中用supervisor守护,命令跑挂了自动拉起来。但这不是重点,只是提醒一下。

还有一个坑是,如果你在`onAfterInsert`事件里直接用了`Cache::store(‘redis’)->handler()->zAdd()`,如果Redis连接失败,会导致整个下单事务回滚吗?实际上TP8中模型事件里抛异常,默认会回滚事务。这就不太好了,因为缓存故障不应该影响核心的下单逻辑。建议在事件里用try-catch包裹,或者把Redis的操作放到消息队列异步执行,不过那样反而复杂了。

我的做法是让代码静默失败,并记一条日志:

protected static function onAfterInsert(Order $order)
{
    try {
        $expireTime = time() + 3600;
        $jobId = 'close_order_' . $order->id;
        $score = $expireTime * 1000;
        Cache::store('redis')->handler()->zAdd('delay:order_close', $score, $jobId);
        Cache::store('redis')->set($jobId, $order->id, 86400);
    } catch (Throwable $e) {
        Log::error('订单延迟任务创建失败: ' . $e->getMessage());
    }
}

这样即使Redis短时不可用,也不会影响用户下单。

总结

这套方案没有用外部消息队列,仅仅依赖Redis的zset就实现了低延迟的定时任务,同时利用ThinkPHP8的模型事件做到了全自动将任务加入队列。整个过程可以说是“无感”的。对于订单量不是特别夸张的中小型项目,这个方案完全够用。

把扫表的轮询改成延迟队列,带来的不仅是数据库压力下降,更大的价值是代码结构变得更整洁了。你不再需要写一堆定时SQL,也不用担心忘记把新订单加入关单列表。模型事件把这些繁琐的细节放在了最合适的地方。

如果你也想在项目里试试,建议先搭建一个简单的订单模型,然后一步步按照上面代码去跑一遍,你会感受延迟队列的流畅。

ThinkPHP8实战:用模型事件+Redis延迟队列搞定订单超时关单
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP8实战:用模型事件+Redis延迟队列搞定订单超时关单 https://www.taomawang.com/server/thinkphp/2532.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务