深入理解 PHP-FPM 的最佳配置 對大多數開發者來説,PHP-FPM 的配置並不是日常工作中需要深入研究的東西。這沒什麼問題,畢竟不是每個人都想或需要在服務器調優上花時間。

況且,現在有很多託管服務(寶塔,1panel 等)可以幫你把服務器配置好,安裝所有依賴(包括 PHP-FPM),你只需要在控制面板點幾下就能部署代碼。也許你們公司有專門的運維,或者有資深開發在負責這塊。即便真要自己配置 PHP-FPM,多半也就是翻幾篇文章,改改參數,或者直接用默認配置。這很正常 —— 誰有那麼多時間去鑽研每個服務器配置細節,尤其這只是工作的一小部分。

但隨着應用不斷迭代、用户越來越多,你可能會發現服務器開始變慢,請求處理時間越來越長,內存佔用接近上限,甚至服務器直接掛掉。

最近我的一台服務器就遇到了類似問題,所以我決定花點時間搞清楚 PHP-FPM 到底是怎麼工作的,不同配置會帶來什麼影響。我看了很多文章、討論和評論,然後自己做了些測試來驗證。以下是我的一些心得。

原文 深入理解 PHP-FPM 的最佳配置

問題排查 如果問題確實出在 PHP-FPM 上,有幾個排查方向。首先檢查 PHP-FPM 的日誌,重點關注 max children 相關的警告。PHP-FPM 的主進程會按需生成子進程,直到達到 max children 的上限。每個子進程一次只能處理一個請求(比如對你應用的一次訪問)。所以如果 max_children 設置成 5,而同時有 10 個用户在訪問應用,日誌裏很可能會出現這樣的警告:

WARNING: [pool www] server reached pm.max_children setting (5), consider raising it 這會導致部分請求被延遲,直到有子進程空閒出來。可以用下面的命令檢查日誌裏有沒有這類警告。如果你用的是 PHP-FPM 8.2:

sudo grep max_children /var/log/php8.2-fpm.log.1 /var/log/php8.2-fpm.log 注意你係統上的日誌路徑可能不同,記得先確認。另外,除了替換 PHP 版本號,有些系統的日誌文件名裏不帶版本號,那就直接用 php-fpm:

sudo grep max_children /var/log/php-fpm.log.1 /var/log/php-fpm.log 後面提到的所有 php-fpm 命令都是同樣的道理。我會用 php-fpm8.2 或 php8.2-fpm,因為這是我的版本,你的可能是 php-fpm7.4(php7.4-fpm)或者直接就是 php-fpm。

不想打開配置文件的話,可以用這個命令快速查看當前配置:

sudo php-fpm8.2 -tt 這樣就能找到 pm.max_children 這一行,確認 max_children 是不是真的設置成了 5:

[19-Mar-2024 22:48:10] NOTICE: pm.max_children = 5 另外要關注的是服務器內存使用情況。用 htop 按內存排序,可以看到內存是不是快用完了,PHP-FPM 進程佔了多少。

這可能是 max_children 設置太高,生成的子進程太多,服務器內存撐不住了。或者,如果重啓 PHP-FPM 後內存使用量下降,然後又慢慢漲回去,那多半是代碼有內存泄漏。理想情況下當然要找到泄漏點並修復,但定位內存泄漏有時候挺難的,尤其在大項目裏,而且泄漏可能來自某個必需的第三方庫。

第一個問題可以通過優化配置解決,內存泄漏的話 PHP-FPM 這邊也有些緩解辦法,稍後會講。

順便説一下,重啓 PHP-FPM 的命令可能是這樣的(但你的情況可能不同,所以要確認一下):

sudo service php8.2-fpm restart 如前面説的,重啓 PHP-FPM 能臨時緩解內存泄漏問題(但不是根本解決),給你爭取時間去修復泄漏或調整配置。

配置進程管理器 現在可以開始修改 PHP-FPM 的配置文件了,看看怎麼針對實際情況做優化。編輯主配置文件的命令:

sudo nano /etc/php/8.2/fpm/pool.d/www.conf 裏面有各種配置項,我們只講幾個對性能影響最大的。首先要決定進程管理器如何控制子進程數量。有 3 個選項:static、dynamic 和 ondemand。大多數情況下默認是 dynamic。這幾個選項有什麼區別?假設你確定服務器最多需要 10 個子進程:

static 會始終保持所有 10 個進程運行,理論上最快,因為進程都已經在那兒了,負載上來時不需要 fork 新進程。但代價是即使沒人訪問網站,這 10 個進程也會一直佔着內存。

dynamic 可以靈活調整;比如一開始啓動 3 個進程,負載上來時 fork 到最多 10 個,負載下去後減少到 6 個等待連接。這個選項在內存佔用和響應速度之間找平衡,至少理論上如此。

最後是 ondemand,一開始不生成任何子進程,負載上來時最多創建 10 個,負載下去後可能又回到 0 個。這個選項(理論上)適合流量不大的中小型應用、預發佈環境,或者多租户共享服務器。由於子進程一直在回收,可以幫助控制內存泄漏,因為進程在內存累積之前就被幹掉了。缺點是需要頻繁 fork 新進程,可能影響性能和響應速度。

設置這些選項之前,需要算出 PHP-FPM 進程的最大負載。也就是確定服務器能跑多少個子進程,然後設置 max_children 值。怎麼算?這有點麻煩,因為理想情況下要知道單個子進程平均用多少內存。問題是多個進程通常會共享一些內存,所以很難精確算出單個進程的實際內存使用量。

網上有很多腳本和文章教你怎麼算 PHP-FPM 進程的平均內存消耗,但大多數我試下來感覺不太對,算出來的值比預期高很多。

有個 Python 腳本在好幾篇文章裏都提到了,看起來比較靠譜。可以用這個命令算出每個程序的總內存使用量:

cd ~ && wget https://raw.githubusercontent.com/pixelb/ps_mem/master/ps_mem.py && chmod a+x ps_mem.py && sudo python3 ps_mem.py 注意我用的是 python3,你的系統可能只需要 python。跑完腳本後,可能會看到類似這樣的結果:

2.1 GiB + 127.5 MiB = 2.2 GiB php-fpm8.2 (31) 這説明 31 個 PHP-FPM 進程用了 2.2 GB 內存,平均每個進程約 73 MB。另一個有用的命令可以查看空閒和活動進程數:

sudo service php8.2-fpm status -l 看這一行就能知道子進程的當前狀態:

Status: "Processes active: 0, idle: 30, Requests: 56116, slow: 0, Traffic: 0req/sec" 這裏沒有活動進程,30 個空閒進程,加上 1 個主進程,總共 31 個,和 Python 腳本報的一致。

Python 腳本也會報總內存使用量。可以隨時跑 htop 或 free -hl 檢查服務器當前內存使用情況,看看這些數字是否合理、是否對得上。

還有一點,如果真有內存泄漏,單個進程的內存可能會漲到 php.ini 裏定義的 memory_limit,默認一般是 128 MB。所以保守起見,可以直接用這個值作為單個進程的平均值。

好,現在可以算 max_children 值了。假設有台 8 GB 內存的服務器,其他程序用了 2 GB,剩 6 GB。再留 1 GB 作為緩衝,防止意外情況或者未來應用增長、新增進程什麼的。這樣就剩 5 GB 給 PHP-FPM。前面算出單個進程用約 73 MB 內存,用 5 GB 除以 73 MB 就得到 max_children 值:

5120 (MB) / 73 (MB) = 70.14 所以這台服務器的 max_children 應該設置成 70。這樣無論選哪個進程管理器(pm)選項,PHP-FPM 最多都只會生成 70 個子進程。

如果用 pm = static,就不需要設置其他選項了。70 個子進程會立即生成,隨時待命。但要記住代價:這些進程會一直佔着 5 GB 內存。

如果用 pm = ondemand,只需要考慮一個額外設置:pm.process_idle_timeout。由於 ondemand 模式下子進程會不斷生成和終止,這個設置告訴 PHP-FPM 什麼時候幹掉空閒的子進程。默認是 10 秒,需要的話可以改,不過默認值已經挺合理了。

如果用 pm = dynamic,需要考慮幾個額外設置。pm.start_servers 是啓動或重啓 PHP-FPM 時立即生成的子進程數。pm.min_spare_servers 設置最小空閒子進程數。pm.max_spare_servers 決定最大空閒子進程數。

假設我們這樣設置:

pm = dynamic pm.max_children = 70 pm.start_servers = 20 pm.min_spare_servers = 20 pm.max_spare_servers = 40 實際運行是這樣的。上面例子裏 start_servers 設置為 20,PHP-FPM 一啓動就會生成 20 個子進程,佔用相應的內存。有請求過來時,這 20 個進程中的部分或全部會變成活動狀態處理請求。沒流量時,這 20 個進程會空閒等待;它們不會被終止,繼續佔着內存。

把 min_spare_servers 設置得比 start_servers 低(比如 15)沒什麼意義,因為 20 個子進程會立即生成,即使空閒,主進程也不會為了達到 15 的最小值而終止 5 個。而且 min_spare_servers 不能比 start_servers 大,所以最好就把 min_spare_servers 設置成和 start_servers 一樣。

如果大量請求涌入,20 個子進程不夠用,主進程會生成額外的子進程,最多到 max_children 值,這裏是 70。假設為了應對流量激增生成了 70 個子進程。過一會兒,流量恢復正常,不再需要 70 個進程了,大部分或全部進程變成空閒。這時主進程會終止空閒子進程,直到 max_spare_servers 值,這裏是 40。然後你就剩 40 個空閒進程,它們不會被進一步終止,繼續佔着內存。

所以設置這些值時要記住:如果需要生成比 start_servers 更多的進程,流量高峯過後你會剩下那麼多子進程(最多到 max_spare_servers 值)在運行。比如需要生成 30 個子進程,你會剩 30 個在運行;需要生成 50 個,過一會兒會剩 40 個(因為 max_spare_servers)在運行。所以如果不想最終可能有 40 個子進程在後台跑着,可以考慮降低這個值,甚至讓它和 start_servers 一樣。這些情況會一直保持到你重啓 PHP-FPM。重啓後會根據 start_servers 重新生成 20 個子進程。

很多文章裏有個公式,建議根據 CPU 核心數來設置 start_servers、min_spare_servers 和 max_spare_servers 以獲得最佳性能:

pm.start_servers = CPU 核心數 x 4 pm.min_spare_servers = CPU 核心數 x 2 pm.max_spare_servers = CPU 核心數 x 4 這個公式據説是基於單個 CPU 核心能併發處理多少進程的某種假設。我不確定是誰開始這麼搞的,這些乘數怎麼推導出來的,但有一點讓我覺得不對勁 —— 把 min_spare_servers 設得比 start_servers 低,如我上面解釋的,會導致 min_spare_servers 值永遠用不上。而且在我的測試中(稍後會講),用這個方法並沒看到明顯的性能提升。所以我覺得這個公式不該盲目照搬,要根據實際情況調整。

關於 max_children 和 dynamic 相關設置,最好的建議就是邊監控邊調優。每種情況都不一樣 —— 你的資源、負載、整體策略都不同。按照上面的指導原則,從合理的值開始,隨着應用增長和變化,不斷調整配置。

這一節還有個值得注意的選項:pm.max_requests。如果真有內存泄漏,這個設置可以讓子進程在處理一定數量請求後被回收。默認是 0,意味着進程不會因為這個選項被終止。合理的值可能是 500 或 1000 個請求,取決於你的場景。比如設置成 500,子進程處理了 500 個請求後會被終止(釋放累積的內存),然後重新生成。

實際測試 我決定做幾個性能測試來驗證理論:static 處理請求應該最快,因為不需要臨時 fork 子進程;dynamic 應該居中,因為部分進程已經在跑,部分需要按需 fork;ondemand 應該最慢,因為它不停地生成和終止進程。結果如下。

我用 ApacheBench 做測試,按照建議從另一台服務器發送請求,不是從被測服務器本身發的。被測服務器有 16 GB 內存和 4 個 CPU 核心,PHP-FPM 配合 NGINX,請求走 Laravel 應用。所有測試用例的 max_children 都是 80。我比較的是 90% 請求的響應時間。下面表格裏只列出不同 pm 選項之間的毫秒差異,0ms 是最快的。

測試命令示例:

ab -n 1000 -c 10 https://example.com 這個例子會發送 1000 個請求,併發級別是每次 10 個。

先看第一個測試。我想看看明顯達到 max_children 限制時,不同 pm 選項如何影響響應時間。所以發了 25000 個請求,併發級別 1000。

用 dynamic 時的額外值如下(後面簡稱 20/20/40):

pm.start_servers = 20 pm.min_spare_servers = 20 pm.max_spare_servers = 40 結果如下:

Static Dynamic On demand +1223ms +845ms 0ms 結果顯示,理論上應該最慢的 ondemand 實際上最快,而 static 出人意料地最慢,比 ondemand 慢了一秒多。

第二個測試發了 10000 個請求,併發級別 100。測試了兩種 dynamic 設置,一種是第一個測試的(20/20/40),另一種用基於 CPU 核心的公式(16/8/16):

Static Dynamic (20/20/40) Dynamic (16/8/16) On demand 0ms +14ms +2ms +24ms 這次 static 最快,基於公式的 dynamic 緊隨其後,ondemand 最慢,符合理論預期。但總的來説,對大多數網站而言,+24ms 算不上什麼性能提升,不同選項之間差異不大。

最後一個測試只發了 2000 個請求,併發級別 16。同樣用了兩種 dynamic 設置(20/20/40 和 16/8/16):

Static Dynamic (20/20/40) Dynamic (16/8/16) On demand +2ms +7ms +10ms 0ms 這個規模下 ondemand 再次獲勝,static 緊隨其後,基於公式的 dynamic 墊底。這次差異更小了,第一名和最後一名之間只差 10ms。

最終得到了一些意外結果。測試表明,當併發請求數量遠超 max_children 值,或者遠低於 max_children 值時,ondemand 是最佳選擇。當併發請求數量接近 max_children 值時,static 最佳。但要注意,第二個測試,特別是第三個測試中,響應時間差異相當小。而且如果重新跑這些測試,排名很可能會變。

所以這些結果不能當成鐵律,理論也是如此。我覺得在現代服務器上,fork 一個新子進程已經不是什麼昂貴操作了,不會明顯影響響應時間,至少在測試的規模上不會。這就是為什麼 ondemand 不該被輕易否定,即使在處理請求速度方面也是如此。

最好的前進方式是做你自己的測試,因為你的負載、設置和每個請求執行的操作可能完全不同,然後根據這些測試,應用看起來最高效的設置。

其他設置 還有幾個額外的設置我們應該瞭解一下,在 PHP-FPM 出問題或者你需要追蹤慢請求時可能會很有用。