有人说,限制激发创造力。如果真这样,PHP就是成熟的创造性解决方案。我刚上周构建了调用Segment.io的API的PHP库,发现了各种不同的方法可以提高服务端请求性能。
设计客户端类向API发送数据时,我们的首要任务之一就是保证我的代码不影响到你的核心程序。这是很棘手的,尤其是使用单线程,无共享的语言,如PHP。
服务商PHP安装方式很多,让问题更复杂。幸运的,你的服务商允许你创建进程,写入文件和安装自己的扩展。不幸的话,你就得和一些纠结的邻居分享同一个安装配置,只能上传文件。
理想状态,我们喜欢用最小的满足实现各种情况。当运行PHP时(可能就一两个脚本),你应该深入理解这些。
我们尝试用三种主要方法实现PHP发出请求,以下就是。
一:快速打开一个套接字(Socket)
搜索PHP异步请求,最先的结果都是相同的方法:写一个Socket然后在等待返回前关闭它。
这个想法是开启一次连接到服务端,连接好就写入内容。Socket写入是很快的,而且你不要返回信息,写入后直接关闭连接。这就节省了等待一次往返的事件。
但是当你看StackOverflow上的评论,Socket到底发生了什么有一些争论。也让我疑问:Socket怎么实现的异步?
下面是我们的Socket实现:
02 | private function request( $body ) { |
05 | $host = "api.segment.io" ; |
07 | $path = "/v1/" . $body ; |
08 | $timeout = $this ->options[ 'timeout' ]; |
11 | # Open our socket to the API Server. |
12 | $socket = fsockopen ( $protocol . "://" . $host , $port , |
13 | $errno , $errstr , $timeout ); |
15 | # Create the request body, and make the request. |
16 | $req = $this ->create_body( $host , $path , $content ); |
17 | fwrite( $socket , $req ); |
19 | } catch (Exception $e ) { |
最初的结果并不乐观。一次fsockopen花了300毫秒,偶尔更长。
事实证明,fsockopen是阻塞的——不是异步的!要了解到底发生了什么,需要深入研究fsockopen是怎么工作。当fsockopen选择协议时,需要考虑使用哪种socket。这个过程在连接完成前是阻塞的。
复习一下,internet的基本协议是TCP。它使电脑之间的信息传递可靠并有序。几乎所有HTTP都运行于TCP上。我们用HTTP来简化自定义的客户端使用。
这是TCP Socket创建连接:
- 客户端发送SYN消息给服务端
- 服务端返回SYN-ACK消息确认包
- 客户端发送最终ACK包及传送数据
作为计时的部分,这是传输数据之前完成的完整来回,在fsockopen之前这就已经返回。一旦连接开启,我们可以为socket写入数据。通常,需要30-100ms连接到我们的服务器。
TCP连接比较快,罪魁祸首是SSL需要的额外握手。SSL也实现在TCP上。TCP握手后又开始TLS握手。
光SSL连接就需要三次握手,更不要说加上创建公共密匙的时间。
浏览器的SSL连接可以共享密匙,避免允许访问的客户端和服务端重复握手。可是PHP执行的Socket无法共享密匙,我们只能每次都是重新连接。
还可以使用socket_set_nonblock创建“非阻塞”的Socket。不过这是在打开Socket的时候不阻塞,你还是要等待完成才能写入内容。如果精确考虑打开Socket写入数据的时间,页面加载会慢约100ms。
总结起来:
- Socket可以在有权限限制的PHP上运行
- fscokopen是阻塞的,即使不阻塞Socket也需要等待再写入数据
- SSL连接明显减慢连接,因为额外的握手和加密过程
- 打开连接使页面延迟100ms
二,写日志文件
如果你没有其他系统权限的时候,Sokets是非常棒的方案。这儿我们介绍一种在性能上更好的方法,那就是把所有事件以日志方式写到文件。这个日志文件可以被工作进程或者cron做"带外"处理。
基于文件方法的优点是具有最小的API对外请求。当php代码发出track 或者identify请求的时候,通过这种方法工作进程可以同时处理100个事件的请求,而不是仅一个请求。
这种方法的另外一个优点是php进程可以相对更快的记录文件,一个写操作往往只需要几毫秒。当php打开一个文件句柄的时候,用fwrite进行追加写是很简单的操作。由于纯php不具有“共享内存队列”机制,在这日志文件实际上和“共享内存队列”具有异曲同工的效果。
为了读日志文件,我利用analytics-pythonlibrary库写了一个python上传脚本。为了防止日志文件太大,脚本自动进行更名操作。可以动态的写php文件,还可以写内存中的文件句柄,在老请求创建的地方,新请求会创建一个新的日志文件。
这种方法没有太多的逻辑,只要开发者多写点cron任务,并且通过PyPI分别安装我们的python库。(这两段感觉是在给他们的python库做公告,既然python那么好,用php干嘛,bs)
方法总结(关键点):
- 写文件较快,系统资源开销少。
- 需要消耗磁盘空间,要求守护进程对文件有写权限。
- 必须运行工作进程处理带外的记录消息。
三:调用Curl过程
还有一个可选择的方法,我们可以通过exec 操作curl工具来发出请求。curl请求才可以做为独立进程一部分来完成,允许php代码继续执行,而不会阻塞socket连接。
这种方法的性能介于前面两种方法之间,比soket方法快,比写文件的方法花费更少的系统资源。
操作 forkd curl 方法,最简单的例子如下:
02 | private function request( $url , $payload ) { |
04 | $cmd = "curl -X POST -H 'Content-Type: application/json'" ; |
05 | $cmd .= " -d '" . $payload . "' " . "'" . $url . "'" ; |
07 | if (! $this ->debug()) { |
08 | $cmd .= " > /dev/null 2>&1 &" ; |
11 | exec ( $cmd , $output , $exit ); |
如果运行在生产模式,我们不希望等着fork进程的消息输出。所以代码中加添了"> /dev/null 2>&1 &"让进程正确的执行 ,而把任何可能输出都丢弃掉。
同样功能的shell脚本如下:
1 | curl -X POST -H 'Content-Type: application/json' \ |
2 | -d '{ "batch" :[{ "secret" : "testsecret" , "userId" : "some_user" , |
3 | "event" : "PHP Fork Queued Event" , "properties" :null, "timestamp" : |
4 | "2013-01-30T14:34:50-08:00" , "context" :{ "library" : "analytics-php" }, |
5 | "action" : "track" }], "secret" : "testsecret" }' \ |
6 | 'https://api.segment.io/v1/import' > /dev/null 2>&1 & |
脚本花费了大概1秒多一点的时间,占用大约4k的的常驻内存。而curl进程用了 标准SSL 300毫秒完成请求,exce调用立刻相应php程序。这使得服务页面能很快相应用户。
笔者用一台一般水平的机器试验,这种方法curl可以每秒响应100个左右https请求,而没有任何的内存开销。如果不用SSL,响应的请求会更多。
不用等待输入,Fork一个进程非常快。
curl花费了和socket同样时间响应一个请求,但是这个外带的过程。
调用curl需要仅仅普通的unix基础。
Fork发起一个简单的请求,只需要几毫秒的时间,但是大量的同步调用(forks)会导致系统变慢。
使用析构函数减少出栈请求
虽然不是一个异步请求的方法,但是我们可以用析构函数帮助我们进行批量API请求。
为了减少请求的数量,我们首先将他们放在内存中,然后对他们进行批处理。如果不适用运行时扩展,他们只能在一个单一的PHP脚本中运行。要做到这一点,我们首先初始化一个队列,在程序脚本运行结束时,将所有队列请求批量发送出去。
02 | class Analytics_SomeConsumer { |
04 | public function __construct() { |
05 | $this ->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); |
06 | socket_set_nonblock( $this ->socket); |
07 | socket_connect( $this ->socket, $this ->host, $this ->port); |
08 | $this ->queue = array (); |
11 | public function __destruct() { |
12 | $payload = json_encode( $this ->queue); |
14 | socket_write( $this ->socket, $payload ); |
15 | socket_close( $this ->socket); |
18 | public function track( $item ) { |
19 | array_push ( $this ->queue, $item ); |
队列中的对象创建后,当它被销毁时将队列进行刷新,这样保证了队列在每次请求时只刷新一次。
另外,当PHP解释器忙着来渲染页面而我们等待实际写入套接字时,我们可以以非阻塞的方式在构造函数中创建套接字,然后写入析构函数,这样可以预留更多的时间来建立连接。
抉择?
最完美的方法是用纯php实现,而不是调用其他进程,这也是响应请求保守做法。我们更趋向于开发者最方便,不会把精力分散在其他地方。
实际中,这往往是不可触及的。基于处理的问题的大小,以及系统的限制,以上每种方法都是有缺点和限制。由于简单的方法不可能满足实际中的用户状况,我们创建不同的适配器以支持不同用户的不同需求。
我们以调用curl方法做为基础,调用一个进程不会导致重大的页面性能负担,同时他还支持扩展到每属主每秒处理多请求。请求的数量通过usinglimits.conf严格限制。
高并发用户或者拥有高系统权限的用户可以实用日志文件系统。系统权限受限的用户(虚拟空间等)可以使用sockets方法。
最后,需要你去了解一下实际中你能拥有的系统限制和系统的负载情况。这些都最终决定你选择更合适的方法。