網路程式設計一直是PHP的短板,儘管Swoole擴充套件彌補了這個缺陷,但是其程式設計風格偏向了NodeJS或GoLang,與原本的同步程式設計風格迥然相異。目前PHP的大部分主流應用框架依然是同步程式設計風格,所以一直在探索Swoole與同步程式設計結合的途徑。
lumen-swoole-http正是連線同步程式設計Lumen和非同步程式設計Swoole的一座橋樑,有興趣可以關注一下。
LNMP的不足
LNMP是經典的Web應用架構組合,雖然(Linux、NginX、MySQL和PHP-FPM)四者各種是優秀的系統或軟體,但是組合到一起的總體效能並不盡人意,明顯的不是1+1+1+1>4
,而是4+3+2+1<1
。Linux系統無可厚非,主要問題出現在:
從NginX到PHP-FPM
NginX利用IO多路複用機制epoll,極大地減少了IO阻塞等待,可以輕鬆應對C10K。可是每次NginX將使用者請求傳遞給PHP-FPM時,PHP-FPM總是需要從新載入PHP專案程式碼:建立執行環境,讀取PHP檔案和程式碼解析、編譯等操作一次又一次的重複執行,造成不小的消耗。
從PHP-FPM到MySQL
由於PHP程式碼本身是同步執行,PHP-FPM連線MySQL查詢資料時,只能空閒等待MySQL返回查詢結果。一個查詢語句執行時間可能會需要幾秒鐘,期間PHP-FPM若是能暫時放下當前使用者慢查詢請求,而去處理其他使用者請求,效率必然有所提高。
<!–more–>
Swoole HTTP伺服器
Swoole HTTP伺服器也採用了epoll機制,執行效能與NginX相比,雖不及,猶未遠。不過Swoole HTTP伺服器嵌入PHP中作為其一部分,可以直接執行PHP,完全可以取代NginX + PHP-FPM組合。
以目前流行的為框架Lumen(Laravel的子框架)為例,用Swoole HTTP伺服器執行Lumen專案十分簡單,只需要在$worker->onRequest($request, $response)
(收到使用者請求)時將$request
傳給Lumen處理,$response
再將Lumen的處理結果返回給使用者,而且$worker
的整個生命週期裡只會載入一次Lumen專案程式碼,沒有多餘的磁碟IO和PHP程式碼編譯的開銷。
壓力測試
在4GB+4Core的虛擬機器下,測試HTTP伺服器的靜態輸出:
- 2000客戶端併發500000請求,不開啟HTTP Keepalive,平均QPS:
NginX + HTML QPS:25883.44
NginX + PHP-FPM + Lumen QPS:828.36
Swoole + Lumen QPS:13647.75
- 2000客戶端併發500000請求,開啟HTTP Keepalive,平均QPS:
NginX + HTML QPS:86843.11
NginX + PHP-FPM + Lumen QPS:894.06
Swoole + Lumen QPS:18183.43
可以看出,Swoole + Lumen
組合的執行效率遠高於NginX + PHP-FPM + Lumen
組合。
非同步MySQL客戶端
以上都是鋪墊,以下才是整篇文章的重點???
一個PHP應用要做的事不會是單純的資料計算和資料輸出,更多的是與資料庫資料互動。以MySQL資料庫為例,在只有一個PHP程式的情況,有10個使用者同時請求執行select sleep(1);
(耗時1秒)查詢語句,若是使用MySQL同步查詢,那麼總耗時至少是10秒;若是使用MySQL非同步查詢,那麼總耗時可能壓縮到1到2秒內。
在PHP應用中能夠實現資料庫非同步查詢,才能更大的突破效能瓶頸。
雖然Swoole提供了非同步MySQL客戶端,但是其非同步程式設計風格與Lumen這種同步程式設計風格的專案框架衝突,那麼有沒有可能在同步程式設計風格程式碼中呼叫非同步MySQL客戶端呢?
一開始我覺得這是不可能的,直到我看到了這片文章:Cooperative multitasking using coroutines (in PHP!)。當然,我看的是中文版: 在PHP中使用協程實現多工排程,文中提到了PHP5.5加入的一個新功能:yield。
Yield
yield
是個動詞,意思是“生成”,PHP中yield
生出的東西叫Generator
,意思是“生成器”???。
個人理解是:yield將當前執行的上下文作為當前函式的結果返回(yield必須在函式中使用)。
在系統層面,各個程式的執行秩序由CPU排程;而有了yield,在PHP程式內,程式設計師可以自由排程各個程式碼塊的執行順序。比如,當“發現”當前使用者請求的MySQL查詢將會花費較多的時間,那麼可以將當前執行上下文記錄起來,交給非同步MySQL客戶端處理(與使用者請求相關的$request
和$response
也傳遞過去),而主程式繼續處理下一個使用者請求。
約定宣告
前面用了“發現”這個詞,當然程式不可能智慧地發現還沒執行的查詢語句將會是個慢查詢,我們需要一些約定和宣告。
Lumen框架是經典的MVC模式,我們約定C即Controller是處理使用者請求的最後一步——Controller接受使用者請求$request
並返回響應$response
。同時我們宣告一個類,叫SlowQuery
,這個類十分簡單(具體請參見SlowQuery.php):
<?php
namespace BLSwooleHttpDatabase;
class SlowQuery
{
public $sql = ``;
public function __construct($sql)
{
$this->sql = $sql;
}
}
比如,Lumen專案中有這麼一個Controller:
<?php
namespace AppHttpControllers;
use AppHttpControllersController;
use DB;
class TestController extends Controller
{
public function test()
{
$a = DB::select(`select sleep(1);`);
response()->json($a);
}
}
上面的DB::select
使用的同步MySQL客戶端查詢,我們用SlowQuery
物件替換它:
<?php
namespace AppHttpControllers;
use AppHttpControllersController;
use BLSwooleHttpDatabaseSlowQuery;
class TestController extends Controller
{
public function test()
{
$a = yield new SlowQuery(`select sleep(1);`);
response()->json($a);
}
}
以Swoole HTTP伺服器執行Lumen專案時,我們一定會獲取Controller的返回結果。Controller的返回結果一般可以直接包裝成Lumen響應返回給使用者的,但返回結果若是一個生成器Generator物件,而且其當前值是一個慢查詢SlowQuery物件的話,那麼我們可以取出SlowQuery物件的sql屬性,交由非同步MySQL客戶端執行;在非同步查詢的回撥函式中將查詢結果放回Generator物件儲存的上下文中執行,得到最後結果才返回給使用者;而主程式沒有阻塞,可以繼續處理其他使用者請求。
當然,如果想用Eloquent ORM,那也很簡單:我們先繼承Lumen的Model,封裝成一個新的Model類(具體參見Model.php),應用中的資料模型都繼承於新的Model,Controller就可以這樣寫:
<?php
namespace AppHttpControllers;
use AppHttpControllersController;
use AppModelsUser;
use DB;
class TestController extends Controller
{
public function test()
{
$a = yield User::select(DB::raw(`sleep(1)`))->yieldGet(); // 注意User須繼承自BLSwooleHttpDatabaseModel
response()->json($a);
}
}
以上三個Controller最終產出的使用者響應都是一樣的,不過後兩者使用的是非同步MySQL客戶端,效率更高。
任務排程器
當然,我們還需要一個任務排程器來執行這些生成器,任務排程器的實現方法 在PHP中使用協程實現多工排程文中“多工協作”章節裡有介紹,這裡不展開。
Lumen框架中的程式碼保持了同步程式設計風格,而任務排程器中使用了非同步程式設計風格來呼叫非同步MySQL客戶端。任務排程器是在Swoole HTTP伺服器層面使用的,具體參見Service.php。
連線限制
其實,每開啟一個Swoole非同步MySQL客戶端,主程式就會新建一個執行緒連線MySQL,若是建立太多連線(執行緒),會增加自身伺服器的壓力,也會增加MySQL資料庫伺服器的壓力。
這種利用yield來呼叫非同步MySQL客戶端處理慢查詢而產生的執行緒,暫且稱它為“慢查詢協程”。
為了限制資料庫連線數量,我們可以設定一個全域性變數記錄可新建慢查詢協程的數量MAX_COROUTINE
,開啟一個非同步MySQL客戶端時讓其減一,關閉一個非同步MySQL客戶端時讓其加一;當使用者請求慢查詢時,MAX_COROUTINE
大於0則由非同步MySQL客戶端處理,MAX_COROUTINE
等於0時則由主程式“硬著頭皮”自己處理。
壓力測試
在4GB+4Core的虛擬機器下,測試HTTP伺服器與資料庫讀寫:
一般的快速查詢和快速寫入測試:
- 200併發50000請求讀,利用HTTP Keepalive,平均QPS:
NginX + PHP-FPM + Lumen + MySQL QPS:521.56
Swoole + Lumen + MySQL QPS:7509.99
- 200併發50000請求寫,利用HTTP Keepalive,平均QPS:
NginX + PHP-FPM + Lumen + MySQL QPS:449.44
Swoole + Lumen + MySQL QPS:1253.93
慢查詢協程測試:
- 16worker的Swoole HTTP伺服器,併發執行
select sleep(1);
請求的最大效率是15.72rps; - 16worker x 10coroutine的Swoole HTTP伺服器,併發執行
select sleep(1);
請求的最大效率是151.93rps。
這裡為什麼說最大效率呢?因為當併發量遠大於worker數目 x coroutine數目時,可開啟慢查詢協程的Swoole HTTP伺服器的效率會逐漸跌向普通Swoole HTTP伺服器。
select sleep(1);
查詢語句耗時1秒,每個使用者請求都需要1秒時間來處理;不過,16程式的、每個程式可開啟10個慢查詢協程的Swoole HTTP伺服器的每秒最多可以處理160個使用者請求,而16程式的普通Swoole HTTP伺服器每秒最多隻能處理16個使用者請求。
延伸
其實利用yield,我們還可以實現各種各樣的“協程”。比如,Swoole2.1版本已經開始支援go函式與通道,後續我們可能還可以將Lumen Controller中一些IO阻塞的操作的上下文移至go函式裡執行,這樣既保留了同步程式設計的風格,由達到非同步執行的效能。
最後
以上理論,已經在lumen-swoole-http專案中實現。lumen-swoole-http
是連線同步程式設計Lumen和非同步程式設計Swoole的一座橋樑,可以幫助原生PHP的Lumen應用專案快速遷移到Swoole HTTP伺服器上;當然也可以快速遷移回去?。
有興趣的同學可以嘗試使用: