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,仅用来做定时服务和少量其他服务,能够提高这种方式的准确性和可靠性。