PHP编程之谨防阻塞式调用

PHP每次请求都会有单独的php-fpm进程响应和处理。php-fpm的进程模式又可以分为static静态执行和dynamic动态执行。不管是何种执行方式,都是启动了一定数量的php-fpm进程来响应排山倒海般流量的请求。假设,这些进程全部都被占满时,系统就会出现异常、崩溃和无法响应新请求和新访问。
识别哪些会产生阻塞式调用的操作,是预防这一危机最基本的要求。不要说刚学习PHP的新手,哪怕是从事了多事PHP开发的同学,估计也会对这一块有所遗漏。
<h2″>4.4.1 file_get_contents()真的好吗

从我最初接触商业项目的开发,就看到项目中会用到file_get_contents()函数获取远程服务器上的数据文件。到现在,过去了接近十年,在商业项目中仍然看到大量地使用file_get_contents()函数调用远程接口服务,或者用于获取远程图片资源。
file_get_contents()函数使用很简单,这也难怪为什么会有很多开发同学会喜欢它,甚至是不加思索就喜欢它,以致在项目开发中也不加思考都用它,能用到的地方都用上。因为它不仅可以读取本地系统的文件资源,还可以获取远程服务器的资源。但这个函数有个最大的弊端,就是无法设置超时时间。如果需要获取的远程资源,或者待请求的接口一直无法响应的话,就会导致阻塞。如果这一超时的接口或者页面又刚好是外部访问量热区时,就会导致大量的php-fpm进程被占用。最后,整个系统处于满负载运行状态,因为php-fpm进程资源得不到释放。
要模拟这一现象也不难,下面我们来尝试一下。看下当出现这种情况时,会有哪些现象,以及该怎么改进和应对。
先来调整一下php-fpm的相关配置。把php-fpm进程改成静态执行,最大进程数改为5个,并且把PHP处理的超时时间设置为60秒。如下:

; pm = dynamic
pm = static

;pm.max_children = 5
pm.max_children = 5

;request_terminate_timeout = 0
request_terminate_timeout = 60

修改后,重启php-fpm。
然后,添加一个PHP文件,并在里面通过file_get_contents()函数获取接口http://api.okayapi.com/返回的信息并输出。如下:

# $ vim ./file_get_contents.php
<?php
$rs = file_get_contents('http://api.okayapi.com/');
echo $rs;

非常简单的代码。访问一下,可以看到正常的结果输出。
但是如果出现异常情况呢?例如我们可以给api.okayapi.com故意绑定一个错误的IP地址,让它无法正常连接。以root用户权限修改/etc/hosts文件,并在最后添加:

192.168.22.33 api.okayapi.com

再重新请求前面的链接,会在等待1分钟后,提示:504 Gateway Time-out。如果同时对此发起5次及以上的请求,再额外请求其他页面,就会出现卡住的现象。这是因为服务端的5个php-fpm进程已经全部在服务./file_get_contents.php这个请求,很难再抽身出来响应新的请求。而查看Nginx的错误日志,可以发现有Resource temporarily unavailable这样的错误提示。
解决改进的方案就是使用可以设置超时的方式来获取远程服务端资源,一如脍炙人口的curl。作为对比,另外新建一个curl.php文件,并输入以下代码:

<?php
// 创建新的 cURL 资源
$ch = curl_init();

// 设置 URL 和相应的选项
curl_setopt($ch, CURLOPT_URL, "http://api.okayapi.com/");
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);

// 抓取 URL 并把它传递给浏览器
$rs = curl_exec($ch);
echo $rs;

// 关闭 cURL 资源,并且释放系统资源
curl_close($ch);

上面代码,将最长执行时间CURLOPT_TIMEOUT设置为3秒,当超过这个时间就会停止继续执行。这样会更为可控,因为你自己的系统可以知道发生了超时的情况,超时情况下curl_exec()返回的结果是FALSE。针对超时,你可以进行报警、容错、降级等处理,而不是漫无目的地一直等待。
我们阻止不了问题的发生,但可以改进处理问题的方式。建议除非读取的是本地文件资源,否则尽量在获取远程资源时使用cURL,而不是file_get_contents(),尤其有请求接口时。
除此之外,还要甄别其他因为无法设置超时而导致阻塞的函数和操作。这里不过多展开,但例如取得图像大小的getimagesize()函数就是其中一个。曾经在现实系统中,我也遇见过因为这个函数使用不当而导致整个页面无法访问的故障。希望大家引以为鉴。

让我“睡”一下

导致阻塞的原因可以划分为两大类,一类是前面所说的,因为外部原因而导致的超时阻塞,这是不可控的。但另外一类,说起来有点好笑,因为是可控的,并且是内部原因,是开发人员(很可能就是你自己)一手造成的。
PHP有三个函数可用于延缓执行,分别是:

sleep() 延缓执行
usleep() 以指定的微秒数延迟执行
time_sleep_until() — 使脚本睡眠到指定的时间为止

包括我在内,很多PHP程序员都喜欢使用这几个睡眠函数。但这几个函数是有一定弊端的,原因和前面类似。对于每个请求,php-fpm都是单进程阻塞式地响应。任何PHP的操作,基本都是同步地进程,与Javascript的机制不同。虽然Javascript也是单线进,但它可以做到事件轮询,触发式回调响应,因此不会造成阻塞。
曾经我见过一个很好笑但又隐藏重大问题的真实案例。用户在发表评论的同时,可以上传多张图片。因为需要判断用户的图片是否已全部上传完毕,服务端觉得需要“等一等”,等用户全部的图片都上传完毕后才能将评论内容连带图片一起持久化。它的做法是,在判断图片是否已上传完毕的接口里,最多等待10次,每等待一次就调用sleep()函数延缓执行1秒钟,然后每次都是检查图片是否上传完毕。此这法导致该接口每次响应的时间平均都是好几秒,最坏情况下去到10秒!
这种对sleep()不合理的调用,不仅导致了当前请求响应慢,会产生PHP慢调用外,当流量过大时,同样会出现上述全部php-fpm进程被占满不可用的情况。这是服务端资源的间接浪费,因为php-fpm进程无法及时释放出来做一些更有意义的事情。令我惊讶的是,当我和维护这块代码的工程师讨论为什么要这样做时,他竟然告诉我此做法性能更高效,因为可以减少客户端的多次请求。天哪!于是我稍微和他解释了一下为什么延迟执行的方案非但不高效反而祸患重重。
假设,请求一次判断图片是否已上传完毕的接口,需要100毫秒。原来的做法,在服务端检测一次就等待1秒,即对于服务端来说,CPU的有效时间利用率只有9%,因为有1000毫秒是毫无用处,白白浪费的。

如果换一种做法,让客户端来轮询的话,那么虽然客户端会发起多次请求,最多会尝试10次。也就是把服务端的循环检测移到客户端来执行。那么,原来睡眠的1000毫秒,就可以释放出来服务其他请求或页面的请求。即剩下的91%的有效时间可以继续充分利用,继续响应其他请求,不至于被浪费。

两种做法的时间对比

发表评论