PHP + Redis 实现定任务触发

定时任务,是很常见的业务场景了。比如说游戏服的定时开服,定时发消息,定时发邮件等等。

定时任务的触发方式有很多,有的人借助 linux 系统的 crontab 服务,但是 crontab需要每分钟去轮询,所以 crontab 会有一分钟误差。也有的人选择自己写一个定时器去处理定时任务。

这里我们介绍一种通过订阅 redis 键过期的消息回调来触发定时任务的方式。

 

具体原理

利用 redis 键事件的消息订阅,当 redis 键过期时,会触发一次回调事件,利用该次回调的触发,带上相应参数,便可完成一次定时任务的唤起。

一、调整 redis 配置

1、修改redis配置文件开启键值事件的通知:vim redis.conf

原来的:notify-keyspace-events ""

更改后:notify-keyspace-events "Ex"

保存redis.conf并重启redis服务。

2、执行redis-cli进入redis查看配置是否生效:config get notify-keyspace-events

3、如果结果不是 xE 那么还需要再redis-cli中执行配置修改:config set notify-keyspace-events Ex

 

二、编写相应的脚本(以下为测试用的脚本)

1、LibRedis.php

<?php
class LibRedis
{
    private $redis;
 
    public function __construct($host = ‘127.0.0.1‘, $port = ‘6379‘,$password = ‘‘,$db = ‘15‘)
    {
        $this->redis = new Redis();
        $this->redis->connect($host, $port);    
        $this->redis->auth($password);     
        $this->redis->select($db);    
    }
 
    public function setex($key, $time, $val)
    {
        return $this->redis->setex($key, $time, $val);
    }public function psubscribe($patterns = array(), $callback)
    {
        $this->redis->psubscribe($patterns, $callback);
    }
 
    public function setOption()
    {
        $this->redis->setOption(\Redis::OPT_READ_TIMEOUT, -1);
    }
 
}

 

2、添加定时任务的脚本:set_timer_task.php

<?php
require_once ‘LibRedis.php‘;
$redis = new \LibRedis(‘127.0.0.1‘,‘6379‘,‘‘,‘0‘);
$time = 100; //设置redis键100s后过期,即定时100s后触发定时任务。
$ctl = ‘timerTaskManage‘; //定时器调用的控制器
$fun = ‘callbackFun‘; //定时器调用的控制器中的方法
$param = ‘id=1‘;  //透传的参数(需要透传的参数尽量少,并且简单,因为这些数据要拼接成一个redis键)
$key = "timerTask:{$ctl}:{$fun}:{$param}"; //最终在监听脚本中调用: $ctl->$fun($param);
$re = $redis->setex($key,$time,1); 
var_dump($re);

 

3、常驻进程脚本,监听redis键值过期事件,从而触发定时器:get_timer_task.php

<?php
ini_set(‘default_socket_timeout‘, -1);  //不超时
require_once ‘LibRedis.php‘;
$redis_db = ‘0‘;
$redis = new \LibRedis(‘127.0.0.1‘,‘6379‘,‘‘,$redis_db);
// 解决Redis客户端订阅时候超时情况
$redis->setOption();
//当key过期的时候就看到通知,订阅的key __keyevent@<db>__:expired 这个格式是固定的,db代表的是数据库的编号,由于订阅开启之后这个库的所有key过期时间都会被推送过来,所以最好单独使用一个数据库来进行隔离
$redis->psubscribe(array(‘__keyevent@‘.$redis_db.‘__:expired‘), ‘keyCallback‘);

/*回调函数,这里写处理逻辑,格式固定
 *@param $redis 固定格式参数,一般不会用到,但必须带上。
 *@param $pattern 固定格式参数,一般不会用到,但必须带上。
 *@param $channel 固定格式参数,一般不会用到,但必须带上。
 *@param $msg 真正业务中用到的参数,也就是在设置redis时的键。set_timer_task.php 脚本中的$key变量
*/
function keyCallback($redis, $pattern, $channel, $msg)
{
    try{
        //可能有其他非定时器的键值对过期了,它们也会回调过来,此处将这部分键的事件过滤掉
        if($arr[0] != ‘timerTask‘){
            return true;
        }
        //多记录日志,方便后面查验结果和问题
        file_put_contents(‘/data/logs/timer_task_‘.date(‘Ymd‘).‘.log‘, ‘N1:‘.date(‘Y-m-d H:i:s:‘).$msg."\n", FILE_APPEND);
        $controller  = $arr[1]; //控制器:timerTaskManage
        $function = $arr[2]; //方法:callbackFun
        $param = $arr[3]; //透传参数id=1
        $ctl = new $controller();
        $re = $ctl->$function($param); //该类的方法执行具体任务逻辑,
        file_put_contents(‘/data/logs/timer_task‘ . date(‘Ymd‘) . ‘.log‘, date(‘Y-m-d H:i:s:‘) . var_export(array($msg, $re), true) . "\n", FILE_APPEND);
        if(!$re){
            //TODO:任务执行出问题,此处为报警逻辑
        }
        //不论结果执行如何,都有个返回,如果执行出问题了,就在错误处理逻辑中处理。
        return true;
    }catch(Exception $e){
        //TODO:报警逻辑,错误处理逻辑
        return true;
    }
}
 

这里有一点比较重要的问题需要注意,由于脚本 get_timer_task.php 是常驻进程的脚本,那么在该脚本中去实例话一个类,若是要注意做好该类何相应方法的处理,否则一旦出现什么问题都会在该进程中延续下去直到进程挂掉。

比如如果在任务方法中有数据库连接,那么这个连接超时的时候,下一次任务执行时会报错。并且报错会一直存在。

最好是做一种优化,使用 CGI 的方式来调用定时任务的逻辑。这样,将任务的执行逻辑放到CGI的接口中,每一次任务的执行都跟一次CGI请求一样,不能论该次任务执行遇到什么错误或异常,都不会影响常驻脚本,不会有数据库连接超时的问题产生,也不会影响接下来其他任务的执行。

比如以下优化方式:

<?php
ini_set(‘default_socket_timeout‘, -1);  //不超时
require_once ‘LibRedis.php‘;
$redis_db = ‘0‘;
$redis = new \LibRedis(‘127.0.0.1‘,‘6379‘,‘‘,$redis_db);
// 解决Redis客户端订阅时候超时情况
$redis->setOption();
//当key过期的时候就看到通知,订阅的key __keyevent@<db>__:expired 这个格式是固定的,db代表的是数据库的编号,由于订阅开启之后这个库的所有key过期时间都会被推送过来,所以最好单独使用一个数据库来进行隔离
$redis->psubscribe(array(‘__keyevent@‘.$redis_db.‘__:expired‘), ‘keyCallback‘);

/*回调函数,这里写处理逻辑,格式固定
 *@param $redis 固定格式参数,一般不会用到,但必须带上。
 *@param $pattern 固定格式参数,一般不会用到,但必须带上。
 *@param $channel 固定格式参数,一般不会用到,但必须带上。
 *@param $msg 真正业务中用到的参数,也就是在设置redis时的键。set_timer_task.php 脚本中的$key变量
*/
function keyCallback($redis, $pattern, $channel, $msg)
{
    try{
        //可能有其他非定时器的键值对过期了,它们也会回调过来,此处将这部分键的事件过滤掉
        if($arr[0] != ‘timerTask‘){
            return true;
        }
        //多记录日志,方便后面查验结果和问题
        file_put_contents(‘/data/logs/timer_task_‘.date(‘Ymd‘).‘.log‘, ‘N1:‘.date(‘Y-m-d H:i:s:‘).$msg."\n", FILE_APPEND);
        $data[‘controller‘]  = $arr[1];
        $data[‘function‘]  = $arr[2];
        $data[‘param‘] = $arr[3];
        $api_url = "http://www.test.com/apiTimerTask.php"; //定时任务执行接口
        $re = make_request($api_url,$data);
        $str = json_encode($re);
        file_put_contents(‘/data/logs/timer_task‘ . date(‘Ymd‘) . ‘.log‘, date(‘Y-m-d H:i:s:‘) . var_export(array($msg, $re), true) . "\n", FILE_APPEND);
        if(!$re){
            //TODO:任务执行出问题,此处为报警逻辑
        }
        //不论结果执行如何,都有个返回,如果执行出问题了,就在错误处理逻辑中处理。
        return true;
    }catch(Exception $e){
        //TODO:报警逻辑,错误处理逻辑
        return true;
    }
}
 
function make_request($url,$data)
{
     //TODO:执行curl请求
}
 

 

三、总结

在业务的使用过程中,还需要注意以下问题。

用来做定时器的redis最好是单独的一台比较低配的redis服务,该redis服务出来用来做定时器尽量不要再做其他用处,或者不要放其他数据,更不要有太多定时过期的键。

由于redis过期策略的问题,如果该redis服务中存在太多需要过期的键值对,那么定时器的键可能并不能准时过期,导致事件不能准时触发。具体细节可以去详细了解下 redis键的过期策略。

所以,单独搞一台小配置的redis,仅用来做定时服务和少量其他服务,能够提高这种方式的准确性和可靠性。

 

PHP + Redis 实现定任务触发

上一篇:漏洞复现——Apache Flink任意文件读取(CVE-2020-17519)


下一篇:微信小程序之for循环