本指南假設您正在執行 MRI,這是 Ruby 的標準實作,也稱為 CRuby。如果您正在使用另一個 Ruby 實作,例如 JRuby 或 TruffleRuby,本指南的大部分內容不適用。如果需要,請檢查特定於您的 Ruby 實作的來源。
1 選擇應用程式伺服器
Puma 是 Rails 的預設應用程式伺服器,也是社群中最常用的伺服器。它在大多數情況下都運作良好。在某些情況下,您可能希望變更為另一個伺服器。
應用程式伺服器使用特定的並行方法。例如,Unicorn 使用程序,Puma 和 Passenger 是混合程序和基於執行緒的並行,而 Falcon 使用纖程。
全面討論 Ruby 的並行方法超出本文檔的範圍,但將介紹程序和執行緒之間的主要權衡。如果您想使用程序和執行緒以外的方法,您將需要使用不同的應用程式伺服器。
本指南將重點介紹如何調整 Puma。
2 要最佳化什麼?
本質上,調整 Ruby Web 伺服器是在記憶體使用量、吞吐量和延遲等多個屬性之間進行權衡。
吞吐量是伺服器每秒可以處理的請求數量的度量,而延遲是單個請求花費的時間長度(也稱為回應時間)的度量。
有些使用者可能希望最大化吞吐量以保持較低的託管成本,有些其他使用者可能希望最小化延遲以提供最佳的使用者體驗,而許多使用者將在中間的某處尋找一些折衷方案。
重要的是要了解,針對一個屬性進行最佳化通常至少會損害另一個屬性。
2.1 了解 Ruby 的並行性和平行性
CRuby 有一個全域解釋器鎖,通常稱為 GVL 或 GIL。GVL 會阻止多個執行緒在單個程序中同時執行 Ruby 程式碼。多個執行緒可以等待網路資料、資料庫操作或其他非 Ruby 工作,通常稱為 I/O 操作,但一次只能有一個執行緒主動執行 Ruby 程式碼。
這表示基於執行緒的並行性可以透過在 Web 請求執行 I/O 操作時並行處理來提高吞吐量,但每當 I/O 操作完成時,可能會降低延遲。執行它的執行緒可能必須等待才能繼續執行 Ruby 程式碼。同樣,Ruby 的垃圾收集器是「停止所有」,因此當它觸發時,所有執行緒都必須停止。
這也表示無論 Ruby 程序包含多少個執行緒,它永遠不會使用超過單個 CPU 核心。
因此,如果您的應用程式只有 50% 的時間在執行 I/O 操作,則每個程序使用超過 2 或 3 個執行緒可能會嚴重損害延遲,並且吞吐量的增加將很快達到邊際效益遞減。
一般而言,設計良好的 Rails 應用程式如果沒有遇到慢速 SQL 查詢或 N+1 問題,則執行 I/O 操作的時間不會超過 50%,因此不太可能從 3 個以上的執行緒中受益。但是,某些應用程式確實會內聯呼叫第三方 API,可能會花費大量時間執行 I/O 操作,並可能從更多執行緒中受益。
使用 Ruby 實現真正平行性的方法是使用多個程序。只要有空閒的 CPU 核心,Ruby 程序就不必在 I/O 操作完成後恢復執行之前互相等待。但是,程序僅透過寫入時複製共享一小部分記憶體,因此一個額外的程序會比額外的執行緒使用更多的記憶體。
請注意,雖然執行緒比程序便宜,但它們並非免費,並且增加每個程序的執行緒數量也會增加記憶體使用量。
2.2 實際影響
有興趣針對吞吐量和伺服器利用率進行最佳化的使用者,會希望每個 CPU 核心執行一個程序,並增加每個程序的執行緒數量,直到延遲的影響被認為太重要。
有興趣針對延遲進行最佳化的使用者,會希望保持每個程序的執行緒數量較低。為了進一步最佳化延遲,使用者甚至可以將每個程序的執行緒計數設定為 1
,並針對程序處於閒置等待 I/O 操作時,每個 CPU 核心執行 1.5
或 1.3
個程序。
重要的是要注意,某些託管解決方案可能只為每個 CPU 核心提供相對較小的記憶體 (RAM),導致您無法執行足夠的程序來使用所有 CPU 核心。但是,大多數託管解決方案都有不同的方案,其中記憶體和 CPU 的比例不同。
另一個需要考慮的是,由於寫入時複製,Ruby 記憶體使用量受益於規模經濟。因此,2
台伺服器,每台伺服器有 32
個 Ruby 程序,將比 16
台伺服器,每台伺服器有 4
個 Ruby 程序,每個 CPU 核心使用的記憶體少。
3 配置
3.1 Puma
Puma 配置位於 config/puma.rb
檔案中。兩個最重要的 Puma 配置是每個程序的執行緒數量和程序的數量,Puma 稱之為 workers
。
每個程序的執行緒數量是透過 thread
指令配置的。在預設產生的配置中,它設定為 3
。您可以透過設定 RAILS_MAX_THREADS
環境變數或直接編輯設定檔來修改它。
程序數量由 workers
指令設定。如果每個程序使用超過一個執行緒,則應設定為伺服器上可用的 CPU 核心數量,或者如果伺服器正在運行多個應用程式,則設定為您希望該應用程式使用的核心數量。如果每個 worker 只使用一個執行緒,則可以將其增加到每個程序一個以上,以考慮 worker 在等待 I/O 操作時處於閒置狀態的情況。
您可以通過設定 WEB_CONCURRENCY
環境變數來配置 Puma worker 的數量。
3.2 YJIT
最近的 Ruby 版本帶有一個名為 即時編譯器 的 YJIT
。
在不深入太多細節的情況下,JIT 編譯器可以加快程式碼的執行速度,但會消耗更多記憶體。除非您真的無法負擔額外的記憶體使用量,否則強烈建議啟用 YJIT。
對於 Rails 7.2,如果您的應用程式在 Ruby 3.3 或更高版本上執行,Rails 預設會自動啟用 YJIT。較舊版本的 Rails 或 Ruby 必須手動啟用它,請參閱 YJIT 文件
了解如何操作。
如果額外的記憶體使用量是一個問題,在完全禁用 YJIT 之前,您可以嘗試通過 --yjit-exec-mem-size
配置調整它以使用較少的記憶體。
3.3 記憶體分配器和配置
由於大多數 Linux 發行版上預設記憶體分配器的工作方式,使用多個執行緒運行 Puma 可能會導致 記憶體碎片 導致記憶體使用量意外增加。反過來,這種增加的記憶體使用量可能會阻止您的應用程式充分利用伺服器 CPU 核心。
為了緩解這個問題,強烈建議配置 Ruby 以使用替代記憶體分配器:jemalloc。
Rails 生成的預設 Dockerfile 已經預先配置為安裝和使用 jemalloc
。但是,如果您的託管解決方案不是基於 Docker 的,您應該研究如何在其中安裝和啟用 jemalloc。
如果由於某些原因無法做到這一點,一個效率較低的替代方案是在環境中設定 MALLOC_ARENA_MAX=2
,以減少記憶體碎片的方式配置預設分配器。但請注意,這可能會使 Ruby 變慢,因此 jemalloc
是首選解決方案。
4 效能測試
由於每個 Rails 應用程式都不同,並且每個 Rails 使用者可能想要針對不同的屬性進行最佳化,因此不可能提供對每個人都最有效的預設配置或指南。
因此,選擇應用程式設定的最佳方法是測量應用程式的效能,並調整配置,直到它滿足您的目標。
這可以使用模擬生產負載來完成,也可以直接在生產環境中使用即時應用程式流量來完成。
效能測試是一個深入的主題。本指南僅提供簡單的指南。
4.1 測量什麼
吞吐量是您的應用程式每秒成功處理的請求數量。任何良好的負載測試程式都會測量它。吞吐量通常是以「每秒請求數」表示的單一數字。
延遲是從請求發送的時間到成功接收到回應的時間之間的延遲,通常以毫秒表示。每個單獨的請求都會有自己的延遲。
百分位數延遲給出了特定百分比的請求具有比其更好的延遲。例如,P90
是第 90 百分位的延遲。P90
是單次負載測試的延遲,其中只有 10% 的請求處理時間超過此時間。P50
是延遲,使得一半的請求速度較慢,也稱為中位數延遲。
「尾部延遲」指的是高百分位的延遲。例如,P99
是延遲,使得只有 1% 的請求更差。P99
是尾部延遲。P50
不是尾部延遲。
一般來說,平均延遲不是一個好的最佳化指標。最好關注中位數(P50
)和尾部(P95
或 P99
)延遲。
4.2 生產環境測量
如果您的生產環境包含多個伺服器,那麼在那裡進行 A/B 測試 可能是一個好主意。例如,您可以讓一半的伺服器以每個程序 3
個執行緒運行,另一半以每個程序 4
個執行緒運行,然後使用應用程式效能監控服務來比較兩組的吞吐量和延遲。
應用程式效能監控服務有很多,有些是自託管的,有些是雲端解決方案,許多都提供免費方案。推薦特定的服務超出本指南的範圍。
4.3 負載測試器
您將需要一個負載測試程式來對您的應用程式發出請求。這可以是某種專用的負載測試程式,也可以編寫一個小應用程式來發出 HTTP 請求並追蹤它們所花費的時間。您通常不應該檢查 Rails 日誌檔案中的時間。該時間僅是 Rails 處理請求所花費的時間。它不包括應用程式伺服器所花費的時間。
發送許多同時請求並對它們進行計時可能很困難。很容易引入微妙的測量錯誤。通常您應該使用負載測試程式,而不是自己編寫。許多負載測試器都很容易使用,而且許多優秀的負載測試器都是免費的。
4.4 您可以更改的內容
您可以更改測試中的執行緒數量,以找到應用程式吞吐量和延遲之間的最佳權衡。
具有更多記憶體和 CPU 核心的較大主機將需要更多程序才能獲得最佳使用率。您可以更改託管供應商提供的主機大小和類型。
增加迭代次數通常會給出更精確的答案,但需要更長的測試時間。
您應該在與生產環境中運行的相同類型的主機上進行測試。在您的開發機器上進行測試只會告訴您哪些設定最適合該開發機器。
4.5 預熱
您的應用程式在啟動後應處理許多請求,這些請求不包括在您的最終測量中。這些請求稱為「預熱」請求,通常比稍後的「穩態」請求慢得多。
您的負載測試程式通常會支援預熱請求。您也可以多次運行它並丟棄第一組時間。
當增加數量不會顯著改變您的結果時,您就有了足夠的預熱請求。這背後的理論可能很複雜,但大多數常見情況都很簡單:使用不同的預熱量進行多次測試。看看在結果大致保持相同之前需要多少預熱迭代。
非常長的預熱對於測試記憶體碎片和其他僅在許多請求之後才會發生的問題很有用。
4.6 哪些請求
您的應用程式可能接受許多不同的 HTTP 請求。您應該首先只使用其中幾個進行負載測試。您可以隨著時間的推移添加更多種類的請求。如果您的生產應用程式中某種特定類型的請求太慢,您可以將其添加到您的負載測試程式碼中。
合成負載無法完全匹配應用程式的生產流量。它仍然有助於測試配置。
4.7 要尋找的內容
您的負載測試程式應該允許您檢查延遲,包括百分位數和尾部延遲。
對於不同數量的程序和執行緒,或一般而言的不同配置,請檢查吞吐量和一個或多個延遲,例如 P50
、P90
和 P99
。增加執行緒將在一定程度上提高吞吐量,但會使延遲惡化。
根據應用程式的需求,選擇延遲和吞吐量之間的權衡。