假设已经了解一致性Hash的相关知识,如果不了解可以先看www.zsythink.net/archives/11…
代码地址:github.com/zexho994/Co…
我们知道一致性hash的核心思想是对2^32进行取模,然后保存到一个Hash环上:
要实现一致性Hash,先解决几个问题:
- 如何表示这个环?
- 如何在环上插入节点、删除节点、查找最近的节点?
- 虚拟节点如何表示,怎么插入到环中?
Hash环
思考下这个hash环的特点,我们的节点要插入到哪个位置由节点的hash值决定,现在加入有两个节点A和B,A.hashcode = 50,B.hashcode = 100,由于环是按顺时针方向查找,那么查找的逻辑就是:
- [0,50] , (50,2^32] 的数据会找到A
- (50,100] 的数据会找到B
可以发现一个特点,就是要有序,以及可以支持快速找到下一个节点,
TreeMap就十分契合, 每个节点的右子树就是最小的最近的大于某个hashcode的节点。整个算法核心的部分也是基于
TreeMap实现的,首先声明ring,后面所有的节点数据都将存储到ring上。
public class ConsistentHashManager {
private final SortedMap<Integer/*索引大小*/, Node/*节点*/> ring = new TreeMap();
}
ring中key存储的节点的索引值(后面会讲计算方式),val中存储节点对象实例Node,其中Node是模拟表示现实服务器信息的对象,需要重写equals()和hashcode()方法:
public class Node {
//服务器名称
private String name;
//服务器域名
private String host;
//服务器端口
private Integer port;
public Node(String name, String host, int port) {
this.name = name;
this.host = host;
this.port = port;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Node)) return false;
Node node = (Node) o;
return getPort() == node.getPort() && getName().equals(node.getName()) && getHost().equals(node.getHost());
}
@Override
public int hashCode() {
return Objects.hash(getName(), getHost(), getPort());
}
}
添加新节点
要将一个节点存储到环上,首先是要知道存储到哪个位置上,位置由索引的大小决定,这就需要计算Node对象的索引值
先声明一个接口对象,因为以后可能会有各种计算方式,那么会有各种不同的具体实现类,所以这里采用基于接口而非实例的方式:
public interface HashUtil {
int hash(String key);
}
下面是使用MD5的方式,这肯定不是最好的方法,重点是表达hash()的作用
public class Md5HashUtil implements HashUtil {
private MessageDigest messageDigest;
public Md5HashUtil() {
try {
this.messageDigest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
}
@Override
public int hash(String key) {
this.messageDigest.update(String.valueOf(key).getBytes();
byte[] digest = this.messageDigest.digest();
int h = 0;
for (int i = 0; i < 4; i++) {
h |= ((int) digest[i]) & 0xFF;
// 循环4次,每次移动8位,正好32次
h <<= 8;
}
return h;
}
}
有了hash()方法,可以知道节点的索引值了,就可以将Node存储到ring中了
public void addNode(Node node) {
ring.put(hashUtil.hash(node.getKey()), node);
}
那么这时候还有一件事要做,如何添加虚拟节点呢?刚开始的时候我认为为了保证平均分布,那么虚拟节点的分布特征应该如下:
但是会发现一个问题,要继续增加更多物理节点时,如何继续保持平均分布?这很难实现,从另一个方面再想,虚拟节点的作用是什么?是为了让分配更平均,防止血崩的概率,其实哪怕这些虚拟节点的位置都是随机的,只要节点数量够多,就已经达到了目的。
完善一下addNode()方法
public void addNode(Node node) {
ring.put(hashUtil.hash(node.getKey()), node);
for (int i = 0; i < this.virNodeCount; i++) {
this.ring.put(hashUtil.hash(node.getKey() + i), node);
}
}
删除节点
ring中存储的val是Node,这样在遍历ring的匹配node进行删除即可
public void removeNote(Node node) {
ring.entrySet().removeIf(next -> next.getValue().equals(node));
}
获取下一个节点
这部分看代码了解下TreeMap的api就可以理解,没什么难度
public Node getNextNode(String key) {
SortedMap<Integer, Node> longNodeSortedMap = ring.tailMap(hashUtil.hash(key));
if (longNodeSortedMap.isEmpty()) {
return ring.get(ring.firstKey());
}
return longNodeSortedMap.get(longNodeSortedMap.firstKey());
}
测试
负载均衡
@Test
void loadBalancingTest() {
ConsistentHashManager consistentHashManager = new ConsistentHashManager();
// 添加四个节点
consistentHashManager.addNode(new Node("node1", "192.0.0.1", 8080));
consistentHashManager.addNode(new Node("node2", "192.0.0.2", 8080));
consistentHashManager.addNode(new Node("node3", "192.0.0.3", 8080));
consistentHashManager.addNode(new Node("node4", "192.0.0.4", 8080));
String preKey = "Data_";
// map用来记录每个节点命中的次数
Map<String, Integer> map = new HashMap<>(200000);
map.put("node1", 0);
map.put("node2", 0);
map.put("node3", 0);
map.put("node4", 0);
// 假设有20w个数据
for (int i = 0; i < 200000; i++) {
Node nextNote = consistentHashManager.getNextNote(preKey + i);
// 累加命中次数
map.computeIfPresent(nextNote.getName(), (k, v) -> v + 1);
}
// 打印
map.entrySet().forEach(System.out::println);
}
整体的比例是 5 : 6 : 4.5 : 4.5
删除节点后的命中率
@Test
void removeNodeTest() {
// 省略添加节点的代码 ...
// 移除一个节点
consistentHashManager.removeNote(node4);
// 测试下移除节点后的命中率
AtomicInteger n1 = new AtomicInteger(0);
AtomicInteger n2 = new AtomicInteger(0);
AtomicInteger n3 = new AtomicInteger(0);
for (int i = 0; i < 200000; i++) {
Node nextNode = consistentHashManager.getNextNode(preKey + i);
if (nextNode.getName().equals("node1")) {
n1.incrementAndGet();
} else if (nextNode.getName().equals("node2")) {
n2.incrementAndGet();
} else if (nextNode.getName().equals("node3")) {
n3.incrementAndGet();
} else {
throw new RuntimeException("node4 未清理干净");
}
}
// 打印命中率, 原本的次数/删除后的次数
statistic.forEach((key, value) -> {
if (key.equals("node1")) {
System.out.println("Node1,总访问次数=" + n1 + ",有效访问 " + value + " 命中率 = " + (double) (value) / n1.get());
} else if (key.equals("node2")) {
System.out.println("Node2,总访问次数=" + n2 + ",有效访问 " + value + " 命中率 = " + (double) (value) / n2.get());
} else if (key.equals("node3")) {
System.out.println("Node3,总访问次数=" + n3 + ",有效访问 " + value + " 命中率 = " + (double) (value) / n3.get());
}
});
}