LikeShop 定时任务与队列:秒杀、订单超时关闭等异步场景实现
2026/9/24 20:55:51 网站建设 项目流程

一、前言

在之前的系列文章中,我写了 LikeShop 的分层架构、支付模块、数据库设计,以及营销模块的优惠券、积分、分销。这一篇进入异步场景——定时任务与消息队列。

为什么单独讲这个?因为电商系统里最耗性能的操作,往往不是用户能看到的那些。秒杀抢购、订单超时关闭、退款状态扫描、佣金自动结算——这些操作如果全部同步执行,系统根本扛不住。LikeShop 用定时任务和消息队列两套机制,把这些“不该让用户等待”的操作从主链路中剥离出来。

这篇文章就从源码出发,把这两套机制的实现思路拆开讲清楚。

二、LikeShop 定时任务的整体设计

入口:一行命令跑起所有定时逻辑

LikeShop 的定时任务入口非常简洁——项目根目录下的think文件,通过命令行调用crontab命令:

php thinkcrontab

这行命令是 LikeShop 所有定时任务的统一入口。它对应的是server/app/common/command/Crontab.php这个命令行类。

服务器端的配置方式

在服务器上,需要把这条命令加入 Crontab 计划任务,设置为每分钟执行一次:

*/1 * * * * php /www/wwwroot/likeshop/server/thinkcrontab

其中/www/wwwroot/likeshop/server/think是项目的实际绝对路径。

宝塔面板提供了更友好的配置方式。在「计划任务」中新建任务,有两种方式可选:

方式一:访问 URL(推荐 2022 年 5 月后的版本使用)

  • 任务类型选择「访问 URL」
  • URL 填写https://你的域名/crontab
  • 执行周期设为「N 分钟 1 分钟」

这个 URL 对应的路由定义在server/route/route.php中:

// 定时任务Route::rule('crontab',function(){think\Console::call('crontab');});

这行代码的意思是:当访问/crontab时,通过think\Console::call()调用命令行中的crontab命令,实现和命令行方式完全相同的效果。

方式二:Shell 脚本

  • 任务类型选择「Shell 脚本」
  • 命令填写php72 /www/wwwroot/likeshop/server/think crontab

定时任务管理:后台可配置

LikeShop 的一个特色是定时任务可以在管理后台配置和开关。设置路径是「系统设置 → 系统维护 → 定时任务」。

管理后台提供了完整的定时任务 CRUD 接口,包括添加任务(/crontab.crontab/add)、操作任务(/crontab.crontab/operate)等。每个任务可以单独设置开启或停止,状态必须为开启时才会真正执行。

系统还支持记录定时任务运行日志,方便排查任务执行情况。

Crontab 命令内部的调度逻辑

Crontab.php的核心逻辑是:每分钟被触发一次,然后检查哪些子任务到了执行时间

每个子任务在数据库中有自己的 cron 表达式和执行状态。Crontab 命令每次执行时,会遍历所有已开启的任务,判断当前时间是否匹配任务的 cron 表达式。如果匹配,就执行对应的任务逻辑,并更新最后执行时间。

这种设计的好处是:你不需要为每个子任务单独配置一条服务器 Crontab。所有任务共用一条php think crontab命令,由 PHP 层面的调度器统一管理。新增任务时只需要在数据库中添加记录,不需要登录服务器改配置。

LikeShop 的定时任务主要处理以下几类业务:

  • 订单退款扫描:定时检查退款状态的订单,推进退款流程
  • 关闭超时未支付订单:扫描超过支付时限仍未付款的订单,自动关闭
  • 佣金自动结算:检查满足结算条件的订单,执行佣金发放
  • 活动自动开启/结束:检查营销活动的开始和结束时间

三、消息队列:异步削峰的核心机制

ThinkPHP Queue 的引入

LikeShop 基于ThinkPHP Queue组件实现异步任务。这是 ThinkPHP 官方提供的消息队列服务,支持 sync(同步)和 redis(异步)两种驱动模式。

当驱动类型为redis时,Queue::later()Queue::push()方法会将任务推入 Redis 队列,由独立的消费者进程异步执行。

任务的定义与发布

一个队列任务由两部分组成:任务类发布调用

任务类需要继承BaseJob,并实现具体的消费逻辑:

classOrderTimeoutJobextendsBaseJob{publicfunctionfire($data){// 处理订单超时逻辑// 返回 true 表示消费完成// 返回 false 表示消费失败(默认3次失败后删除任务)returntrue;}}

发布任务时,有两种模式——立即执行和延时执行:

// 立即执行Queue::instance()->do('fire')->job(OrderTimeoutJob::class)->data($data)->push();// 延时执行(N 秒后执行)Queue::instance()->do('fire')->job(OrderTimeoutJob::class)->data($data)->secs(30)->push();

消费者进程的启动

队列任务需要有消费者进程来执行。LikeShop 通过以下命令启动消费者:

php think queue:listen# 或者php think queue:work

在生产环境中,建议配合 Supervisor 使用,保证消费者进程常驻。在宝塔面板中可以安装 Supervisor,添加守护进程,启动命令为php think queue:listen,进程数量建议设置为 1。

队列解决的问题

队列在 LikeShop 中主要解决三类问题:

第一,异步化非核心操作。下单成功后,发送短信通知、记录用户行为日志、更新统计数据等操作,不需要用户等待,全部推入队列异步执行。

第二,削峰填谷。秒杀场景下,瞬时涌入的订单创建请求如果全部同步写数据库,数据库会直接崩溃。队列把这些请求暂时“缓冲”起来,由消费者按自己的节奏处理。

第三,延时任务。订单超时关闭、优惠券到期提醒、自动收货确认等场景,都需要“N 分钟后执行某个操作”,这正是延时队列的用武之地。

四、秒杀场景的异步实现

秒杀的技术挑战

秒杀是电商系统中最典型的高并发场景。核心挑战有三个:库存不能超卖、系统不能被瞬时流量打穿、用户体验不能太差

LikeShop 的解决方案是Redis 预减库存 + 异步队列的组合。

核心流程

秒杀下单的流程大致如下:

用户点击秒杀 ↓ ① Redis 判断库存是否充足(原子 DECR 操作) ↓ 库存不足 → 直接返回“已售罄” ↓ 库存充足 → 继续 ② 生成订单,推入异步队列 ↓ ③ 立即返回“排队中”给用户 ↓ (异步)消费者从队列取出任务 ↓ ④ 写入数据库,扣减真实库存 ↓ ⑤ 更新订单状态

第一步(Redis 预减库存)是整个流程的关键。利用 Redis 的DECR原子操作,在内存中快速判断库存是否充足。如果DECR后返回的值小于 0,说明库存已耗尽,直接回滚 Redis 操作并返回失败。

第二步(推入队列)将写操作异步化。真正的订单落库、库存扣减等数据库操作交给消费者异步执行,前端用户不需要等待数据库写入完成。

为什么这样做

传统方案中,秒杀请求直接在数据库层面扣减库存。当并发量达到数千 QPS 时,数据库的行锁竞争会变得极其严重——多个请求同时竞争同一行库存记录,后面的请求全部排队等待,响应时间急剧上升。

Redis 预减库存把竞争转移到了内存层面,Redis 的单线程模型天然保证了操作的原子性。数据库只负责最终的数据持久化,不再是性能瓶颈。

代价是引入了延迟和数据一致性的挑战。Redis 中的库存值和数据库中的库存值可能存在短暂的不一致。LikeShop 通过异步消费者最终将 Redis 的库存变更同步到数据库,保证最终一致性。

五、订单超时关闭的实现

业务需求

用户下单后如果没有在规定时间内完成支付,订单需要自动关闭,释放占用的库存。这个时间通常由后台的“交易设置”配置决定——如果 C 端没有取消订单按钮,需要检查“允许取消订单时长”的设置。

实现方式:定时任务扫描

订单超时关闭是 LikeShop 定时任务的经典应用场景。

Crontab 命令每分钟执行一次,每次执行时扫描ls_order表,找出满足以下条件的订单:

  • order_status为待付款状态(0)
  • create_time距离当前时间已超过配置的支付时限
  • 订单未被取消或关闭

找到超时订单后,执行以下操作:

  1. 将订单状态更新为“已关闭”
  2. 释放占用的库存(如果库存是预占模式)
  3. 如果使用了优惠券,返还优惠券
  4. 如果使用了积分,返还积分
  5. 写入订单日志ls_order_log

与状态机的配合

订单超时关闭涉及的是一次状态迁移:CREATED(已创建)→ TIMEOUT(已超时)。在之前写支付模块时我提到过,LikeShop 把订单建模为有限状态机,任何状态变更都必须经过合法性校验。

CREATED 状态可以迁移到 TIMEOUT,这是状态机中明确定义的合法路径。定时任务在关闭订单时,调用的应该是OrderLogic中定义的状态迁移方法,而不是直接update数据库。

一个需要注意的坑

订单超时关闭和支付回调之间存在竞争条件。假设订单在超时关闭的瞬间,用户刚好完成了支付——如果两个操作同时执行,可能出现“订单已关闭但支付成功”的状态冲突。

LikeShop 的应对策略是在状态迁移时做乐观锁校验——更新订单状态时带上当前状态的判断条件,只有状态仍然符合预期时才执行更新。这样即使出现并发,也只有一个操作能成功。

六、二开实战:新增一个定时任务

理解了定时任务的机制后,新增一个任务就很简单了。假设业务需要在每天凌晨 3 点清理过期优惠券。

第一步:在server/app/common/command/Crontab.php中新增任务逻辑,或者在管理后台通过定时任务配置界面添加新任务。

第二步:在server/app/common/logic/下新增处理逻辑:

classCouponLogic{publicstaticfunctionclearExpired(){// 找出所有已过期的未使用优惠券$now=time();$expiredList=CouponListModel::where('status',0)->where('expire_time','<',$now)->select();foreach($expiredListas$item){// 批量更新状态为已过期CouponListModel::where('id',$item['id'])->update(['status'=>2,'update_time'=>$now]);}}}

第三步:在管理后台的定时任务配置中,设置 cron 表达式为0 3 * * *(每天凌晨 3 点),并开启任务。

需要注意的是,批量操作要控制每次处理的数据量。如果过期优惠券数量很大,一次全量扫描会拖慢数据库。建议加limit分页处理。

七、二开实战:新增一个队列任务

假设业务需要在下单成功后异步发送短信通知。

第一步:创建任务类server/app/job/OrderNotifyJob.php

classOrderNotifyJobextendsBaseJob{publicfunctionfire($data){$orderId=$data['order_id'];// 查询订单和用户信息,发送短信// 返回 true 表示消费成功returntrue;}}

第二步:在下单成功的逻辑中发布任务:

// OrderLogic 中下单成功后Queue::instance()->do('fire')->job(OrderNotifyJob::class)->data(['order_id'=>$orderId])->push();

第三步:确保消费者进程正在运行(php think queue:listen)。

八、二开避坑指南

坑一:定时任务路径写错。Crontab 配置中的think文件路径必须是绝对路径,很多人用相对路径导致任务无法执行。

坑二:队列消费者进程没有常驻。php think queue:listen手动启动的消费者在终端关闭后就会停止。生产环境必须用 Supervisor 守护进程。

坑三:定时任务改了但没生效。如果通过宝塔配置了定时任务,修改后需要重新保存并确认执行周期设置正确。2022 年 5 月前的版本建议用 Shell 脚本方式而不是 URL 方式。

坑四:秒杀场景没有做限流。即使有 Redis 预减库存,如果请求量远超系统处理能力,Redis 本身也可能成为瓶颈。建议在 Nginx 层做 IP 级别的连接数限制。

坑五:队列任务失败没有告警。默认情况下队列任务失败 3 次后会被删除,但没有任何通知。建议在 BaseJob 中增加失败回调,或者在消费者进程中监听失败事件。

九、总结

LikeShop 的异步机制由两条线组成:

定时任务解决的是“周期性执行”的问题。通过一条php think crontab命令统一调度,每分钟检查一次,执行到期的子任务。订单超时关闭、退款扫描、佣金结算都靠它。

消息队列解决的是“异步削峰”的问题。秒杀下单、短信通知等场景,将非核心操作从主链路剥离,由消费者进程异步处理。Redis 作为队列存储,Supervisor 保证消费者常驻。

两条线配合,构成了 LikeShop 在高并发场景下的异步处理底座。


本文基于 LikeShop 单商户版 v3.5.1 源码及官方开发文档整理,不同版本的命令路径和配置方式可能略有差异,请以实际源码为准。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询