手撕一个异步任务通用组件
目的
取代linux crontab的计划任务,那玩儿意最小粒度一分钟,意味着服务器不管如何清闲都会有一分钟延迟
实现原理
while (TRUE) {}
没错,就是这么粗暴,一个永不停止的无限循环,具体不多说,代码里注释写的非常明白了,上代码
1、首先实现一个进程管理器,因为一个处理进程开启后,ssh客户端再一关,就失去对它的控制权了,这个管理器就是一组命令实现对进程的控制
<?php
namespace app\command;
use app\common\constant\CommonConstant;
use extendRedis\VekiseRedis;
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
/**
* Desc:异步任务进程管理
* Author:glt 2022/12/20 17:36
*/
class SyncTaskProcessManager extends Command {
protected function configure()
{
$this->setName('SyncTaskProcessManager')
->addArgument('id', Argument::OPTIONAL, "操作id")
->addOption('operate', NULL, Option::VALUE_REQUIRED, '操作类型')
->setDescription('异步任务进程管理');
}
protected function execute(Input $input, Output $output)
{
if(!$input->hasOption('operate')){
dd('请选择操作类型');
}
$operate = $input->getOption('operate');
if(!in_array($operate, ['list', 'restart', 'del'])){
dd('操作类型不存在');
}
$redis = VekiseRedis::getInstance();
$process_ids = $redis->keys('task_process_*');
//查看进程列表
if($operate === 'list'){
$list = [];
foreach($process_ids as $process_id){
$process_id = str_replace('wms_', '', $process_id);
$item_process = $redis->hGetAll($process_id);
$process_id = str_replace('task_process_', '', $process_id);
$item_process['id'] = $process_id;
$list[] = $item_process;
}
dd($list);
}
//删除进程
if($operate === 'del'){
$id = trim($input->getArgument('id'));
if(empty($id)){
dd('请选择操作对象id');
}
if($id === 'all'){//操作全部进程
foreach($process_ids as $process_id){
$process_id = str_replace('wms_', '', $process_id);
$redis->rm($process_id);
}
}else{//操作单个进程
$redis->rm('task_process_' . $id);
}
dd('操作成功');
}
//重启进程
if($operate === 'restart'){
//删掉原有进程
foreach ($process_ids as $process_id) {
$process_id = str_replace('wms_','',$process_id);
$redis->rm($process_id);
}
foreach(QueueConstant::CONFIG as $item){
for($i = 1;$i<=$item['num'];$i++){
system("nohup php think task {$item['name']} {$item['field']} >>task.txt &");
}
}
//这里是无需投递的自主运行无限循环任务
system("nohup php think tmall_pull_msg >>tmall_pull_msg.txt &");
dd('操作成功');
}
dd('操作类型非法');
}
}
2、执行异步任务
<?php
namespace app\command;
use app\api\logic\OrderLg;
use app\common\constant\CommonConstant;
use app\common\model\QueueData;
use extendRedis\VekiseRedis;
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\Output;
use vekise\vekise_packages\request\CenterRequest;
/**
* Desc:即时执行的异步任务
* Author:glt 2022/12/20 17:36
*/
class Task extends Command{
protected function configure()
{
$this->setName('task')
->addArgument('sense', Argument::REQUIRED, "场景")
->addArgument('field', Argument::REQUIRED, "队列字段名")
->setDescription('即时执行的异步任务,nohup php think SyncTask 2 &');
}
protected function execute(Input $input, Output $output)
{
//接收参数
$sense = trim($input->getArgument('sense'));
$field = trim($input->getArgument('field'));
//进程管理思路:每次启动异步消费者,生成一个进程id写入redis,
//之后每次轮询都检查一遍进程状态,通过修改这个状态可以控制当前进程结束
//生成进程id
$redis = VekiseRedis::getInstance();
$process_id = $field . '_' . date('YmdHis') . uniqid();
//进程id写入
$redis->hMset('task_process_' . $process_id, [
'memory_usage' => 0,//内存消耗
'last_exec_time' => '',//最后一次执行时间
'sense' => $sense,//场景
]);
while(TRUE){
sleep(1);
//检查进程状态
$process = $redis->hGetAll('task_process_' . $process_id);
if(empty($process)){//需注意,常驻进程的任务,如果改了代码需要重启进程,这里的设计是能做到平滑重启的
dd('进程被删除');
}
//开始执行
$task_str = $redis->lpop('task_' . $field);//先进先出,rpush尾部插入,lpop头部取出
if($task_str !== FALSE){//消息不为空
try{//任何地方不能出现报错,会导致进程中断,所以提前捕获错误
$data = json_decode($task_str, TRUE);
$response = $this->execTask($data);
$res = $response['res'];
$msg = $response['msg'];
}catch(\Exception $e){
$res = FALSE;
$msg = $e->getMessage();
}
//写入一条数据到mysql备份或者人工排查问题,这张表只写不查,消息id用于唯一标识当前消息,还有个用处,临时存储消息体,当任务抛错时,直接读取这条数据同步执行调试那个错误
//这里曾经设计过出错三次重试机制,后面删掉了,原因是没啥用,基本第一次错后面也必错,白白浪费资源
QueueData::insertGetId([
'param' => is_array($data['param']) ? json_encode($data['param']) : $data['param'],
'class_name' => $data['class_name'],
'func_name' => $data['func_name'],
'res' => $res ? 1 : 2,
'msg' => $msg,
'create_time' => date(CommonConstant::YMDHIS_FORMAT),
'scene' => $data['scene']
]);
}
//执行结束,更新进程
$redis->hMset('task_process_' . $process_id, [
'memory_usage' => round(memory_get_usage() / 1024 / 1024, 2) . 'M',//内存消耗
'last_exec_time' => date(CommonConstant::YMDHIS_FORMAT),//最后一次执行时间,这个时间长期不更新的话,认为是死进程清理掉,比如ctrl+c人为停止的就会这样
]);
}
}
/**
* Desc:执行任务
* Author:glt 2022/12/22 10:46
*
* @param $data
*
* @return array
*/
private function execTask($data)
{
$class_name = $data['class_name'];
$func_name = $data['func_name'];
$target_class = (new $class_name);
$exec_res = $target_class->$func_name($data['param']);
if($exec_res === FALSE){
//这里要适应下现有的代码写法,错误消息定义为当前逻辑类的一个属性error
//一层调用没问题,很清楚去拿谁的error属性,但是好几层调用那就是地狱体验了,直接抛异常不香吗???
if($target_class instanceof OrderLg && $func_name === 'ordersUpload'){
(new CenterRequest())->qyWechatMsg(['touser'=>'null','content'=>'进销存系统订单上传失败【'.$target_class->error.'】,参数:'.json_encode($data['param'])]);
}
return ['res' => FALSE, 'msg' => $target_class->error ?? ''];
}
if(is_array($exec_res)){
$msg = json_encode($exec_res);
}elseif($exec_res === TRUE){
$msg = '执行成功';
}else{
$msg = $exec_res;
}
return ['res' => TRUE, 'msg' => $msg];
}
/**
* Desc:异步任务生产者
* Author:glt 2022/12/22 10:22
*
* @param $param
* @param $class_name
* @param $func_name
* @param $scene
* @param $field
*
* @return bool|int
*/
public static function producer($param, $class_name, $func_name, $scene, $field)
{
$redis = VekiseRedis::getInstance();
return $redis->rpush('task_' .$field, json_encode([
'param' => $param,
'class_name' => $class_name,
'func_name' => $func_name,
'scene' => $scene//场景,方便数据库查看而已,没别的用
]));
}
/**
* Desc:异步任务调试麻烦,所以先写个入口,用于同步执行异步任务,$msg_id是mysql表主键
* Author:glt 2023/6/17 16:48
*
* @param $msg_id
*
* @return array
*/
public function handleExec($msg_id)
{
$msg = QueueData::where('id', $msg_id)->find()->toArray();
return $this->execTask([
'class_name' => $msg['class_name'],
'func_name' => $msg['func_name'],
'param' => json_decode($msg['param'], TRUE),
]);
}
}
3、异步任务配置
<?php
/**
* Created by PhpStorm.
* User: Administrator
* Date: 2023/6/25
* Time: 15:19
*/
namespace app\common\constant;
class QueueConstant{
/**
* name:异步场景名称
* num:开启消费的进程数,如果有死锁问题,就只能填1,比如订单上传占用库存
* field:队列redis key
* 时效性要求较高的任务,应该独立一条队列,避免被其他任务阻塞
*/
const CONFIG = [
[
'name' => '新订单上传',
'num' => 1,
'field' => self::QUEUE_FIELD_NEW_ORDER_UPLOAD
],
[
'name' => '修改订单上传',
'num' => 3,
'field' => self::QUEUE_FIELD_UPDATE_ORDER_UPLOAD
],
[
'name' => '推库存',
'num' => 3,
'field' => self::QUEUE_FIELD_PUSH_STOCK
],
[
'name' => '通用队列',
'num' => 4,
'field' => self::QUEUE_FIELD_COMMON
],
];
const QUEUE_FIELD_NEW_ORDER_UPLOAD = 'new_order_upload';
const QUEUE_FIELD_UPDATE_ORDER_UPLOAD = 'update_order_upload';
const QUEUE_FIELD_PUSH_STOCK = 'push_stock';
const QUEUE_FIELD_COMMON = 'common';
}
4、大部分框架的数据库连接都是用单例模式设计的,一个进程用的永远是同一个数据库连接,当长时间没有任务需要处理时,数据库会断开连接,我这里用的thinkphp框架,解决办法是修改数据库配置自动重连database.php break_reconnect => true
5、怎么用?
- 查看进行中的任务进程
php think SyncTaskProcessManager --operate list
,返回结果如下:
注意memory_usage字段是监控内存使用情况的,实测内存不会自动释放,但是会循环利用,所以内存消耗并不大
last_exec_time字段是最后一次执行时间,距当前时间太久视为死进程,已经终止执行
-
重启任务进程
php think SyncTaskProcessManager --operate restart
删掉原有的所有进程,重新开启配置文件里面的异步进程 -
写入队列,调用producer方法就行
-
已经生产环境实际运行了一年多,稳如老狗!!!