淺談Node.js和PHP程式管理

zhangmeng發表於2016-01-21

所周知,PHP 佔據了服務端程式語言的半壁江山,正如汪峰在音樂圈的地位一般。隨著 Node.js 逐漸走上服務端程式設計的舞臺,關於 PHP 和 Node.js 孰優孰劣的爭論也不曾間斷。

壟斷性的市場份額足以佐證 PHP 的優秀。並且 HHVM 虛擬機器、PHP 7 的革新,也給 PHP 帶來了跨越式的效能突破。然而,當我們為語言層面的效能差異喋喋不休時,卻往往忽略了 Web 模型在效能表現中的權重。

從 CGI 到 FastCGI

早期的 Web 服務,是基於傳統的 CGI 協議實現的。每個傳送到伺服器的請求,都需要經過啟動程式、處理請求、結束程式三個步驟,以至於訪問量增大時,系統資源(如記憶體、CPU 等)開銷也巨大,導致伺服器效能下降甚至服務中斷。

圖 1:簡單的 CGI 流程示意

在 CGI 協議下,解析器的反覆載入是效能低下的主要原因。如果讓解析器程式長駐記憶體,那麼它只需啟動一次,就可以一直執行著,不必每次都重新 fork 程式,這就有了後來的 FastCGI 協議。

如果 FastCGI 僅僅做到這樣,那麼和 Node.js 單程式單執行緒的模型是基本一致的:Node.js 程式啟動後保持持續執行,所有的請求都由這個程式接收和處理,當某個請求引起未知錯誤時,才可能致使程式退出。

事實上 FastCGI 並沒有那麼簡單,為了保證服務的穩定性,他被設計成了多程式排程的模式:

圖 2:Nginx + FastCGI 執行過程

這個過程同樣可以描述為三個步驟:

  • 首先,初始化 FastCGI 程式管理器,並啟動多個 CGI 直譯器子程式;
  • 接著,當請求到達 Web 伺服器時,程式管理器選擇並連線一個子程式,將環境變數和標準輸入傳送給它,處理完成後將標準輸出和錯誤資訊返還給 Web 伺服器;
  • 最終,子程式關閉連線,繼續等待下一個請求的到來;

從 child_process 到 cluster

我們回過頭來看看 Node.js 的程式管理方式。

原生 Node.js 的單程式單執行緒模型是一個極易被噴的槽點。這種機制也決定了 Node.js 天生只支援單核 CPU,無法有效地利用多核資源,一旦程式崩潰,還會導致整個 Web 服務的土崩瓦解。

圖 3:簡單的 Node.js 的請求模型

和 CGI 一樣,單一程式始終面臨著可靠性低、穩定性差的問題,當真正服務於生產環境時,這樣的弱點相當致命。如果程式碼本身足夠健壯,倒可以在一定程度上避免出錯,但同時也對測試工作提出了更高要求。現實中我們無法避免程式碼 100% 不出紕漏,有些東西容易編寫測試用例,有些東西卻只能依靠人肉目測。

所幸 Node.js 提供了 child_process 模組,通過簡單 fork 即可隨意建立出子程式。如果為每個 CPU 分別指派一個子程式,多核利用就完美實現了。於此同時,由於 child_process 模組本身繼承自 EventEmitter 這個基礎類,事件驅動使得程式間的通訊非常高效。

圖 4:簡單的 Node.js master-worker 模型(扒的淘傑老溼的圖)

為了簡化龐雜的父子程式模型實現,Node.js 緊接著又封裝了 cluster 模組,不論是負載均衡、資源回收,還是程式守護,它都會像保姆一樣幫你默默地搞定一切。具體技術細節可以參考淘傑老溼的《當我們談論 cluster 時我們在談論什麼(上)》《當我們談論 cluster 時我們在談論什麼(下)》,裡面有所有關於 cluster 方案的推演和實現,這裡不再贅述。

在 Node.js 裡,要讓應用跑在多核叢集上,只需寥寥幾行程式碼就萬事大吉了:

var cluster = require(`cluster`);
var os = require(`os`);

if (cluster.isMaster) {
    for (var i = 0, n = os.cpus().length; i < n; i ++) {
        cluster.fork();
    }
} else {
    // 啟動應用...
}

那麼反觀 FastCGI 協議,它又是如何處理這種模型的呢?

PHP-FPM 的天生缺陷

PHP-FPM 是 PHP 針對 FastCGI 協議的具體實現,也是 PHP 在多種伺服器端應用程式設計埠(SAPI:cgi、fast-cgi、cli、isapi、apache)裡使用最普遍、效能最佳的一款程式管理器。它同樣實現了類似 Node.js 的父子程式管理模型,確保了 Web 服務的可靠性和高效能。

PHP-FPM 這種模型是非常典型的多程式同步模型,意味著一個請求對應一個程式執行緒,並且 IO 是同步阻塞的。所以儘管 PHP-FPM 維護著獨立的 CGI 程式池、系統也可以很輕鬆的管理程式的生命週期,但註定無法像 Node.js 那樣,一個程式就可以承擔巨大的請求壓力。

受制於伺服器的硬體設施,PHP-FPM 需要指定合理的 php-fpm.conf 配置:

pm.max_children # 子程式最大數
pm.start_servers # 啟動時的子程式數
pm.min_spare_servers # 最小空閒程式數,空閒程式不夠時自動補充
pm.max_spare_servers # 最大空閒程式數,空閒程式超過時自動清理
pm.max_requests = 1000 # 子程式請求數閾值,超過後自動回收

和 JS 不一樣的是,PHP 程式本身並不存在記憶體洩露的問題,每個程式完成請求處理後會回收記憶體,但是並不會釋放給作業系統,這就導致大量記憶體被 PHP-FPM 佔用而無法釋放,請求量升高時效能驟降。

所以 PHP-FPM 需要控制單個子程式請求次數的閾值。很多人會誤以為 max_requests 控制了程式的併發連線數,實際上 PHP-FPM 模式下的程式是單一執行緒的,請求無法併發。這個引數的真正意義是提供請求計數器的功能,超過閾值數目後自動回收,緩解記憶體壓力。

或許你已經發現了問題的關鍵:儘管 PHP-FPM 架構卓越,但還是卡在單一程式的效能上了。

Node.js 天生沒有這個問題,而 PHP-FPM 卻無法保證,它的穩定性受制於硬體設施和配置檔案的契合度,以及 Web 伺服器(通常是 Nginx)對 PHP-FPM 服務的負載排程能力。

ReactPHP,事件驅動,非同步執行,非阻塞 IO

對 PHP 7 的狂熱掩蓋了 Node.js 帶來的猛烈衝擊。當大家還沉醉在如何選擇 HHVM 還是 PHP 7 的時候,ReactPHP 也在茁壯成長,它徹徹底底拋棄了 nginx + php-fpm 的傳統架構,轉而模仿並接納了 Node.js 的事件驅動和非阻塞 IO 模型,甚至連副標題,都起得一毛一樣:

Event-driven, non-blocking I/O with PHP.

鑑於大家都比較瞭解 Node.js,對 ReactPHP 的原理就不再贅述了,我們可以認為它就是個 PHP 版的 Node.js。拿它和傳統架構(Nginx + PHP-FPM,公平起見,PHP-FPM 只開一個程式)去做對比,結果是這樣的:

圖 5:輸出“Hello World”時的 QPS 曲線

圖 6:查詢 SQL 時的 QPS 曲線

我們可以看到,當事件驅動、非同步執行、非阻塞 IO 被移植嫁接到 PHP 上後,即便沒了 PHP-FPM 支撐,QPS 曲線依然不錯,在 IO 密集型的場景下,效能甚至得到了成倍成倍的提升。

事件和非同步回撥機制真是太讚了,它巧妙地將大規模併發、大吞吐量時的擁堵化解為一個非同步事件佇列,然後挨個解決阻塞(如檔案讀取,資料庫查詢等)。

針對單程式模型的吐槽,或許有些偏激。不過顯而易見的事實是,單程式模型的可靠性,在 Web 伺服器和程式管理器層面是有很大的優化空間的,而高併發的處理能力取決於語言特性,說白了就是事件和非同步的支援。

這兩點想必是讓 Node.js 天生驕傲的事情,但在 PHP 裡沒有得到原生支援,只能通過模擬步進操作的方式來支援類似 Node.js 的事件機制,所以 ReactPHP 其實也並沒有想象中那麼完美。

結束語

大部分時候,當我們比較語言優劣,容易侷限在語言本身,而忽視了配套的一些關鍵因素。

就拿 PHP 來說,這兩年聽到了太多關於即時編譯器(JIT)、opcode 快取、抽象語法樹(AST)、HHVM 等等之類的話題。當這些優化逐步完備,語言層面的問題,早已不再是 Web 效能的短板了。如果實在不行,我們還可以把複雜任務交給 C 和 C++,以 Node.js addon 或者 PHP 擴充套件的形式,輕輕鬆鬆就搞定了。

都說 PHP 是“世界上最好的語言”,既然如此,也是時候學習下 Node.js 事件驅動和非同步回撥,考慮考慮如何對 PHP-FPM 進行大刀闊斧的革新。畢竟不管是 Node.js 還是 PHP,我們所擅長的地方,終將還是 Web,高效能的 Web。

相關資料

該文章來自於阿里巴巴技術協會(ATA

作者:邦彥


相關文章