一致性Hash算法(Consistent Hash)

##1 分佈式算法## 在作服務器負載均衡時候可供選擇的負載均衡的算法有不少,包括: 輪循算法(Round Robin)、哈希算法(HASH)、最少鏈接算法(Least Connection)、響應速度算法(Response Time)、加權法(Weighted )等。其中哈希算法是最爲經常使用的算法.java

典型的應用場景是: 有N臺服務器提供緩存服務,須要對服務器進行負載均衡,將請求平均分發到每臺服務器上,每臺機器負責1/N的服務。node

經常使用的算法是對hash結果取餘數 (hash() mod N ):對機器編號從0到N-1,按照自定義的 hash()算法,對每一個請求的hash()值按N取模,獲得餘數i,而後將請求分發到編號爲i的機器。但這樣的算法方法存在致命問題,若是某一臺機器宕機,那麼應該落在該機器的請求就沒法獲得正確的處理,這時須要將當掉的服務器從算法從去除,此時候會有(N-1)/N的服務器的緩存數據須要從新進行計算;若是新增一臺機器,會有N /(N+1)的服務器的緩存數據須要進行從新計算。對於系統而言,這一般是不可接受的顛簸(由於這意味着大量緩存的失效或者數據須要轉移)。那麼,如何設計一個負載均衡策略,使得受到影響的請求儘量的少呢?web

在Memcached、Key-Value Store 、Bittorrent DHT、LVS中都採用了Consistent Hashing算法,能夠說Consistent Hashing 是分佈式系統負載均衡的首選算法。 ##2 分佈式緩存問題## 在大型web應用中,緩存可算是當今的一個標準開發配置了。在大規模的緩存應用中,應運而生了分佈式緩存系統。分佈式緩存系統的基本原理,你們也有所耳聞。key-value如何均勻的分散到集羣中?說到此,最常規的方式莫過於hash取模的方式。好比集羣中可用機器適量爲N,那麼key值爲K的的數據請求很簡單的應該路由到hash(K) mod N對應的機器。的確,這種結構是簡單的,也是實用的。可是在一些高速發展的web系統中,這樣的解決方案仍有些缺陷。隨着系統訪問壓力的增加,緩存系統不得不經過增長機器節點的方式提升集羣的相應速度和數據承載量。增長機器意味着按照hash取模的方式,在增長機器節點的這一時刻,大量的緩存命不中,緩存數據須要從新創建,甚至是進行總體的緩存數據遷移,瞬間會給DB帶來極高的系統負載,設置致使DB服務器宕機。 那麼就沒有辦法解決hash取模的方式帶來的詬病嗎?算法

假設咱們有一個網站,最近發現隨着流量增長,服務器壓力愈來愈大,以前直接讀寫數據庫的方式不太給力了,因而咱們想引入Memcached做爲緩存機制。如今咱們一共有三臺機器能夠做爲Memcached服務器,以下圖所示。數據庫

在此輸入圖片描述

很顯然,最簡單的策略是將每一次Memcached請求隨機發送到一臺Memcached服務器,可是這種策略可能會帶來兩個問題:一是同一份數據可能被存在不一樣的機器上而形成數據冗餘,二是有可能某數據已經被緩存可是訪問卻沒有命中,由於沒法保證對相同key的全部訪問都被髮送到相同的服務器。所以,隨機策略不管是時間效率仍是空間效率都很是很差。緩存

要解決上述問題只需作到以下一點:保證對相同key的訪問會被髮送到相同的服務器。不少方法能夠實現這一點,最經常使用的方法是計算哈希。例如對於每次訪問,能夠按以下算法計算其哈希值:服務器

h = Hash(key) % 3

其中Hash是一個從字符串到正整數的哈希映射函數。這樣,若是咱們將Memcached Server分別編號爲0、一、2,那麼就能夠根據上式和key計算出服務器編號h,而後去訪問。負載均衡

這個方法雖然解決了上面提到的兩個問題,可是存在一些其它的問題。若是將上述方法抽象,能夠認爲經過:分佈式

h = Hash(key) % N

這個算式計算每一個key的請求應該被髮送到哪臺服務器,其中N爲服務器的臺數,而且服務器按照0 – (N-1)編號。ide

這個算法的問題在於容錯性和擴展性很差。所謂容錯性是指當系統中某一個或幾個服務器變得不可用時,整個系統是否能夠正確高效運行;而擴展性是指當加入新的服務器後,整個系統是否能夠正確高效運行。

現假設有一臺服務器宕機了,那麼爲了填補空缺,要將宕機的服務器從編號列表中移除,後面的服務器按順序前移一位並將其編號值減一,此時每一個key就要按h = Hash(key) % (N-1)從新計算;一樣,若是新增了一臺服務器,雖然原有服務器編號不用改變,可是要按h = Hash(key) % (N+1)從新計算哈希值。所以系統中一旦有服務器變動,大量的key會被重定位到不一樣的服務器從而形成大量的緩存不命中。而這種狀況在分佈式系統中是很是糟糕的。

一個設計良好的分佈式哈希方案應該具備良好的單調性,即服務節點的增減不會形成大量哈希重定位。一致性哈希算法就是這樣一種哈希方案。

Hash 算法的一個衡量指標是單調性( Monotonicity ),定義以下:

單調性是指若是已經有一些內容經過哈希分派到了相應的緩衝中,又有新的緩衝加入到系統中。哈希的結果應可以保證原有已分配的內容能夠被映射到新的緩衝中去,而不會被映射到舊的緩衝集合中的其餘緩衝區。

容易看到,上面的簡單 hash 算法 hash(object)%N 難以知足單調性要求。 ##3 一致性哈希算法## ###3.1 算法簡述### 一致性哈希算法(Consistent Hashing Algorithm)是一種分佈式算法,經常使用於負載均衡。Memcached client也選擇這種算法,解決將key-value均勻分配到衆多Memcached server上的問題。它能夠取代傳統的取模操做,解決了取模操做沒法應對增刪Memcached Server的問題(增刪server會致使同一個key,在get操做時分配不到數據真正存儲的server,命中率會急劇降低)。

簡單來講,一致性哈希將整個哈希值空間組織成一個虛擬的圓環,如假設某哈希函數H的值空間爲0 - (2^32)-1(即哈希值是一個32位無符號整形),整個哈希空間環以下:

在此輸入圖片描述

整個空間按順時針方向組織。0和(2^32)-1在零點中方向重合。

下一步將各個服務器使用H進行一個哈希,具體能夠選擇服務器的ip或主機名做爲關鍵字進行哈希,這樣每臺機器就能肯定其在哈希環上的位置,這裏假設將上文中三臺服務器使用ip地址哈希後在環空間的位置以下:

在此輸入圖片描述

接下來使用以下算法定位數據訪問到相應服務器:將數據key使用相同的函數H計算出哈希值h,通根據h肯定此數據在環上的位置,今後位置沿環順時針「行走」,第一臺遇到的服務器就是其應該定位到的服務器。

例如咱們有A、B、C、D四個數據對象,通過哈希計算後,在環空間上的位置以下:

在此輸入圖片描述

根據一致性哈希算法,數據A會被定爲到Server 1上,D被定爲到Server 3上,而B、C分別被定爲到Server 2上。

###3.2 容錯性與可擴展性分析### 下面分析一致性哈希算法的容錯性和可擴展性。現假設Server 3宕機了:

在此輸入圖片描述

能夠看到此時A、C、B不會受到影響,只有D節點被重定位到Server 2。通常的,在一致性哈希算法中,若是一臺服務器不可用,則受影響的數據僅僅是此服務器到其環空間中前一臺服務器(即順着逆時針方向行走遇到的第一臺服務器)之間數據,其它不會受到影響。

下面考慮另一種狀況,若是咱們在系統中增長一臺服務器Memcached Server 4:

在此輸入圖片描述

此時A、D、C不受影響,只有B須要重定位到新的Server 4。通常的,在一致性哈希算法中,若是增長一臺服務器,則受影響的數據僅僅是新服務器到其環空間中前一臺服務器(即順着逆時針方向行走遇到的第一臺服務器)之間數據,其它不會受到影響。

綜上所述,一致性哈希算法對於節點的增減都只需重定位環空間中的一小部分數據,具備較好的容錯性和可擴展性。

###3.3 虛擬節點### 一致性哈希算法在服務節點太少時,容易由於節點分部不均勻而形成數據傾斜問題。例如咱們的系統中有兩臺服務器,其環分佈以下:

在此輸入圖片描述

此時必然形成大量數據集中到Server 1上,而只有極少許會定位到Server 2上。爲了解決這種數據傾斜問題,一致性哈希算法引入了虛擬節點機制,即對每個服務節點計算多個哈希,每一個計算結果位置都放置一個此服務節點,稱爲虛擬節點。具體作法能夠在服務器ip或主機名的後面增長編號來實現。例如上面的狀況,咱們決定爲每臺服務器計算三個虛擬節點,因而能夠分別計算「Memcached Server 1#1」、「Memcached Server 1#2」、「Memcached Server 1#3」、「Memcached Server 2#1」、「Memcached Server 2#2」、「Memcached Server 2#3」的哈希值,因而造成六個虛擬節點:

在此輸入圖片描述

##4 Java實現##

package com.king.consistenthash;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 一致性Hash算法
 *
 * @param <T> 節點類型
 */
public class ConsistentHash<T> {
	/**
	 * Hash計算對象,用於自定義hash算法
	 */
	HashFunc hashFunc;
	/**
	 * 複製的節點個數
	 */
	private final int numberOfReplicas;
	/**
	 * 一致性Hash環
	 */
	private final SortedMap<Long, T> circle = new TreeMap<>();

	/**
	 * 構造,使用Java默認的Hash算法
	 * @param numberOfReplicas 複製的節點個數,增長每一個節點的複製節點有利於負載均衡
	 * @param nodes            節點對象
	 */
	public ConsistentHash(int numberOfReplicas, Collection<T> nodes) {
		this.numberOfReplicas = numberOfReplicas;
		this.hashFunc = new HashFunc() {

			@Override
			public Long hash(Object key) {
//				return fnv1HashingAlg(key.toString());
				return md5HashingAlg(key.toString());
			}
		};
		//初始化節點
		for (T node : nodes) {
			add(node);
		}
	}

	/**
	 * 構造
	 * @param hashFunc         hash算法對象
	 * @param numberOfReplicas 複製的節點個數,增長每一個節點的複製節點有利於負載均衡
	 * @param nodes            節點對象
	 */
	public ConsistentHash(HashFunc hashFunc, int numberOfReplicas, Collection<T> nodes) {
		this.numberOfReplicas = numberOfReplicas;
		this.hashFunc = hashFunc;
		//初始化節點
		for (T node : nodes) {
			add(node);
		}
	}

	/**
	 * 增長節點<br>
	 * 每增長一個節點,就會在閉環上增長給定複製節點數<br>
	 * 例如複製節點數是2,則每調用此方法一次,增長兩個虛擬節點,這兩個節點指向同一Node
	 * 因爲hash算法會調用node的toString方法,故按照toString去重
	 *
	 * @param node 節點對象
	 */
	public void add(T node) {
		for (int i = 0; i < numberOfReplicas; i++) {
			circle.put(hashFunc.hash(node.toString() + i), node);
		}
	}

	/**
	 * 移除節點的同時移除相應的虛擬節點
	 *
	 * @param node 節點對象
	 */
	public void remove(T node) {
		for (int i = 0; i < numberOfReplicas; i++) {
			circle.remove(hashFunc.hash(node.toString() + i));
		}
	}

	/**
	 * 得到一個最近的順時針節點
	 *
	 * @param key 爲給定鍵取Hash,取得順時針方向上最近的一個虛擬節點對應的實際節點
	 * @return 節點對象
	 */
	public T get(Object key) {
		if (circle.isEmpty()) {
			return null;
		}
		long hash = hashFunc.hash(key);
		if (!circle.containsKey(hash)) {
			SortedMap<Long, T> tailMap = circle.tailMap(hash); //返回此映射的部分視圖,其鍵大於等於 hash
			hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
		}
		//正好命中
		return circle.get(hash);
	}

	/**
	 * 使用MD5算法
	 * @param key
	 * @return
	 */
	private static long md5HashingAlg(String key) {
		MessageDigest md5 = null;
		try {
			md5 = MessageDigest.getInstance("MD5");
			md5.reset();
			md5.update(key.getBytes());
			byte[] bKey = md5.digest();
			long res = ((long) (bKey[3] & 0xFF) << 24) | ((long) (bKey[2] & 0xFF) << 16) | ((long) (bKey[1] & 0xFF) << 8)| (long) (bKey[0] & 0xFF);
			return res;
		} catch (NoSuchAlgorithmException e) {
			e.printStackTrace();
		}
		return 0l;
	}

	/**
	 * 使用FNV1hash算法
	 * @param key
	 * @return
	 */
	private static long fnv1HashingAlg(String key) {
		final int p = 16777619;
		int hash = (int) 2166136261L;
		for (int i = 0; i < key.length(); i++)
			hash = (hash ^ key.charAt(i)) * p;
		hash += hash << 13;
		hash ^= hash >> 7;
		hash += hash << 3;
		hash ^= hash >> 17;
		hash += hash << 5;
		return hash;
	}

	/**
	 * Hash算法對象,用於自定義hash算法
	 */
	public interface HashFunc {
		public Long hash(Object key);
	}
}

##5 總結## Consistent Hashing最大限度地抑制了hash鍵的從新分佈。另外要取得比較好的負載均衡的效果,每每在服務器數量比較少的時候須要增長虛擬節點來保證服務器能均勻的分佈在圓環上。由於使用通常的hash方法,服務器的映射地點的分佈很是不均勻。使用虛擬節點的思想,爲每一個物理節點(服務器)在圓上分配100~200個點。這樣就能抑制分佈不均勻,最大限度地減少服務器增減時的緩存從新分佈。用戶數據映射在虛擬節點上,就表示用戶數據真正存儲位置是在該虛擬節點表明的實際物理服務器上。

相關文章
相關標籤/搜索