一致性哈希算法在很多领域有应用,例如分布式缓存领域的 MemCache,Redis,负载均衡领域的 Nginx,各类 RPC 框架。不同领域场景不同,需要顾及的因素也有所差异,本文主要讨论在负载均衡中一致性哈希算法的设计。

在介绍一致性哈希算法之前,我将会介绍一些哈希算法,讨论它们的区别和使用场景。也会给出一致性哈希算法的 Java 通用实现,可以直接引用,文末会给出 github 地址。

友情提示:阅读本文前,最好对一致性哈希算法有所了解,例如你最好听过一致性哈希环这个概念,我会在基本概念上缩短篇幅。

一致性哈希负载均衡介绍

负载均衡这个概念可以抽象为:从 n 个候选服务器中选择一个进行通信的过程。负载均衡算法有多种多样的实现方式:随机、轮询、最小负载优先等,其中也包括了今天的主角:一致性哈希负载均衡。一致性哈希负载均衡需要保证的是“相同的请求尽可能落到同一个服务器上”,注意这短短的一句描述,却包含了相当大的信息量。“相同的请求” — 什么是相同的请求?一般在使用一致性哈希负载均衡时,需要指定一个 key 用于 hash 计算,可能是:

  1. 请求方 IP

  2. 请求服务名称,参数列表构成的串

  3. 用户 ID

“尽可能” —为什么不是一定?因为服务器可能发生上下线,所以少数服务器的变化不应该影响大多数的请求。这也呼应了算法名称中的“一致性”。

同时,一个优秀的负载均衡算法还有一个隐性要求:流量尽可能均匀分布。

综上所述,我们可以概括出一致性哈希负载均衡算法的设计思路。

  • 尽可能保证每个服务器节点均匀的分摊流量

  • 尽可能保证服务器节点的上下线不影响流量的变更

哈希算法介绍

哈希算法是一致性哈希算法中重要的一个组成部分,你可以借助 Java 中的 inthashCode()去理解它。 说到哈希算法,你想到了什么?Jdk 中的 hashCode、SHA-1、MD5,除了这些耳熟能详的哈希算法,还存在很多其他实现,详见 HASH 算法一览。可以将他们分成三代:

  • 第一代:SHA-1(1993),MD5(1992),CRC(1975),Lookup3(2006)

  • 第二代:MurmurHash(2008)

  • 第三代:CityHash, SpookyHash(2011)

这些都可以认为是广义上的哈希算法,你可以在 wiki 百科 中查看所有的哈希算法。当然还有一些哈希算法如:Ketama,专门为一致性哈希算法而设计。

既然有这么多哈希算法,那必然会有人问:当我们在讨论哈希算法时,我们再考虑哪些东西?我大概总结下有以下四点:

  1. 实现复杂程度

  2. 分布均匀程度

  3. 哈希碰撞概率

  4. 性能

先聊聊性能,是不是性能越高就越好呢?你如果有看过我曾经的文章 《该如何设计你的 PasswordEncoder?》,应该能了解到,在设计加密器这个场景下,慢 hash 算法反而有优势;而在负载均衡这个场景下,安全性不是需要考虑的因素,所以性能自然是越高越好。

优秀的算法通常比较复杂,但不足以构成评价标准,有点黑猫白猫论,所以 2,3 两点:分布均匀程度,哈希碰撞概率成了主要考虑的因素。

我挑选了几个值得介绍的哈希算法,重点介绍下。

  1. MurmurHash 算法:高运算性能,低碰撞率,由 Austin Appleby 创建于 2008 年,现已应用到 Hadoop、libstdc++、nginx、libmemcached 等开源系统。2011 年 Appleby 被 Google 雇佣,随后 Google 推出其变种的 CityHash 算法。官方只提供了 C 语言的实现版本。Java 界中 Redis,Memcached,Cassandra,HBase,Lucene 都在使用它。 在 Java 的实现,Guava 的 Hashing 类里有,上面提到的 Jedis,Cassandra 里都有相关的 Util 类。

  2. FNV 算法:全名为 Fowler-Noll-Vo 算法,是以三位发明人 Glenn Fowler,Landon Curt Noll,Phong Vo 的名字来命名的,最早在 1991 年提出。 特点和用途:FNV 能快速 hash 大量数据并保持较小的冲突率,它的高度分散使它适用于 hash 一些非常相近的字符串,比如 URL,hostname,文件名,text,IP 地址等。

  3. Ketama 算法:将它称之为哈希算法其实不太准确,称之为一致性哈希算法可能更为合适,其他的哈希算法有通用的一致性哈希算法实现,只不过是替换了哈希方式而已,但 Ketama 是一整套的流程,我们将在后面介绍。

以上三者都是最合适的一致性哈希算法的强力争夺者。

一致性哈希算法实现

一致性哈希的概念我不做赘述,简单介绍下这个负载均衡中的一致性哈希环。首先将服务器(ip+端口号)进行哈希,映射成环上的一个节点,在请求到来时,根据指定的 hash key 同样映射到环上,并顺时针选取最近的一个服务器节点进行请求(在本图中,使用的是 userId 作为 hash key)。

当环上的服务器较少时,即使哈希算法选择得当,依旧会遇到大量请求落到同一个节点的问题,为避免这样的问题,大多数一致性哈希算法的实现度引入了虚拟节点的概念。

在上图中,只有两台物理服务器节点:11.1.121.1 和 11.1.121.2,我们通过添加后缀的方式,克隆出了另外三份节点,使得环上的节点分布的均匀。一般来说,物理节点越多,所需的虚拟节点就越少。

介绍完了一致性哈希换,我们便可以对负载均衡进行建模了:

public interface LoadBalancer {Server select(List<Server> servers, Invocation invocation);
}

下面直接给出通用的算法实现:

public class ConsistentHashLoadBalancer implements LoadBalancer{private HashStrategy hashStrategy = new JdkHashCodeStrategy();private final static int VIRTUAL_NODE_SIZE = 10;private final static String VIRTUAL_NODE_SUFFIX = "&&";@Overridepublic Server select(List<Server> servers, Invocation invocation) {int invocationHashCode = hashStrategy.getHashCode(invocation.getHashKey());TreeMap<Integer, Server> ring = buildConsistentHashRing(servers);Server server = locate(ring, invocationHashCode);return server;}private Server locate(TreeMap<Integer, Server> ring, int invocationHashCode) {// 向右找到第一个 keyMap.Entry<Integer, Server> locateEntry = ring.ceilingEntry(invocationHashCode);if (locateEntry == null) {// 想象成一个环,超过尾部则取第一个 keylocateEntry = ring.firstEntry();}return locateEntry.getValue();}private TreeMap<Integer, Server> buildConsistentHashRing(List<Server> servers) {TreeMap<Integer, Server> virtualNodeRing = new TreeMap<>();for (Server server : servers) {for (int i = 0; i < VIRTUAL_NODE_SIZE; i++) {// 新增虚拟节点的方式如果有影响,也可以抽象出一个由物理节点扩展虚拟节点的类virtualNodeRing.put(hashStrategy.getHashCode(server.getUrl() + VIRTUAL_NODE_SUFFIX + i), server);}}return virtualNodeRing;}}

对上述的程序做简单的解读:

Server 是对服务器的抽象,一般是 ip+port 的形式。

public class Server {private String url;
}

Invocation 是对请求的抽象,包含一个用于 hash 的 key。

public class Invocation {private String hashKey;
}

使用 TreeMap 作为一致性哈希环的数据结构, ring.ceilingEntry 可以获取环上最近的一个节点。在 buildConsistentHashRing 之中包含了构建一致性哈希环的过程,默认加入了 10 个虚拟节点。

计算方差,标准差的公式:

public class StatisticsUtil {//方差s^2=[(x1-x)^2 +...(xn-x)^2]/npublic static double variance(Long[] x) {int m = x.length;double sum = 0;for (int i = 0; i < m; i++) {//求和sum += x[i];}double dAve = sum / m;//求平均值double dVar = 0;for (int i = 0; i < m; i++) {//求方差dVar += (x[i] - dAve) * (x[i] - dAve);}return dVar / m;}//标准差σ=sqrt(s^2)public static double standardDeviation(Long[] x) {int m = x.length;double sum = 0;for (int i = 0; i < m; i++) {//求和sum += x[i];}double dAve = sum / m;//求平均值double dVar = 0;for (int i = 0; i < m; i++) {//求方差dVar += (x[i] - dAve) * (x[i] - dAve);}return Math.sqrt(dVar / m);}}

其中, HashStrategy 是下文中重点讨论的一个内容,他是对 hash 算法的抽象,我们将会着重对比各种 hash 算法给测评结果带来的差异性。

public interface HashStrategy {int getHashCode(String origin);
}

测评程序

前面我们已经明确了一个优秀的一致性哈希算法的设计思路。这一节我们给出实际的量化指标:假设 m 次请求打到 n 个候选服务器上

  • 统计每个服务节点收到的流量,计算方差、标准差。测量流量分布均匀情况,我们可以模拟 10000 个随机请求,打到 100 个指定服务器,测试最后个节点的方差,标准差。

  • 记录 m 次请求落到的服务器节点,下线 20% 的服务器,重放流量,统计 m 次请求中落到跟原先相同服务器的概率。测量节点上下线的情况,我们可以模拟 10000 个随机请求,打到 100 个指定服务器,之后下线 20 个服务器并重放流量,统计请求到相同服务器的比例。

public class LoadBalanceTest {static String[] ips = {...}; // 100 台随机 ip/*** 测试分布的离散情况*/@Testpublic void testDistribution() {List<Server> servers = new ArrayList<>();for (String ip : ips) {servers.add(new Server(ip));}ConsistentHashLoadBalancer chloadBalance = new ConsistentHashLoadBalancer();// 构造 10000 随机请求List<Invocation> invocations = new ArrayList<>();for (int i = 0; i < 10000; i++) {invocations.add(new Invocation(UUID.randomUUID().toString()));}// 统计分布AtomicLongMap<Server> atomicLongMap = AtomicLongMap.create();for (Invocation invocation : invocations) {Server selectedServer = chloadBalance.select(servers, invocation);atomicLongMap.getAndIncrement(selectedServer);}System.out.println(StatisticsUtil.standardDeviation(atomicLongMap.asMap().values().toArray(new Long[]{})));}/*** 测试节点新增删除后的变化程度*/@Testpublic void testNodeAddAndRemove() {List<Server> servers = new ArrayList<>();for (String ip : ips) {servers.add(new Server(ip));}List<Server> serverChanged = servers.subList(0, 80);ConsistentHashLoadBalancer chloadBalance = new ConsistentHashLoadBalancer();// 构造 10000 随机请求List<Invocation> invocations = new ArrayList<>();for (int i = 0; i < 10000; i++) {invocations.add(new Invocation(UUID.randomUUID().toString()));}int count = 0;for (Invocation invocation : invocations) {Server origin = chloadBalance.select(servers, invocation);Server changed = chloadBalance.select(serverChanged, invocation);if (origin.getUrl().equals(changed.getUrl())) count++;}System.out.println(count / 10000D);}

不同哈希算法的实现及测评

最简单、经典的 hashCode 实现:

public class JdkHashCodeStrategy implements HashStrategy {@Overridepublic int getHashCode(String origin) {return origin.hashCode();}
}

FNV132HASH 算法实现:

public class FnvHashStrategy implements HashStrategy {private static final long FNV_32_INIT = 2166136261L;private static final int FNV_32_PRIME = 16777619;@Overridepublic int getHashCode(String origin) {final int p = FNV_32_PRIME;int hash = (int) FNV_32_INIT;for (int i = 0; i < origin.length(); i++)hash = (hash ^ origin.charAt(i)) * p;hash += hash << 13;hash ^= hash >> 7;hash += hash << 3;hash ^= hash >> 17;hash += hash << 5;hash = Math.abs(hash);return hash;}
}

CRC 算法:

public class CRCHashStrategy implements HashStrategy {@Overridepublic int getHashCode(String origin) {CRC32 crc32 = new CRC32();crc32.update(origin.getBytes());return (int) ((crc32.getValue() >> 16) & 0x7fff & 0xffffffffL);}
}

Ketama 算法:

public class KetamaHashStrategy implements HashStrategy {private static MessageDigest md5Digest;static {try {md5Digest = MessageDigest.getInstance("MD5");} catch (NoSuchAlgorithmException e) {throw new RuntimeException("MD5 not supported", e);}}@Overridepublic int getHashCode(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 class MurmurHashStrategy implements HashStrategy {@Overridepublic int getHashCode(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);// for big-endian version, do this first:// finish.position(8-buf.remaining());finish.put(buf).rewind();h ^= finish.getLong();h *= m;}h ^= h >>> r;h *= m;h ^= h >>> r;buf.order(byteOrder);return (int) (h & 0xffffffffL);}
}

测评结果:

  方差 标准差 不变流量比例
JdkHashCodeStrategy 29574.08 171.97 0.6784
CRCHashStrategy 3013.02 54.89 0.7604
FnvHashStrategy 792.02 28.14 0.7892
KetamaHashStrategy 1147.08 33.86 0.80
MurmurHashStrategy 634.82 25.19 0.80

其中方差和标准差反映了均匀情况,越低越好,可以发现 MurmurHashStrategy,KetamaHashStrategy,FnvHashStrategy 都表现的不错,其中 MurmurHashStrategy 最为优秀。

不变流量比例体现了服务器上下线对原有请求的影响程度,不变流量比例越高越高,可以发现 KetamaHashStrategy 和 MurmurHashStrategy 表现最为优秀。

我并没有对小集群,小流量进行测试,样本偏差性较大,仅从这个常见场景来看,MurmurHashStrategy 似乎是最优的选择。

至于性能测试,MurmurHash 也十分的高性能,我并没有做测试(感兴趣的同学可以对几种 strategy 用 JMH 测评一下),这里我贴一下 MurmurHash 官方的测评数据:

OneAtATime - 354.163715 mb/sec
FNV - 443.668038 mb/sec
SuperFastHash - 985.335173 mb/sec
lookup3 - 988.080652 mb/sec
MurmurHash 1.0 - 1363.293480 mb/sec
MurmurHash 2.0 - 2056.885653 mb/sec

扩大虚拟节点可以明显降低方差和标准差,但虚拟节点的增加会加大内存占用量以及计算量

Ketama 一致性哈希算法实现

Ketama 算法有其专门的配套实现方式

public class KetamaConsistentHashLoadBalancer implements LoadBalancer {private static MessageDigest md5Digest;static {try {md5Digest = MessageDigest.getInstance("MD5");} catch (NoSuchAlgorithmException e) {throw new RuntimeException("MD5 not supported", e);}}private final static int VIRTUAL_NODE_SIZE = 12;private final static String VIRTUAL_NODE_SUFFIX = "-";@Overridepublic Server select(List<Server> servers, Invocation invocation) {long invocationHashCode = getHashCode(invocation.getHashKey());TreeMap<Long, Server> ring = buildConsistentHashRing(servers);Server server = locate(ring, invocationHashCode);return server;}private Server locate(TreeMap<Long, Server> ring, Long invocationHashCode) {// 向右找到第一个 keyMap.Entry<Long, Server> locateEntry = ring.ceilingEntry(invocationHashCode);if (locateEntry == null) {// 想象成一个环,超过尾部则取第一个 keylocateEntry = ring.firstEntry();}return locateEntry.getValue();}private TreeMap<Long, Server> buildConsistentHashRing(List<Server> servers) {TreeMap<Long, Server> virtualNodeRing = new TreeMap<>();for (Server server : servers) {for (int i = 0; i < VIRTUAL_NODE_SIZE / 4; i++) {byte[] digest = computeMd5(server.getUrl() + VIRTUAL_NODE_SUFFIX + i);for (int h = 0; h < 4; h++) {Long k = ((long) (digest[3 + h * 4] & 0xFF) << 24)| ((long) (digest[2 + h * 4] & 0xFF) << 16)| ((long) (digest[1 + h * 4] & 0xFF) << 8)| (digest[h * 4] & 0xFF);virtualNodeRing.put(k, server);}}}return virtualNodeRing;}private long getHashCode(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 rv;}private 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();}}

稍微不同的地方便在于:Ketama 将四个节点标为一组进行了虚拟节点的设置。

  方差 标准差 不变流量比例

KetamaConsistent

HashLoadBalancer

911.08 30.18 0.7936

实际结果并没有太大的提升,可能和测试数据的样本规模有关。

总结

优秀的哈希算法和一致性哈希算法可以帮助我们在大多数场景下应用的高性能,高稳定性,但在实际使用一致性哈希负载均衡的场景中,最好针对实际的集群规模和请求哈希方式进行压测,力保流量均匀打到所有的机器上,这才是王道。

不仅仅是分布式缓存,负载均衡等等有限的场景,一致性哈希算法、哈希算法,尤其是后者,是一个用处很广泛的常见算法,了解它的经典实现是很有必要的,例如 MurmurHash,在 guava 中就有其 Java 实现,当需要高性能,分布均匀,碰撞概率小的哈希算法时,可以考虑使用它。

一文搞懂负载均衡中的一致性哈希算法相关推荐

  1. 深入理解分布式技术 - 负载均衡实现之一致性哈希算法

    文章目录 概述 常见的负载均衡策略 及优缺点 哈希取模路由 一致性哈希 小结 概述 在业务开发中,缓存服务和其他数据服务一样,需要满足高可用性,而高可用最常用的手段就是集群扩展. 目前 Redis 流 ...

  2. 10分钟带你彻底搞懂负载均衡

    文章目录 十分钟搞懂系列 负载均衡是如何保证软件系统的生产部署的? 负载均衡分发策略 请求由谁来分发? 服务器端负载均衡器 客户端负载均衡 请求分发到哪去? 静态负载均衡算法 动态负载均衡算法 十分钟 ...

  3. redis实现轮询算法_白话分布式系统中的一致性哈希算法

    本文首发于:白话分布式系统中的一致性哈希算法 微信公众号:后端技术指南针 持续输出干货 欢迎关注! 通过本文将了解到以下内容:分布式系统的概念和作用 分布式系统常用负责均衡策略 普通哈希取模策略优缺点 ...

  4. Swift中的一致性哈希算法(补充)

    2019独角兽企业重金招聘Python工程师标准>>> 总结一下,理解算法时的几个问题,搞懂这些基本上就算理解Swift rebalnce的算法了. Swift如何保证文件的随机存储 ...

  5. 一文搞懂高速电路中的电源设计

    一块单板常常涉及多种电源,常见的如5V,3.3V,2.5V,1.8V,1.5V,1.2V,1.0V,0.9V,0.75V等,如此多种类的电源不可能都直接通过背板从电源获得,一般,单板仅有一种或两种输入 ...

  6. 一文搞懂│王者游戏中荣耀水晶难抽?探索游戏中的抽奖算法

    目录 一.初始化奖品 二.谢谢参与 三.过滤抽奖.如充值条件 四.重组概率 五.进行抽奖 六.过滤回调 七.最终抽奖结果 八.抽奖封装成类 一.初始化奖品 奖品详情应该从数据库中读出来 奖品详情应该加 ...

  7. 一文搞懂异常检测中离群、异常、新类、开集、分布外检测异同

    点击上方"迈微AI研习社",选择"星标★"公众号 重磅干货,第一时间送达 选自丨机器之心 MMLab@NTU 你是否也曾迷惑于「离群检测,异常检测,新类检测,开 ...

  8. 一文搞懂中建、中交、中能建、中铁、中铁建等企业

    一.中国建筑(CSCEC) 中国建筑股份有限公司(2016<财富>世界500强第27位) 中国建筑工程总公司: ► 是中国最大建筑房地产综合企业和中国最大国际承包商: ► 是中央直接管理的 ...

  9. 一文搞懂Pandas Dataframe中的apply方法

    告诉你如何在Pandas数据框架中使用apply()的方法. 扫码关注<Python学研大本营>,加入读者群,分享更多精彩 热点 在这篇文章中,我们将探索如何在DataFrame中使用ap ...

最新文章

  1. MIT重新发明飞机:无需燃料,每秒万米喷射带你上天 | Nature封面
  2. 6.没有Release文件。N:无法安全地用该源进行更新,所以默认禁用该源解决
  3. 图解设计模式-Abstract Factory模式
  4. 1个人,耗时2年半,这款大型仙侠3D硬核ARPG是怎么做出来的?
  5. BugkuCTF-MISC题有黑白棋的棋盘
  6. 雷霄骅--H264视频编解码分析--目录转载
  7. 【scala初学】scala IDE eclipse
  8. VSCode使用记录三:中文显示乱码、设置字体大小、常用快捷键
  9. 我有几个粽子,和一个故事
  10. python批量检测域名和url能否打开
  11. BF-9500警用(PDT)数字集群通信系统
  12. 共用体union与枚举enum(C++)
  13. qua数据统计缺失问题之终结
  14. Pr 入门教程了解基本校正选项
  15. 各手机品牌系列侧重方向
  16. 如何将DWG另存为kml文件?
  17. [博学谷学习记录]超强总结,用心分享|人工智能机械学习基础知识KMeans总结分享
  18. 关于使用Vivado在仿真时报错的问题
  19. 我如何选择Parse.com的替代品
  20. 2021年偃师一高高考成绩查询,2021洛阳市地区高考成绩排名查询,洛阳市高考各高中成绩喜报榜单...

热门文章

  1. TypeScript 3.0 新功能介绍(二)
  2. windows installer无法启动
  3. Zabbix如何实现Server和Agent的通信加密
  4. Ruby Fiber指南(三)过滤器
  5. 上拉加载你这个坑货~
  6. Android性能优化案例研究(上)
  7. 千家BBS系列-技术宝典(免费下载软件)
  8. C#三层结构(4)——扩展-加密字符窜源代码
  9. 你会么?图形不正,角度是随机的
  10. linux AIO (异步IO) 那点事儿