在本文中,瞭解高階容量估計和工作負載最佳化所需的排隊理論基礎知識。
到處都是排隊!
- Java 的 fork-join 池使用具有工作竊取機制的多個佇列。
- 相比之下,所有老式的Java 執行緒池預設都使用無界佇列——導致延遲和記憶體問題。
- Resilience4J 有許多涉及排隊的實現,例如 bulkheads.
- 所有常見的釋出-訂閱代理都涉及佇列的使用——包括 Kafka、RabbitMQ 等。Redis也提供釋出-訂閱機制。
- 當然,一般的作業系統都有多個內建的工作佇列,包括CPU執行佇列,磁碟IO佇列,網路介面有環形緩衝區等等。
佇列是當今軟體中無處不在的內建機制。不熟悉佇列理論的基礎知識將阻礙您理解延遲和吞吐量之間的關係、高階容量估計和工作負載最佳化。瞭解佇列模型的內部原理其實並不難掌握。在本文中,我將總結軟體工程師在其領域更有效率所需的基本知識。
佇列術語說明
- 到達率:(lambda)——新工作項到達佇列的速率。
- 等待時間:(W)——系統中每個工作項花費的總時間。通常由兩個元素組成。在佇列中花費的時間(由前面的工作項決定)和服務花費的時間。
- 伺服器:(c)——指並行化級別。
- 服務時間:(tau=1/mu)— 處理單個工作項所需的時間。它通常被翻譯成速率,也被稱為出發率。
- 出發率:(mu)— 物品的處理率。
- 利用率:(rho=lambda/mu)——簡單描述到達率和離開率之間的比率。
- 正在服務的專案數 (L):系統中的元素總數,包括正在處理和在佇列中等待的元素。
在軟體工程中,我們在談論性能時使用的術語略有不同。您可以找到排隊理論中使用的符號與其對應符號之間的對映:
- 到達率= 吞吐量/負載: 根據上下文區分兩者是很好的。到達率通常稱為負載、平均負載
- 等待時間=延遲: 他們都沒有區分排隊的等待時間和因被服務而等待的時間。
- 伺服器 =執行器/處理器: 我們主要指的是 CPU/worker 的數量
- 服務時間 =執行時間/持續時間 : 以完成一項任務所需的時間衡量。通常與延遲混合。
- 利用率 = 利用率: 實際上,利用率的計算方法多種多樣。通常,利用率是指給定時間視窗(例如 5 秒)內未使用的 CPU 時鐘週期與已使用的 CPU 時鐘週期之比。
- 佇列長度 =飽和: 它們通常表示同一件事:系統必須應對的額外工作量是多少?“飽和”一詞最流行的用法來自Google SRE 書中提到的四個黃金訊號。
簡單用例
1、順序
現在,讓我們設想一個類似上面的簡單佇列。
佇列中有 8 個專案,沒有新專案到達,執行時間為 100 毫秒。
吞吐量和延遲是多少?
我們可以在每 100 毫秒內生成一個佇列訊息,這樣就有 10 個/秒。
延遲時間的確定比較麻煩。第一個專案的延遲時間為 100 毫秒。最後一個專案需要處理之前的所有專案,因此其延遲時間為 8 * 100 毫秒。
因此,延遲時間最小為 100 毫秒,最大為 800 毫秒,平均為 450 毫秒。
2、並行Parallel
讓我們嘗試增加執行器的數量。如果我們再新增一個,之前的用例會有什麼變化?
現在,我們每100 毫秒可以生成 2 個工作項,因此吞吐量翻倍至20/s 。處理第一個專案需要100 毫秒,處理最後一個專案需要400 毫秒(我們可以在每個100 毫秒週期內處理 2 個專案)。因此,最小延遲為100 毫秒,最大延遲為400 毫秒,平均值為250 毫秒。
一個立即需要注意的重要觀察是,我們所經歷的延遲取決於我們在佇列中的位置:如果佇列為空或幾乎為空 — —擴充套件對延遲幾乎沒有影響。正如我們將在接下來的部分中看到的那樣,排隊的工作項數量增加也有其負面影響。
3、管道Pipeline
如果我們以不同於上例的方式劃分工作,情況會有什麼變化?
假設我們現在有兩個佇列透過兩個執行器相互連線。總體執行時間將與以前相同。劃分工作會對延遲/吞吐量產生影響嗎?
現在,我們每50 毫秒可以生成 1 個工作項,吞吐量翻倍至20 /s。第一個工作項仍需要100毫秒才能透過,但最後一個工作項只需要450 毫秒。想象一下,在第一個工作項處理完畢後,我們將每50 毫秒看到一個新的佇列訊息,直到佇列為空。最小延遲為100 毫秒,最大延遲為450 毫秒,平均值為275 毫秒。
這就是反應庫的強大之處。即使你可能認為你編寫了順序程式碼,但它並不是按原樣執行的。只有你的因果關係宣告是順序的。你宣告瞭一個執行管道,它將你的整體工作負載分成更小的部分並並行執行。
我可以舉出很多例子,比如 Kotlin 協程、Java 可完成的未來、ReactiveX 庫、Akka 等。
上面的觀察仍然是正確的:如果我們的佇列只有幾個元素,延遲就不會改變
從上面的延遲數字可以看出,最小值不會過多地暴露佇列長度。另一方面,最大延遲可以指示飽和度:它告訴您整個系統在佇列上花費了多少時間。
如果您對執行持續時間不太瞭解,那麼瞭解黑盒系統飽和度的一種方法是關注:
- 最大延遲與最小延遲
或
- p99(延遲) — 平均延遲
高階用例
為了能夠研究更高階的用例,我們正在改變思路。我將在接下來的幾個部分中使用這個簡單的建模庫。
穩定和不穩定的情況
如果利用率高於100%會發生什麼?在這些情況下,到達率大於我們的整體吞吐量。它如何影響我們的延遲?我們的到達間隔(100ms )將比我們的執行間隔( 120ms )略小。
import matplotlib.pyplot as plt |
執行結果:
- 佇列長度顯示元素數量呈線性增長。達到 SAMPLE_SIZE 後,到達率降為零,佇列長度開始線性遞減。時間視窗等於處理所有資訊所需的總時間:執行時間 * SAMPLE_SIZE。
- 另外我們的延遲在持續增加。
如果到達率大於吞吐量,系統就必須採取行動。您有兩種選擇:
- 縮減執行器:還記得我之前說過的透過擴大規模來減少延遲嗎?增加執行器數量將改善延遲,直到利用率達到 100%(例如,您的系統趨於穩定)。在此之前,擴大規模無法改善延遲。
- 使用速率限制和拒絕新到達:實際上,可以透過限制佇列大小來實現這一點。
擴大規模
讓我們看看在這種情況下,當我們擴充套件執行器的數量時會發生什麼:
from ipywidgets import interactive |
當擴充套件到一定程度,利用率趨於穩定時,飽和現象就會消除。延遲與執行持續時間相關,增加更多執行器也不會帶來更多收益。在這種情況下,延遲會有一些波動,但主要由執行時長決定。
這就是為什麼在沒有佇列大小指標的情況下,p99(延遲) - avg(latency) 是飽和度的良好指標。
容量規劃:從 DAU 到吞吐量
我們如何使用排隊模型來預測工作負載?這項任務的複雜性源於到達分佈的未知因素:我應該如何對使用者行為進行建模?因為我們系統的整體使用情況並不恆定。通常週末或下班時間是高峰期。如果我們觀察傳入的請求,它們通常會形成波浪狀模式,峰值負載可能是平均值的兩倍。更糟糕的是——兩側的陡度通常不同。這就是利特爾定律發揮作用的地方。
好的,現在讓我們假設我們有 100,000 DAU。我們如何將其轉換為任何有意義的吞吐量?假設思考時間為z且平均延遲為r,則每個請求需要的總延遲為z+r。通常,在邊緣,r可以從標準Web 效能指標中得出。一般來說,它應該小於2 秒。估計的思考時間大約是該數字的兩倍,因此4 秒是一個很好的“估計”,這給了我們最小延遲z+r = 6 秒。如果每個“使用者”均勻分佈在 24 小時內,那麼每分鐘的視窗將為我們提供L = 70 個使用者。吞吐量應該與我們的到達率相匹配,因此我們需要利特爾定律來了解如何根據給定的數字計算到達率。
利特爾定律
利特爾定律簡單地說就是:L = lambda * W。
這意味著在上述情況下,lambda = L / W,或者在我們的例子中,lambda = 70/(6*60)。因此,我們預計該系統的平均運算元約為0.2 ops/min 。
注意:始終使用真實數字驗證並重新評估您的估計,以便對模型的準確性有一個整體的印象。