本文首发于 vivo互联网技术 微信公众号
连接:https://mp.weixin.qq.com/s/LGLqEOlGExKob8xEXXWckQ
做者:钱幸川node
在分布式环境下面,咱们常常会经过必定的规则来进行数据分布的定义,本文描述的取模算法和一致性 Hash(Consistent Hash)是经过必定规则产生一个key,对这个key进行必定规则的运算,得出这个数据该去哪儿。算法
本文使用软件环境:Java 8数据库
在分布式环境下面,咱们常常会经过必定的规则来进行数据分布的定义,好比用户1的数据存储到数据库一、用户2的数据存储到数据库2......缓存
通常来讲,有这么几种经常使用的方式:微信
有一个分布式环境中惟一的中心分发节点,每次在数据存储的时候,都会询问中心节点这个数据该去哪儿,这个分发节点明确告诉这个数据该去哪儿。负载均衡
/** * 数据分布hash算法接口定义 * @author xingchuan.qxc * */ public interface HashNodeService { /** * 集群增长一个数据存储节点 * @param node */ public void addNode(Node node); /** * 数据存储时查找具体使用哪一个节点来存储 * @param key * @return */ public Node lookupNode(String key); /** * hash的算法 * @param key * @return */ public Long hash(String key); /** * 模拟意外状况断掉一个节点,用于测试缓存命中率 * @param node */ public void removeNodeUnexpected(Node node); }
取模算法的应用场景描述以下:分布式
须要在集群中实现一个用户数据存储的负载均衡,集群中有n个存储节点,如何均匀的把各个数据分布到这n个节点呢?ide
实现步骤大概分红两步:测试
经过用户的key来取一个hash值大数据
经过这个hash值来对存储节点数n进行取模,得出一个index
Note:本文例子我生成hash值的方式,我采用CRC32的方式。
/** * 取模数据分布算法实现 * @author xingchuan.qxc * */ public class NormalHashNodeServiceImpl implements HashNodeService{ /** * 存储节点列表 */ private List<Node> nodes = new ArrayList<>(); @Override public void addNode(Node node) { this.nodes.add(node); } @Override public Node lookupNode(String key) { long k = hash(key); int index = (int) (k % nodes.size()); return nodes.get(index); } @Override public Long hash(String key) { CRC32 crc32 = new CRC32(); crc32.update(key.getBytes()); return crc32.getValue(); } @Override public void removeNodeUnexpected(Node node) { nodes.remove(node); } }
经过上述例子咱们能够看到,lookupNode的时候,是要先去取这个key的CRC32的值,而后对集群中节点数进行取模获得r,最后返回下标为r的Node。
测试代码以下:
HashNodeService nodeService = new NormalHashNodeServiceImpl(); Node addNode1 = new Node("xingchuan.node1", "192.168.0.11"); Node addNode2 = new Node("xingchuan.node2", "192.168.0.12"); Node addNode3 = new Node("xingchuan.node3", "192.168.0.13"); Node addNode4 = new Node("xingchuan.node4", "192.168.0.14"); Node addNode5 = new Node("xingchuan.node5", "192.168.0.15"); Node addNode6 = new Node("xingchuan.node6", "192.168.0.16"); Node addNode7 = new Node("xingchuan.node7", "192.168.0.17"); Node addNode8 = new Node("xingchuan.node8", "192.168.0.18"); nodeService.addNode(addNode1); nodeService.addNode(addNode2); nodeService.addNode(addNode3); nodeService.addNode(addNode4); nodeService.addNode(addNode5); nodeService.addNode(addNode6); nodeService.addNode(addNode7); nodeService.addNode(addNode8); //用于检查数据分布状况 Map<String, Integer> countmap = new HashMap<>(); Node node = null; for (int i = 1; i <= 100000; i++) { String key = String.valueOf(i); node = nodeService.lookupNode(key); node.cacheString(key, "TEST_VALUE"); String k = node.getIp(); Integer count = countmap.get(k); if (count == null) { count = 1; countmap.put(k, count); } else { count++; countmap.put(k, count); } } System.out.println("初始化数据分布状况:" + countmap);
运行结果以下:
初始化数据分布状况:{192.168.0.11=12499, 192.168.0.12=12498, 192.168.0.13=12500, 192.168.0.14=12503, 192.168.0.15=12500, 192.168.0.16=12502, 192.168.0.17=12499, 192.168.0.18=12499}
能够看到,每一个节点的存储分布数量是大体同样的。
咱们能够很清楚的看到,取模算法是经过数据存储节点个数来进行运算的,因此,当存储节点个数变化了,就会形成灾难性的缓存失效。
举例:
初始集群里面只有4个存储节点(Node0,Node1,Node2,Node3),这时候我要存储id为1~10的用户,我能够经过id % 4来运算得出各个ID的分布节点
这时候,若是集群新增一个存储节点Node4,会发生什么呢?
这里咱们会发现,大量的存储节点的key和原先的对应不上了,这时候咱们若是在生产环境,就须要作大量的数据迁移。
删除一个节点,原理同上,再也不赘述。
代码模拟一个分布式缓存存储,使用取模的方式,新增一个节点带来的问题。测试代码以下:
HashNodeService nodeService = new NormalHashNodeServiceImpl(); Node addNode1 = new Node("xingchuan.node1", "192.168.0.11"); Node addNode2 = new Node("xingchuan.node2", "192.168.0.12"); Node addNode3 = new Node("xingchuan.node3", "192.168.0.13"); Node addNode4 = new Node("xingchuan.node4", "192.168.0.14"); Node addNode5 = new Node("xingchuan.node5", "192.168.0.15"); Node addNode6 = new Node("xingchuan.node6", "192.168.0.16"); Node addNode7 = new Node("xingchuan.node7", "192.168.0.17"); Node addNode8 = new Node("xingchuan.node8", "192.168.0.18"); nodeService.addNode(addNode1); nodeService.addNode(addNode2); nodeService.addNode(addNode3); nodeService.addNode(addNode4); nodeService.addNode(addNode5); nodeService.addNode(addNode6); nodeService.addNode(addNode7); nodeService.addNode(addNode8); //用于检查数据分布状况 Map<String, Integer> countmap = new HashMap<>(); Node node = null; for (int i = 1; i <= 100000; i++) { String key = String.valueOf(i); node = nodeService.lookupNode(key); node.cacheString(key, "TEST_VALUE"); String k = node.getIp(); Integer count = countmap.get(k); if (count == null) { count = 1; countmap.put(k, count); } else { count++; countmap.put(k, count); } } System.out.println("初始化数据分布状况:" + countmap); // 正常状况下的去获取数据,命中率 int hitcount = 0; for (int i = 1; i <= 100000; i++) { String key = String.valueOf(i); node = nodeService.lookupNode(key); if (node != null) { String value = node.getCacheValue(key); if (value != null) { hitcount++; } } } double h = Double.parseDouble(String.valueOf(hitcount))/ Double.parseDouble(String.valueOf(100000)); System.out.println("初始化缓存命中率:"+ h); // 移除一个节点 Node addNode9 = new Node("xingchuan.node0", "192.168.0.19"); nodeService.addNode(addNode9); hitcount = 0; for (int i = 1; i <= 100000; i++) { String key = String.valueOf(i); node = nodeService.lookupNode(key); if (node != null) { String value = node.getCacheValue(key); if (value != null) { hitcount++; } } } h = Double.parseDouble(String.valueOf(hitcount))/ Double.parseDouble(String.valueOf(100000)); System.out.println("增长一个节点后缓存命中率:"+ h);
运行结果以下:
初始化数据分布状况:{192.168.0.11=12499, 192.168.0.12=12498, 192.168.0.13=12500, 192.168.0.14=12503, 192.168.0.15=12500, 192.168.0.16=12502, 192.168.0.17=12499, 192.168.0.18=12499} 初始化缓存命中率:1.0 增长一个节点后缓存命中率:0.11012
取模算法的劣势很明显,当新增节点和删除节点的时候,会涉及大量的数据迁移问题。为了解决这一问题,引入了一致性Hash。
一致性Hash算法的原理很简单,描述以下:
想象有一个巨大的环,好比这个环的值的分布能够是 0 ~ 4294967296
仍是在取模算法中的那个例子,这时候咱们假定咱们的4个节点经过一些key的hash,分布在了这个巨大的环上面。
那么问题来了,若是只有4个节点,可能会形成数据分布不均匀的状况,举个例子,上图中的Node3和Node4离的很近,这时候,Node1的压力就会很大了。如何解决这个问题呢?虚拟节点能解决这个问题。
什么是虚拟节点?
简单说,就是在环上模拟不少个不存在的节点,这时候这些节点是能够尽量均匀分布在环上的,在key的hash后,顺时针找最近的存储节点,存储完成以后,集群中的数据基本上就分配均匀了。惟一要作的,必需要维护一个虚拟节点到真实节点的关系。
下面,咱们就来经过两个进阶,实现一个一致性Hash。
进阶一咱们不引入虚拟节点,进阶二咱们引入虚拟节点
@Override public void addNode(Node node) { nodeList.add(node); long crcKey = hash(node.getIp()); nodeMap.put(crcKey, node); } @Override public Node lookupNode(String key) { long crcKey = hash(key); Node node = findValidNode(crcKey); if(node == null){ return findValidNode(0); } return node; } /** * @param crcKey */ private Node findValidNode(long crcKey) { //顺时针找到最近的一个节点 Map.Entry<Long,Node> entry = nodeMap.ceilingEntry(crcKey); if(entry != null){ return entry.getValue(); } return null; } @Override public Long hash(String key) { CRC32 crc = new CRC32(); crc.update(key.getBytes()); return crc.getValue(); }
这里咱们发现,计算key的hash的算法和取模算法例子里是同样的,这不是重点,重点是,在addNode的时候,咱们经过ip地址来进行一次hash,而且丢到了一个TreeMap里面,key是一个Long,是能够自动排序的。
在lookupNode的时候,咱们是顺时针去找最近的一个节点,若是没有找到,数据就会存在环上顺时针数第一个节点。
和取模算法的同样,惟一不一样的,就是把算法实现的那一行改掉 HashNodeService nodeService = new ConsistentHashNodeServiceImpl();
运行结果以下:
初始化数据分布状况:{192.168.0.11=2495, 192.168.0.12=16732, 192.168.0.13=1849, 192.168.0.14=32116, 192.168.0.15=2729, 192.168.0.16=1965, 192.168.0.17=38413, 192.168.0.18=3701} 初始化缓存命中率:1.0 增长一个节点后缓存命中率:0.97022
这里咱们能够看到,数据分布是不均匀的,同时咱们也发现,某一个节点失效了,对于缓存命中率的影响,要比取模算法的场景,要好得多。
咱们在新增节点的时候,每一个真实节点对应128个虚拟节点
删除节点的代码以下,对应的虚拟节点也一并删掉。
测试代码不变,运行结果以下:
初始化数据分布状况:{192.168.0.11=11610, 192.168.0.12=14600, 192.168.0.13=13472, 192.168.0.14=11345, 192.168.0.15=11166, 192.168.0.16=12462, 192.168.0.17=14477, 192.168.0.18=10868} 初始化缓存命中率:1.0 增长一个节点后缓存命中率:0.91204
这时,咱们发现数据分布的状况已经比上面没有引入虚拟节点的状况好太多了。
我理解一致性Hash就是为了解决在分布式存储扩容的时候涉及到的数据迁移的问题。
可是,一致性Hash中若是每一个节点的数据都很平均,每一个都是热点,在数据迁移的时候,仍是会有比较大数据量迁移。