設計負載均衡器 — Round Robin、一致性雜湊與健康檢查實戰 | 資料結構與演算法
負載均衡器(Load Balancer) 是分散式系統的核心基礎設施——從 Nginx 反向代理、AWS ALB 雲端負載均衡、到 Kubernetes 的 Service 路由,背後都是負載均衡演算法在將流量智慧分配至後端伺服器池。本文作為「資料結構與演算法」系列的 最終篇(第 047 篇),將從需求分析出發,完整比較 Round Robin、加權輪詢、最少連線、一致性雜湊(Consistent Hashing) 與 IP Hash 五種演算法,搭配 JavaScript/TypeScript 與 C++ 雙語言完整實作與健康檢查機制,帶你徹底掌握負載均衡器的設計與實戰。
前言
在上一篇文章中,我們實作了
文本差異比對系統(Text Diff),學會如何用 Myers Diff 演算法與 Unified Diff 格式化器打造程式碼審查工具。今天我們來到本系列的最終站——負載均衡器(Load Balancer)。
想像你的電商網站在雙十一促銷時,瞬間湧入每秒五萬次請求。如果所有請求都打到同一台伺服器,那台機器會在幾秒內被壓垮;如果有 20 台伺服器卻沒有合理分配流量,可能某幾台過載當機、其餘閒置浪費資源。負載均衡器 就是解決這個問題的核心元件——它是後端服務的「交通警察」,智慧地將每一筆請求導向最適合處理的伺服器。
這個主題完美收束了我們 47 篇文章的學習之旅:負載均衡器運用了我們在
第 005 篇雜湊表中學過的雜湊函數、在
第 016 篇進階雜湊中深入的一致性雜湊、在
第 008 篇二分搜尋中掌握的 O(log N) 查找、以及在
第 006 篇堆積中學過的優先權佇列——這正是整個系列融會貫通的絕佳實戰。
本文你將學到:
- 負載均衡器的完整需求分析與規模估算
- 五種核心負載均衡演算法的原理、優缺點與適用場景
- 完整可運行的 JavaScript/TypeScript 與 C++ 雙語言實作(含健康檢查機制)
- 生產環境考量:L4 vs L7、Sticky Sessions、Circuit Breaker、DNS-based LB
- 4 道 LeetCode 相關練習題
1. 需求分析
功能性需求(Functional Requirements)
- 流量分配:將 HTTP/TCP 請求分散至多台後端伺服器,避免單點過載
- 健康檢查:定期探測後端節點存活狀態,自動剔除故障節點、恢復復原節點
- Session Affinity:支援同一用戶的請求路由至同一後端(Sticky Sessions)
- 動態擴縮容:後端節點增減時,最小化流量重新分配
- 權重分配:依節點容量(CPU / 記憶體)配置不同流量比例
- 路由策略可切換:支援多種負載均衡演算法,可依場景選擇
非功能性需求(Non-Functional Requirements)
| 需求 | 目標 | 說明 |
|---|---|---|
| 低延遲 | 單次路由決策 < 0.1ms | 不成為請求的效能瓶頸 |
| 高吞吐 | > 100 萬 RPS | 單台 LB(Nginx/HAProxy 等級) |
| 高可用 | 99.999%(五個九) | LB 本身不能是單點故障 |
| 故障偵測 | < 5 秒 | 節點故障後快速從路由池移除 |
| 記憶體效率 | < 1 MB | 路由狀態佔用極輕量 |
規模估算
以中型電商平台為參考場景:
場景:中型電商平台負載均衡
用戶數:1,000 萬活躍用戶
尖峰並發請求:50,000 RPS
後端伺服器:20 台(每台最大處理 5,000 RPS)
健康檢查開銷:
- 20 台節點 × 每 5 秒一次 = 4 次/秒健康探測
- 健康探測封包:< 1KB(HTTP HEAD 請求)
- 開銷可忽略不計
路由決策記憶體(Consistent Hashing):
- 20 節點 × 150 虛擬節點 = 3,000 個雜湊點
- 每個雜湊點:8 bytes(uint32 hash)+ 指標 = ~16 bytes
- 總計:3,000 × 16 bytes ≈ 48 KB(極輕量)
連線狀態追蹤(Least Connections):
- 20 台節點 × 8 bytes(int64 計數器)= 160 bytes
- 原子操作更新,無鎖競爭
水平擴展能力:
- 單台 LB(Nginx/HAProxy):~100 萬 RPS
- 多台 LB + DNS 輪詢或 Anycast:線性擴展至億級 RPS
結論:負載均衡器的路由決策本身極為輕量——48 KB 記憶體、亞毫秒延遲。核心挑戰在於 健康檢查的即時性、節點增減時的流量穩定性 與 LB 本身的高可用架構。
2. 方案設計——五種負載均衡演算法
2.1 演算法全景與比較
| 演算法 | 原理 | 時間複雜度 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|---|---|
| Round Robin | 依序循環分配 | O(1) | 最簡單,零狀態 | 不考慮節點負載差異 | 節點同質化、請求均勻 |
| Weighted Round Robin | 按權重比例循環 | O(1) | 支援異質節點 | 仍不考慮即時負載 | 節點規格不同 |
| Least Connections | 選當前連線數最少的節點 | O(N) | 即時負載感知 | 需維護全局狀態 | 長連線(WebSocket、DB) |
| IP Hash | hash(client_ip) % N | O(1) | Session Affinity | 節點增減時全部重映射 | 有狀態服務(傳統方案) |
| Consistent Hashing | 雜湊環 + 虛擬節點 | O(log V) | 節點變化只影響 1/N 流量 | 實作較複雜 | 快取服務、微服務路由 |
V = 虛擬節點總數,N = 伺服器數量
2.2 Round Robin(輪詢)
最簡單的負載均衡演算法——維護一個遞增索引,依序將請求分配給每台伺服器。
請求序列: #1 #2 #3 #4 #5 #6 #7 #8 #9
分配結果: A B C A B C A B C
↻ 循環分配,每台伺服器得到 1/3 流量
優勢:實作僅需一行 index++ % N,零狀態、零開銷。
劣勢:假設所有節點等效——如果 Server A 是 2 核、Server B 是 8 核,仍然平均分配,造成 A 過載而 B 閒置。
2.3 Weighted Round Robin(加權輪詢)
Nginx 預設使用的 平滑加權輪詢(Smooth Weighted Round Robin) 演算法,確保按權重比例分配流量,且分配序列盡可能平滑(不會連續命中同一節點)。
節點池(權重 = CPU 核心數比例):
Server A:weight=2
Server B:weight=4
Server C:weight=2
總 weight = 8
平滑加權輪詢過程:
每輪:所有節點 current_weight += weight
選 current_weight 最大的節點
選中後:chosen.current_weight -= total_weight
→ 分配序列:B, A, B, C, B, A, B, C(每 8 次循環)
→ B 得到 50%、A 和 C 各 25%,且分布平滑
2.4 Least Connections(最少連線)
即時追蹤每台節點的活躍連線數,每次選擇連線數最少的節點。
當前狀態:
Server A: 12 connections
Server B: 5 connections ← 選這台
Server C: 8 connections
新請求到來 → 選 Server B → Server B 連線數變為 6
請求完成 → Server B 連線數回到 5
最佳場景:請求處理時間差異大——有些 API 回應 10ms、有些需要 10 秒。Round Robin 會讓某些節點堆積慢請求;Least Connections 自動「填坑」。
2.5 IP Hash
hash(client_ip) % N → 固定路由至同一台伺服器
192.168.1.100 → hash → 2847561 % 3 = 0 → Server A
192.168.1.100 → hash → 2847561 % 3 = 0 → Server A(一定相同)
問題:N=3 → N=4 時
2847561 % 3 = 0 → Server A
2847561 % 4 = 1 → Server B ← 映射完全改變!
→ 約 75% 的請求被路由到不同伺服器
2.6 Consistent Hashing(一致性雜湊)
在
第 016 篇進階雜湊中,我們已經深入介紹過一致性雜湊的理論基礎。這裡我們聚焦在它作為負載均衡演算法的實際運用。
Hash Ring(0 到 2³²-1,首尾相連):
0
│
┌─────────┴─────────┐
│ Hash Ring │
│ │
2³²──Node B(hash=25億)──┤
│ Node A(hash=5億)
│ │
│ Node C(hash=15億)─────────────────┤
│ │
└────────────────────┘
路由規則:
hash(request_key) → 在環上找到該點
→ 順時針方向找到第一個 Node
→ 路由至該 Node
新增 Node D(hash=10億):
→ 只影響原本路由到 Node A 的部分 key
→ 其他節點的流量完全不受影響
→ 理論上只重分配 1/(N+1) 的流量
虛擬節點(Virtual Nodes) 解決負載不均問題——真實節點數太少時,環上的分布可能極不均勻。每個真實節點映射 150 個虛擬節點到環上,統計上近似均勻:
Node A → A-0, A-1, ..., A-149(分散在環上 150 個位置)
Node B → B-0, B-1, ..., B-149
Node C → C-0, C-1, ..., C-149
環上共 450 個點,每個節點約佔 1/3
節點性能差異時,可用不同數量的虛擬節點反映
(Node B 效能是 Node A 的 2 倍 → 分配 2 倍虛擬節點)
3. 核心實作
3.1 TypeScript — 完整負載均衡器系統
以下實作包含五種演算法與健康檢查機制,每個類別都可以獨立使用。
Round Robin
// ================================================================
// Round Robin — 最簡單的負載均衡
// ================================================================
interface ServerNode {
id: string;
host: string;
port: number;
weight: number; // 1~10,用於加權演算法
}
class RoundRobinBalancer {
private index = 0;
constructor(private servers: ServerNode[]) {}
next(): ServerNode | null {
if (this.servers.length === 0) return null;
const node = this.servers[this.index % this.servers.length];
this.index++;
return node;
}
// 動態新增/移除節點
addServer(server: ServerNode): void {
this.servers.push(server);
}
removeServer(serverId: string): void {
this.servers = this.servers.filter((s) => s.id !== serverId);
this.index = this.index % Math.max(1, this.servers.length);
}
}
// 使用範例
const servers: ServerNode[] = [
{ id: "server-1", host: "10.0.0.1", port: 8080, weight: 2 },
{ id: "server-2", host: "10.0.0.2", port: 8080, weight: 4 },
{ id: "server-3", host: "10.0.0.3", port: 8080, weight: 2 },
];
const rr = new RoundRobinBalancer(servers);
for (let i = 0; i < 6; i++) {
console.log(`請求 #${i + 1} → ${rr.next()?.id}`);
}
// 輸出:
// 請求 #1 → server-1
// 請求 #2 → server-2
// 請求 #3 → server-3
// 請求 #4 → server-1
// 請求 #5 → server-2
// 請求 #6 → server-3
Weighted Round Robin(平滑加權輪詢)
// ================================================================
// Weighted Round Robin — Nginx 使用的平滑加權輪詢
// ================================================================
class WeightedRoundRobinBalancer {
private currentWeights: number[];
constructor(private readonly servers: ServerNode[]) {
this.currentWeights = new Array(servers.length).fill(0);
}
next(): ServerNode | null {
if (this.servers.length === 0) return null;
const totalWeight = this.servers.reduce((sum, s) => sum + s.weight, 0);
// 每輪:所有節點 current_weight += weight
for (let i = 0; i < this.servers.length; i++) {
this.currentWeights[i] += this.servers[i].weight;
}
// 選 current_weight 最大的節點
let maxIdx = 0;
for (let i = 1; i < this.servers.length; i++) {
if (this.currentWeights[i] > this.currentWeights[maxIdx]) {
maxIdx = i;
}
}
// 選中後減去 total_weight
this.currentWeights[maxIdx] -= totalWeight;
return this.servers[maxIdx];
}
}
// 使用範例:驗證分配比例
const wrr = new WeightedRoundRobinBalancer(servers);
const counts: Record<string, number> = {};
for (let i = 0; i < 800; i++) {
const node = wrr.next()!;
counts[node.id] = (counts[node.id] ?? 0) + 1;
}
console.log(counts);
// 輸出:{ 'server-1': 200, 'server-2': 400, 'server-3': 200 }
// → server-2(weight=4)得到 50%,符合權重比例
Least Connections(最少連線)
// ================================================================
// Least Connections — 即時負載感知
// ================================================================
class LeastConnectionsBalancer {
private connections: Map<string, number>;
constructor(private servers: ServerNode[]) {
this.connections = new Map(servers.map((s) => [s.id, 0]));
}
// 獲取下一個節點(連線數 +1)
acquire(): ServerNode | null {
if (this.servers.length === 0) return null;
let minConn = Infinity;
let chosen = this.servers[0];
for (const server of this.servers) {
const conn = this.connections.get(server.id) ?? 0;
if (conn < minConn) {
minConn = conn;
chosen = server;
}
}
// 增加連線計數
this.connections.set(
chosen.id,
(this.connections.get(chosen.id) ?? 0) + 1
);
return chosen;
}
// 請求完成後釋放連線
release(serverId: string): void {
const current = this.connections.get(serverId) ?? 0;
this.connections.set(serverId, Math.max(0, current - 1));
}
getConnections(): Map<string, number> {
return new Map(this.connections);
}
}
// 使用範例:模擬長短請求混合場景
const lc = new LeastConnectionsBalancer(servers);
const s1 = lc.acquire(); // server-1(連線數 0)
const s2 = lc.acquire(); // server-2(連線數 0)
const s3 = lc.acquire(); // server-3(連線數 0)
console.log("分配後:", Object.fromEntries(lc.getConnections()));
// 輸出:{ 'server-1': 1, 'server-2': 1, 'server-3': 1 }
lc.release(s1!.id); // server-1 釋放連線
const s4 = lc.acquire(); // server-1(連線數最少 = 0)
console.log("server-1 釋放後再分配:", s4?.id);
// 輸出:server-1
IP Hash
// ================================================================
// IP Hash — Session Affinity(同 IP 固定路由)
// ================================================================
class IPHashBalancer {
constructor(private servers: ServerNode[]) {}
next(clientIP: string): ServerNode | null {
if (this.servers.length === 0) return null;
const hash = this.simpleHash(clientIP);
return this.servers[hash % this.servers.length];
}
private simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash * 31 + str.charCodeAt(i)) >>> 0; // 保持為 uint32
}
return hash;
}
}
// 使用範例:驗證同 IP 固定路由
const ipHash = new IPHashBalancer(servers);
console.log(ipHash.next("192.168.1.100")?.id); // 固定結果
console.log(ipHash.next("192.168.1.100")?.id); // 同 IP → 同節點
console.log(ipHash.next("10.0.0.55")?.id); // 不同 IP → 可能不同節點
Consistent Hashing(一致性雜湊環)
// ================================================================
// Consistent Hash Ring — 完整實作
// 支援:虛擬節點、加權分配、動態增刪節點、O(log V) 路由
// ================================================================
import * as crypto from "crypto";
class ConsistentHashRing {
private readonly ring: Map<number, ServerNode> = new Map();
private readonly sortedKeys: number[] = [];
private readonly nodes: Map<string, ServerNode> = new Map();
private readonly virtualNodesPerWeight: number;
constructor(virtualNodesPerWeight: number = 150) {
this.virtualNodesPerWeight = virtualNodesPerWeight;
}
// 新增節點:根據 weight 決定虛擬節點數量
addNode(node: ServerNode): void {
if (this.nodes.has(node.id)) {
this.removeNode(node.id); // 先移除再重新加入(冪等操作)
}
this.nodes.set(node.id, node);
const virtualCount = this.virtualNodesPerWeight * node.weight;
for (let i = 0; i < virtualCount; i++) {
const hashKey = this.hash(`${node.id}#${i}`);
this.ring.set(hashKey, node);
this.insertSorted(hashKey);
}
}
// 移除節點:從環上移除所有虛擬節點
removeNode(nodeId: string): void {
const node = this.nodes.get(nodeId);
if (!node) return;
this.nodes.delete(nodeId);
const virtualCount = this.virtualNodesPerWeight * node.weight;
for (let i = 0; i < virtualCount; i++) {
const hashKey = this.hash(`${nodeId}#${i}`);
this.ring.delete(hashKey);
this.removeSorted(hashKey);
}
}
// 路由:O(log V) 二分搜尋找順時針最近節點
getNode(key: string): ServerNode | null {
if (this.sortedKeys.length === 0) return null;
const hashKey = this.hash(key);
const idx = this.binarySearchCeil(hashKey);
// 若 hashKey 大於所有點,回到環的起點(第一個節點)
const ringIdx = idx < this.sortedKeys.length ? idx : 0;
const targetHash = this.sortedKeys[ringIdx];
return this.ring.get(targetHash) ?? null;
}
// 取得 key 對應的 N 個不重複節點(用於資料複製/備援)
getNodes(key: string, count: number): ServerNode[] {
if (count >= this.nodes.size) {
return [...this.nodes.values()];
}
const hashKey = this.hash(key);
let idx = this.binarySearchCeil(hashKey);
if (idx >= this.sortedKeys.length) idx = 0;
const result: ServerNode[] = [];
const seenIds = new Set<string>();
while (result.length < count) {
const node = this.ring.get(this.sortedKeys[idx])!;
if (!seenIds.has(node.id)) {
seenIds.add(node.id);
result.push(node);
}
idx = (idx + 1) % this.sortedKeys.length;
}
return result;
}
// 統計:各節點的虛擬節點分布
getDistribution(): Map<string, number> {
const dist = new Map<string, number>();
for (const node of this.ring.values()) {
dist.set(node.id, (dist.get(node.id) ?? 0) + 1);
}
return dist;
}
get nodeCount(): number {
return this.nodes.size;
}
get virtualNodeCount(): number {
return this.sortedKeys.length;
}
// ---- 私有工具函數 ----
private hash(key: string): number {
const buf = crypto.createHash("md5").update(key).digest();
return buf.readUInt32BE(0); // 取前 4 bytes 作為 uint32
}
private insertSorted(key: number): void {
let lo = 0,
hi = this.sortedKeys.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.sortedKeys[mid] < key) lo = mid + 1;
else hi = mid;
}
this.sortedKeys.splice(lo, 0, key);
}
private removeSorted(key: number): void {
const idx = this.binarySearchExact(key);
if (idx !== -1) this.sortedKeys.splice(idx, 1);
}
private binarySearchCeil(target: number): number {
let lo = 0,
hi = this.sortedKeys.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.sortedKeys[mid] < target) lo = mid + 1;
else hi = mid;
}
return lo;
}
private binarySearchExact(target: number): number {
let lo = 0,
hi = this.sortedKeys.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (this.sortedKeys[mid] === target) return mid;
if (this.sortedKeys[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
}
// 使用範例
const ring = new ConsistentHashRing(150);
servers.forEach((s) => ring.addNode(s));
console.log("虛擬節點總數:", ring.virtualNodeCount);
// 輸出:1200((2+4+2)×150)
console.log("分布:", Object.fromEntries(ring.getDistribution()));
// 輸出:{ 'server-1': 300, 'server-2': 600, 'server-3': 300 }
// 一致性路由測試
const testKeys = ["user-12345", "session-abc", "cache-key-99"];
for (const key of testKeys) {
console.log(`${key} → ${ring.getNode(key)?.id}`);
}
// 節點故障模擬
console.log("\n--- 移除 server-2 ---");
ring.removeNode("server-2");
for (const key of testKeys) {
console.log(`${key} → ${ring.getNode(key)?.id}`);
}
// → 原本路由到 server-2 的 key 會被重新分配到 server-1 或 server-3
// → 原本路由到 server-1/3 的 key 不受影響
健康檢查管理器
// ================================================================
// Health Checker — 主動健康檢查與自動故障轉移
// ================================================================
interface HealthCheckConfig {
intervalMs: number; // 檢查間隔(毫秒)
timeoutMs: number; // 超時時間
path: string; // 健康檢查路徑(如 "/health")
unhealthyThreshold: number; // 連續失敗幾次才標記為不健康
healthyThreshold: number; // 連續成功幾次才標記為健康
}
class HealthChecker {
private failureCount: Map<string, number> = new Map();
private successCount: Map<string, number> = new Map();
private healthyNodes: Set<string>;
constructor(
private readonly ring: ConsistentHashRing,
private readonly allNodes: ServerNode[],
private readonly config: HealthCheckConfig
) {
this.healthyNodes = new Set(allNodes.map((n) => n.id));
}
// 啟動定時健康檢查
start(): NodeJS.Timeout {
return setInterval(async () => {
await Promise.all(this.allNodes.map((node) => this.checkNode(node)));
}, this.config.intervalMs);
}
private async checkNode(node: ServerNode): Promise<void> {
const url = `http://${node.host}:${node.port}${this.config.path}`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
this.config.timeoutMs
);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (response.ok) {
this.onSuccess(node);
} else {
this.onFailure(node);
}
} catch {
this.onFailure(node);
}
}
private onSuccess(node: ServerNode): void {
this.failureCount.set(node.id, 0);
const successes = (this.successCount.get(node.id) ?? 0) + 1;
this.successCount.set(node.id, successes);
// 節點恢復健康:連續成功次數達到閾值
if (
!this.healthyNodes.has(node.id) &&
successes >= this.config.healthyThreshold
) {
this.healthyNodes.add(node.id);
this.ring.addNode(node);
console.log(`[HealthCheck] ✓ ${node.id} is back online`);
}
}
private onFailure(node: ServerNode): void {
this.successCount.set(node.id, 0);
const failures = (this.failureCount.get(node.id) ?? 0) + 1;
this.failureCount.set(node.id, failures);
// 節點標記為不健康:連續失敗次數達到閾值
if (
this.healthyNodes.has(node.id) &&
failures >= this.config.unhealthyThreshold
) {
this.healthyNodes.delete(node.id);
this.ring.removeNode(node.id);
console.log(
`[HealthCheck] ✗ ${node.id} is DOWN (${failures} consecutive failures)`
);
}
}
getHealthyNodes(): string[] {
return [...this.healthyNodes];
}
isHealthy(nodeId: string): boolean {
return this.healthyNodes.has(nodeId);
}
}
// 使用範例
const healthConfig: HealthCheckConfig = {
intervalMs: 5000, // 每 5 秒檢查一次
timeoutMs: 2000, // 超時 2 秒視為失敗
path: "/health",
unhealthyThreshold: 3, // 連續 3 次失敗 → 標記不健康
healthyThreshold: 2, // 連續 2 次成功 → 恢復健康
};
const healthyRing = new ConsistentHashRing(150);
servers.forEach((s) => healthyRing.addNode(s));
const checker = new HealthChecker(healthyRing, servers, healthConfig);
// checker.start(); // 啟動定時健康檢查
console.log("健康節點:", checker.getHealthyNodes());
// 輸出:['server-1', 'server-2', 'server-3']
3.2 C++ — 高效能負載均衡器
以下是完整的 C++ 實作,使用 std::map 維護有序雜湊環,實現 O(log N) 路由決策。
// ================================================================
// 負載均衡器 — C++ 高效能完整實作
// 包含:Consistent Hash Ring、Weighted Round Robin、Least Connections
// ================================================================
#include <iostream>
#include <map>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <string>
#include <atomic>
#include <mutex>
#include <optional>
#include <chrono>
struct ServerNode {
std::string id;
std::string host;
int port;
int weight; // 1-10
};
// ================================================================
// 1. Consistent Hash Ring(一致性雜湊環)
// ================================================================
class ConsistentHashRing {
public:
explicit ConsistentHashRing(int virtualNodesPerWeight = 150)
: virtualNodesPerWeight_(virtualNodesPerWeight) {}
void addNode(const ServerNode& node) {
removeNode(node.id); // 冪等操作
nodes_[node.id] = node;
const int virtualCount = virtualNodesPerWeight_ * node.weight;
for (int i = 0; i < virtualCount; i++) {
const std::string key = node.id + "#" + std::to_string(i);
const uint32_t hash = hashKey(key);
ring_[hash] = &nodes_[node.id];
}
}
void removeNode(const std::string& nodeId) {
auto it = nodes_.find(nodeId);
if (it == nodes_.end()) return;
const int virtualCount = virtualNodesPerWeight_ * it->second.weight;
for (int i = 0; i < virtualCount; i++) {
const std::string key = nodeId + "#" + std::to_string(i);
ring_.erase(hashKey(key));
}
nodes_.erase(it);
}
// 路由:O(log V),V = 虛擬節點總數
std::optional<ServerNode> getNode(const std::string& requestKey) const {
if (ring_.empty()) return std::nullopt;
const uint32_t hash = hashKey(requestKey);
auto it = ring_.lower_bound(hash); // 找到第一個 >= hash 的位置
if (it == ring_.end()) it = ring_.begin(); // 環繞
return *it->second;
}
// 取得 N 個不重複節點(用於資料複製)
std::vector<ServerNode> getNodes(
const std::string& requestKey, int count
) const {
if (ring_.empty()) return {};
count = std::min(count, static_cast<int>(nodes_.size()));
const uint32_t hash = hashKey(requestKey);
auto it = ring_.lower_bound(hash);
if (it == ring_.end()) it = ring_.begin();
std::vector<ServerNode> result;
std::unordered_set<std::string> seen;
auto cur = it;
while (static_cast<int>(result.size()) < count) {
if (seen.find(cur->second->id) == seen.end()) {
seen.insert(cur->second->id);
result.push_back(*cur->second);
}
++cur;
if (cur == ring_.end()) cur = ring_.begin();
if (cur == it) break; // 環繞一圈
}
return result;
}
// 分布統計
std::unordered_map<std::string, int> getDistribution() const {
std::unordered_map<std::string, int> dist;
for (const auto& [hash, node] : ring_) {
dist[node->id]++;
}
return dist;
}
int nodeCount() const { return static_cast<int>(nodes_.size()); }
int virtualNodeCount() const { return static_cast<int>(ring_.size()); }
private:
// FNV-1a Hash(快速且分布均勻)
uint32_t hashKey(const std::string& key) const {
uint32_t hash = 2166136261u; // FNV offset basis
for (char c : key) {
hash ^= static_cast<uint8_t>(c);
hash *= 16777619u; // FNV prime
}
return hash;
}
std::map<uint32_t, ServerNode*> ring_; // 有序環
std::unordered_map<std::string, ServerNode> nodes_; // 節點儲存
int virtualNodesPerWeight_;
};
// ================================================================
// 2. Weighted Round Robin(平滑加權輪詢,執行緒安全)
// ================================================================
class WeightedRoundRobin {
public:
explicit WeightedRoundRobin(std::vector<ServerNode> servers)
: servers_(std::move(servers)) {
totalWeight_ = 0;
for (const auto& s : servers_) totalWeight_ += s.weight;
currentWeights_.resize(servers_.size(), 0);
}
const ServerNode* next() {
if (servers_.empty()) return nullptr;
std::lock_guard<std::mutex> lock(mutex_);
// 所有節點 current_weight += weight
for (size_t i = 0; i < servers_.size(); i++) {
currentWeights_[i] += servers_[i].weight;
}
// 選最大的
int maxIdx = 0;
for (size_t i = 1; i < servers_.size(); i++) {
if (currentWeights_[i] > currentWeights_[maxIdx]) {
maxIdx = static_cast<int>(i);
}
}
// 選中後減去 total_weight
currentWeights_[maxIdx] -= totalWeight_;
return &servers_[maxIdx];
}
private:
std::vector<ServerNode> servers_;
std::vector<int> currentWeights_;
int totalWeight_;
std::mutex mutex_;
};
// ================================================================
// 3. Least Connections(原子計數器,無鎖高效能)
// ================================================================
class LeastConnections {
public:
explicit LeastConnections(std::vector<ServerNode> servers)
: servers_(std::move(servers)) {
connections_.resize(servers_.size());
for (auto& conn : connections_) conn.store(0);
}
LeastConnections(const LeastConnections&) = delete; // atomic 不可複製
const ServerNode* acquire() {
if (servers_.empty()) return nullptr;
int minIdx = 0;
int minConn = connections_[0].load(std::memory_order_relaxed);
for (size_t i = 1; i < servers_.size(); i++) {
int conn = connections_[i].load(std::memory_order_relaxed);
if (conn < minConn) {
minConn = conn;
minIdx = static_cast<int>(i);
}
}
connections_[minIdx].fetch_add(1, std::memory_order_relaxed);
return &servers_[minIdx];
}
void release(const std::string& serverId) {
for (size_t i = 0; i < servers_.size(); i++) {
if (servers_[i].id == serverId) {
int prev = connections_[i].fetch_sub(
1, std::memory_order_relaxed
);
if (prev <= 0) connections_[i].store(0);
return;
}
}
}
void printConnections() const {
for (size_t i = 0; i < servers_.size(); i++) {
std::cout << servers_[i].id << ": "
<< connections_[i].load() << " connections\n";
}
}
private:
std::vector<ServerNode> servers_;
std::vector<std::atomic<int>> connections_;
};
// ================================================================
// 主程式示範
// ================================================================
int main() {
std::vector<ServerNode> servers = {
{"server-1", "10.0.0.1", 8080, 2},
{"server-2", "10.0.0.2", 8080, 4},
{"server-3", "10.0.0.3", 8080, 2},
};
// === Consistent Hash Ring ===
std::cout << "=== Consistent Hash Ring ===\n";
ConsistentHashRing ring(150);
for (const auto& s : servers) ring.addNode(s);
std::cout << "虛擬節點總數: " << ring.virtualNodeCount() << "\n";
// 輸出:1200((2+4+2)×150)
auto dist = ring.getDistribution();
for (const auto& [id, count] : dist) {
std::cout << id << ": " << count << " 虛擬節點\n";
}
// 輸出:server-1: 300, server-2: 600, server-3: 300
// 路由測試
std::vector<std::string> keys = {
"user-12345", "session-abc", "cache-key-99"
};
for (const auto& key : keys) {
auto node = ring.getNode(key);
if (node) std::cout << key << " → " << node->id << "\n";
}
// 節點移除(僅影響 ~1/3 流量)
std::cout << "\n移除 server-2 後:\n";
ring.removeNode("server-2");
for (const auto& key : keys) {
auto node = ring.getNode(key);
if (node) std::cout << key << " → " << node->id << "\n";
}
// === Weighted Round Robin ===
std::cout << "\n=== Weighted Round Robin ===\n";
WeightedRoundRobin wrr(servers);
std::unordered_map<std::string, int> counts;
for (int i = 0; i < 800; i++) {
const auto* node = wrr.next();
if (node) counts[node->id]++;
}
for (const auto& [id, count] : counts) {
std::cout << id << ": " << count << "/800 ("
<< (count * 100 / 800) << "%)\n";
}
// 輸出:server-1: 200 (25%), server-2: 400 (50%), server-3: 200 (25%)
// === Least Connections ===
std::cout << "\n=== Least Connections ===\n";
LeastConnections lc(servers);
const auto* s1 = lc.acquire(); // server-1
const auto* s2 = lc.acquire(); // server-2
const auto* s3 = lc.acquire(); // server-3
lc.printConnections();
// 輸出:各 1 connection
if (s1) lc.release(s1->id);
std::cout << "釋放 " << s1->id << " 後:\n";
lc.printConnections();
// 輸出:server-1: 0, server-2: 1, server-3: 1
return 0;
}
4. 效能分析
時間複雜度
| 操作 | 複雜度 | 說明 |
|---|---|---|
Round Robin next() | O(1) | 索引遞增 + 取餘 |
Weighted Round Robin next() | O(N) | 掃描所有節點找 max,N 通常很小(< 100) |
Least Connections acquire() | O(N) | 掃描所有節點找 min |
IP Hash next() | O(1) | 雜湊 + 取餘 |
Consistent Hashing getNode() | O(log V) | 二分搜尋雜湊環,V = 虛擬節點數 |
Consistent Hashing addNode() | O(W × log V) | 插入 W 個虛擬節點(W = weight × base) |
Consistent Hashing removeNode() | O(W × log V) | 移除 W 個虛擬節點 |
| 健康檢查(單次) | O(N) | 並行探測所有 N 個節點 |
空間複雜度
| 結構 | 空間 | 說明 |
|---|---|---|
| Round Robin | O(1) | 只需一個索引 |
| Weighted Round Robin | O(N) | N 個 current_weight 計數器 |
| Least Connections | O(N) | N 個連線計數器 |
| IP Hash | O(1) | 無狀態 |
| Consistent Hashing(有序環) | O(V) | V = 所有虛擬節點,存雜湊值 + 指標 |
效能基準
| 場景 | 節點數 | 演算法 | 單次路由延遲 | 記憶體 |
|---|---|---|---|---|
| 小型服務 | 3-5 台 | Round Robin | < 10ns | < 1 KB |
| 中型平台 | 20 台 | WRR | < 50ns | < 1 KB |
| 微服務叢集 | 100 台 | Consistent Hashing | < 500ns | ~240 KB |
| 大型 CDN | 1,000 台 | Consistent Hashing | < 2μs | ~24 MB |
| 超大規模 | 10,000 台 | P2C + Least Connections | < 100ns | ~80 KB |
關鍵洞察:在實際生產環境中,路由決策的延遲(奈秒至微秒級)相比網路傳輸延遲(毫秒級)完全可以忽略。選擇演算法的核心考量不是速度,而是 負載均勻性 與 節點變化時的穩定性。
5. 生產環境考量
5.1 L4 vs L7 負載均衡
生產環境通常採用多層架構,L4 與 L7 負載均衡器各司其職:
┌────────────────────────────────────────────────────────────┐
│ DNS 層(Anycast / GeoDNS) │
│ 用戶 DNS 查詢 → 返回最近 POP(Point of Presence)的 IP │
└────────────────────────┬───────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ L4 Load Balancer(傳輸層) │
│ 基於 IP/Port 的 TCP 轉發,超高吞吐(>1000 萬 PPS) │
│ 代表:Linux IPVS、AWS NLB、Google Cloud Network LB │
└────────────────────────┬───────────────────────────────────┘
│
┌─────────┴──────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ L7 LB Node 1 │ │ L7 LB Node 2 │
│ (Nginx / Envoy) │ │ (Nginx / Envoy) │
│ 路由算法: │ │ 路由算法: │
│ Consistent Hash │ │ Consistent Hash │
└──────────┬──────────┘ └──────────┬──────────┘
│ │
└────────┬───────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Backend │ │ Backend │ │ Backend │
│ Server A │ │ Server B │ │ Server C │
└──────────┘ └──────────┘ └──────────┘
| 維度 | L4(傳輸層) | L7(應用層) |
|---|---|---|
| 解析層級 | IP + Port | HTTP Headers、URL、Cookie |
| 吞吐量 | > 1000 萬 PPS | ~100 萬 RPS |
| 功能 | 純轉發 | 路由規則、SSL 終結、重寫 |
| 延遲 | < 10μs | < 100μs |
| 代表 | IPVS、AWS NLB | Nginx、Envoy、HAProxy |
| 適用場景 | 入口層、高吞吐 | 應用層路由、API Gateway |
5.2 Sticky Sessions(黏性會話)
三種 Sticky Sessions 實作方案:
方案 1:Cookie-based Affinity
首次請求 → LB 選擇後端 → 設定 Cookie:LB_SESSION=server-1
後續請求 → 讀取 Cookie → 直接路由至 server-1
問題:server-1 故障後 Cookie 失效,需處理 fallback
方案 2:Consistent Hashing(以 Session ID 為 key)
hash(session_id) → 一致性路由至同一後端
節點故障時,只有 1/N 的 Session 受影響
→ 推薦用於需要快取親和性的場景
方案 3:外部化 Session Store(最佳實踐)
Session → Redis / Memcached(所有節點共享)
→ 任何後端都能處理任何請求
→ 完全消除 Sticky Sessions 需求
5.3 Graceful Shutdown(優雅關閉)
當後端節點需要重啟或部署時,直接斷開連線會導致進行中的請求失敗。優雅關閉的流程:
1. 從 LB 路由池移除該節點(停止分配新請求)
2. 等待所有進行中的請求完成(設定超時時間,如 30 秒)
3. 超時後強制關閉剩餘連線
4. 節點停機、更新、重啟
5. 健康檢查通過後重新加入路由池
在 Kubernetes 中,這對應到 Pod 的 preStop hook 與 terminationGracePeriodSeconds:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5 && kill -SIGTERM 1"]
# readinessProbe 失敗後 → 從 Service Endpoints 移除
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 5
failureThreshold: 1
5.4 Circuit Breaker(熔斷器)
當後端節點不是完全當機、而是「慢死」(回應時間急劇增加),健康檢查可能仍然通過,但實際處理能力已大幅下降。熔斷器是解決這個問題的關鍵機制:
熔斷器三種狀態:
Closed(閉合)→ 正常路由,計數連續錯誤
│
│ 連續錯誤 ≥ 閾值
▼
Open(斷開)→ 完全停止路由至此節點
│ 直接返回降級回應或 503
│
│ 經過重置時間(如 30 秒)
▼
Half-Open(半開)→ 允許少量試探性請求
│
├── 試探成功 → 回到 Closed
└── 試探失敗 → 回到 Open
5.5 DNS-based 負載均衡
在負載均衡器之上,DNS 層提供了更大範圍的流量分配:
DNS Round Robin:
example.com → 1.1.1.1 (數據中心 A)
example.com → 2.2.2.2 (數據中心 B)
DNS 交替返回不同 IP,實現跨數據中心分流
GeoDNS:
亞洲用戶 → 東京數據中心 IP
歐洲用戶 → 法蘭克福數據中心 IP
→ 降低跨洲延遲
Anycast:
多個數據中心宣告相同 IP
BGP 路由自動選擇最近的數據中心
→ 最低延遲 + 天生 DDoS 防護
代表:Cloudflare、Google Cloud CDN
5.6 Power of Two Choices(P2C)
在大規模叢集(> 100 節點)中,完整的 Least Connections 需要掃描所有節點(O(N)),開銷不小。P2C 是一個巧妙的近似方案:
P2C 演算法:
1. 隨機選兩個節點
2. 比較兩者的活躍連線數
3. 選連線數較少的那個
時間複雜度:O(1)(只需比較 2 個節點)
效果:數學上可以證明,P2C 的負載不均勻度從 O(log N / log log N)
降低到 O(log log N)——指數級改善!
Envoy proxy 的預設策略就是 P2C + EWMA(指數加權移動平均延遲)
6. LeetCode 練習
以下是與負載均衡器核心演算法相關的練習題目:
題目一:LeetCode 1603 — Design Parking System
| 項目 | 內容 |
|---|---|
| 難度 | Easy |
| 連結 | LeetCode 1603 |
| 核心 | 設計一個停車系統,管理大、中、小三種車位的容量 |
| 提示 | 這是資源管理的基礎練習——停車位就像後端伺服器的容量,每種類型有上限,需要判斷是否還能接受新請求。類似於 Least Connections 中追蹤每台伺服器的連線數是否達到上限 |
題目二:LeetCode 380 — Insert Delete GetRandom O(1)
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 380 |
| 核心 | 設計一個支援 O(1) 插入、刪除和隨機取值的資料結構 |
| 提示 | 負載均衡器需要 O(1) 動態新增/移除節點,並且能快速隨機選擇節點(P2C 演算法需要隨機選兩個節點)。本題的 HashMap + Array 技巧正是伺服器池管理的核心資料結構 |
題目三:LeetCode 710 — Random Pick with Blacklist
| 項目 | 內容 |
|---|---|
| 難度 | Hard |
| 連結 | LeetCode 710 |
| 核心 | 在 [0, N) 範圍內隨機選擇一個不在黑名單中的數字 |
| 提示 | 這對應健康檢查後的伺服器選擇問題:從 N 台伺服器中排除不健康的(黑名單),在剩餘的健康伺服器中隨機選擇。需要巧妙的 HashMap 映射,避免每次都重建有效伺服器列表 |
題目四:LeetCode 528 — Random Pick with Weight
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 528 |
| 核心 | 根據權重陣列,按權重比例隨機選擇索引 |
| 提示 | 這正是 Weighted Round Robin 和 加權 Consistent Hashing 的數學基礎!使用前綴和 + 二分搜尋,按權重比例隨機選擇伺服器。前綴和的二分搜尋技巧與 Consistent Hash Ring 上的 binarySearchCeil 完全一致 |
練習建議:先完成 1603 理解資源容量管理的基本概念,再做 380 掌握 O(1) 動態集合操作(伺服器池管理)。528 直接對應 Weighted Round Robin 的數學模型,710 對應健康檢查後的排除邏輯。四題串起來就是負載均衡器的完整知識鏈。
7. 總結——系列完結篇
在這篇文章中,我們從需求分析出發,完整設計並實作了一套負載均衡器系統:
五種核心演算法:Round Robin 的極簡優雅、Weighted Round Robin 的權重感知、Least Connections 的即時負載追蹤、IP Hash 的 Session 親和性、Consistent Hashing 的動態擴縮穩定性——每種演算法都有其最佳適用場景。
一致性雜湊環:運用了雜湊函數將節點與請求映射到同一個環上,搭配虛擬節點實現均勻分布,透過二分搜尋實現 O(log V) 路由決策。新增或移除節點時只有 1/N 的流量受影響,這是大規模分散式系統擴縮容的關鍵。
健康檢查機制:主動探測 + 閾值判定 + 自動故障轉移,確保流量不會被路由到故障節點。搭配 Circuit Breaker 熔斷器處理「慢死」場景。
生產環境架構:L4/L7 多層負載均衡、DNS-based 全球分流、Sticky Sessions 三種方案、Graceful Shutdown 優雅關閉、P2C 大規模近似演算法——這些是從原型到生產的必經之路。
系列回顧——從 Big O 到 Load Balancer 的 47 篇旅程
寫到這裡,「資料結構與演算法」系列的全部 47 篇文章 正式完結。讓我們回顧這段從零到一的學習旅程:
第一階段:基礎資料結構(001-008)
我們從
001 Big O 與複雜度分析出發,建立了分析演算法效能的共同語言。接著依序學習了
陣列與字串、
鏈結串列、
堆疊與佇列、
雜湊表、
堆積與優先權佇列、
排序演算法與
二分搜尋——這些是所有進階主題的基石。
第二階段:進階資料結構(009-016)
我們深入了
二元樹與
二元搜尋樹的世界,學會了
Trie 字典樹、
Union-Find 並查集、
線段樹、
樹狀陣列、
進階樹與
進階雜湊——今天的 Consistent Hashing 正是第 016 篇知識的直接應用。
第三階段:基礎演算法(017-024)
遞迴與回溯教會我們拆解複雜問題,
雙指標與
滑動窗口是陣列高效處理的利器,
動態規劃基礎與
進階動態規劃是面試的核心考點,
貪心演算法、
分治法與
位元運算各展所長。
第四階段:圖論與進階(025-032)
從
圖的表示出發,
BFS 與 DFS、
最短路徑、
拓撲排序、
最小生成樹、
樹演算法、
網路流到
字串演算法——圖論是系統設計(如網路拓撲、任務排程)的理論基礎。
第五階段:數學與特化(033-039)
數論、
計算幾何、
博弈論、
隨機化演算法、
近似與線上演算法、
後綴結構與
持久化資料結構——這些特化主題為我們打開了演算法世界更深邃的大門。
第六階段:實戰應用(040-047)
最後 8 篇文章是整個系列的精華——我們將前面所學的一切融會貫通,設計了真實世界中的系統:
Rate Limiter 限流器、
LRU/LFU 快取、
搜尋自動補全、
任務排程器、
短網址系統、
推薦系統、
文本差異比對,以及本篇的 負載均衡器。
回顧這 47 篇文章,有一個貫穿始終的核心洞見:每一個看似複雜的系統設計,拆解之後都是基礎資料結構與演算法的組合。
- Rate Limiter 用到了雜湊表(O(1) 查找)+ 滑動窗口(時間序列管理)
- LRU Cache 是雙向鏈結串列 + 雜湊表的經典組合
- 搜尋自動補全 建立在 Trie 字典樹之上
- 任務排程器 靠的是堆積(優先權佇列)+ 拓撲排序
- 短網址系統 利用了 Base62 編碼 + 雜湊碰撞處理
- 推薦系統 運用了排序演算法 + Bloom Filter + 雜湊表
- 文本差異比對 是動態規劃(LCS)+ 圖搜尋(Myers Diff)
- 負載均衡器 結合了一致性雜湊 + 二分搜尋 + 雜湊環
當你扎實地掌握了基礎,面對任何新的系統設計問題,都能從容地拆解為已知的資料結構與演算法,然後逐步組裝出解決方案。
感謝你跟著這個系列走到最後。 從第一篇 Big O 的 O(1) 到最後一篇負載均衡器的 Consistent Hashing,47 篇文章、數百個程式碼範例、上百道 LeetCode 練習——這段旅程的每一步都在為你建立系統性的演算法思維。
希望這個系列能夠成為你在軟體工程道路上的堅實基礎。無論是面試準備、日常開發、還是系統架構設計,這些知識都將伴隨你的職業生涯持續發揮作用。
如有任何問題或建議,歡迎隨時聯繫!
FAQ
Q: Consistent Hashing 和普通 IP Hash 有什麼差別?什麼時候該用 Consistent Hashing?
普通 IP Hash 使用 hash(client_ip) % N 將請求映射到 N 台伺服器之一,問題在於當 N 變化時(新增或移除節點),幾乎所有請求的映射都會改變——假設原本 N=3,擴展為 N=4,約 75% 的請求會被路由到不同的伺服器,導致快取全部失效或 Session 全部中斷。Consistent Hashing 則將節點與請求都映射到同一個雜湊環上(0 到 2³²-1),請求順時針找到最近的節點。新增或移除節點時,只有相鄰區段的流量受影響,理論上只有 1/N 的請求需要重新路由。搭配虛擬節點後,負載分布也能做到近似均勻。當你的系統需要頻繁擴縮容、使用分散式快取、或需要 Session Affinity 時,應該選擇 Consistent Hashing;如果節點數量固定且沒有快取依賴,簡單的 IP Hash 就足夠了。
Q: Round Robin 和 Least Connections 該怎麼選擇?
選擇 Round Robin 還是 Least Connections 取決於你的請求處理模型。Round Robin 適合所有請求處理時間相近、節點規格相同的場景——例如純 API 查詢服務、靜態檔案伺服器。它的優勢是零狀態、零開銷、實作最簡單。然而,如果請求處理時間差異很大(有些 10ms、有些 10 秒),Round Robin 會導致某些節點堆積大量慢請求。Least Connections 會即時追蹤每台節點的活躍連線數,優先分配給最閒的節點,適合 WebSocket 長連線、資料庫連線池、影片串流等處理時間不均勻的場景。代價是需要維護全局的連線計數狀態,在分散式負載均衡器中需要額外的同步機制。實務上,Nginx 預設使用 Round Robin,切換到 Least Connections 只需加一行設定 least_conn;Kubernetes 的 kube-proxy 在 IPVS 模式下也支援 lc(Least Connection)排程。
Q: 生產環境中負載均衡器掛了怎麼辦?如何避免單點故障?
負載均衡器本身成為單點故障(SPOF)是實務中必須解決的問題。解決方案是多層架構:第一層是 DNS 層負載均衡,使用 DNS Round Robin 或 Anycast 將流量分散到多個入口 IP,每個 IP 對應一台 L4 負載均衡器;第二層是 L4 負載均衡器的高可用,使用 VRRP(Virtual Router Redundancy Protocol)實現主備切換——主 LB 掛掉後,備用 LB 在數秒內接管 VIP(Virtual IP),Linux 環境常用 Keepalived + IPVS 實現;第三層是 L7 負載均衡器叢集,多台 Nginx/Envoy 實例由 L4 LB 分配流量。雲端環境更簡單:AWS 的 ALB/NLB、GCP 的 Cloud Load Balancing 都是全託管服務,本身就是跨可用區部署的高可用架構。另外,可以搭配 Circuit Breaker 熔斷器——當檢測到 LB 後方的後端節點大面積故障時,快速返回降級回應而非無限等待,保護上游系統不被拖垮。