做电商项目,关单是个绕不过去的标准功能。用户下单后如果一直不支付,总得有个定时任务把订单状态改成已取消,顺便回滚库存。以前我是写一个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,也不用担心忘记把新订单加入关单列表。模型事件把这些繁琐的细节放在了最合适的地方。
如果你也想在项目里试试,建议先搭建一个简单的订单模型,然后一步步按照上面代码去跑一遍,你会感受延迟队列的流畅。

