Skip to content
BEE
Backend Engineering Essentials

[BEE-3005] 負載平衡器 ​

INFO

L4 vs L7 負載平衡、演算法、健康檢查、連線排除,以及高可用性。

背景 ​

每個流量超過單台伺服器負載能力的生產服務,前面都需要一個負載平衡器(load balancer)。但「負載平衡器」涵蓋了廣泛的設計空間:Layer 4 的封包轉發交換器、Layer 7 的完整 HTTP proxy、雲端託管的 ALB,或是 DNS-based round-robin 方案。選錯層級——或設定錯誤——會導致流量分配不均、已宕機的 backend 仍收到請求、部署時 session 資料遺失,以及一個自身就是單點故障(SPOF)的負載平衡器。

參考資料:

原則 ​

在正確的 OSI 層部署負載平衡器,在上線前設定好健康檢查與連線排除(connection draining),並消除負載平衡層自身的單點故障。

負載平衡器不只是流量分流器。它是故障隔離的第一道防線、client IP 元資料的持有者、TLS 終止的位置,通常也是 timeout 和重試預算的執行者。從一開始就做對,遠比事後補強 HA 或除錯 client 為何總落在同一個 backend 要便宜得多。

L4 vs L7 負載平衡 ​

最基本的選擇,是負載平衡器運作於哪個 OSI 層。

Layer 4 — 傳輸層負載平衡 ​

L4 負載平衡器根據 5-tuple(來源 IP、來源 port、目標 IP、目標 port、協定)轉發 TCP 或 UDP 連線。它不讀取 payload。每條 TCP 連線被 proxy 或 NAT 轉換到某個 backend,不檢查 HTTP method、URL 或 header。

特性:

  • 延遲:每跳約 50–100 µs(封包層轉發)
  • 吞吐量:每節點 10–40 Gbps 線速
  • 除非搭配獨立的 TLS offload 階段,否則無法做 TLS 檢查
  • 路由決策依據:IP 位址、port、協定
  • Backend 收到原始 TCP 串流;連線追蹤在網路層是有狀態的

適用時機:

  • 原始吞吐量是首要考量(資料庫、訊息佇列、遊戲伺服器)
  • 協定不是 HTTP(SMTP、原始 TCP、UDP-based 協定)
  • 需要最低延遲且不需要基於內容的路由

Layer 7 — 應用層負載平衡 ​

L7 負載平衡器終止來自 client 的 TCP 連線,讀取應用層請求(HTTP、gRPC、WebSocket),再開啟一條新連線到 backend。因為它能看到完整請求,可以根據任何請求屬性進行路由。

特性:

  • 延遲:每跳約 0.5–3 ms(需完整解析請求)
  • 吞吐量:每節點 1–5 Gbps
  • 可終止 TLS、檢查 HTTP header、改寫路徑、注入 header(例如 X-Forwarded-For)
  • 路由決策依據:URL path、Host header、HTTP method、cookie、gRPC service 名稱、query parameter
  • 支援 per-request 健康檢查、重試與熔斷

適用時機:

  • 受益於路徑或 header 路由的 HTTP/HTTPS 流量
  • A/B 測試、canary 部署、按比例分流
  • Backend 前的 TLS 終止(見 BEE-3004)
  • WebSocket upgrade 與 HTTP/2 多路複用(見 BEE-3003)

Mermaid 圖:L4 vs L7 檢查點 ​

比較表 ​

維度L4L7
OSI 層傳輸層(TCP/UDP)應用層(HTTP、gRPC)
延遲開銷~50–100 µs~0.5–3 ms
最大吞吐量非常高中等
TLS 終止否(或獨立階段)是
基於內容的路由否是
Header 注入否是(X-Forwarded-For 等)
連線模型直通或 NAT終止並重新發起
健康檢查精細度TCP 連線HTTP 狀態碼

負載平衡演算法 ​

Round Robin(輪詢) ​

每個新請求(L7)或連線(L4)依序轉發到下一個 backend。

Request 1 → Backend A
Request 2 → Backend B
Request 3 → Backend C
Request 4 → Backend A   (循環重複)
  • 簡單,無狀態
  • 適合所有 backend 容量相同且請求成本均勻的情況
  • 伺服器異質或請求時間差異大時效果不佳

Weighted Round Robin(加權輪詢) ​

每個 backend 被指定一個與其容量成比例的權重。weight=3 的 backend 每次接收三個請求,而 weight=1 的 backend 只接收一個。

nginx
upstream api_backends {
    server 10.0.0.1 weight=3;   # 處理 3 倍流量
    server 10.0.0.2 weight=1;
}

適用於 backend 硬體規格不同,或在 canary 部署期間漸進式轉移流量的場景。

Least Connections(最少連線) ​

下一個請求轉發到當下 active connection 最少的 backend。

Active connections: A=10, B=2, C=7
Next request → Backend B
  • 比 round robin 更適合請求時間差異大的工作負載(例如長時間的 streaming、資料庫查詢、WebSocket 連線)
  • 負載平衡器需要追蹤每個 backend 的連線數,增加少量狀態

IP Hash ​

client 的來源 IP 被雜湊,只要該 backend 可用,就會一致地對應到同一個 backend。

hash(client_ip) % num_backends = backend_index
  • 提供簡單的 session affinity(工作階段親和性):同一 client 總是落在同一個 backend
  • Backend 集合變更時(新增伺服器改變 % num_backends)會影響大量 client
  • 在大多數真正需要 affinity 的場景中,已被 consistent hashing 取代

Consistent Hashing(一致性雜湊) ​

雜湊函式將 backend 和請求 key(client IP、session ID、user ID)映射到一個概念環(ring)上。每個請求被路由到環上順時針方向最近的 backend。

關鍵特性: 新增或移除一個 backend 只會影響 1/N 的 key,而非重新映射所有 key。這使其成為快取叢集和有狀態服務的標準演算法。

Ring positions(簡化):
  Backend A: 0–120
  Backend B: 121–240
  Backend C: 241–360

hash(client_ip) = 180 → Backend B
hash(client_ip) = 350 → Backend C

當 Backend B 被移除,key 121–240 重新映射到 Backend C。A 和 C 服務的 key 不受影響。

演算法選擇指南 ​

工作負載建議演算法
同質伺服器、短請求Round Robin
伺服器容量不同Weighted Round Robin
長連線(WebSocket、streaming)Least Connections
快取叢集、有狀態路由Consistent Hashing
需要簡單 affinity、池穩定IP Hash

健康檢查 ​

為何健康檢查不可省略 ​

沒有健康檢查,負載平衡器無法知道 backend 已崩潰或失去回應。流量持續流向死掉的 backend,client 看到錯誤。健康檢查是負載平衡器自動將不健康 backend 移出輪換的機制。

被動健康檢查(Passive Health Check) ​

被動檢查從真實流量推斷健康狀態。如果負載平衡器收到連線錯誤、逾時或 5xx 回應,則計入失敗次數。在 fail_timeout 秒內發生 max_fails 次連續失敗後,將 backend 標記為不健康。

nginx
# NGINX 被動健康檢查設定
upstream api_backends {
    server 10.0.0.1 max_fails=3 fail_timeout=30s;
    server 10.0.0.2 max_fails=3 fail_timeout=30s;
    server 10.0.0.3 max_fails=3 fail_timeout=30s;
}
  • 不產生額外流量
  • 偵測延遲:在 backend 被移除前,前 max_fails 個真實請求會失敗
  • 在 NGINX open-source 版本中即可使用

主動健康檢查(Active Health Check) ​

主動檢查按排程向每個 backend 發送合成探測請求,獨立於真實流量。

nginx
# NGINX Plus 主動健康檢查
upstream api_backends {
    server 10.0.0.1;
    server 10.0.0.2;
    server 10.0.0.3;
}

server {
    location / {
        proxy_pass http://api_backends;
        health_check interval=10s fails=3 passes=2 uri=/healthz;
    }
}

此設定說明:

  • 每 10 秒對每個 backend 探測 /healthz
  • 連續 3 次失敗後標記為不健康
  • 需要連續 2 次通過才重新標記為健康(避免狀態震盪)

主動 vs 被動比較:

屬性被動主動
額外流量無有(探測請求)
偵測速度慢(需等真實失敗)快(持續探測)
準確性較低較高
可用性所有負載平衡器通常需付費版本

建議: 在生產環境使用主動健康檢查。探測流量的開銷相對於將真實請求路由到死亡 backend 的代價微不足道。

健康檢查 Endpoint 設計 ​

每個 backend 的 /healthz 或 /health endpoint 應該:

  • 只在程序完全準備好服務請求時回傳 200 OK
  • 若服務缺少依賴項就無意義,則檢查依賴項(資料庫連線、關鍵快取)
  • 快速回應(100 ms 以內);緩慢的健康檢查本身可能觸發誤判為不健康
  • 區分 liveness(程序存活)與 readiness(程序準備好服務)——Kubernetes 區分這兩者;負載平衡器通常使用 readiness

連線排除(Connection Draining) ​

Connection draining(也稱為 graceful shutdown,優雅關機)是將 backend 從輪換中移除而不丟棄進行中請求的過程。

沒有 Draining 的問題 ​

1. 部署:Backend A 需要重啟以發布新版本
2. LB 立即將 Backend A 從池中移除
3. 40 個進行中的 HTTP 請求收到 TCP RST
4. Client 看到 502 或連線錯誤

正確的順序 ​

1. 信號:Backend A 被標記為「draining」(軟移除出池)
2. LB 停止將新請求發送到 Backend A
3. Backend A 上現有連線的進行中請求繼續正常執行
4. Backend A 向其應用程式發送信號,停止接受新連線
5. 等待:所有進行中請求完成(或 drain timeout 到期)
6. Backend A 完全移除並關機

在 HAProxy 中,將 backend 的 weight 設為 0 可達到步驟 1 的效果:伺服器不參與新的負載平衡決策,但現有的持久連線(keep-alive)維持到自然關閉。

# HAProxy:透過 stats socket drain 一個 backend
echo "set weight be_api/10.0.0.1 0%" | socat stdio /var/run/haproxy/admin.sock

在 Kubernetes 中,preStop lifecycle hook 搭配 terminationGracePeriodSeconds 可達到相同效果:Pod 停止從 Service endpoint 收到新流量,等待 hook 時間後退出。

Drain Timeout ​

務必設定有限的 drain timeout。若進行中的請求在(例如)30 秒內未完成,強制終止連線。長時間的 WebSocket 或 streaming 連線可能需要更長或獨立的 drain 預算。

Session Affinity(工作階段親和性)/Sticky Sessions ​

什麼是 Sticky Sessions ​

Session affinity 將特定 client 的所有請求路由到同一個 backend。通常透過以下方式實作:

  • Cookie 注入:LB 設定 cookie(例如 SERVERID=backend_A);後續請求攜帶該 cookie 並被路由到 backend_A
  • IP hash:如演算法章節所述

為何應盡量避免 ​

Sticky sessions 是未正確外部化的伺服器端狀態的症狀。它帶來幾個問題:

  1. 不均勻的負載分配:若某個 client 產生 90% 的流量,sticky sessions 會將其固定在一個 backend,無論其他 backend 多閒置
  2. 故障時仍失去狀態:若固定的 backend 死亡,該 backend 上的所有 session 無論如何都會丟失——affinity 無法防止故障
  3. 部署複雜性:Rolling deployment 變得更複雜,因為在移除 backend 前需要排除 sticky sessions,否則接受 session 遺失
  4. 違背水平擴展目的:多個 backend 的意義在於任何 backend 都可以處理任何請求

首選替代方案: 將 session 狀態存儲在共享的外部存儲(Redis、資料庫)中,讓任何 backend 都能服務任何請求,使應用程式在負載平衡器後面真正無狀態。

Sticky sessions 可接受的情況:

  • 重構 session 存儲不可行的老舊應用程式
  • 協定要求與同一個 backend 保持持久連線的 WebSocket 連線(部署時需與 connection draining 配合)
  • session 遺失代價很低的極短暫 session

Client IP 保留:X-Forwarded-For ​

當 L7 負載平衡器終止 client 連線並向 backend 開啟新連線時,backend 看到的是負載平衡器的 IP,而非真實的 client IP。轉發原始 client IP 的標準機制是 X-Forwarded-For header(XFF)。

Client (1.2.3.4) → LB (10.0.0.100) → Backend

HTTP request to backend:
  X-Forwarded-For: 1.2.3.4
  X-Real-IP: 1.2.3.4

在 NGINX 中:

nginx
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP       $remote_addr;

XFF 安全考量 ​

如果負載平衡器盲目附加而不剝除不受信任的值,X-Forwarded-For 可能被 client 偽造。發送 X-Forwarded-For: 127.0.0.1 的 client 不應繞過基於 IP 的存取控制。

  • 設定負載平衡器取代(而非附加)XFF header,或在附加真實來源 IP 前剝除 client 提供的值
  • 只信任來自已知可信 proxy(你自己的負載平衡器)的 XFF,不信任任意上游跳躍的值
  • 在 AWS 使用 ALB 的 X-Forwarded-For;在 Cloudflare 使用 CF-Connecting-IP

高可用性:消除 LB 自身的單點故障 ​

單台負載平衡器本身就是單點故障。如果它宕機,無論 backend 多健康,所有流量都會停止。

Active-Passive 配對 ​

兩台負載平衡器共享一個虛擬 IP(VIP)。主節點處理所有流量;備節點監控主節點。若主節點故障,備節點晉升並透過 GARP(Gratuitous ARP)或 BGP 宣告 VIP。

VIP: 203.0.113.10
  ├── LB-Primary(active,持有 VIP)
  └── LB-Secondary(standby,透過 heartbeat 監控主節點)

主節點故障時:
  LB-Secondary 接管 VIP → 流量流向備節點

切換時間:基於 GARP 的 VIP 切換通常 1–5 秒。在此視窗期間,新的 TCP 連線會失敗;現有 TCP 連線能否存活取決於連線狀態是否共享(例如 Linux 中的 conntrack 同步)。

工具:Keepalived(VRRP)、Pacemaker/Corosync、雲端供應商 HA 選項(AWS NLB 本身即 HA;GCP forwarding rules 路由到 managed instance group)。

Active-Active 配對 ​

兩台負載平衡器同時處理流量,通常透過:

  • DNS round-robin:同一主機名有兩個 A 記錄分別指向 LB-1 和 LB-2
  • Anycast:兩個節點宣告相同的 BGP prefix;client 路由到最近的節點
  • ECMP(Equal-Cost Multipath):路由器將 flow 以雜湊方式分配到兩台 LB

Active-active 可倍增吞吐量並提供冗餘,但要求兩台 LB 對 backend 狀態有一致的視圖(健康檢查結果、least-connections 演算法的連線數)。

雲端託管負載平衡器 ​

雲端供應商(AWS ALB/NLB、GCP Load Balancing、Azure Load Balancer)在內部處理 HA。底層叢集是多節點且地理分散的。代價是以較少對低層級行為的控制換取操作簡便性。

Direct Server Return(DSR,直接伺服器回傳) ​

在標準 L4 負載平衡中,入站和出站流量都通過負載平衡器:

Client → LB → Backend → LB → Client   (對稱路徑)

在 Direct Server Return(DSR)中,backend 直接回應 client,繞過負載平衡器的回傳路徑:

Client → LB → Backend → Client   (非對稱路徑;回傳繞過 LB)

DSR 透過 LB 改寫目標 MAC 位址(而非 IP)來實作,使 backend 收到目標為 VIP 的封包,但直接透過自己的 NIC 回應 client 閘道。

優點: 回傳路徑——通常遠大於請求(例如大型檔案下載)——不通過 LB,消除瓶頸並大幅降低 LB CPU 負載。

限制:

  • 需要 L2 鄰近(LB 和 backend 在同一網段)或 tunneling(IP-in-IP、GRE)
  • 操作複雜;DSR 模式下無法使用 L7 功能(header 注入、TLS 終止)
  • 主要用於高吞吐量、低延遲場景(CDN origin serving、遊戲、金融交易)

常見錯誤 ​

1. 沒有健康檢查——將流量發送到死亡的 Backend ​

最常見也最具破壞性的錯誤。沒有健康檢查,backend 崩潰對負載平衡器是不可見的。所有後續請求到該 backend 都會失敗,直到有人手動將其從池中移除。

修正: 在上生產環境前設定主動健康檢查。在任何生產就緒審查中,將「未設定健康檢查」視為阻斷性問題。

2. Sticky Sessions 違背負載平衡的目的 ​

Sticky sessions 看起來像是解決有狀態性的方案,實際上只是推遲了問題。當固定的 backend 被移除(部署、故障、縮容)時,其上的所有 session 無論如何都會丟失,而且隨著 session 在一個節點上累積,流量分配會變得不均。

修正: 外部化 session 狀態。使用 Redis 或資料庫存儲 session。設計為任何 backend 都能處理任何請求。

3. 缺少 X-Forwarded-For——遺失 Client IP ​

Backend 將每個請求的 10.0.0.100(LB 位址)記錄為 client IP。速率限制、地理限制和稽核日誌全部失去意義。

修正: 在 NGINX 中設定 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;(或同等設定)。確認 backend 從 X-Forwarded-For 而非 REMOTE_ADDR 讀取 client IP。

4. 單台負載平衡器作為 SPOF ​

新增負載平衡以消除 backend SPOF,同時留下單台負載平衡器作為新 SPOF,是常見的架構錯誤。

修正: 部署 HA 配對(使用 Keepalived/VRRP 的 active-passive,或使用 DNS/anycast 的 active-active)。在雲端使用預設提供 HA 的託管 LB 產品。

5. 在 L4 足夠時使用 L7 ​

L7 負載平衡在每次跳躍時引入完整請求解析。對於不需要基於內容路由的非 HTTP 協定或純基於連線路由的場景,這種開銷是不必要的。

修正: 對不需要基於內容路由的 TCP 服務(資料庫 proxy、訊息佇列、原始 TCP 服務)使用 L4。將 L7 保留給受益於 header 路由、TLS 終止或 per-request 重試的 HTTP/gRPC 工作負載。

相關 BEE ​

  • BEE-3001 — TCP/IP 與網路堆疊:L4 負載平衡器在 TCP 層運作;理解 TCP 連線狀態(SYN、ESTABLISHED、TIME_WAIT)是推理 connection draining 和健康檢查行為的必要條件
  • BEE-3002 — DNS 解析:DNS-based 負載平衡(多個 A 記錄、GeoDNS)是互補技術;另見 DNS TTL 如何影響 client 偵測失敗 backend 的速度
  • BEE-3003 — HTTP/2 與 HTTP/3:HTTP/2 在一條 TCP 連線上多路複用多個 stream;L7 LB 必須理解多路複用,才能將一條連線的工作分配到多個 backend,而非將所有 stream 固定到一個 backend
  • BEE-3004 — TLS/SSL Handshake:L7 負載平衡器是標準的 TLS 終止點;見 TLS 終止拓撲(邊緣終止、重加密、直通)以及 SNI 如何在 TLS handshake 完成前就實現路由
  • BEE-3006 — Reverse Proxy:reverse proxy 和 L7 負載平衡器有大量重疊;BEE-3006 涵蓋支撐 L7 負載平衡行為的 proxy 語義、請求緩衝與上游連線池