手撕一个异步任务通用组件

目的

取代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,返回结果如下:
    image

注意memory_usage字段是监控内存使用情况的,实测内存不会自动释放,但是会循环利用,所以内存消耗并不大
last_exec_time字段是最后一次执行时间,距当前时间太久视为死进程,已经终止执行

  • 重启任务进程php think SyncTaskProcessManager --operate restart
    删掉原有的所有进程,重新开启配置文件里面的异步进程

  • 写入队列,调用producer方法就行

  • 已经生产环境实际运行了一年多,稳如老狗!!!

posted @ 2022-12-27 10:33  gltttt  阅读(37)  评论(0)    收藏  举报