天天看點

什麼是負載均衡,看完文章秒懂一、負載均衡簡介二、負載均衡的分類三、負載均衡算法

一、負載均衡簡介

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。

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

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

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

什麼是負載均衡,看完文章秒懂一、負載均衡簡介二、負載均衡的分類三、負載均衡算法

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

什麼是負載均衡,看完文章秒懂一、負載均衡簡介二、負載均衡的分類三、負載均衡算法

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

反向代理的 優點:

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

2) 可以監控伺服器:基于 HTTP 協定,可以監控轉發伺服器的狀态,如:系統負載、響應時間、是否可用、連接配接數、流量等,進而根據這些資料調整負載均衡的政策。

反向代理的 缺點:

1) 額外的轉發開銷:反向代理的轉發操作本身是有性能開銷的,可能會包括建立連接配接,等待連接配接響應,分析響應結果等操作。
2) 增加系統複雜度:反向代理常用于做分布式應用的水準擴充,但反向代理服務存在以下問題,為了解決以下問題會給系統整體增加額外的複雜度和運維成本:
  • 反向代理服務如果自身當機,就無法通路站點,是以需要有 高可用 方案,常見的方案有:主備模式(一主一備)、雙主模式(互為主備)。
  • 反向代理服務自身也存在性能瓶頸,随着需要轉發的請求量不斷攀升,需要有 可擴充 方案。

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 Random) 算法在随機算法的基礎上,按照機率調整權重,進行負載配置設定。

【示例】權重随機算法實作示例

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 的一緻性簡化哈希負載均衡算法。