最近的业务需要做15分钟内未完成支付就自动取消订单的功能,类似的功能还有很多,例如:订单完成后自动确认收货、完成付款5分钟后发送短信通知等等,这些看起来就像定时任务,即多久后去做什么事。
实现上述功能的简单办法有两种:一种是Laravel的任务调度+crontab定时去执行任务;另一种是使用Redis的键过期事件去执行任务。对于业务量小的场景且不严格要求实时性的,推荐第一种方法;如果业务复杂且数据量大,又要求具有实时性,还要简单(即方便又准确),那么第二种方法就很适合了。
创建数据表:
我们先准备一张订单表吧:
DROP TABLE IF EXISTS `tb_order`;
CREATE TABLE `tb_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_num` int(11) NOT NULL DEFAULT 0 COMMENT '订单编号',
`price` decimal(8, 2) NOT NULL DEFAULT 0.00 COMMENT '价格',
`order_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '订单状态',
`is_deleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '删除逻辑标识符',
`created_time` int(11) NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_time` int(11) NOT NULL DEFAULT 0 COMMENT '修改时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `i2`(`created_time`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
虽然直接创建数据表是挺方便的,但是在多人合作开发中并不建议这样做,最好还是使用Laravel中的数据库迁移(migrate)来创建数据表吧:
// 打开终端,cd到项目根目录,执行以下命令
php artisan make:migration create_tb_order_table
项目根目录database中的migrations文件夹里多了一个“2019_07_09_203633_create_tb_order_table.php”文件,前面的数字是创建时间, 打开上述文件,修改up方法。
public function up()
{
Schema::create('tb_order', function (Blueprint $table) {
$table->increments('id');
$table->integer('order_num')->default(0)->comment('订单编号');
$table->decimal('price', 8, 2)->default(0)->comment('价格');
$table->tinyInteger('order_status')->default(0)->comment('订单状态');
$table->tinyInteger('is_deleted')->default(0)->comment('删除逻辑标识符');
$table->integer('created_time')->default(0)->comment('创建时间');
$table->integer('updated_time')->default(0)->comment('更新时间');
$table->index('created_time');
});
}
执行下面命令:
php artisan migrate
这样,数据表就创建完成了。
一、Laravel+crontab定时任务:
先到项目根目录创建任务文件OrderCancel.php:
php artisan make:command OrderCancel
再修改 app\Console\Commands\OrderCancel.php:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Exception;
use Illuminate\Support\Facades\Log;
class OrderCancel extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'order:cancel';
/**
* The console command description.
*
* @var string
*/
protected $description = '15分钟未支付,取消订单';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
try {
$time = time();
$orders = DB::table('order')
->where('is_deleted', 0)
->where('order_status', 0)
->where('created_time', '<=', $time - 15*60)
->get();
foreach ($orders as $order) {
DB::table('order')->where('id', $order->id)->increment('is_deleted');
Log::info('订单取消成功:'. $order->id);
}
}
catch (\Exception $e) {
Log::info('未知错误:' . $e->getMessage());
}
}
}
接着,我们就可以弄个计划任务了,执行系统命令 crontab -e ,在里面添加内容,:wq 保存,执行 crontab -l 查看内容:
* * * * * php /项目根目录/artisan schedule:run >> /dev/null 2>&1
如果发现任务调度不执行,可能php的路径并不识别,修改为:
* * * * * /usr/bin/php /项目根目录/artisan schedul:run >> /dev/null 2>&1
注意:/usr/bin/php是php文件路径,如果不知道自己的php路径,执行系统命令:which php 就可以找到路径。
另外,我在这里还会出现一个问题:执行crontab -e,添加完内容后,发现 crontab -l 里面并没有内容,定时任务还是不执行,真头疼!!!
后面发现解决方法:原来 crontab -e 会在 /var/spool/cron/crontabs 中创建一个与用户名相同名字的文件(如果是root用户就会创建一个root文件),只要把root文件移动到 /var/spool/cron 并且添加上面的内容就行了。
mv /var/spool/cron/crontabs/root /var/spool/cron/
然后在 app\Console\Kernel.php
中的 $command
属性和 schedule
方法里,加入下面两行:
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use App\Console\Commands\OrderCancel; // 引入OrderCancel类
class Kernel extends ConsoleKernel
{
protected $commands = [
\App\Console\Commands\OrderCancel::class,
];
protected function schedule(Schedule $schedule)
{
// 采用自定义的命令:OrderCancel类中的$signature属性->order:cancel
$schedule->command('order:cancel')->everyMinute(); // 每分钟执行一次
// 也可以这样写,采用类型进行调用
// $schedule->command(OrderCancel::class)->everyMinute();
}
protected function commands()
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}
保存测试下就行了。执行下面命令测试(只执行一次)。
php artisan order:cancel
二、Laravel+Redis键过期事件:
接着介绍使用Redis来做自动取消订单的功能,先确保Redis的版本大于2.8,接着修改redis的配置文件,启用键过期的通知:
notify-keyspace-events "Ex"
重启Redis,给Laravel安装redis扩展,到项目根目录执行以下命令:
composer require predis/predis
在 .env 文件中配置好Redis的相关信息:
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
REDIS_RW_TIMEOUT=-1
修改 config\database.php
中Redis的配置:
'redis' => [
'client' => 'predis',
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
'read_write_timeout' => env('REDIS_RW_TIMEOUT', -1), // 读写超时设定
],
],
然后就可以开始监听 ORDER_UNPAID:ORDER_ID 的过期事件了,同样先建立任务文件 OrderExpireListen.php :
php artisan make:command OrderExpireListen
修改 app\Console\Commands\OrderExpireListen.php
:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class OrderExpireListen extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'order:expire';
/**
* The console command description.
*
* @var string
*/
protected $description = '监听订单创建,15分钟内未付款就取消订单';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$db = config('database.redis.default.database', 0); // 监听的数据库
$pattern = '__keyevent@'. $db . '__:expired'; // 监听事件:键过期事件
Redis::subscribe([$pattern], function ($channel) { // 订阅键过期事件,$channel为返回的键名
$key_type = str_before($channel,':');
switch ($key_type) {
case 'ORDER_UNPAID':
$order_id = str_after($channel,':'); // 取出订单 ID
$order = DB::table('order')->find($order_id);
if ($order && $order->order_status == 0) {
// 这里我们直接修改数据库
$result = DB::table('order')->where('id', $order_id)->update(['is_deleted'=>1]);
Log::info('订单ID:' . $order_id . ':' . $result ? '取消成功' : '取消失败');
// 如果业务量大的话,我们可以把取消订单的操作放到队列中去执行
// Job::dispatch($order_id);
}
break;
case 'ORDER_OTHEREVENT':
break;
default:
break;
}
});
}
}
之后我们用 supervisor 进行管理就行了,让它用守护进程在后台执行,或者直接在项目根目录执行以下命令也可以:
php artisan order:expire &
最后我们测试一下:
// Redis::set(key, value, 'EX', seconds)
Redis::set('ORDER_UNPAID:1', 1, 'EX', 3); // 设置ORDER_UNPAID:1这个键3s后过期
查看一下日志文件,在 /storage/logs/laravel.log :
至此,两种方法已经介绍完毕,都是比较简单的实现,根据实际业务需求选择更好的方法吧。