ps -ef | grep php-fpm
![](https://blog.junphp.com/public/upload/20201224171106_393.png)
发现有好些个进程被堵塞了很长时间,之后就用`strace`命令查看这些进程的跟踪状态
strace -p 进程ID
![](https://blog.junphp.com/public/upload/20201224171218_147.png)
![](https://blog.junphp.com/public/upload/20201224171231_292.png)
看样子像是在循环写什么文件,但又是写入空的,贼奇怪。
然后,可以通过`ll /proc`系统目录,查看各个进程的虚拟内存运行状态,进而得知这个进程现在在干些什么
ll /proc/进程ID/fd
![](https://blog.junphp.com/public/upload/20201224171436_810.png)
奇怪,怎么指向了一个session文件,想不通。
然后`yum install lsof`安装了`lsof`工具,该指令可以查看进程打开的文件,但该工具需要用root身份使用才能看到内存信息。
lsof 文件地址
![](https://blog.junphp.com/public/upload/20201224171718_259.png)
卧槽,怎么都卡在了session文件读写这里?怎么还有个session触发了独占锁?
一头雾水,然后想到了之前ThinkPHP3.1不是默认开了`session_start();`吗,然后想了想,会不会是某个用户,在客户端异步并发时,产生了session排他锁,同时异步接口又在定时触发,导致一直无法释放锁呢?
因为之前的开发同事都是用的原生`$_SESSION`来读写session,所以就没办法直接在框架配置文件中,修改`session_start();`为不全局开启。
之前的方案是在单个控制器中,通过
public function __destruct() {
session_destroy();
解决了session文件创建过多的问题,但如果请求没正常释放,那`__destruct()`就有概率不会被触发到。
再三思量下没办法,还是决定直接修改框架源码,因为考虑到框架之后也没办法直接升级,所以小改动应该没有太大关系。
在`config.php`应用配置文件中,加入一个路由过滤参数
// +----------------------------------------------------------------------
// | 不需要加载session的路由前缀 - 全转小写
// +----------------------------------------------------------------------
'SESSION_NO_URL' => array(
'/index.php/Order',
'/index.php/User',
'/index.php/Car',
然后再修改ThinkPHP3.1的框架公告函数库`/ThinkPHP/Common/functions.php`文件中的`session()`方法
原代码第`551`行
// 启动session
if(C('SESSION_AUTO_START')) session_start();
整个框架,当配置`SESSION_AUTO_START`为`true`时,默认开启`session_start();`,所以为了兼容框架的原流程,我们只能读取新加的`SESSION_NO_URL`参数,来过滤掉一些,不想默认打开session的路由,将这行代码,修改成以下代码:
// 启动session
if(C('SESSION_AUTO_START')) {session_start();
$SESSION_NO_URL = C('SESSION_NO_URL');
$route_url = strtolower($_SERVER['SCRIPT_NAME'].'/'.MODULE_NAME);
$session_start_status = true;
foreach ($SESSION_NO_URL as $v) {
if ($route_url == $v) {
$session_start_status = false;
break;
if ($session_start_status) session_start();
完成到这里,满心欢喜,TMD,这下应该没问题了吧!
心情愉悦,打开Xshell,清空LOG,重启PHP-FPM,又过1小时,突然Zabbix又彪了一下?What?
这不改了吗,怎么还有?
由于这个BUG出现了2天,前晚运维同事打开了PHP-FPM的status进程监控配置,就过去看了下,发现该功能只能抓取到当前尚还存活用户访问的文件,而且没办法记录到`PHP_INFO`的伪静态路由参数,也就是说,当我们URL为伪静态优化后的`index.php/控制器/操作方法`这类地址时,他只能记录到`/index.php`
又是头疼,这虽然抓到了那个堵塞的进程在什么时候,触发了框架入口文件,但没办法追踪到相关的业务文件,相关的调用栈,还是没办法定位到BUG在哪...
又是再三思量,没办法了,只能改框架的加载流程,在业务代码被初始化,但没被执行的前一步,将其进程ID连同调用栈,一起记录到文件中,等堵塞进程再次发生时,就能知道是哪个文件触发了堵塞的逻辑,毫无底线说干就干。
打开`/ThinkPHP/Lib/Core/App.class.php`文件,该文件是ThinkPHP3.1的框架流程加载文件。我们跳到其中的`exec()`方法,该方法是最终用于加载业务逻辑代码的调度器。
去到`136`行,
//执行当前操作
$method = new ReflectionMethod($module, $action);
这行代码就行初始化业务代码,但不执行,实际上它是`new`了`class`,也表示它引入了最终的业务文件,我们只需要在他下面记录整个进程的调用栈就行,在这行代码下面插入以下代码。
// 记录当前进程调用栈 start
$php_pid = posix_getpid();
$array = get_included_files();
$array[] = $action.'()';
$array[] = $_GET;
$array[] = $_POST;
// 注意这里要绝对路径写死,因为该文件最终会被加载到缓存中,如果写相对路径,就是相对于缓存路径了
$php_file = '/var/www/html/phppid/'.date('nd_H').'/';
if (!is_dir($php_file)){
mkdir($php_file, 0777);
$php_file .= $php_pid.'.log';
error_log(date('H:i:s', time()).' | '.json_encode($array)."\r\n", 3, $php_file);
// 记录当前进程调用栈 end
这下信心满满,就等那偷偷堵塞的家伙自己上门送死了。
不出所料,大概又过了一小时,又一个堵塞的进程出现,终于被上面的代码捕捉到,内容如下(因为同个进程可能触发很多次请求写,所以我们只需要看最后一条,那条就是堵塞的请求调用栈):
"/Lib/Action/XXXAction.class.php",// 这个是业务控制器
"/Lib/Action/BackendAction.class.php", // 这个是父类控制器
.......中间省略一堆框架的加载文件
"index()", // 这个是访问的方法
{"work_card":"1","xxxx_id":"65349","_URL_":["XXX","index","work_card","1","xxx_id","65349"]}, // GET参数
[] // POST参数
看着很正常,一个普通请求的代码,没啥特别的,之后尝试着用GET参数,拼接到URL上模拟访问一次,奇迹发生,请求被堵塞也没结果返回,同时刷新页面也没办法刷新到。
打开`/Lib/Action/XXXAction.class.php`的`index()`方法,发现是通过`xxx_id`这个参数,查下了一个用户的资料,并调用PHP内置的`ZipArchive`类,将文件导出到`Zip`文件中。
以下为导出时的业务代码:
$zip = new \ZipArchive();
$res = $zip->open($this->gzfilename, \ZipArchive::OVERWRITE);
if ($res === true) {
$Path = $arrayFile;
foreach ($Path as $file) {
//这里直接用原文件的名字进行打包,也可以直接命名,需要注意如果文件名字一样会导致后面文件覆盖前面的文件,所以建议重新命名
$aa = explode('/', $file);
$new_filename = iconv('utf-8',"gbk//IGNORE", $aa[count($aa)-2].'/'.$aa[count($aa)-1]);
$zip->addFile($file, $new_filename);
//关闭文件
$zip->close();
//5、判断是否要求输出下载
if($Output === true){
$this->ZipDow($this->gzfilename);
很正常啊,看着没啥问题啊!然后想了下,如果上层没查出文件,又调用了这个生成会怎样,然后去翻了下官方对`ZipArchive`的介绍,发现:
当`ZipArchive`写入的压缩包内容为空时,当我们`close()`后,该扩展会自动把压缩包恢复到最初状态,也就是它会清掉这个压缩包。What?
那你清掉了文件,下面的`ZipDow()`下载怎么办?
看下`ZipDow()`的代码
* 输出压缩包下载
private function ZipDow($url){
sleep(1);
clearstatcache();
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename='.basename($url));
header('Content-Transfer-Encoding: binary');
header('Expires: 0');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header('Content-Length: ' . filesize($url));
ob_clean();
flush();// 刷新内容
$file=fopen($url,"r");
while (!feof($file)){
// 限速发送当前部分文件给浏览者
print fread($file,round($this->download_rate*1024));
flush();// flush 内容输出到浏览器端
usleep(0.5 * 1000 * 1000);// 终端0.5秒后继续
fclose($file);// 关闭文件流
// 下载完成,删除文件
@unlink($url);
略微一看,没啥问题,仔细一看好像有点怪,认真一看不对劲!`while (!feof($file)) `?
这不是读取文件到末尾,就跳出循环吗。那就是说,如果`feof()`为`false`它就一直循环,为`true`才跳出循环,那问题来了,如果`fopen()`时就已经是`false`,那`feof()`就肯定一直都是`false`啊,这不是死循环了?
至此bug解决,将`ZipDow()`改成以下代码即可:
private function ZipDow($url){
flush();// 刷新内容
$file=fopen($url,"r");
if ($file === false) return false;
sleep(1);
clearstatcache();
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename='.basename($url));
header('Content-Transfer-Encoding: binary');
header('Expires: 0');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header('Content-Length: ' . filesize($url));
ob_clean();
while (!feof($file)){
// 限速发送当前部分文件给浏览者
print fread($file,round($this->download_rate*1024));
flush();// flush 内容输出到浏览器端
usleep(0.5 * 1000 * 1000);// 终端0.5秒后继续
fclose($file);// 关闭文件流
// 下载完成,删除文件
@unlink($url);
文章努力渲染中,请稍等片刻....