深入淺出負載均衡

vivo網際網路技術發表於2021-06-07

一、負載均衡簡介

1.1. 大型網站面臨的挑戰

大型網站都要面對龐大的使用者量,高併發,海量資料等挑戰。為了提升系統整體的效能,可以採用垂直擴充套件和水平擴充套件兩種方式。

垂直擴充套件:在網站發展早期,可以從單機的角度通過增加硬體處理能力,比如 CPU 處理能力,記憶體容量,磁碟等方面,實現伺服器處理能力的提升。但是,單機是有效能瓶頸的,一旦觸及瓶頸,再想提升,付出的成本和代價會極高。這顯然不能滿足大型分散式系統(網站)所有應對的大流量,高併發,海量資料等挑戰。

水平擴充套件:通過叢集來分擔大型網站的流量。叢集中的應用伺服器(節點)通常被設計成無狀態,使用者可以請求任何一個節點,這些節點共同分擔訪問壓力。水平擴充套件有兩個要點:

  • 應用叢集:將同一應用部署到多臺機器上,組成處理叢集,接收負載均衡裝置分發的請求,進行處理,並返回相應資料。

  • 負載均衡:將使用者訪問請求,通過某種演算法,分發到叢集中的節點。

1.2. 什麼是負載均衡

負載均衡(Load Balance,簡稱 LB)是高併發、高可用系統必不可少的關鍵元件,目標是 盡力將網路流量平均分發到多個伺服器上,以提高系統整體的響應速度和可用性。

負載均衡的主要作用如下:

高併發:負載均衡通過演算法調整負載,盡力均勻的分配應用叢集中各節點的工作量,以此提高應用叢集的併發處理能力(吞吐量)。

伸縮性:新增或減少伺服器數量,然後由負載均衡進行分發控制。這使得應用叢集具備伸縮性。

高可用:負載均衡器可以監控候選伺服器,當伺服器不可用時,自動跳過,將請求分發給可用的伺服器。這使得應用叢集具備高可用的特性。

安全防護:有些負載均衡軟體或硬體提供了安全性功能,如:黑白名單處理、防火牆,防 DDos 攻擊等。

二、負載均衡的分類

支援負載均衡的技術很多,我們可以通過不同維度去進行分類。

2.1 載體維度分類

從支援負載均衡的載體來看,可以將負載均衡分為兩類:硬體負載均衡、軟體負載均衡

2.1.1硬體負載均衡

硬體負載均衡,一般是在定製處理器上執行的獨立負載均衡伺服器,價格昂貴,土豪專屬。硬體負載均衡的主流產品有:F5 和 A10。

硬體負載均衡的 優點:

  • 功能強大:支援全域性負載均衡並提供較全面的、複雜的負載均衡演算法。

  • 效能強悍:硬體負載均衡由於是在專用處理器上執行,因此吞吐量大,可支援單機百萬以上的併發。

  • 安全性高:往往具備防火牆,防 DDos 攻擊等安全功能。

硬體負載均衡的 缺點:

  • 成本昂貴:購買和維護硬體負載均衡的成本都很高。

  • 擴充套件性差:當訪問量突增時,超過限度不能動態擴容。

2.1.2 軟體負載均衡

軟體負載均衡,應用最廣泛,無論大公司還是小公司都會使用。

軟體負載均衡從軟體層面實現負載均衡,一般可以在任何標準物理裝置上執行。

軟體負載均衡的 主流產品 有:Nginx、HAProxy、LVS

  • LVS 可以作為四層負載均衡器。其負載均衡的效能要優於 Nginx。

  • HAProxy 可以作為 HTTP 和 TCP 負載均衡器。

  • Nginx、HAProxy 可以作為四層或七層負載均衡器。

軟體負載均衡的 優點:

  • 擴充套件性好:適應動態變化,可以通過新增軟體負載均衡例項,動態擴充套件到超出初始容量的能力。

  • 成本低廉:軟體負載均衡可以在任何標準物理裝置上執行,降低了購買和運維的成本。

軟體負載均衡的 缺點:

  • 效能略差:相比於硬體負載均衡,軟體負載均衡的效能要略低一些。

2.2 網路通訊分類

軟體負載均衡從通訊層面來看,又可以分為四層和七層負載均衡。

1) 七層負載均衡:就是可以根據訪問使用者的 HTTP 請求頭、URL 資訊將請求轉發到特定的主機。

  • DNS 重定向

  • HTTP 重定向

  • 反向代理

2) 四層負載均衡:基於 IP 地址和埠進行請求的轉發。

  • 修改 IP 地址

  • 修改 MAC 地址

2.2.1 DNS 負載均衡

DNS 負載均衡一般用於網際網路公司,複雜的業務系統不適合使用。大型網站一般使用 DNS 負載均衡作為 第一級負載均衡手段,然後在內部使用其它方式做第二級負載均衡。DNS 負載均衡屬於七層負載均衡。

DNS 即 域名解析服務,是 OSI 第七層網路協議。DNS 被設計為一個樹形結構的分散式應用,自上而下依次為:根域名伺服器,一級域名伺服器,二級域名伺服器,... ,本地域名伺服器。顯然,如果所有資料都儲存在根域名伺服器,那麼 DNS 查詢的負載和開銷會非常龐大。

因此,DNS 查詢相對於 DNS 層級結構,是一個逆向的遞迴流程,DNS 客戶端依次請求本地 DNS 伺服器,上一級 DNS 伺服器,上上一級 DNS 伺服器,... ,根 DNS 伺服器(又叫權威 DNS 伺服器),一旦命中,立即返回。為了減少查詢次數,每一級 DNS 伺服器都會設定 DNS 查詢快取。

DNS 負載均衡的工作原理就是:基於 DNS 查詢快取,按照負載情況返回不同伺服器的 IP 地址。

DNS 重定向的 優點:

使用簡單:負載均衡工作,交給 DNS 伺服器處理,省掉了負載均衡伺服器維護的麻煩

提高效能:可以支援基於地址的域名解析,解析成距離使用者最近的伺服器地址(類似 CDN 的原理),可以加快訪問速度,改善效能;

DNS 重定向的 缺點:

可用性差:DNS 解析是多級解析,新增/修改 DNS 後,解析時間較長;解析過程中,使用者訪問網站將失敗;

擴充套件性低:DNS 負載均衡的控制權在域名商那裡,無法對其做更多的改善和擴充套件;

維護性差:也不能反映伺服器的當前執行狀態;支援的演算法少;不能區分伺服器的差異(不能根據系統與服務的狀態來判斷負載)。

2.2.2 HTTP 負載均衡

HTTP 負載均衡是基於 HTTP 重定向實現的。HTTP 負載均衡屬於七層負載均衡。

HTTP 重定向原理是:根據使用者的 HTTP 請求計算出一個真實的伺服器地址,將該伺服器地址寫入 HTTP 重定向響應中,返回給瀏覽器,由瀏覽器重新進行訪問。

HTTP 重定向的優點:方案簡單。

HTTP 重定向的 缺點:

效能較差:每次訪問需要兩次請求伺服器,增加了訪問的延遲。

降低搜尋排名:使用重定向後,搜尋引擎會視為 SEO 作弊。

如果負載均衡器當機,就無法訪問該站點。

由於其缺點比較明顯,所以這種負載均衡策略實際應用較少。

2.2.3 反向代理負載均衡

反向代理(Reverse Proxy)方式是指以 代理伺服器 來接受網路請求,然後 將請求轉發給內網中的伺服器,並將從內網中的伺服器上得到的結果返回給網路請求的客戶端。反向代理負載均衡屬於七層負載均衡。

反向代理服務的主流產品:Nginx、Apache

正向代理與反向代理有什麼區別?

正向代理:發生在 客戶端,是由使用者主動發起的。FQ軟體就是典型的正向代理,客戶端通過主動訪問代理伺服器,讓代理伺服器獲得需要的外網資料,然後轉發回客戶端。

反向代理:發生在 服務端,使用者不知道代理的存在。

反向代理是如何實現負載均衡的呢?以 Nginx 為例,如下所示:

首先,在代理伺服器上設定好負載均衡規則。然後,當收到客戶端請求,反向代理伺服器攔截指定的域名或 IP 請求,根據負載均衡演算法,將請求分發到候選伺服器上。其次,如果某臺候選伺服器當機,反向代理伺服器會有容錯處理,比如分發請求失敗 3 次以上,將請求分發到其他候選伺服器上。

反向代理的 優點:

  1. 多種負載均衡演算法:支援多種負載均衡演算法,以應對不同的場景需求。

  2. 可以監控伺服器:基於 HTTP 協議,可以監控轉發伺服器的狀態,如:系統負載、響應時間、是否可用、連線數、流量等,從而根據這些資料調整負載均衡的策略。

反向代理的 缺點:

  1. 額外的轉發開銷:反向代理的轉發操作本身是有效能開銷的,可能會包括建立連線,等待連線響應,分析響應結果等操作。
  1. 增加系統複雜度:反向代理常用於做分散式應用的水平擴充套件,但反向代理服務存在以下問題,為了解決以下問題會給系統整體增加額外的複雜度和運維成本:
  • 反向代理服務如果自身當機,就無法訪問站點,所以需要有 高可用 方案,常見的方案有:主備模式(一主一備)、雙主模式(互為主備)。

  • 反向代理服務自身也存在效能瓶頸,隨著需要轉發的請求量不斷攀升,需要有 可擴充套件 方案。

2.2.4 IP負載均衡

IP 負載均衡是在網路層通過修改請求目的地址進行負載均衡。

如上圖所示,IP 均衡處理流程大致為:

客戶端請求 192.168.137.10,由負載均衡伺服器接收到報文。

負載均衡伺服器根據演算法選出一個服務節點 192.168.0.1,然後將報文請求地址改為該節點的 IP。

真實服務節點收到請求報文,處理後,返回響應資料到負載均衡伺服器。

負載均衡伺服器將響應資料的源地址改負載均衡伺服器地址,返回給客戶端。

IP 負載均衡在核心程式完成資料分發,較反向代理負載均衡有更好的從處理效能。但是,由於所有請求響應都要經過負載均衡伺服器,叢集的吞吐量受制於負載均衡伺服器的頻寬。

2.2.5 資料鏈路層負載均衡

資料鏈路層負載均衡是指在通訊協議的資料鏈路層修改 mac 地址進行負載均衡。

在 Linux 平臺上最好的鏈路層負載均衡開源產品是 LVS (Linux Virtual Server)。LVS 是基於 Linux 核心中 netfilter 框架實現的負載均衡系統。netfilter 是核心態的 Linux 防火牆機制,可以在資料包流經過程中,根據規則設定若干個關卡(hook 函式)來執行相關的操作。

LVS 的工作流程大致如下:

當使用者訪問 www.sina.com.cn 時,使用者資料通過層層網路,最後通過交換機進入 LVS 伺服器網路卡,並進入核心網路層。

進入 PREROUTING 後經過路由查詢,確定訪問的目的 VIP 是本機 IP 地址,所以資料包進入到 INPUT 鏈上

IPVS 是工作在 INPUT 鏈上,會根據訪問的 vip+port 判斷請求是否 IPVS 服務,如果是則呼叫註冊的 IPVS HOOK 函式,進行 IPVS 相關主流程,強行修改資料包的相關資料,並將資料包發往 POSTROUTING 鏈上。

POSTROUTING 上收到資料包後,根據目標 IP 地址(後端伺服器),通過路由選路,將資料包最終發往後端的伺服器上。

開源 LVS 版本有 3 種工作模式,每種模式工作原理截然不同,說各種模式都有自己的優缺點,分別適合不同的應用場景,不過最終本質的功能都是能實現均衡的流量排程和良好的擴充套件性。主要包括三種模式:DR 模式、NAT 模式、Tunnel 模式。

三、負載均衡演算法

負載均衡器的實現可以分為兩個部分:

根據負載均衡演算法在候選伺服器列表選出一個伺服器;

將請求資料傳送到該伺服器上。

負載均衡演算法是負載均衡服務核心中的核心。負載均衡產品多種多樣,但是各種負載均衡演算法原理是共性的。負載均衡演算法有很多種,分別適用於不同的應用場景,本文僅介紹最為常見的負載均衡演算法的特性及原理:輪詢、隨機、最小活躍數、源地址雜湊、一致性雜湊

:負載均衡演算法的實現,推薦閱讀 Dubbo 官方負載均衡演算法說明 ,原始碼講解非常詳細,非常值得借鑑。

3.1 隨機

3.1.1 隨機演算法

隨機(Random) 演算法將請求隨機分發到候選伺服器。

隨機演算法 適合伺服器硬體相同的場景。學習過概率論的都知道,呼叫量較小的時候,可能負載並不均勻,呼叫量越大,負載越均衡。

【示例】隨機演算法實現示例

負載均衡介面

public interface LoadBalance<N extends Node> {

    N select(List<N> nodes, String ip);

}

負載均衡抽象類

public abstract class BaseLoadBalance<N extends Node> implements LoadBalance<N> {
​
    @Override
    public N select(List<N> nodes, String ip) {
        if (CollectionUtil.isEmpty(nodes)) {
            return null;
        }
​
        // 如果 nodes 列表中僅有一個 node,直接返回即可,無需進行負載均衡
        if (nodes.size() == 1) {
            return nodes.get(0);
        }
​
        return doSelect(nodes, ip);
    }
​
    protected abstract N doSelect(List<N> nodes, String ip);
​
}


伺服器節點類

public class Node implements Comparable<Node> {
​
    protected String url;
​
    protected Integer weight;
​
    protected Integer active;
​
    // ...
}

隨機演算法實現

public class RandomLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {
​
    private final Random random = new Random();
​
    @Override
    protected N doSelect(List<N> nodes, String ip) {
        // 在列表中隨機選取一個節點
        int index = random.nextInt(nodes.size());
        return nodes.get(index);
    }
​
}

3.1.2 加權隨機演算法

加權隨機(Weighted Rando****m) 演算法在隨機演算法的基礎上,按照概率調整權重,進行負載分配。

【示例】加權隨機演算法實現示例

public class WeightRandomLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {
​
    private final Random random = ThreadLocalRandom.current();
​
    @Override
    protected N doSelect(List<N> nodes, String ip) {
​
        int length = nodes.size();
        AtomicInteger totalWeight = new AtomicInteger(0);
        for (N node : nodes) {
            Integer weight = node.getWeight();
            totalWeight.getAndAdd(weight);
        }
​
        if (totalWeight.get() > 0) {
            int offset = random.nextInt(totalWeight.get());
            for (N node : nodes) {
                // 讓隨機值 offset 減去權重值
                offset -= node.getWeight();
                if (offset < 0) {
                    // 返回相應的 Node
                    return node;
                }
            }
        }
​
        // 直接隨機返回一個
        return nodes.get(random.nextInt(length));
    }
​
}

3.2 輪詢

3.2.1 輪詢演算法

輪詢(Round Robin)演算法的策略是:將請求依次分發到候選伺服器。

如下圖所示,負載均衡器收到來自客戶端的 6 個請求,(1, 3, 5) 的請求會被髮送到伺服器 1,(2, 4, 6) 的請求會被髮送到伺服器 2。

該演算法適合場景:各伺服器處理能力相近,且每個事務工作量差異不大。如果存在較大差異,那麼處理較慢的伺服器就可能會積壓請求,最終無法承擔過大的負載。

【示例】輪詢演算法示例

輪詢負載均衡演算法實現

public class RoundRobinLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {
​
    private final AtomicInteger position = new AtomicInteger(0);
​
    @Override
    protected N doSelect(List<N> nodes, String ip) {
        int length = nodes.size();
        // 如果位置值已經等於節點數,重置為 0
        position.compareAndSet(length, 0);
        N node = nodes.get(position.get());
        position.getAndIncrement();
        return node;
    }
​
}

3.2.2 加權輪詢演算法

加權輪詢(Weighted Round Robbin)演算法在輪詢演算法的基礎上,增加了權重屬性來調節轉發伺服器的請求數目。效能高、處理速度快的節點應該設定更高的權重,使得分發時優先將請求分發到權重較高的節點上。

如下圖所示,伺服器 A 設定權重為 5,伺服器 B 設定權重為 1,負載均衡器收到來自客戶端的 6 個請求,那麼 (1, 2, 3, 4, 5) 請求會被髮送到伺服器 A,(6) 請求會被髮送到伺服器 B。

【示例】加權輪詢演算法實現示例

以下實現基於 Dubbo 加權輪詢演算法做了一些簡化。

public class WeightRoundRobinLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {
​
    /**
     * 60秒
     */
    private static final int RECYCLE_PERIOD = 60000;
​
    /**
     * Node hashcode 到 WeightedRoundRobin 的對映關係
     */
    private ConcurrentMap<Integer, WeightedRoundRobin> weightMap = new ConcurrentHashMap<>();
​
    /**
     * 原子更新鎖
     */
    private AtomicBoolean updateLock = new AtomicBoolean();
​
    @Override
    protected N doSelect(List<N> nodes, String ip) {
​
        int totalWeight = 0;
        long maxCurrent = Long.MIN_VALUE;
​
        // 獲取當前時間
        long now = System.currentTimeMillis();
        N selectedNode = null;
        WeightedRoundRobin selectedWRR = null;
​
        // 下面這個迴圈主要做了這樣幾件事情:
        //   1. 遍歷 Node 列表,檢測當前 Node 是否有相應的 WeightedRoundRobin,沒有則建立
        //   2. 檢測 Node 權重是否發生了變化,若變化了,則更新 WeightedRoundRobin 的 weight 欄位
        //   3. 讓 current 欄位加上自身權重,等價於 current += weight
        //   4. 設定 lastUpdate 欄位,即 lastUpdate = now
        //   5. 尋找具有最大 current 的 Node,以及 Node 對應的 WeightedRoundRobin,
        //      暫存起來,留作後用
        //   6. 計算權重總和
        for (N node : nodes) {
            int hashCode = node.hashCode();
            WeightedRoundRobin weightedRoundRobin = weightMap.get(hashCode);
            int weight = node.getWeight();
            if (weight < 0) {
                weight = 0;
            }
​
            // 檢測當前 Node 是否有對應的 WeightedRoundRobin,沒有則建立
            if (weightedRoundRobin == null) {
                weightedRoundRobin = new WeightedRoundRobin();
                // 設定 Node 權重
                weightedRoundRobin.setWeight(weight);
                // 儲存 url 唯一標識 identifyString 到 weightedRoundRobin 的對映關係
                weightMap.putIfAbsent(hashCode, weightedRoundRobin);
                weightedRoundRobin = weightMap.get(hashCode);
            }
            // Node 權重不等於 WeightedRoundRobin 中儲存的權重,說明權重變化了,此時進行更新
            if (weight != weightedRoundRobin.getWeight()) {
                weightedRoundRobin.setWeight(weight);
            }
​
            // 讓 current 加上自身權重,等價於 current += weight
            long current = weightedRoundRobin.increaseCurrent();
            // 設定 lastUpdate,表示近期更新過
            weightedRoundRobin.setLastUpdate(now);
            // 找出最大的 current
            if (current > maxCurrent) {
                maxCurrent = current;
                // 將具有最大 current 權重的 Node 賦值給 selectedNode
                selectedNode = node;
                // 將 Node 對應的 weightedRoundRobin 賦值給 selectedWRR,留作後用
                selectedWRR = weightedRoundRobin;
            }
​
            // 計算權重總和
            totalWeight += weight;
        }
​
        // 對 weightMap 進行檢查,過濾掉長時間未被更新的節點。
        // 該節點可能掛了,nodes 中不包含該節點,所以該節點的 lastUpdate 長時間無法被更新。
        // 若未更新時長超過閾值後,就會被移除掉,預設閾值為60秒。
        if (!updateLock.get() && nodes.size() != weightMap.size()) {
            if (updateLock.compareAndSet(false, true)) {
                try {
                    // 遍歷修改,即移除過期記錄
                    weightMap.entrySet().removeIf(item -> now - item.getValue().getLastUpdate() > RECYCLE_PERIOD);
                } finally {
                    updateLock.set(false);
                }
            }
        }
​
        if (selectedNode != null) {
            // 讓 current 減去權重總和,等價於 current -= totalWeight
            selectedWRR.decreaseCurrent(totalWeight);
            // 返回具有最大 current 的 Node
            return selectedNode;
        }
​
        // should not happen here
        return nodes.get(0);
    }
​
    protected static class WeightedRoundRobin {
​
        // 服務提供者權重
        private int weight;
        // 當前權重
        private AtomicLong current = new AtomicLong(0);
        // 最後一次更新時間
        private long lastUpdate;
​
        public long increaseCurrent() {
            // current = current + weight;
            return current.addAndGet(weight);
        }
​
        public long decreaseCurrent(int total) {
            // current = current - total;
            return current.addAndGet(-1 * total);
        }
​
        public int getWeight() {
            return weight;
        }
​
        public void setWeight(int weight) {
            this.weight = weight;
            // 初始情況下,current = 0
            current.set(0);
        }
​
        public AtomicLong getCurrent() {
            return current;
        }
​
        public void setCurrent(AtomicLong current) {
            this.current = current;
        }
​
        public long getLastUpdate() {
            return lastUpdate;
        }
​
        public void setLastUpdate(long lastUpdate) {
            this.lastUpdate = lastUpdate;
        }
​
    }
​
}

3.3 最小活躍數

最小活躍數(Least Active)演算法 將請求分發到連線數/請求數最少的候選伺服器(目前處理請求最少的伺服器)。

  • 特點:根據候選伺服器當前的請求連線數,動態分配。

  • 場景:適用於對系統負載較為敏感或請求連線時長相差較大的場景。

由於每個請求的連線時長不一樣,如果採用簡單的輪循或隨機演算法,都可能出現某些伺服器當前連線數過大,而另一些伺服器的連線過小的情況,這就造成了負載並非真正均衡。雖然,輪詢或演算法都可以通過加權重屬性的方式進行負載調整,但加權方式難以應對動態變化。

例如下圖中,(1, 3, 5) 請求會被髮送到伺服器 1,但是 (1, 3) 很快就斷開連線,此時只有 (5) 請求連線伺服器 1;(2, 4, 6) 請求被髮送到伺服器 2,只有 (2) 的連線斷開。該系統繼續執行時,伺服器 2 會承擔過大的負載。

最小活躍數演算法會記錄當前時刻,每個候選節點正在處理的連線數,然後選擇連線數最小的節點。該策略能夠動態、實時地反應伺服器的當前狀況,較為合理地將負責分配均勻,適用於對當前系統負載較為敏感的場景。

例如下圖中,伺服器 1 當前連線數最小,那麼新到來的請求 6 就會被髮送到伺服器 1 上。

加權最小活躍數(Weighted Least Connection)在最小活躍數的基礎上,根據伺服器的效能為每臺伺服器分配權重,再根據權重計算出每臺伺服器能處理的連線數。

最小活躍數演算法實現要點:活躍呼叫數越小,表明該服務節點處理能力越高,單位時間內可處理更多的請求,應優先將請求分發給該服務。在具體實現中,每個服務節點對應一個活躍數 active。初始情況下,所有服務提供者活躍數均為 0。每收到一個請求,活躍數加 1,完成請求後則將活躍數減 1。在服務執行一段時間後,效能好的服務提供者處理請求的速度更快,因此活躍數下降的也越快,此時這樣的服務提供者能夠優先獲取到新的服務請求、這就是最小活躍數負載均衡演算法的基本思想。

【示例】最小活躍數演算法實現

以下實現基於 Dubbo 最小活躍數負載均衡演算法做了些許改動。

public class LeastActiveLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {
​
    private final Random random = new Random();
​
    @Override
    protected N doSelect(List<N> nodes, String ip) {
        int length = nodes.size();
        // 最小的活躍數
        int leastActive = -1;
        // 具有相同“最小活躍數”的服務者提供者(以下用 Node 代稱)數量
        int leastCount = 0;
        // leastIndexs 用於記錄具有相同“最小活躍數”的 Node 在 nodes 列表中的下標資訊
        int[] leastIndexs = new int[length];
        int totalWeight = 0;
        // 第一個最小活躍數的 Node 權重值,用於與其他具有相同最小活躍數的 Node 的權重進行對比,
        // 以檢測是否“所有具有相同最小活躍數的 Node 的權重”均相等
        int firstWeight = 0;
        boolean sameWeight = true;
​
        // 遍歷 nodes 列表
        for (int i = 0; i < length; i++) {
            N node = nodes.get(i);
            // 發現更小的活躍數,重新開始
            if (leastActive == -1 || node.getActive() < leastActive) {
                // 使用當前活躍數更新最小活躍數 leastActive
                leastActive = node.getActive();
                // 更新 leastCount 為 1
                leastCount = 1;
                // 記錄當前下標值到 leastIndexs 中
                leastIndexs[0] = i;
                totalWeight = node.getWeight();
                firstWeight = node.getWeight();
                sameWeight = true;
​
                // 當前 Node 的活躍數 node.getActive() 與最小活躍數 leastActive 相同
            } else if (node.getActive() == leastActive) {
                // 在 leastIndexs 中記錄下當前 Node 在 nodes 集合中的下標
                leastIndexs[leastCount++] = i;
                // 累加權重
                totalWeight += node.getWeight();
                // 檢測當前 Node 的權重與 firstWeight 是否相等,
                // 不相等則將 sameWeight 置為 false
                if (sameWeight && i > 0
                    && node.getWeight() != firstWeight) {
                    sameWeight = false;
                }
            }
        }
​
        // 當只有一個 Node 具有最小活躍數,此時直接返回該 Node 即可
        if (leastCount == 1) {
            return nodes.get(leastIndexs[0]);
        }
​
        // 有多個 Node 具有相同的最小活躍數,但它們之間的權重不同
        if (!sameWeight && totalWeight > 0) {
            // 隨機生成一個 [0, totalWeight) 之間的數字
            int offsetWeight = random.nextInt(totalWeight);
            // 迴圈讓隨機數減去具有最小活躍數的 Node 的權重值,
            // 當 offset 小於等於0時,返回相應的 Node
            for (int i = 0; i < leastCount; i++) {
                int leastIndex = leastIndexs[i];
                // 獲取權重值,並讓隨機數減去權重值
                offsetWeight -= nodes.get(leastIndex).getWeight();
                if (offsetWeight <= 0) {
                    return nodes.get(leastIndex);
                }
            }
        }
        // 如果權重相同或權重為0時,隨機返回一個 Node
        return nodes.get(leastIndexs[random.nextInt(leastCount)]);
    }
​
}

3.4 源地址雜湊

源地址雜湊(IP Hash)演算法 根據請求源 IP,通過雜湊計算得到一個數值,用該數值在候選伺服器列表的進行取模運算,得到的結果便是選中的伺服器。

可以保證同一 IP 的客戶端的請求會轉發到同一臺伺服器上,用來實現會話粘滯(Sticky Session)。

特點:保證特定使用者總是請求到相同的伺服器,若伺服器當機,會話會丟失。

【示例】源地址雜湊演算法實現示例

public class IpHashLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {
​
    @Override
    protected N doSelect(List<N> nodes, String ip) {
        if (StrUtil.isBlank(ip)) {
            ip = "127.0.0.1";
        }
​
        int length = nodes.size();
        int index = hash(ip) % length;
        return nodes.get(index);
    }
​
    public int hash(String text) {
        return HashUtil.fnvHash(text);
    }
​
}

3.5 一致性雜湊

一致性雜湊(Consistent Hash)演算法的目標是:相同的請求儘可能落到同一個伺服器上。

一致性雜湊 可以很好的解決 穩定性問題,可以將所有的 儲存節點 排列在 首尾相接 的 Hash 環上,每個 key 在計算 Hash 後會 順時針 找到 臨接 的 儲存節點 存放。而當有節點 加入 或 退出 時,僅影響該節點在 Hash環上順時針相鄰的後續節點。

1)相同的請求是指:一般在使用一致性雜湊時,需要指定一個 key 用於 hash 計算,可能是:

使用者 ID

請求方 IP

請求服務名稱,引數列表構成的串

2)儘可能是指:伺服器可能發生上下線,少數伺服器的變化不應該影響大多數的請求。

當某臺候選伺服器當機時,原本發往該伺服器的請求,會基於虛擬節點,平攤到其它候選伺服器,不會引起劇烈變動。

優點:加入 和 刪除 節點隻影響 雜湊環 中 順時針方向 的 相鄰的節點,對其他節點無影響。

缺點:加減節點 會造成 雜湊環 中部分資料 無法命中。當使用 少量節點 時,節點變化 將大範圍影響 雜湊環 中 資料對映,不適合 少量資料節點 的分散式方案。普通 的 一致性雜湊分割槽 在增減節點時需要 增加一倍 或 減去一半 節點才能保證 資料 和 負載的均衡。

注意:因為 一致性雜湊分割槽 的這些缺點,一些分散式系統採用 虛擬槽 對 一致性雜湊 進行改進,比如 Dynamo 系統。

【示例】一致性雜湊演算法示例

public class ConsistentHashLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {
​
    private final ConcurrentMap<String, ConsistentHashSelector<?>> selectors = new ConcurrentHashMap<>();
​
    @SuppressWarnings("unchecked")
    @Override
    protected N doSelect(List<N> nodes, String ip) {
        // 分片數,這裡設為節點數的 4 倍
        Integer replicaNum = nodes.size() * 4;
        // 獲取 nodes 原始的 hashcode
        int identityHashCode = System.identityHashCode(nodes);
​
        // 如果 nodes 是一個新的 List 物件,意味著節點數量發生了變化
        // 此時 selector.identityHashCode != identityHashCode 條件成立
        ConsistentHashSelector<N> selector = (ConsistentHashSelector<N>) selectors.get(ip);
        if (selector == null || selector.identityHashCode != identityHashCode) {
            // 建立新的 ConsistentHashSelector
            selectors.put(ip, new ConsistentHashSelector<>(nodes, identityHashCode, replicaNum));
            selector = (ConsistentHashSelector<N>) selectors.get(ip);
        }
        // 呼叫 ConsistentHashSelector 的 select 方法選擇 Node
        return selector.select(ip);
    }
​
    /**
     * 一致性雜湊選擇器
     */
    private static final class ConsistentHashSelector<N extends Node> {
​
        /**
         * 儲存虛擬節點
         */
        private final TreeMap<Long, N> virtualNodes;
​
        private final int identityHashCode;
​
        /**
         * 構造器
         *
         * @param nodes            節點列表
         * @param identityHashCode hashcode
         * @param replicaNum       分片數
         */
        ConsistentHashSelector(List<N> nodes, int identityHashCode, Integer replicaNum) {
            this.virtualNodes = new TreeMap<>();
            this.identityHashCode = identityHashCode;
            // 獲取虛擬節點數,預設為 100
            if (replicaNum == null) {
                replicaNum = 100;
            }
            for (N node : nodes) {
                for (int i = 0; i < replicaNum / 4; i++) {
                    // 對 url 進行 md5 運算,得到一個長度為16的位元組陣列
                    byte[] digest = md5(node.getUrl());
                    // 對 digest 部分位元組進行 4 次 hash 運算,得到四個不同的 long 型正整數
                    for (int j = 0; j < 4; j++) {
                        // h = 0 時,取 digest 中下標為 0 ~ 3 的4個位元組進行位運算
                        // h = 1 時,取 digest 中下標為 4 ~ 7 的4個位元組進行位運算
                        // h = 2, h = 3 時過程同上
                        long m = hash(digest, j);
                        // 將 hash 到 node 的對映關係儲存到 virtualNodes 中,
                        // virtualNodes 需要提供高效的查詢操作,因此選用 TreeMap 作為儲存結構
                        virtualNodes.put(m, node);
                    }
                }
            }
        }
​
        public N select(String key) {
            // 對引數 key 進行 md5 運算
            byte[] digest = md5(key);
            // 取 digest 陣列的前四個位元組進行 hash 運算,再將 hash 值傳給 selectForKey 方法,
            // 尋找合適的 Node
            return selectForKey(hash(digest, 0));
        }
​
        private N selectForKey(long hash) {
            // 查詢第一個大於或等於當前 hash 的節點
            Map.Entry<Long, N> entry = virtualNodes.ceilingEntry(hash);
            // 如果 hash 大於 Node 在雜湊環上最大的位置,此時 entry = null,
            // 需要將 TreeMap 的頭節點賦值給 entry
            if (entry == null) {
                entry = virtualNodes.firstEntry();
            }
            // 返回 Node
            return entry.getValue();
        }
​
    }
​
    /**
     * 計算 hash 值
     */
    public static long hash(byte[] digest, int number) {
        return (((long) (digest[3 + number * 4] & 0xFF) << 24)
            | ((long) (digest[2 + number * 4] & 0xFF) << 16)
            | ((long) (digest[1 + number * 4] & 0xFF) << 8)
            | (digest[number * 4] & 0xFF))
            & 0xFFFFFFFFL;
    }
​
    /**
     * 計算 MD5 值
     */
    public static byte[] md5(String value) {
        MessageDigest md5;
        try {
            md5 = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e.getMessage(), e);
        }
        md5.reset();
        byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
        md5.update(bytes);
        return md5.digest();
    }
​
}

以上示例基於 Dubbo 的一致性雜湊負載均衡演算法做了一些簡化。

四、參考資料

1. Comparing Load Balancing Algorithms

2. 《大型網站技術架構:核心原理與案例分析》

3. 大型網站架構系列:負載均衡詳解(1)

4. 什麼是負載均衡

5. What Is Load Balancing

6. Dubbo 官方負載均衡演算法說明

7. 負載均衡演算法及手段

8. 利用 dns 解析來實現網站的負載均衡

作者:vivo網際網路團隊-Zhang Peng

相關文章