Swoole+Lumen:同步程式設計風格呼叫MySQL非同步查詢

breeze2發表於2019-02-16

網路程式設計一直是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伺服器上;當然也可以快速遷移回去?。
有興趣的同學可以嘗試使用:

相關文章