一次由Opcache导致线上PHP服务异常的案例分析

Posted by WGrape的博客 on May 25, 2021

文章内容更新请以 WGrape GitHub博客 : 一次由Opcache导致线上PHP服务异常的案例分析 为准

前言

本文原创,著作权归WGrape所有,未经授权,严禁转载

一、背景

2021-05-13 14:10服务上线后,开始大量出现“Call to undefined method”错误。通过Grafana、查日志都可以有如下发现

image

  • 错误出现在五台服务器上
  • 每台服务器的报错都是持续约3秒钟

二、问题梳理

1、上线时间线

  • 14:10:50 申请上线
  • 14:10:57 数据准备完成
  • 14:10:57 开始上线
  • 14:10:57 通过parallel-rsync同步目录文件到目标服务器
  • 14:11:26 第一台服务器同步完成
  • 14:11:41 最后一台服务器同步完成
  • 14:11:41 上线结束

2、服务器1时间线

  • 14:11:35 代码至服务器同步完成
  • 14:11:36 第一条报错开始
  • 14:11:37 最后一条报错结束(持续1秒,共计220条)

3、服务器2时间线

  • 14:11:35 代码至服务器同步完成
  • 14:11:35 第一条报错开始
  • 14:11:38 最后一条报错结束(持续3秒,共计237条)

4、服务器3时间线

  • 14:11:35 代码至服务器同步完成
  • 14:11:36 第一条报错开始
  • 14:11:40 最后一条报错结束(持续4秒,共计700条)

5、服务器4时间线

  • 14:11:39 代码至服务器同步完成
  • 14:11:45 第一条报错开始
  • 14:11:45 最后一条报错结束(持续1秒,共计196条)

6、服务器5时间线

  • 14:11:28 代码至服务器同步完成
  • 14:11:30 第一条报错开始
  • 14:11:32 最后一条报错结束(持续2秒,共计399条)

三、问题分析

1、完整报错信息

报错信息为调用了不存在的方法

{
    "logtime":"2021-05-13 14:11:45",
    "Mode":"fpm-fcgi",
    "Msg":"Call to undefined method app\\xxx\\services\\xxx\\XXXService::doSomething()",
    "Trace":"\/home\/xxx\/xxxxxx\/xxxx.php(68)\n#0 {main}",
    "Uri":"\/xxx.php?xxxxxxxxxxxxxx",
    "Clientip":"xx.xx.xx.xx"
}

2、问题疑惑

根据打印的调用栈,发现是在xxx.php中调用的,从当时的Git提交记录来看,这个doSomething()方法是有的,换言之不可能会出现找不到这个方法的问题。

3、可能原因

出现问题的可能原因主要有两类,一个是上线问题,另一个是opcache问题

(1) 上线问题

在上线同步代码到目标机器的时候,调用方的文件代码已经同步过去,但doSomething()方法所在的文件还未同步完成

(2) opcache问题

两个文件均已同步到目标机器,但Zend引擎解析代码时,opcache出现了如下的分布情况

image

4、原因分析

(1) 上线问题

通过查看上线脚本,预估上传项目大概需要时间32秒。上线平台日志显示从上线至所有机器同步完成,使用了30秒时间。

  • 问题时间线中,报错都是从文件上传完成后才开始的
  • 实际上传30多秒和预计32秒基本一致

从上述两个结论,可以排除上线问题,即代码文件确实已经全部正常同步完成

(2) opcache问题

① opcache伪代码

$now = time();
if( isOpcached(file) ){
  
    // check opcache code
    if( $now - file.lastUpdatedTime < revalidate_freq ){
      
        // 读opcache
        return getOpcache(file);
    }
}

// 重新解析PHP文件
$result = reParse(file);
writeOpcache(file, $result);
return $result;

② Opcache 执行原理

③ opcache中文件的上次更新时间参差不齐

由于同步代码文件到服务器上需要30秒,所以在opcache中每个文件的上次更新时间会存在参差不齐的情况

image

④ 结论

由于opcache中每个文件的上次更新时间参差不齐,所以会出现如下情况

  • Zend引擎在检查A文件的opcache时,发现缓存已过期,所以会解析新的A文件
  • B文件在opcache中的上次更新时间很近,即opcache中B文件的内容还处于有效期内
  • Zend引擎会直接读取opcache中的B文件内容,但是这个内容是旧的

理论上会存在上述这种场景,但是需要测试并复现此场景,如果可以复现,则可以确认是opcache中AB文件的上次更新时间不一致,且不需要重新解析B文件

四、复现opcache导致PHP错误问题

1、测试准备

(1) opcache配置

opcache配置如下,其中有效期为5秒

(2) 7个测试脚本

  • online.sh :上线脚本,用于模拟上线操作
cat TestController.php > /home/TestController.php
cat TestService.php > /home/TestService.php
  • rollback.sh :回滚脚本,用于模拟回滚操作
cat TestControllerOld.php > /home/TestController.php
cat TestServiceOld.php > /home/TestService.php
  • TestControllerOld.php :旧的Controller文件,即回滚后的Controller内容(调用getOpcacheStatus1方法)
<?php

class TestController {

    public function test(){
         phpinfo();
    }

    public function test2(int $number){

	// opcache_invalidate(__FILE__, true);
        $opcache = TestService::getInstance()->getOpcacheStatus1();
	      $this->result = [
	        'number' => $number,
	        'opcache' => $opcache,
    	    'time' => date('Y-m-d H:i:s'),
        ];

    }
}
  • TestController.php :新的Controller文件,即上线后的Controller内容(调用getOpcacheStatus2方法)
<?php

class TestController {

    public function test(){
         phpinfo();
    }

    public function test2(int $number){

	// opcache_invalidate(__FILE__, true);
        $opcache = TestService::getInstance()->getOpcacheStatus2();
	      $this->result = [
	        'number' => $number,
	        'opcache' => $opcache,
    	    'time' => date('Y-m-d H:i:s'),
        ];

    }
}
  • TestServiceOld.php :旧的Service文件,即回滚后的Service内容(没有getOpcacheStatus2方法)
<?php

class TestService
{

        public function getOpcacheStatus1(){
	          return 1;
        }

}
  • TestService.php :新的Service文件,即上线后的Service内容(有getOpcacheStatus2方法)
<?php

class TestService
{

        public function getOpcacheStatus1(){
	          return 1;
        }

        public function getOpcacheStatus2(){
		        sleep(1);
		        return 2;
        }
}
  • loop.php :循环脚本,用于不间断的依次执行 上线 / 回滚 操作
<?php

while(1){
    sleep(1);
    echo "上线\n";
    system('bash ./online.sh');
    sleep(1);
    echo "回滚\n";
    system('bash ./rollback.sh');
}

2、测试计划

(1) 测试方法

通过不间断执行上下线,可以加大opcache中不同文件上次更新时间的差异,在这种高概率的情况下且保持高QPS的访问,就比较容易复现 Call to undefined method 错误

  • 执行 loop 脚本 ,即不间断的依次执行上下线
  • 启动Go脚本并发请求接口 ,QPS 为 100

执行上两步操作,然后观察 tail -f /home/log/sys_fatal.log 日志

(2) 预期结果

日志中出现 Call to undefined method getOpcacheStatus2 的报错

(3) 预期结论

如果出现上述报错则说明PHP自身问题,导致在opcache中,Controller内容已更新,但Service内容未更新

image

3、测试过程

在执行所有待操作后,开始观察日志,很快就不断出现预期中的 Call to undefined method 错误,如下图所示

image

4、测试结论

  • 上线同步文件时间越长,每个文件的上次更新时间差异就越大
  • 每个文件的上次更新时间差异越大,对应到opcache中每个文件的有效期差异就越大
  • opcache中每个文件的有效期差异越大,PHP进程读取到非一致性版本文件内容(A文件中的新内容,B文件中的旧内容)的可能性就越大
  • PHP进程读取到非一致性版本文件内容的可能性越大,出现PHP错误的可能性就越取决于QPS
  • QPS越高(100以上),出现PHP错误的可能性就越大

5、测试总结

在可以确认所有文件是最新的情况下,同步文件需要的时间越长,每个文件的上次更新时间差异越大,上线后由opcache引起PHP错误的可能性就越大

四、问题总结

由于上线同步文件需要30秒,所以调用方文件和被调用方文件的上次更新时间一定不同,对应在opcache中的两个文件的上次更新时间也不是一致的,根据当时的日志可以确定QPS处于100以上,导致触发了由opcache引起的PHP错误

image