数据分布之一致性哈希
一、数据分布
在分布式环境下,数据分布也即是将数据拆分,存放到不同节点上,是分布式系统中的基本问题之一。不同的数据分布方式需要权衡诸如伸缩性、数据倾斜(负载的均衡)、元数据维护等问题。没有一种万能的方案能够解决所有的问题,不能脱离应用场景谈优劣,应该要针对不同的应用场景选择合适的方案。
一般而言,可以有以下几种数据分布的方式:
1)哈希分区(或者叫余数法)
基本思想是根据数据的某项特征(如ID或者键)计算hash值,然后对节点数量N求摸,其逻辑为:hash(key) % N。这种方式的优点是设计简单;缺点是扩展性不佳,增删节点后,原有的映射关系大部分将失效,并且容易出现“数据倾斜”的现象。
2)按数据范围分布
这种分区方式将数据按特征值的值域范围划分为不同的区间,然后每个节点存储不同区间的数据。
例如, 已知某业务系统中用户 ID 的值域范围是[1,100),集群有 3 个节点。则可以将用户 ID的值域分为三个区间[1, 33)、 [33, 90)、 [90, 100),分别由 3 个节点Node1、Node2、Node3负责存储。
3)按数据量分布
这种方式将数据视为一个顺序增长的文件,并将这个文件按照某一较为固定的大小划分为若干数据块(chunk),不同的数据块分布到不同的服务器上,数据量分布数据与具体的数据特征无关。
4)一致性哈希
一致性哈希主要用在分布式缓存系统中,通过一种特殊的环形结构和分布规则来实现,改进的一致性哈希能够比较好的解决扩展性问题和负载均衡问题。
本文主要讨论一致性哈希的一些有趣的原理和特性,并实现一个简洁地可演示和模拟的Demo算法,最后也简单的提及Redis Cluster中的数据分布方式,其与一致性哈希的思想相似之处但也有些差别。
二、一致性哈希
2.1 概述
一致性哈希的概念最初在论文Consistent Hashing and Random Trees:Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web的第四节Consistent Hashing中被提出来,具有如下四个特性,其陈述个人觉得比较理论化:
1、平衡性(Balance):平衡性是指哈希的结果能够尽可能分布到所有的缓冲中去,这样可以使得所有的缓冲空间都得到利用。很多哈希算法都能够满足这一条件。
2、单调性(Monotonicity):单调性是指如果已经有一些内容通过哈希分派到了相应的缓冲中,又有新的缓冲加入到系统中。哈希的结果应能够保证原有已分配的内容可以被映射到原有的或者新的缓冲中去,而不会被映射到旧的缓冲集合中的其他缓冲区。
3、分散性(Spread):在分布式环境中,终端有可能看不到所有的缓冲,而是只能看到其中的一部分。当终端希望通过哈希过程将内容映射到缓冲上时,由于不同终端所见的缓冲范围有可能不同,从而导致哈希的结果不一致,最终的结果是相同的内容被不同的终端映射到不同的缓冲区中。这种情况显然是应该避免的,因为它导致相同内容被存储到不同缓冲中去,降低了系统存储的效率。分散性的定义就是上述情况发生的严重程度。好的哈希算法应能够尽量避免不一致的情况发生,也就是尽量降低分散性。
4、负载(Load):负载问题实际上是从另一个角度看待分散性问题。既然不同的终端可能将相同的内容映射到不同的缓冲区中,那么对于一个特定的缓冲区而言,也可能被不同的用户映射为不同 的内容。与分散性一样,这种情况也是应当避免的,因此好的哈希算法应能够尽量降低缓冲的负荷。
本文所讲的一致性哈希算法满足平衡性和单调性,分散性和负载并似乎不具备也没有含义。
2.2 基本的算法原理
一致性哈希算法基本原理大致可以通过几个步骤来解释:构造一致性哈希环、节点映射、路由规则。以下以键值对缓存服务器为场景。
1)构造一致性哈希环
一致性哈希算法中首先有一个哈希函数,哈希函数产生hash值,所有可能的哈希值构成一个哈希空间,哈希空间为[0,2^32-1],这本来是一个“线性”的空间,但是在算法中通过恰当逻辑控制,使其首尾相衔接,也即是0=2^32,这样就构造一个逻辑上的环形空间。
2)节点映射
将集群中的各节点映射到环上的某个一位置。比如集群中有三个节点,那么可以大致均匀的将其分布在环上。
3)路由规则
路由规则包括存储(setX)和取值(getX)规则。
当需要存储一个<key-value>对时,首先计算键key的hash值:hash(key),这个hash值必然对应于一致性hash环上的某个位置,然后沿着这个值按顺时针找到第一个节点,并将该键值对存储在该节点上。例如存储<key1-value1>时,按此规则应该存储在Node1服务器上(见下图)。
当需要按某个键获取值时,与上述规则基本相同,也是首先计算key的hash值,找到对应的节点,从该节点中获取对应键的值。
整个算法的模型如下图所示,
集群中有三个节点(Node1、Node2、Node3),五个键(key1、key2、key3、key4、key5),其路由规则为:
key1 -> Node1 key2、key3 -> Node2 key4、key5 -> Node3
不难发现,基本的一致性哈希算法有一些地方不太让人满意。
当集群中增加节点时,比如当在Node2和Node3之间增加了一个节点Node4,此时再访问节点key4时,不能在Node4中命中,更一般的,介于Node2和Node4之间的key均失效,这样的失效方式太过于“集中”和“暴力”,更好的方式应该是“平滑”和“分散”地失效。如下图所示:
特别是当集群中节点本身比较少时,因增删节点导致的不命中现象比较明显。
除了上面的问题,还有一个比较明显的问题是负载问题:增加节点只能对下一个相邻节点有比较好的负载分担效果,例如上图中增加了节点Node4只能够对Node3分担部分负载,对集群中其他的节点基本没有起到负载分担的效果;类似地,删除节点会导致下一个相邻节点负载增加,而其他节点却不能有效分担负载压力。
针对以上两个主要的问题,特别是如何解决各节点负载动态均衡的问题,出现了一种通过增加虚拟节点的改进算法。
2.3 增加虚拟节点改进算法
为了在增删节点的时候,各节点能够保持动态的均衡,将每个真实节点虚拟出若干个虚拟节点,再将这些虚拟节点随机映射到环上。此时每个真实节点不再映射到环上,真实节点只是用来存储键值对,它负责接应各自的一组环上虚拟节点。当对键值对进行存取路由时,首先路由到虚拟节点上,再由虚拟节点找到真实的节点。
如下图所示,三个节点真实节点:Node1、Node2和Node3,每个真实节点虚拟出三个虚拟节点:X#V1、X#V2和X#V3,这样每个真实节点所负责的hash空间不再是连续的一段,而是分散在环上的各处,这样就可以将局部的压力均衡到不同的节点,虚拟节点越多,分散性越好,理论上负载就越倾向均匀,如下图所示:
通俗的理解,增加虚拟节点其实是减小了路由规则过程中的粒度,使每个真实节点可以分摊局部压力。
三、Demo实现
以下是针对带虚拟节点的一致性哈希算法的一个简单的Demo实现,重点在于演示其算法的工作原理。
元数据包括真实节点、虚拟节点以及各虚拟节点对应的真实节点映射关系。虚拟节点采用平衡二叉搜索树存储,虚拟节点名通过真实节点拼接序列号实现,这样只要得到虚拟节点名截取其前缀就可以得到对应的真实节点,简单方便。
通过增加节点和删除节点模拟节点上线和下线的情况,并测试集群总节点变化过程中的负载均衡情况。
3.1 实现类
import java.util.*; /** * 一致性哈希算法 * author:Qcer * date:2018/07/18 * */ public class ConsistentHash { // 每个真实节点负责多少个虚拟节点 private int virtualNodesPerRealNode; private int totalVirtualNodes; // 真实结点列表 private List<String> realNodes = new LinkedList<String>(); // 真实结点与各虚拟的映射关系 private HashMap<String,LinkedList<String>> mapping = new HashMap<>(); // 虚拟节点,key表示虚拟节点的hash值,value表示虚拟节点的名称,采用平衡二叉搜索树结构存储 private SortedMap<Integer, String> virtualNodes = new TreeMap<Integer, String>(); public ConsistentHash(String[] nodes,int virtualNodesPerRealNode){ this.virtualNodesPerRealNode = virtualNodesPerRealNode; addNode(nodes); } // 使用FNV1_32_HASH算法计算服务器的Hash值,hash空间为[0,2^32-1],程序控制实现逻辑的环形结构 private 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; } // 根据某个key,首先访问到虚拟节点,再访问到真实节点。 public String visit(String key){ // 得到该key的hash值 int hash = getHash(key); // 得到大于该hash值的所有Map SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash); String virtualNode = null; if (subMap.isEmpty()){ // 如果没有比该key的hash值更大的,表明该hash值刚好是一致性hash环的尾端 // 此时从0开始,顺时针取第一个虚拟节点 Integer i = virtualNodes.firstKey(); // 返回对应的虚拟节点 virtualNode = virtualNodes.get(i); } else { // 按顺时针方向当前最近的虚拟结点 Integer i = subMap.firstKey(); // 返回对应的虚拟节点 virtualNode = subMap.get(i); } // 截取virtualNode的前缀,获得真实节点 if(virtualNode != null){ virtualNode = virtualNode.substring(0, virtualNode.indexOf("##")); } return virtualNode; } // 增加节点,模拟服务器上线的情况。 public void addNode(String[] nodes) { // 维护元数据,包括真实节点信息,虚拟节点信息 for (String node : nodes){ // 维护真实节点信息 realNodes.add(node); LinkedList<String> list = new LinkedList<>(); // 维护虚拟节点信息,key为hash值,value的前缀为真实节点 for(int count = 0, sequence = 0; count < virtualNodesPerRealNode;){ String virtualNodeName = node + "##VN" + String.valueOf(sequence++); int hash = getHash(virtualNodeName); // 一般来讲,当虚拟节点数量<<hash空间时,hash函数碰撞的可能性比较小,但严谨其见,此处应该考虑冲突。 if (!virtualNodes.containsKey(hash)) { virtualNodes.put(hash, virtualNodeName); count++; list.add(virtualNodeName);//维护虚拟节点与真实节点的映射关系 } } mapping.put(node,list); } // 维护虚拟节点总数 totalVirtualNodes = realNodes.size() * virtualNodesPerRealNode; } // 删除节点,模拟服务器下线的情况。 public void removeNode(String[] nodes) { for (String node : nodes) { if (realNodes.contains(node)) { realNodes.remove(node); } if (mapping.containsKey(node)) { LinkedList<String> list = mapping.remove(node); for (String virtual : list) { virtualNodes.remove(getHash(virtual)); } } } totalVirtualNodes = realNodes.size() * virtualNodesPerRealNode; } // 获取元数据 public void getMetaData() { System.out.println("真实节点:"); for (int i = 0; i < realNodes.size(); i++) { System.out.println(realNodes.get(i)); } System.out.println("虚拟节点数量:" + totalVirtualNodes); for (String str : mapping.keySet()) { System.out.println(mapping.get(str).size()); } } // 测试增删节点后各节点的负载 public void testLoadBalance(String[] keys){ System.out.println("真实节点数量:" + realNodes.size()); System.out.println("虚拟节点数量:" + totalVirtualNodes); System.out.println("各节点负载情况:"); int keyNumber = keys.length; int realNodeNumber = realNodes.size(); String hitNode = ""; int[] count = new int[realNodeNumber]; for(int i = 0; i < keyNumber; i++) { hitNode = visit(keys[i]); for (int j = 0; j < realNodeNumber; j++){ if (hitNode.equals(realNodes.get(j))){ count[j] += 1; } } } for (int i = 0; i < realNodeNumber; i++) { System.out.println("[Node"+i+"-"+realNodes.get(i)+"]" +" : "+count[i]); } } }
3.2 测试类
/**一致性哈希算法Test类 * author:Qcer * date:2018/07/18 * */ public class ConsistentHashTest { // 产生随机字符串,视为key public static String[] genKeys(int number) { String[] ary = new String[number]; int length = 0; for(int j = 0; j < number; j++) { String temp = ""; length = (int)(Math.random() * 8 + 2); for(int i = 0; i < length; i++) { int intValue = (int)(Math.random() * 26 + 97); temp += (char)intValue; } ary[j] = temp; } return ary; } public static void main(String[] args){ String[] nodes = { "10.10.25.11:6379", "10.10.25.12:6379", "10.10.25.13:6379", "10.10.25.14:6379", "10.10.25.15:6379"}; int keyCount = 10000; String[] keys = genKeys(keyCount); System.out.println("--------初始状态-------"); ConsistentHash ch = new ConsistentHash(nodes,200); ch.testLoadBalance(keys); System.out.println("--------模拟上线-------"); String[] onLine = {"10.10.25.20:6379","10.10.25.21:6379"}; ch.addNode(onLine); ch.testLoadBalance(keys); System.out.println("--------模拟下线-------"); String[] offLine = {"10.10.25.11:6379","10.10.25.14:6379"}; ch.removeNode(offLine); ch.testLoadBalance(keys); System.out.println("--------获取元数据-------"); ch.getMetaData(); } }
3.3 测试结果
--------初始状态------- 真实节点数量:5 虚拟节点数量:1000 各节点负载情况: [Node0-10.10.25.11:6379] : 1982 [Node1-10.10.25.12:6379] : 2157 [Node2-10.10.25.13:6379] : 2063 [Node3-10.10.25.14:6379] : 1659 [Node4-10.10.25.15:6379] : 2139 --------模拟上线------- 真实节点数量:7 虚拟节点数量:1400 各节点负载情况: [Node0-10.10.25.11:6379] : 1373 [Node1-10.10.25.12:6379] : 1599 [Node2-10.10.25.13:6379] : 1382 [Node3-10.10.25.14:6379] : 1268 [Node4-10.10.25.15:6379] : 1416 [Node5-10.10.25.20:6379] : 1488 [Node6-10.10.25.21:6379] : 1474 --------模拟下线------- 真实节点数量:5 虚拟节点数量:1000 各节点负载情况: [Node0-10.10.25.12:6379] : 2097 [Node1-10.10.25.13:6379] : 1909 [Node2-10.10.25.15:6379] : 1769 [Node3-10.10.25.20:6379] : 2131 [Node4-10.10.25.21:6379] : 2094 --------获取元数据------- 真实节点: 10.10.25.12:6379 10.10.25.13:6379 10.10.25.15:6379 10.10.25.20:6379 10.10.25.21:6379 虚拟节点数量:1000
可见,在上下线的过程中,各节点能够大致的保持一个动态的负载平衡。
四、Redis Cluster中的虚拟槽分区
在Redis Cluster中,依然采用了虚拟槽的方式,总共有16384=2^14个虚拟槽,其键与槽的映射关系为slot=CRC16(key)&16383,因此虚拟槽只是一个逻辑的概念,并不真实存存储数据,虚拟槽背后的真实节点才是数据存放的地方。
每个真实节点会负责一部分的虚拟槽,采用虚拟槽分区的方式能够解耦数据与节点的关系,方便集群的伸缩。在搭建集群的过程中,需要给定每个虚拟槽与真实节点之间的映射关系。
例如,以6个节点搭建一个小规模redis集群,其真实节点局域网IP和端口如下:
192.168.0.117:6390 192.168.0.117:6391 192.168.0.117:6392 192.168.0.117:6393 192.168.0.117:6394 192.168.0.117:6395
这里采用redis-trib.rb集群管理工具实现节点握手、虚拟槽分配、检查等功能:
[root@localhost cluster]# [root@localhost cluster]# redis-trib.rb create --replicas 1 192.168.0.117:6390 192.168.0.117:6391 192.168.0.117:6392 192.168.0.117:6393 192.168.0.117:6394 192.168.0.117:6395 >>> Creating cluster /usr/local/ruby/lib/ruby/gems/2.4.0/gems/redis-3.3.0/lib/redis/client.rb:459: warning: constant ::Fixnum is deprecated >>> Performing hash slots allocation on 6 nodes... Using 3 masters: 192.168.0.117:6390 192.168.0.117:6391 192.168.0.117:6392 Adding replica 192.168.0.117:6393 to 192.168.0.117:6390 Adding replica 192.168.0.117:6394 to 192.168.0.117:6391 Adding replica 192.168.0.117:6395 to 192.168.0.117:6392 M: c90dd52f29968f10bb99a8bdb9ad839009944406 192.168.0.117:6390 slots:0-5460 (5461 slots) master M: 3b226aa47c0afe5aa76501a61db2ae2af6cfe5ff 192.168.0.117:6391 slots:5461-10922 (5462 slots) master M: 03e45dc39322d0df04bf2cdaba2498f4918a3e76 192.168.0.117:6392 slots:10923-16383 (5461 slots) master S: b3cb797097633c9f95bd4a706fcf9a3f09db5ca1 192.168.0.117:6393 replicates c90dd52f29968f10bb99a8bdb9ad839009944406 S: 1149158458c4a2eaa249b1981e111b1ea1e2a542 192.168.0.117:6394 replicates 3b226aa47c0afe5aa76501a61db2ae2af6cfe5ff S: 5a5038176c110ff6f07d31f81c725caaa8ae7c74 192.168.0.117:6395 replicates 03e45dc39322d0df04bf2cdaba2498f4918a3e76 Can I set the above configuration? (type 'yes' to accept): yes >>> Nodes configuration updated >>> Assign a different config epoch to each node >>> Sending CLUSTER MEET messages to join the cluster Waiting for the cluster to join.. >>> Performing Cluster Check (using node 192.168.0.117:6390) M: c90dd52f29968f10bb99a8bdb9ad839009944406 192.168.0.117:6390 slots:0-5460 (5461 slots) master 1 additional replica(s) M: 3b226aa47c0afe5aa76501a61db2ae2af6cfe5ff 192.168.0.117:6391 slots:5461-10922 (5462 slots) master 1 additional replica(s) S: 1149158458c4a2eaa249b1981e111b1ea1e2a542 192.168.0.117:6394 slots: (0 slots) slave replicates 3b226aa47c0afe5aa76501a61db2ae2af6cfe5ff S: b3cb797097633c9f95bd4a706fcf9a3f09db5ca1 192.168.0.117:6393 slots: (0 slots) slave replicates c90dd52f29968f10bb99a8bdb9ad839009944406 M: 03e45dc39322d0df04bf2cdaba2498f4918a3e76 192.168.0.117:6392 slots:10923-16383 (5461 slots) master 1 additional replica(s) S: 5a5038176c110ff6f07d31f81c725caaa8ae7c74 192.168.0.117:6395 slots: (0 slots) slave replicates 03e45dc39322d0df04bf2cdaba2498f4918a3e76 [OK] All nodes agree about slots configuration. >>> Check for open slots... >>> Check slots coverage... [OK] All 16384 slots covered. [root@localhost cluster]#
从上面的过程中可以看到,总共的16384个虚拟槽被分为5461、5462、5461三部分,分别分配给三个master节点,另外3个slave节点由于只能从对应的master节点复制数据默认只读不可写,因此不分配虚拟槽。
当虚拟槽全部分配完成,集群处于可用状态:
192.168.0.117:6391> 192.168.0.117:6391> cluster keyslot qcer (integer) 7408 192.168.0.117:6391> set qcer "hello world" OK 192.168.0.117:6391>
在集群伸缩的过程中,由于节点上线或者下线,需要进行虚拟槽的迁移。
五、References
1、大型网站技术架构_核心原理与案例分析
2、分布式系统原理介绍
3、Redis开发和运维
4、Consistent Hashing and Random Trees:Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web,SECTION 4 Consistent Hashing.
转载请注明原文出处:https://www.cnblogs.com/qcblog/p/8886360.html
谢谢!