一致性哈希算法在分佈式緩存領域的 MemCached,負載均衡領域的 Nginx 以及各種 RPC 框架中都有普遍的應用,它主要是爲了解決傳統哈希函數添加哈希表槽位數後要將關鍵字從新映射的問題。node
本文會介紹一致性哈希算法的原理及其實現,並給出其不一樣哈希函數實現的性能數據對比,探討Redis 集羣的數據分片實現等,文末會給出實現的具體 github 地址。nginx
Memcached 是一個高性能的分佈式緩存系統,然而服務端沒有分佈式功能,各個服務器不會相互通訊。它的分佈式實現依賴於客戶端的程序庫,這也是 Memcached 的一大特色。好比第三方的 spymemcached 客戶端就基於一致性哈希算法實現了其分佈式緩存的功能。c++
其具體步驟以下:git
在這個過程當中,客戶端的算法首先要保證緩存的數據儘可能均勻地分佈在各個服務器上,其次是當個別服務器下線或者上線時,會出現數據遷移,應該儘可能減小須要遷移的數據量。程序員
客戶端算法是客戶端分佈式緩存性能優劣的關鍵。github
普通的哈希表算法通常都是計算出哈希值後,經過取餘操做將 key 值映射到不一樣的服務器上,可是當服務器數量發生變化時,取餘操做的除數發生變化,全部 key 所映射的服務器幾乎都會改變,這對分佈式緩存系統來講是不能夠接收的。redis
一致性哈希算法能儘量減小了服務器數量變化所致使的緩存遷移。算法
首先,一致性哈希算法依賴於普通的哈希算法。大多數同窗對哈希算法的理解可能都停留在 JDK 的 hashCode
函數上。其實哈希算法有不少種實現,它們在不一樣方面都各有優劣,針對不一樣的場景可使用不一樣的哈希算法實現。編程
下面,咱們會介紹一下幾款比較常見的哈希算法,而且瞭解一下它們在分佈均勻程度,哈希碰撞機率和性能等方面的優劣。緩存
MD5 算法:全稱爲 Message-Digest Algorithm 5,用於確保信息傳輸完整一致。是計算機普遍使用的雜湊算法之一,主流編程語言廣泛已有 MD5 實現。MD5 的做用是把大容量信息壓縮成一種保密的格式(就是把一個任意長度的字節串變換成定長的16進制數字串)。常見的文件完整性校驗就是使用 MD5。
CRC 算法:全稱爲 CyclicRedundancyCheck,中文名稱爲循環冗餘校驗。它是一類重要的,編碼和解碼方法簡單,檢錯和糾錯能力強的哈希算法,在通訊領域普遍地用於實現差錯控制。
MurmurHash 算法:高運算性能,低碰撞率,由 Austin Appleby 建立於 2008 年,現已應用到 Hadoop、libstdc++、nginx、libmemcached 等開源系統。Java 界中 Redis,Memcached,Cassandra,HBase,Lucene和Guava 都在使用它。
FNV 算法:全稱爲 Fowler-Noll-Vo 算法,是以三位發明人 Glenn Fowler,Landon Curt Noll,Phong Vo 的名字來命名的,最先在 1991 年提出。 FNV 能快速 hash 大量數據並保持較小的衝突率,它的高度分散使它適用於 hash 一些很是相近的字符串,好比 URL,hostname,文件名,text 和 IP 地址等。
Ketama 算法:一致性哈希算法的實現之一,其餘的哈希算法有通用的一致性哈希算法實現,只不過是替換了哈希映射函數而已,但 Ketama 是一整套的流程,咱們將在後面介紹。
下面,咱們以分佈式緩存場景爲例,分析一下一致性哈希算法環的原理。
首先將緩存服務器( ip + 端口號)進行哈希,映射成環上的一個節點,計算出緩存數據 key 值的 hash key,一樣映射到環上,並順時針選取最近的一個服務器節點做爲該緩存應該存儲的服務器。具體實現見後續的章節。
好比說,當存在 A,B,C,D 四個緩存服務器時,它們及其 key 值爲1的緩存數據在一致性哈希環上的位置以下圖所示,根據順時針取最近一個服務器節點的規則,該緩存數據應該存儲在服務器 B 上。
當要存儲一個 key 值爲4的緩存數據時,它在一致性哈希環上的位置以下所示,因此它應該存儲在服務器 C 上。
相似的,key 值爲5,6的數據應該存在服務 D 上,key 值爲7,8的數據應該存儲在服務 A 上。
此時,服務器 B 宕機下線,服務器 B 中存儲的緩存數據要進行遷移,但因爲一致性哈希環的存在,只須要遷移key 值爲1的數據,其餘的數據的存儲服務器不會發生變化。這也是一致性哈希算法比取餘映射算法出色的地方。
因爲服務器 B 下線,key 值爲1的數據順時針最近的服務器是 C ,因此數據存遷移到服務器 C 上。
現實狀況下,服務器在一致性哈希環上的位置不可能分佈的這麼均勻,致使了每一個節點實際佔據環上的區間大小不一。
這種狀況下,能夠增長虛節點來解決。經過增長虛節點,使得每一個節點在環上所「管轄」的區域更加均勻。這樣就既保證了在節點變化時,儘量小的影響數據分佈的變化,而同時又保證了數據分佈的均勻。
下面咱們實現 Memcached 分佈式緩存場景下的一致性哈希算法,並給出具體的測試性能數據。該實現借鑑了 kiritomoe 博文中的實現和 spymemcached 客戶端代碼。具體實現請看個人github,地址爲 https://github.com/ztelur/con...。
NodeLocator
是分佈式緩存場景下一致性哈希算法的抽象,它有一個 getPrimary
函數,接收一個緩存數據的 key 值,輸出存儲該緩存數據的服務器實例。
public interface NodeLocator { MemcachedNode getPrimary(String k); }
下面是通用的一致性哈希算法的實現,它使用 TreeMap
做爲一致性哈希環的數據結構,其 ceilingEntry
函數能夠獲取環上最近的一個節點。buildConsistentHashRing
函數中包含了構建一致性哈希環的過程,默認加入了 12 個虛擬節點。
public class ConsistentHashNodeLocator implements NodeLocator { private final static int VIRTUAL_NODE_SIZE = 12; private final static String VIRTUAL_NODE_SUFFIX = "-"; private volatile TreeMap<Long, MemcachedNode> hashRing; private final HashAlgorithm hashAlg; public ConsistentHashNodeLocator(List<MemcachedNode> nodes, HashAlgorithm hashAlg) { this.hashAlg = hashAlg; this.hashRing = buildConsistentHashRing(hashAlg, nodes); } @Override public MemcachedNode getPrimary(String k) { long hash = hashAlg.hash(k); return getNodeForKey(hashRing, hash); } private MemcachedNode getNodeForKey(TreeMap<Long, MemcachedNode> hashRing, long hash) { /* 向右找到第一個key */ Map.Entry<Long, MemcachedNode> locatedNode = hashRing.ceilingEntry(hash); /* 想象成爲一個環,超出尾部取出第一個 */ if (locatedNode == null) { locatedNode = hashRing.firstEntry(); } return locatedNode.getValue(); } private TreeMap<Long, MemcachedNode> buildConsistentHashRing(HashAlgorithm hashAlgorithm, List<MemcachedNode> nodes) { TreeMap<Long, MemcachedNode> virtualNodeRing = new TreeMap<>(); for (MemcachedNode node : nodes) { for (int i = 0; i < VIRTUAL_NODE_SIZE; i++) { // 新增虛擬節點的方式若是有影響,也能夠抽象出一個由物理節點擴展虛擬節點的類 virtualNodeRing.put(hashAlgorithm.hash(node.getSocketAddress().toString() + VIRTUAL_NODE_SUFFIX + i), node); } } return virtualNodeRing; } }
在 getPrimary
函數中,首先使用 HashAlgorithm
計算出 key 值對應的哈希值,而後調用 getNodeForKey
函數從 TreeMap
中獲取對應的最近的服務器節點實例。
HashAlgorithm
是對哈希算法的抽象,一致性哈希算法可使用各類普通的哈希算法,好比說 CRC ,MurmurHash 和 FNV 等。下面,咱們將會對比各類哈希算法給該實現帶來的性能差別性。
測試數據是評價一個算法好壞的最爲真實有效的方法,量化的思惟模式必定要有,這也是程序員進階的法寶之一。咱們如下面四個量化的指標對基於不一樣哈希函數的一致性哈希算法進行評測。
具體評測算法以下。
public class NodeLocatorTest { /** * 測試分佈的離散狀況 */ @Test public void testDistribution() { List<MemcachedNode> servers = new ArrayList<>(); for (String ip : ips) { servers.add(new MemcachedNode(new InetSocketAddress(ip, 8080))); } // 使用不一樣的DefaultHashAlgorithm進行測試,得出不一樣的數據 NodeLocator nodeLocator = new ConsistentHashNodeLocator(servers, DefaultHashAlgorithm.NATIVE_HASH); // 構造 50000 隨機請求 List<String> keys = new ArrayList<>(); for (int i = 0; i < 50000; i++) { keys.add(UUID.randomUUID().toString()); } // 統計分佈 AtomicLongMap<MemcachedNode> atomicLongMap = AtomicLongMap.create(); for (MemcachedNode server : servers) { atomicLongMap.put(server, 0); } for (String key : keys) { MemcachedNode node = nodeLocator.getPrimary(key); atomicLongMap.getAndIncrement(node); } System.out.println(StatisticsUtil.variance(atomicLongMap.asMap().values().toArray(new Long[]{}))); System.out.println(StatisticsUtil.standardDeviation(atomicLongMap.asMap().values().toArray(new Long[]{}))); } /** * 測試節點新增刪除後的變化程度 */ @Test public void testNodeAddAndRemove() { List<MemcachedNode> servers = new ArrayList<>(); for (String ip : ips) { servers.add(new MemcachedNode(new InetSocketAddress(ip, 8080))); } //隨機下線10個服務器, 先shuffle,而後選擇0到90,簡單模仿隨機計算。 Collections.shuffle(servers); List<MemcachedNode> serverChanged = servers.subList(0, 90); NodeLocator loadBalance = new ConsistentHashNodeLocator(servers, DefaultHashAlgorithm.NATIVE_HASH); NodeLocator changedLoadBalance = new ConsistentHashNodeLocator(serverChanged, DefaultHashAlgorithm.NATIVE_HASH); // 構造 50000 隨機請求 List<String> keys = new ArrayList<>(); for (int i = 0; i < 50000; i++) { keys.add(UUID.randomUUID().toString()); } int count = 0; for (String invocation : keys) { MemcachedNode origin = loadBalance.getPrimary(invocation); MemcachedNode changed = changedLoadBalance.getPrimary(invocation); // 統計發生變化的數值 if (!origin.getSocketAddress().equals(changed.getSocketAddress())) count++; } System.out.println(count / 50000D); } static String[] ips = {...}; }
JMH的測試腳本以下所示。
@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Thread) public class JMHBenchmark { private NodeLocator nodeLocator; private List<String> keys; @Benchmark public void test() { for (String key : keys) { MemcachedNode node = nodeLocator.getPrimary(key); } } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(JMHBenchmark.class.getSimpleName()) .forks(1) .warmupIterations(5) .measurementIterations(5) .build(); new Runner(opt).run(); } @Setup public void prepare() { List<MemcachedNode> servers = new ArrayList<>(); for (String ip : ips) { servers.add(new MemcachedNode(new InetSocketAddress(ip, 8080))); } nodeLocator = new ConsistentHashNodeLocator(servers, DefaultHashAlgorithm.MURMUR_HASH); // 構造 50000 隨機請求 keys = new ArrayList<>(); for (int i = 0; i < 50000; i++) { keys.add(UUID.randomUUID().toString()); } } @TearDown public void shutdown() { } static String[] ips = {...}; }
分別測試了 JDK 哈希算法,FNV132 算法,CRC 算法,MurmurHash 算法和Ketama 算法,分別對應 DefaultHashAlgorithm
的 NATIVE_HASH
,FNV1_32_HASH
,CRC_HASH
,MURMUR_HASH
和 KETAMA_HASH
。具體數據以下所示。
有些文章說,Redis 集羣並無使用一致性哈希算法,而是使用虛擬槽分區算法。可是外網(地址見文末)上都說 Redis 使用的虛擬槽分區只是一致性哈希算法的變種,虛擬槽能夠容許 Redis 動態擴容。
或許只有去了解一下Redis的源碼才能對這個問題做出準確的回答。請了解的同窗積極留言解答,謝謝。
github 地址: https://github.com/ztelur/con...
redis分佈式討論的地址: https://www.reddit.com/r/redi...