最近,部门某后台系统数据下载功能无法正常执行,本以为是程序方面执行有问题,但是细查下来,发现却是PHP设置执行超时时间的问题。

一、问题描述

线上后台系统下载数据,几百条数据时,能正常下载。在数据量为1200条左右时,浏览器在下载几秒后报网络错误,无法正常下载。
测试服务器能正常下载,线上服务器无法下载。

二、程序逻辑

  1. 下载函数首先设置set_time_limit(0),防止执行超时。
  2. 数据量在2000条内,主表limit 100分页查询,副表共4张,遍历主表数据单条查询,每个副表查询都有主键索引或普通索引。程序将主副表数据组合返回。
  3. 下载函数以CSV格式通过fputcsv()函数追加放入php://output输出流中。
  4. 每10页即1000条数据通过ob_flush()和flush()函数刷新缓冲区。

以上为下载逻辑。简单分析,程序上貌似没什么问题。

三、初步分析

由于之前没有遇到过这个问题,所以感觉不知道是哪里的问题,网上查询也没有固定的结果。
所以没有太深入分析,参照其他项目下载逻辑,以为可能是刷新缓存区太过频繁,导致执行时间过慢而无法下载成功,所以将程序修改为:

  1. 修改分页查询数据,由limit 100,改为limit 1000。
  2. 依旧每10页,但是10000条数据时通过ob_flush()和flush()函数刷新缓冲区。

修改上线后,再次执行,这次浏览器直接不会下载几秒报网络错误了,而是请求几秒后报HTTP状态码502 Bad Gateway错误。
此时的我想起了马大师的那句话:我大意了,没有闪!竟然改出了另一个问题!

四、数据库分析

问题没有那么简单,所以我去找了运维老师一起分析下问题,运维老师查询了线上nginx日志,发现下载时,该请求时间为7秒多,且未执行完进程就终止了,运维老师感觉是数据库执行太慢,建议打出sql语句进行分析,看看是不是sql执行太慢。
根据运维老师的建议,参照查询条件,将sql语句打印出来后,在数据库中执行后,发现sql执行很快,而且都能用到索引,并不是sql执行过慢问题。
然后我向运维老师反馈在测试环境能正常下载,在线上环境无法正常下载。运维老师说在测试环境php-fpm是static,在线上环境是dynamic。所以还是建议我在程序上看下是不是哪里太耗时了。
此时,就有点手足无措了,到底是什么问题呢?

五、修改下载逻辑

既然不是数据库的问题,那可能程序真的不太合理。所以在本地环境,我将整个下载逻辑由php://output输出流下载修改为将数据写入文件后readfile()下载。
但是在本地环境,通过新逻辑下载时,会遇到504 Gateway timeout。
通过观察文件情况发现,当504 Gateway timeout,文件依旧在写入,并最终会写完,只是本地nginx抛出了504超时。
突然,我想到,是不是线上服务某个地方超时时间的设置导致文件无法下载完,程序就强制结束了。
然后,又去运维老师那里进行配置项的排查。

六、request_terminate_timeout

和运维老师反馈我的想法后,运维老师讲可能是php-fpm的模式问题,于是将线上php服务的php-fpm配置由dynamic修改为static模式。
改完后测试发现还是会502,所以问题又回到执行时间上,肯定是执行时间的问题!
在查看php-fpm配置文件时,发现配置文件中有request_terminate_timeout=3参数项。
查询该参数项,官方解释为:设置单个请求的超时中止时间。该选项可能会对 php.ini 设置中的 ‘max_execution_time’ 因为某些特殊原因没有中止运行的脚本有用。设置为 ‘0’ 表示 ‘Off’。可用单位:s(秒),m(分),h(小时)或者 d(天)。默认单位:s(秒)。默认值:0(关闭)。
根据其解释,我们再查询max_execution_time参数项的解释:
这设置了脚本被解析器中止之前允许的最大执行时间,单位秒。 这有助于防止写得不好的脚本占尽服务器资源。 默认设置为 30。 从 命令行 运行 PHP 时,默认设置为 0。
最大执行时间不会影响系统调用和系统操作等。更多细节参见 set_time_limit()。
你的 web 服务器也可以有其他超时设置,也有可能中断 PHP 的执行。 Apache 有一个 Timeout 指令,IIS 有一个 CGI 超时功能。 他们默认都是 300 秒。更多具体信息参见你的 web 服务器的文档。
到此,我们几乎可以确定就是request_terminate_timeout参数设置的问题了,于是查看测试服务器该参数配置,发现该参数设置值为request_terminate_timeout=600。
运维老师将线上request_terminate_timeout参数设置为request_terminate_timeout=10,发布配置后,下载服务终于能正常下载了。
至此,问题解决。下载依旧使用原始逻辑php://output模式。

七、总结

本次线上下载问题解决比较曲折,整体问题在于php-fpm的超时时间设置上,以前没有关注过php-fpm的request_terminate_timeout设置项,此设置项对max_execution_time的超时时间做了兜底,防止程序执行时间过长导致的问题。
关于程序执行耗时,由于遍历分页查询数据,每页1000条数据,且多表组合数据,所以会有些耗时。

7.1 关于超时时间设置

php-fpm的max_execution_time默认是0,即永不超时。(sleep()等睡眠时间计算在此范围内) 线上应该配置固定的超时时间,以防程序执行时间过长,如果一个正常程序执行超过5秒了还没结束,那么程序肯定有问题的,下载等耗时程序除外。
php.ini的max_execution_time为脚本执行时间,默认为30s。(sleep()等睡眠时间不在此范围内)
程序中通过set_time_limit()函数设置的脚本超时时间是在max_execution_time设置的基础上追加的秒数。(sleep()等睡眠时间亦不在此范围内)
通过测试max_execution_time和max_execution_time可以得出以下结论:

  • max_execution_time=0 && max_execution_time=30时:

程序死循环执行会抛出异常:Fatal error:Maximum execution time of 30 seconds exceeded in xxx。
http状态码为200

  • max_execution_time=15 && max_execution_time=30时:

程序死循环或sleep(100)执行会报 502 Bad Gateway

参考文章:
https://www.php.net/manual/zh/function.set-time-limit.php
https://www.php.net/manual/zh/info.configuration.php#ini.max-execution-time
https://www.php.net/manual/zh/install.fpm.configuration.php
https://blog.csdn.net/loophome/article/details/78604986