解讀一致性hash算法

       熟悉hash算法的你,有沒有對一致性hash算法也比較熟悉?

     一致性hash算法的主要應用場景是在分佈式的算法中,比如在一個緩存的分佈式系統中,我們可以使用一致性hash算法實現間接的人爲控制對每臺服務器的緩存命中情況。一致性hash算法,可以理解成爲了緩存系統在提供緩存服務過程中,更好的實現高可用,即在對服務器節點進行變更時,最大程度的減少對當前系統的影響。

    下面一起來看一致性hash算法

     假設當前的緩存系統中有五臺服務器(A-E),我們將每一臺服務器的IP地址使用某種hash算法,計算出每一個地址對應的hash值,之後將這五個hash值按照大小順序分佈在一個0-2^32的環上,如下圖所示:


       當我們使用四臺服務器(俗稱節點),對當前緩存系統進行訪問時,也需要先將每個節點對應的hash值算出,按照順時針的方向分佈在上圖構成的環中:


         這時,遵照一致性hash算法,當四個節點獲取緩存時,命中的機器分別是當前環中每個節點的順時針方向的下一個緩存服務器,如node1的請求會落在A服務器上,也就是說獲取的緩存來自於A服務器;這時每個節點獲取緩存的請求分佈在不同的服務器上,如下圖所示:


          從上面的圖片中我們不難發現,通過一致性hash算法,我們可以實現對當前獲取緩存的請求進行一定程度上分擔,不至於某臺緩存服務器上壓力過大,但是有朋友就會有疑問,既然這樣我們可以將某個節點的請求綁定爲某個固定的服務器,不也可以實現請求均攤?

      這個問題就是我們接下來要考慮的問題:當上圖中某個緩存服務器出現宕機,比如E出現異常,無法提供服務,那麼按照順時針的方向,當前node2和node3的請求會落在B服務器上,這樣B服務器就會出現承載更大的壓力,一旦壓力過大,B服務器出現宕機,那麼原先需要B和E服務器提供緩存數據的請求都會落在A上,久而久之就會出現“雪崩”,緩存系統中的所有服務器都會因爲無法承載壓力而宕機,導致整個系統的崩潰。這就與我們使用一致性hash算法實現高可用的初衷背道而馳了!

      所以,一致性hash算法中提供了另外一種方式--添加虛擬節點,添加虛擬節點的原理:

      我們將當前緩存系統裏五臺機器做某倍的擴充,即每臺機器都添加一定數目的虛擬機器,這些衍生出的虛擬機器都指向它的實際機器。如,A服務器創建出3個虛擬節點 A1,A2,A3,並將虛擬節點的IP地址設置爲實際機器的IP+標記,這時緩存系統中的機器個數將由5個變爲20個,對每一個機器進行hash計算,環中的服務器的分佈將會更密集:這樣就會出現15個虛擬服務器節點分佈以某種規則分佈在環上,而且環上的服務器分佈密集,環由最開始的六段變成了20多段,這樣的話,第二幅圖中的訪問請求就會發生變化,比如:



        這時,如果其中一臺服務器宕機,該服務器順時針方向的下一臺服務器可能是一個虛擬服務器也可能是實際服務器,但是可以確保的是,這時某臺服務器宕機影響的範圍從最開始的1/6變成了1/20 ,概率大大降低,終止服務的風險也大大降低。

        之前在網上看見有網友說,一致性hash算法在redis2.8中有使用,但是通過查閱和驗證,發現並沒有,只是說redis中使用槽的概念與一致性hash算法的思想相似:


      截圖源於 redis官方網站

    根據上述場景,小編整理了一份不帶虛擬節點的程序和添加虛擬節點的兩份代碼:

package ownTest.redisTest;

import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 不帶虛擬節點的一致性Hash算法實現
 * 
 * @author yangshichao
 *
 */
public class ConsistentHashingWithoutVirtualNode {
	/**
	 * 待添加入Hash環的服務器列表
	 */
	private static String[] servers = { 
			"192.168.0.0:111", 
			"192.168.0.1:111", 
			"192.168.0.2:111", 
			"192.168.0.3:111",
			"192.168.0.4:111"};

	/**
	 * key表示服務器的hash值,value表示服務器的名稱
	 */
	private static SortedMap<Integer, String> sortedMap = new TreeMap<Integer, String>();

	/**
	 * 程序初始化,將所有的服務器放入sortedMap中
	 */
	static {
		for (int i = 0; i < servers.length; i++) {
			int hash = getHash(servers[i]);
			System.out.println("[" + servers[i] + "]加入集合中, 其Hash值爲" + hash);
			sortedMap.put(hash, servers[i]);
		}
		System.out.println();//輸出空行
	}

	/**
	 * 使用FNV1_32_HASH算法計算服務器的Hash值
	 */
	private static int getHash(String str) {
		final int p = 16777619;
		int hash = (int) 2166136261L;
		for (int i = 0; i < str.length(); i++)
			hash = (hash ^ str.charAt(i)) * p;
		hash += hash << 13;
		hash ^= hash >> 7;
		hash += hash << 3;
		hash ^= hash >> 17;
		hash += hash << 5;

		// 如果算出來的值爲負數則取其絕對值
		if (hash < 0)
			hash = Math.abs(hash);
		return hash;
	}

	/**
	 * 得到應當路由到的結點
	 */
	private static String getServer(String node) {
		// 得到帶路由的結點的Hash值
		int hash = getHash(node);
		// 得到大於該Hash值的所有Map
		SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
		// 第一個Key就是順時針過去離node最近的那個結點
		Integer i = subMap.firstKey();
		// 返回對應的服務器名稱
		return subMap.get(i);
	}

	public static void main(String[] args) {
		String[] nodes = { "127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333","192.168.80.21:4444" };
		for (int i = 0; i < nodes.length; i++)
			System.out.println(
					"[" 
					+ nodes[i] 
					+ "]的hash值爲" 
					+ getHash(nodes[i]) 
					+ ", 被路由到結點[" 
					+ getServer(nodes[i]) 
					+ "]");
	}
}
    添加虛擬節點:

package ownTest.redisTest;

import java.util.LinkedList;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 帶虛擬節點的一致性hash算法
 * @author YangShiChao
 *
 */
public class ConsistentHashingWithVirtualNode {

	/**
     * 待添加入Hash環的服務器列表
     */
    private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
            "192.168.0.3:111", "192.168.0.4:111"};
    
    /**
     * 真實結點列表,考慮到服務器上線、下線的場景,即添加、刪除的場景會比較頻繁,這裏使用LinkedList會更好
     */
    private static List<String> realNodes = new LinkedList<String>();
    
    /**
     * 虛擬節點,key表示虛擬節點的hash值,value表示虛擬節點的名稱
     */
    private static SortedMap<Integer, String> virtualNodes = 
            new TreeMap<Integer, String>();
    
    /**
     * 虛擬節點的數目,這裏寫死,爲了演示需要,一個真實結點對應3個虛擬節點
     */
    private static final int VIRTUAL_NODES = 3;
    
    static
    {
        // 先把原始的服務器添加到真實結點列表中
        for (int i = 0; i < servers.length; i++)
            realNodes.add(servers[i]);
        
        // 再添加虛擬節點,遍歷LinkedList使用foreach循環效率會比較高
        for (String str : realNodes)
        {
            for (int i = 0; i < VIRTUAL_NODES; i++)
            {
                String virtualNodeName = str + "&&VN" + String.valueOf(i);
                int hash = getHash(virtualNodeName);
                System.out.println("虛擬節點[" + virtualNodeName + "]被添加, hash值爲" + hash);
                virtualNodes.put(hash, virtualNodeName);
            }
        }
        System.out.println();
    }
    
    /**
     * 使用FNV1_32_HASH算法計算服務器的Hash值 
     */
    private static int getHash(String str)
    {
        final int p = 16777619;
        int hash = (int)2166136261L;
        for (int i = 0; i < str.length(); i++)
            hash = (hash ^ str.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;
        
        // 如果算出來的值爲負數則取其絕對值
        if (hash < 0)
            hash = Math.abs(hash);
        return hash;
    }
    
    /**
     * 得到應當路由到的結點
     */
    private static String getServer(String node)
    {
        // 得到帶路由的結點的Hash值
        int hash = getHash(node);
        // 得到大於該Hash值的所有Map,藉助了treeMap的有序特性
        SortedMap<Integer, String> subMap = 
                virtualNodes.tailMap(hash);
        // 第一個Key就是順時針過去離node最近的那個結點
        Integer i = subMap.firstKey();
        // 返回對應的虛擬節點名稱,這裏字符串稍微截取一下,爲了只顯示當前訪問對應的實際機器
        String virtualNode = subMap.get(i);
        return virtualNode.substring(0, virtualNode.indexOf("&&"));
    }
    
    public static void main(String[] args)
    {
    	/**
    	 * 可以把這些nodes理解成不同的服務器發來的對當前集羣中服務器的請求
    	 */
    	String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
        for (int i = 0; i < nodes.length; i++)
            System.out.println("[" + nodes[i] + "]的hash值爲" + 
                    getHash(nodes[i]) + ", 被路由到結點[" + getServer(nodes[i]) + "]");
    }
}
        綜上所述,在分佈式系統中使用一致性hash算法的主要目的是:更大程度的縮小服務器數量的增減的影響範圍,更好的給程序提供服務,這樣我們是不是可以直接將它理解爲提高了服務器的高可用性?!!










發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章