一致性哈希负载均衡算法思考
一个好的负载均衡算法至少拥有以下特性:
- 在分配请求到各个服务实例上,它是能够尽量均衡的(相同key会尽量落到同一服务实例,不同key尽量分布均匀)
- 在适应新的服务实例加入或离开集群,它是动态的
- 在应对不同场景下的需求,它是多样性的(可以配置权重、策略等)
目前,一致性哈希负载均衡算法有非常多的实现,当我们进行选择时,我们应该从那些方面进行评估后选择合适的:
- 实现复杂程度
- 扰动函数的设计
- 均衡程度
- 性能
对于作用于服务负载的算法,性能肯定是越优秀越佳,而对于实现的复杂度肯定会稍微有点复杂。对于算法实现,扰动函数的设计和均衡程度才是进行评估的重点。下面将从MurmurHash、FNV、Ketama进行分析
一致性哈希算法实现
一、设计一个一致性哈希算法需要做什么
- 需要一个装载ip:port的容器
- 避免数据倾斜、提高节点可用性,引入虚拟节点(物理节点基数低的情况下)
- 设计一个低碰撞的扰动函数(用一些大佬写就很不错)
- 每次请求都会进行扰动,然后取容器去取,向后取的第一个服务实例
// FNV132HASH
public class ConsistentHashStrategy implements LoadBalanceStrategy {
/**
* 虚拟节点个数
*/
private static final int VIRTUAL_NODE_NUM = 500;
/**
* 用于计算hash值的常量
*/
private static final int HASH_CONSTANT = 16777619;
/**
* hash 计算hash值 MurmurHash
*
* @Date 2023/5/6 21:04
* @param str str
* @return {@link long}
* @author Lucky LanAn
* @since 1.0
*/
private static long hash(String str) {
int hash = (int) 2166136261L;
// 计算hash值
for (int i = 0; i < str.length(); i++) {
hash = (hash ^ str.charAt(i)) * HASH_CONSTANT;
}
// 扰动hash值,增加随机性
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
return hash & 0x7FFFFFFF;
}
public String onRefresh(String serviceKey, TreeSet<String> addressList) {
TreeMap<Long, String> virtualNodes = new TreeMap<>();
for (String address : addressList) {
for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
long hash = hash(address + i);
virtualNodes.put(hash, address);
}
}
long hash = hash(serviceKey);
if (virtualNodes.size() == 0) {
return null;
}
Map.Entry<Long, String> longStringEntry = virtualNodes.ceilingEntry(hash);
if (longStringEntry == null) {
longStringEntry = virtualNodes.firstEntry();
}
return longStringEntry.getValue();
}
/**
* route
*
* @param serviceKey serviceKey 服务名
* @param addressSet addressSet 服务地址 set
* @return {@link String}
* @Date 2023/5/6 16:17
* @author Lucky LanAn
* @since 1.0
*/
@Override
public String route(String serviceKey, TreeSet<String> addressSet) {
return onRefresh(serviceKey, addressSet);
}
}
下面就只放出hash函数
// KetamaHash
private static MessageDigest md5Digest;
static {
try {
md5Digest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
}
public int getKetamaHashCode(String origin) {
byte[] bKey = computeMd5(origin);
long rv = ((long) (bKey[3] & 0xFF) << 24)
| ((long) (bKey[2] & 0xFF) << 16)
| ((long) (bKey[1] & 0xFF) << 8)
| (bKey[0] & 0xFF);
return (int) (rv & 0xffffffffL);
}
/**
* Get the md5 of the given key.
*/
public static byte[] computeMd5(String k) {
MessageDigest md5;
try {
md5 = (MessageDigest) md5Digest.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException("clone of MD5 not supported", e);
}
md5.update(k.getBytes());
return md5.digest();
}
// MurmurHash
public int getMurmurHashCode(String origin) {
ByteBuffer buf = ByteBuffer.wrap(origin.getBytes());
int seed = 0x1234ABCD;
ByteOrder byteOrder = buf.order();
buf.order(ByteOrder.LITTLE_ENDIAN);
long m = 0xc6a4a7935bd1e995L;
int r = 47;
long h = seed ^ (buf.remaining() * m);
long k;
while (buf.remaining() >= 8) {
k = buf.getLong();
k *= m;
k ^= k >>> r;
k *= m;
h ^= k;
h *= m;
}
if (buf.remaining() > 0) {
ByteBuffer finish = ByteBuffer.allocate(8).order(
ByteOrder.LITTLE_ENDIAN);
finish.put(buf).rewind();
h ^= finish.getLong();
h *= m;
}
h ^= h >>> r;
h *= m;
h ^= h >>> r;
buf.order(byteOrder);
return (int) (h & 0xffffffffL);
}
测评
我们进行测试的时候,先构建String serviceKey和TreeSet<String> addressSet,然后每次serviceKey选出对应的address,对这个address进行计数加1,然后对每个address的计数进行离散程度计算,就大概了解数据均匀不。
public class TestLoadBalance {
@Test
public void TestA() {
LoadBalanceStrategy loadBalanceStrategy = ExtensionLoader.getLoader(LoadBalanceStrategy.class).getExtension("consistent_hash");
TreeSet<String> addressSet = StatisticsUtil.build(100);
System.out.println("build address set success");
TreeSet<String> request = StatisticsUtil.build(10000);
System.out.println("build request set success");
Map<String, Integer> result = new HashMap<>();
for (String str : request) {
String address = loadBalanceStrategy.route(str, addressSet);
result.put(address, result.getOrDefault(address, 0) + 1);
}
Integer[] longs = result.values().toArray(new Integer[]{});
System.out.println("Load balance result:");
System.out.println(result);
System.out.println("result : " + StatisticsUtil.test(longs));
}
static class StatisticsUtil {
//方差s^2=[(x1-x)^2 +...(xn-x)^2]/n
public static Map<String, Double> test(Integer[] x) {
Map<String, Double> map = new HashMap<>(8);
int m = x.length;
double sum = 0, max = x[0], min = x[0];
for (Integer val : x) {//求和
if (val < min) {
min = val;
}
if (val > max) {
max = val;
}
sum += val;
}
double dAve = sum / m;//求平均值
double dVar = 0;
for (int i = 0; i < m; i++) {//求方差
dVar += (x[i] - dAve) * (x[i] - dAve);
}
double v = dVar / m;
map.put("方差s^2=[(x1-x)^2 +...(xn-x)^2]/n", v);
map.put("标准差σ=sqrt(s^2)", Math.sqrt(v));
map.put("max", max);
map.put("min", min);
return map;
}
public static TreeSet<String> build(int num) {
TreeSet<String> addressSet = new TreeSet<>();
for (int i = 0; i < num; i++) {
addressSet.add("192.168.0." + i + ":8080");
}
return addressSet;
}
}
}
- FNV132HASH result : {标准差σ=sqrt(s2)=11.406138698087096, min=80.0, 方差s2=[(x1-x)2 +…(xn-x)^2]/n=130.1, max=137.0}
- KetamaHash result : {标准差σ=sqrt(s2)=9.302687783646187, min=79.0, 方差s2=[(x1-x)2 +…(xn-x)^2]/n=86.54, max=125.0}
- MurmurHash result : {标准差σ=sqrt(s2)=9.748846085563153, min=73.0, 方差s2=[(x1-x)2 +…(xn-x)2]/n=95.04, max=128.0}
hash name | sqrt(s2) | s2 | max | min |
---|---|---|---|---|
FNV132HASH | 11.406138698087096, | 130.1 | 137.0 | 80.0 |
KetamaHash | 9.302687783646187 | 86.54 | 125.0 | 79.0 |
MurmurHash | 9.748846085563153 | 95.04 | 128.0 | 73.0 |
总结
- MurmurHashStrategy算法在哈希效率上表现较好,适用于需要快速计算哈希值的场景,如缓存和数据存储的负载均衡。但是,它的哈希值分布不如一致性哈希算法均匀。
- 一致性哈希算法通常用于分布式缓存、分布式文件系统等场景的负载均衡。KetamaHashStrategy是一致性哈希算法的一种实现,具有较好的哈希均匀性和可扩展性,能够支持节点的增加、删除和失效检测,具有较好的容错性。
- FnvHashStrategy算法是一种简单的哈希算法,适用于对哈希算法效率和分布均匀性要求较低的场景。它的主要优点是计算速度快,但在一些场景下哈希冲突率较高
最终,在不追求快速计算哈希值的情况下,我选择更加均匀的KetamaHashStrategy