Swoft踩坑记:撑爆内存的定时任务

缘起

公司有一个用Swoft框架开发的项目,里面用到了它的定时任务功能,用于定时计算某些统计数据。最近有同事反映数据好几天没有更新了,刚开始怀疑是定时任务的执行出错了,检查下了项目的错误日志,没发现什么错误信息。后来检查下了PHP的错误日志(也就是php.ini中error_log中配置的文件),发现有下面的错误信息:

PHP Fatal error:  Uncaught ErrorException: Allowed memory size of 536870912 bytes exhausted (tried to allocate 536870920 bytes) in /...此处省略路径.../vendor/swoft/crontab/src/CrontabExpression.php:144

相信各位PHPer对这个错误信息都非常熟悉了,就是某次任务执行过程中请求的内存大小超过了配置文件中限制的内存大小,于是一顿操作,直接将内存大小限制(memory_limit的配置值)由默认的128M干到了512M,重启服务后,发现问题依然如故,错误日志里还是不停地打印上面的错误信息。
静下心来再观察了一遍上面的错误信息,发现报错的文件并不是项目的业务文件,而是Swoft框架里的文件,我把报错行所在的方法抠了出来:

    /**
     * @param string $cronExpress
     *
     * @return array
     */
    public static function parseCronItem(string $cronExpress): array
    {
        $cronItems = preg_split('/\s/', $cronExpress, -1, PREG_SPLIT_NO_EMPTY);
        $times = array();
        $maxLimit=[59,59,23,31,12,6];
        foreach ($cronItems as $k => $item) {
            if ('*' === $item || '?' === $item) {
                $times [$k] = $item;
            }
            if (strpos($item, '/') !== false) {
                str_replace('*', '0', '$value');
                list($start, $end) = explode('/', $item);
                while ($start <= $maxLimit[$k]) {
                    $times [$k][] = $start;  // 这里就是报错信息所指的文件第144行
                    $start += $end;
                }
            }
            if (strpos($item, '-') !== false) {
                list($start, $end) = explode('-', $item);
                $times[$k] = range($start, $end);
            }
            if (strpos($item, ',') !== false) {
                $times[$k] = explode(',', $item);
            }
            if (ctype_digit($item)) {
                $times[$k][] = $item;
            }
        }
        return $times;
    }

报错是由$times [$k][] = $start;这句代码引起的,这句代码是在一个while循环里面执行的,可以看出它把一个变量插入到$times数组中。能把内存撑爆,能想到的原因就只有一个了,就是这句代码就入了一个死循环,即使变量值start很小,在CPU的高速运转下,把512M的内存塞满也是秒秒钟的事情。
那为什么这里会出现死循环呢,为了搞清楚原因,我们先看下这个方法的逻辑。

循因

从方法名parseCronItem我们知道这是一个解析Cron表达式的方法,参数$cronExpress就是要解析的表达式。看了下这个方法并没有任何的外部依赖,直接丢到一个脚本里面跑一下,参数随便写一个*/5 * * * * *(每5秒执行一次),看到输出结果如下:

Array
(
    [0] => Array
        (
            [0] => *
            [1] => 15
            [2] => 30
            [3] => 45
        )

    [1] => *
    [2] => *
    [3] => *
    [4] => *
    [5] => *
)

看到这里大家应该明白了,parseCronItem就是一个计算每个时间档位任务应该执行的时间点。在上面的例子中,就是在这个档位,第0、15、30、45秒这个任务应该被调用执行,至于第一个为什么是*,这个后面再说。
再来看看引起死循环的代码:

if (strpos($item, '/') !== false) {
    str_replace('*', '0', '$value');
    list($start, $end) = explode('/', $item);
    while ($start <= $maxLimit[$k]) {
        $times [$k][] = $start;  // 这里就是报错信息所指的文件第144行
        $start += $end;
    }
}

如果$start <= $maxLimit[$k]这个条件一直是成立的,那么就会进入死循环。当Cron表达式的某一档包含/字符时,会被代码沿着/劈开两半,前面一段就是$start,后面一段$end,在循环体内,$start会被一直加上$end,直到$start小于等于$maxLimit[$k]为止。那么$maxLimit又是个啥玩意呢,它的定义如下:

$maxLimit=[59,59,23,31,12,6];

很明显,它就是Cron表达式各个档位的最大值,例如秒档最大是59,分档最大是59,小时档最大是23,依次类推。我们只要让$start的值永远小于所在档位的最大值,循环就永远无法结束了。这实现起来非常简单,让$end等于0就行了,例如我们写出如下表达式5/0 * * * * *,没错,我当时就是手一抖,把上面例子中秒档位的两个数交换了位置,导致所有定时任务都因为爆内存而无法执行了。

吐槽

原因找到了,其实是因为我自已写的Cron表达式不正确导致的。但是在这里还是不禁要吐槽下,作为一个完善的框架,应该对这种情况作一下检查,直接报错让程序员能够及时发现并修正。就像我们都知道0不能作除数,但是总是免不了在一些情况下出现0除错误,这时候就需要程序作严格检查了。
刚才留了一个小尾巴,在使用*/5 * * * * *表达式调用parseCronItem方法时,我们想当然认为秒档第一位应该是0,但是实际上是*,这是另一个我要吐槽的地方。再来看看刚才死循环的代码段里有这样一行str_replace('*', '0', '$value');,这很明显是要把*替换成0的。但是看清楚了,这行代码执行了,但是又没有执行,因为它并没有把替换的结果赋值出来,而且要替换的字符串是'$value',就算是"$value",但是并没有定义$value啊。要是我们把这行代码改成$item = str_replace('*', '0', $item);,就能得到我们所要的结果了。

if (strpos($item, '/') !== false) {
    str_replace('*', '0', '$value');  // 这是要做什么?!
    list($start, $end) = explode('/', $item);
    while ($start <= $maxLimit[$k]) {
        $times [$k][] = $start;
        $start += $end;
    }
}

总结

  1. Cron表达式中带/时,跟除法一个道理,/右面不能为0。
  2. php.ini中一定要配置好错误日志,框架中找不到错误时,php错误日志中说不定有线索。
  3. 贡献人数少/不够活跃的开源项目要慎用,Github上已经很久没有更新了。
本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 1

我司现在已经转webman了

2年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!