记录一次解决大文件上传偶尔失败问题

1.问题现象

经常被反馈,服务器后台上传一个大文件往往上传到快结束的时候,前端进度条不走了,但是也不是每次都出现,上传大文件在本次环境是一次都不会复现问题的。只出现在线上服务器,服务器用的是nginx。

2.排查过程

2.1 思路分析

大文件上传肯定用的是分段上传,首先前端拿到文件后分割成5M每个的包然后计算出需要分多少个上传,然后传递包的序号,服务器每次收到一个这个样的包都保存在tmp目录下,并且按照一定的规则命名,命名规则包含了文件名和分包的位次信息,方便在传递完成最后一个分包的时候组合。服务器如果收到的分包如果不是最后一个,就会在收到后告诉客户端返回成功,如果是最后一个会把之前的所有分包都取出来,然后合并为一个完整的文件并放在指定目录后,删除tmp的文件,再告诉客户端合并成功,并把最终文件存储目录告诉客户端。

2.2 线上实验

上传一个2G的文件,发现确实会分包,但是上传到最后一步的时候回出现超时,超时的原因有两种,第一种是服务器超时,另一种是客户端超时。服务器超时要么是php执行的时间过长,如果真是这样的话服务器会返回php超时信息,但是服务器返回的是504.说明是nginx的配置问题,是nginx等待php处理的时间超过了预定值,所以尝试更改nginx配置。

fastcgi_connect_timeout 60;
fastcgi_send_timeout 600;
fastcgi_read_timeout 600;

前端不在提示504了,但是还是没有得到正常信息,控制台请求提示

Provisional headers are shown

显示这个的原因其实曾经出现过,就是前端的请求框架自己设置了一个超时时间,超过后就不在等待。为了验证最后合并文件到指定目录到底多长时间,自己在上传过程中手动改了服务器代码,在2.5G文件上传后,并没有将分片信息删除,然后又撤销了代码改动,所以tmp目录下面会有很多分片文件,每个5M。

image

为了测试合并文件到底需要多少时间我写了个接口,我在接口里面分别测试了把合成文件放在tmp目录下和指定目录下所需要的时间,发现时间差别巨大,如果在tmp目录总体需要不到6s,在指定的目录下需要1分14秒。后来才知道指定的目录是阿里云的NAS盘,读写文件文件速度差异巨大。所以我希望前端看看有没有设置超时时间,有的话改的长一点,后来应该是改了,反正消停了一段时间,而且为了稳定还把nginx服务器改成了apache。但是后来又出问题了,症状没有变。而且似乎还更严重了,一旦失败就把整个后台服务器拖死,服务器卡死的原因是CPU被占满,虽说服务器配置不高,但是一个上传文件导致这样的结果还是不正常的。不得不彻底查查了。

自己不断的测试,发现在上传失败的时候确实网页几乎卡住,没有反应,但是我清cookie就好了。所以在出问题的时候我在服务器上看下消耗CPU最多的进程,

找出消耗cpu最多的进程前10名,apache果然在前面

ps auxw|head -1;ps auxw|sort -rn -k3|head -10
image.png

查看进程的调用栈

pstack 30965
image.png

函数调用栈看不出有什么用。

但是执行

strace -p 30965

其实就会发现线程一直在尝试打开一个分片文件,但是文件不存在。所以死循环了。那就代码找出可能包含死循环的部分。

 // 检查分片是否都已上传成功
 do {
      for ($i = 0; $i < $chunks; $i++) {
           $filePath = $tmpDir . "{$chunkName}_{$i}";
           if (!file_exists($filePath)) {
                  $unChecked = true;
                  break;
           } else {
                  $unChecked = false;
            }
       }
 } while($unChecked);

就是这段代码,外面的do while其实是造成死循环的主要原因,do while的目的就是希望能够等待客户端把文件全部都传递上来,因为如果客户端开多线程上传的话,包的顺序可能有先后不一致的情况。等待是合理的,但是如果传递的文件过大的话,chunks比较大,循环里面对每个文件都调用了file_exist这个函数,这样会造成操作系统不断的尝试打开文件,对资源占用过高,存在需要优化的地方。另一个地方就是do while可能会造成死循环,尤其是一个客户端上传某个分片确实失败的时候就永远死循环,这样就导致了浏览器无反应,但是清cookie后有相当于主动断开了服务器的连接,而恢复正常的现象。其实如果在服务器端kill掉这个进程浏览器也会恢复正常的,否则一旦进入死循环不进浏览器卡死,服务器端cpu也会逐渐消耗殆尽。

所以尝试对本段代码进行了优化,不在死循环,而且仅仅对不存在的文件进行检查。另一个是增加了分片信息缺失后删除本次所有分片的代码,之前没有删除本次全部的分片,会导致tmp目录空间越来越小,越大的文件越容易失败,在tmp目录下留下的垃圾文件越大,越失败越上传,所以恶性循环。

 $notExistFiles = [];
            for ($i = 0; $i < $chunks; $i++) {
                $filePath = $tmpDir . "{$chunkName}_{$i}";
                if (!file_exists($filePath)) {
                    $notExistFiles[] = $filePath;
                }
            }
            $time = 0;
            do{
                $tempFiles = [];
                foreach ($notExistFiles as $notExistFile){
                    if (file_exists($notExistFile)){
                        $tempFiles[] = $notExistFiles;
                    }
                }
                $notExistFiles = array_diff($notExistFiles,$tempFiles);//
                $time++;
            }while(count($notExistFiles) > 0 && $time < 100);

            if (count($notExistFiles) > 0){
                for ($n = 0; $n < $chunks; $n++) {
                    $file = $tmpDir . "{$chunkName}_{$n}";
                    if (file_exists($file)){
                        unlink($file);
                    }
                }
                throw new Exception('上传的文件分片缺失,需要重新上传');
            }

这里的time <1000是自己定的,其实可以使用sleep的,让操作系统主动悬挂当前进程,过段时间上传的分片应该会都全了,之所以这样所,是因为php的进程是php-fpm管理的,不是我们代码手动创建的。客户端每次发起一个连接,都会被转发给其他子进程,其实代码都允许在每个子进程中,至于其他子进程是否处理完文件本进程并不知情,所以执行尝试等待,这样的情况主要是应对客户端开多线程分片上传,为了确保正常,和前端沟通,让他只有在前一个上传成功后才传递第二个分包,单线程上传,进一步减少出错的可能性。

你可能感兴趣的:(记录一次解决大文件上传偶尔失败问题)