漫談負載均衡演算法

spacewander發表於2023-02-12

負載均衡是個大話題,我們可以談談:

  • slow start 給新加入的節點分配較低權重,避免過載
  • priority 不同的可用區(AZ)有不同的優先順序,非當前可用區的節點只有在當前可用區節點不可用時才作為備份加入
  • subset 分組負載均衡,會先透過負載均衡演算法選擇一個組,再透過負載均衡演算法在組裡選擇具體的節點
  • retry 當負載均衡碰上重試時,需要考慮一些額外的情況。在重試時,通常我們需要選中另一個節點,而不是重新選中當前節點。此外,在重試完所有節點後,一般情況下我們不會再重試多一輪。

本文會關注以上各功能的基石部分 —— 負載均衡演算法。

Random

隨機負載均衡即隨機選取一個節點。由於該方式是無狀態的,所以是最容易實現的負載均衡。不過這是對於開發者的優點,不是使用者的。隨機負載均衡只保證了數學期望上的均衡,並不保證微觀尺度上是均衡的。有可能連續幾個請求都命中同一個節點,正如運氣不佳的人總會禍不單行一樣。黑天鵝事件是隨機負載均衡抹不去的陰影。我唯一推薦採用隨機負載均衡的場景,就是隻有隨機負載均衡作為唯一的選項。

RoundRobin

RoundRobin 指每個節點都會輪流被選中。對於所有節點權重都一樣的場景,實現 RoundRobin 並不難。只需記錄當前被選中的節點,然後下次選中它的下個節點即可。

對於權重不一樣的場景,則需要考慮如何讓選中的節點足夠均衡。假設有兩個節點 A、B,權重分別是 5、2。如果只是使用權重一致時簡單的 RoundRobin 實現,會得到下面的結果:

A B
A B
A
A
A

節點 A 最多會被連續選中 4 次(當前輪結尾時的 3 次加上下一輪時的 1 次)。考慮到 A、B 節點的權重比例是 2.5:1,這種連續選中 A 節點達 4 次的行為跟兩節點的權重比是不相稱的。

所以針對該場景,我們需要超越簡單的逐節點輪詢,讓不同權重的節點間儘可能在微觀層面上均衡。以前面的 A、B 節點為例,一個微觀層面上的均衡分配會是這樣的:

A
A B
A
A
A B

節點 A 最多被連續選中 3 次,跟權重比例 2.5:1 相差不大。

在實現帶權重的 RoundRobin 演算法的時候,請儘可能不要自己發明一套新演算法。帶權重的 RoundRobin 實現較為容易犯錯。可能會出現這樣的情況:在本地開發測試下沒問題,線上上跑一段時間也 OK,直到業務方輸入了一組特殊的值,然後不均衡就發生了。應當參考主流的實現,如果需要在主流實現上做調整,最好提供數學上的證明。

接下來讓我們看下主流的實現 —— Nginx 和 Envoy 是如何做的。

Nginx 的實現大體上是這樣子的:

  1. 每個節點有自己的一個當前得分。每次選擇時遍歷各個節點,給得分加上一個跟節點權重相關的值。
  2. 每次選擇分數最高的節點。
  3. 節點被選中時分數減去所有權重的和。

權重越高的節點,在減去分數後恢復得越快,也就越有可能被繼續選中。而且這裡存在一個恢復過程,所有保證了在下一次不太可能選中同一個節點。

因為這塊程式碼耦合了被動健康檢查的功能(存在多個 weight;effect_weight 需要根據 max_fails 做調整),所以較為複雜。由於 Nginx 的具體實現程式碼並非本文重點,感興趣的讀者可以自行查閱。

Envoy 的實現相對清晰些。它是基於 EDF 演算法 的簡化版本來做節點選擇。簡單來說,它採用了優先佇列來選擇當前最佳節點。對於每個節點,我們會記錄兩個值:

  • deadline 下一次需要取出節點的時機
  • last_popped_time 上一次取出此節點的時機

(Envoy 的具體實現程式碼與此有些出入。這裡採用 last_popped_time 而非 Envoy 中的 offset_order 是出於容易理解的目的)

再次以我們的 A、B 節點為例。

A、B 兩節點以 1/權重 作為各自的得分。演算法執行方式如下:

  1. 構造一個優先佇列,排序方法為先比較 deadline,前者相同時比較 last_popped_time。每個節點的初始值為各自的得分。
  2. 每次選擇,會從優先佇列中 pop 最新的值。
  3. 每次選中一個節點後,更新它的 last_popped_time 為選中時的 deadline,並往 deadline 中增加對應的得分,重新插入到佇列中。

每次選擇如下:

roundA deadlineB deadlineA last_popped_timeB last_popped_timeSelected
11/51/200A
22/51/21/50A
33/51/22/50B
43/512/51/2A
54/513/51/2A
6114/51/2B
76/514/51A

可以看出,在 EDF 演算法下,節點 A 最多被連續選中 3 次(當前迴圈結尾時的 1 次加上下一迴圈時的 2 次),跟權重比例 2.5:1 相差不大。另外與 Nginx 的演算法相比,在 EDF 下,選擇節點的時間複雜度主要是重新插入時的 O(logn),存在大量節點時會比逐個節點比較分數更快些。

Least Request

最少請求演算法通常又稱之為最小連線數演算法,這一別名來源於早期每個請求往往對應一個連線,且該演算法常常用於長連線的負載均衡當中。RoundRobin 演算法能夠保證發給各個節點的請求是均衡的,但是它並不保證當前節點上的請求數是均衡的,因為它不知道每個請求什麼時候結束。如果服務的負載與當前請求數緊密相關,比如在推送服務中希望每個節點管理的連線數要均衡,那麼一個理想的選擇就是使用最少請求演算法。另外如果請求耗時較長且長短不一,使用最少請求演算法也能保證每個節點上要準備處理的請求數均衡,避免長時間排隊。對於這種情況,也適合採用後文提到的 EWMA 演算法。

要想實現最少請求演算法,我們需要記錄每個節點的當前請求數。一個請求進來時加一,請求結束時減一。對於所有節點權重都一樣的情況,靠 O(n) 的遍歷可以找出最少請求的節點。我們還可以再最佳化下。透過 P2C 演算法,我們可以每次隨機選擇兩個節點,以 O(1) 的時間複雜度來達到近似於 O(n) 的遍歷的效果。事實上,滿足下麵條件的情況,都能用 P2C 演算法來最佳化時間複雜度:

  1. 每個節點有一個分數
  2. 所有節點權重一致

所以有些框架會直接抽象出一個 p2c 的中介軟體作為通用能力。

涉及到各節點權重不一的情況,就沒辦法用 P2C 演算法了。我們可以把權重根據當前請求數做一下調整,變成 weight / (1 + 請求數)。一個節點得到請求數越多,那麼當前權重就會相對應地減少。比如一個權重為 2 的節點,當前有 3 個請求,那麼調整之後的權重為 1/2。如果又來了一個新的請求,那麼權重就變成了 2/5。透過動態調整權重,我們就能讓帶權重的最少請求變成帶權重的 RoundRobin,進而使用遍歷或優先佇列來處理它。

Hash

有些時候,需要保證客戶端訪問到固定的服務端。比如要求代理同一個 Session 的客戶端的請求到同一個節點,或者根據客戶端 IP 路由到固定節點。這時候我們需要採用 Hash 演算法來把客戶端的特徵對映到某個節點上來。不過簡單的 Hash 會有一個問題,如果節點數改變了,會放大影響到的請求數目。

假設這個簡單的 Hash 就是以節點數來取餘,請求是 1 到 10 這幾個數。節點數一開始為 4,隨後變成 3。那麼結果是:

1: 0 1 2 3 0 1 2 3 0 1
2: 0 1 2 0 1 2 0 1 2 0

我們可以看到,70% 的請求對應的節點都變化了,遠大於 25% 的節點數變化。

所以實踐中我們更多的是採用 Consistent Hash,如果沒有才會考慮一般的 Hash 演算法。

Consistent Hash

一致性 Hash 是專門為減少重新 Hash 時結果發生大幅改變而設計的演算法。在前面的 Hash 演算法中,由於 Hash 的結果與節點數強相關,所以一旦節點數發生改變,Hash 結果就會劇烈變化。那麼我們能不能讓 Hash 結果與節點數無關呢?一致性 Hash 為我們提供了新的思路。

最常見的一致性 Hash 演算法是 ring hash。也即把整個 Hash 空間看作一個環,然後每個節點透過 Hash 演算法對映到環上的一個點,每個請求會算出一個 Hash 值,根據 Hash 值找順時針方向最近的一個節點。這樣一來,請求的 Hash 值和節點的數量就沒有關係了。環上節點變更時,請求的 Hash 值不會變,變的只是離它最近的節點可能不一樣。

讀者也許會提出這樣的問題,如果節點的位置取決於 Hash 的值,那麼如何保證它是均衡分配呢?我在之前的文章《漫談非加密雜湊演算法》提到過,Hash 演算法在設計時會考慮到降低碰撞的可能性。一個高質量的演算法,應當儘可能分散 Hash 對映後的結果。當然,如果只是對有限的幾個節點做 Hash,那麼難免會出現結果分得不夠開的情況。所以一致性 Hash 中引入了虛擬節點的概念。每個真實的節點會對應 N 個虛擬節點,比如說 100 個。每個虛擬節點的 Hash 值由類似於 Hash(node + "_" + virtual_node_id) 這樣的演算法得到。這樣一個真實節點,就會對應 Hash 環上 N 個虛擬節點。從統計學的角度上看,我們可以認為只要 N 的值足夠大,節點間距離的標準差就越小,節點在環上的分佈就越均衡。

然而 N 並不能無限變大。即使是環上的虛擬節點,也需要真實的記憶體地址來記錄其位置。N 取得越大,節點就越均衡,但消耗的記憶體就越多。Maglev 演算法是另外一種一致性 Hash 演算法,旨在最佳化記憶體佔用。該演算法由於採用了別的資料結構,能夠在保證同樣的均衡性時使用更少的記憶體。(抑或在使用同樣的記憶體時提供更好的均衡性,取決於你固定那一個變數)。

EWMA

EWMA(Exponential Weighted Moving Average)演算法是一種利用響應時間進行負載均衡的演算法。正如其名,它的計算過程就是“指數加權移動平均”。

假設當前響應時間為 R,距離上次訪問的時間為 delta_time,上次訪問時的得分為 S1,那麼當前得分 S2 為:
S2 = S1 * weight + R * (1.0 - weight),其中 weight = e ^ -delta_time/k。k 是演算法中事先固定的常量。

它是指數加權的:上次訪問距離現在的時間越長,對當前得分的影響越小。
它是移動的:當前得分從上次得分調整過來。
它是平均的:假如 delta_time 足夠大,weight 就足夠小,得分接近當前響應時間;假如 delta_time 足夠小,weight 就足夠大,得分接近上次得分。總體來說,得分是歷次響應時間透過調整得來的。

細心的讀者會問,既然 weight 是由 delta_time 算出來的,那麼使用者在配置時指定的權重該放到哪個位置呢?EWMA 是一種自適應的演算法,能夠按照上游的狀態動態調整。如果你發現你需要配置權重,那麼你的場景就不適用於使用 EWMA。事實上,由於 EWMA 演算法不用操心權重,許多人會考慮把它作為缺乏 slow start 功能時的替代品。

但是 EWMA 並非萬靈藥。由於 EWMA 是基於響應時間的演算法,如果上游響應時間和上游狀態沒多大關係時,就不適用 EWMA。比如前面介紹最少請求演算法時提到的推送場景,響應時間取決於推送的策略,這時採用 EWMA 就不般配了。

另外 EWMA 演算法有個固有缺陷 —— 響應時間不一定反映了問題的全貌。設想一個場景,上游有個節點不斷快速丟擲 500 錯誤。在 EWMA 演算法看來,這個節點反而是個優秀節點,畢竟它有著無與倫比的響應時間。結果大部分流量就會打到這個節點上。所以當你採用 EWMA 時,務必同時開啟健康檢查,及時摘掉有問題的節點。不過有些時候,一個看上去不屬於節點問題的狀態碼也可能導致流量不均衡。舉個例子,某次灰度升級時,新版本增加了一個錯誤的校驗,會把生產環境上部分正確的請求給拒絕掉(返回 400 狀態碼)。由於 EWMA 會傾向於響應更快的節點,會導致更多的請求落入這個有問題的版本上。

除了 EWMA 之外,也有其他基於響應時間的演算法,比如 Dubbo 的加權最短響應優先

相關文章