有人說,限制激發創造力。如果真這樣,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方法。
最後,需要你去了解一下實際中你能擁有的系統限制和系統的負載情況。這些都最終決定你選擇更合適的方法。