首发于负载均衡

一致性哈希和分布式哈希(DHT)

一致性哈希就是为了在数学这条路上不依赖流表来解决一个RS掉线的问题。当一个RS掉线的时候,如果使用普通的哈希算法,所有的五元组都会被重算,也就意味着所有的已有连接都会断开,这也是LVS的默认行为。

但是这种情况在企业场景通常是不能接受的。一致性哈希不能说根本的解决这个问题,但是能尽量的让被重新计算的五元组更少一些。事实上,并没有没有被重新计算,而是使用了一致性哈希算法之后,重新计算五元组仍然会比较高概率的落到原来的节点上。这就是所有一致性哈希算法的核心追求。

我们知道通常意义的一致性哈希可以用一个环来代表,环上挂了每一个RS,当环上的一个RS掉线之后,到这个RS的五元组被期望落到这个RS左右附近的节点,而其他节点的流量尽量的不变。

环形一致性哈希原理简单,也是最长被使用的做法。其原理是使用一个大的key数组(数组的大小包含了所有的key可能的取值空间),这个数组在概念上被看成一个环。每一个五元组经过哈希得到一个key之后都会对应这个数组上的一个索引。同样的,对每一个rs也做哈希得到一个同样取之空间的key,将这个rs直接对应一个数组上的索引。

当一个五元组来到的时候,哈希计算得到key之后,由于RS已经分布在整个key空间数组的各个点。五元组的key就可以找到与这个key的值最接近的那个RS的节点,从而选择了这个RS。整个过程的目的就是通过五元组选择一个RS。手段是通过创建一个key空间的环,每个五元组经过哈希得到的key都会落到这个环上的某一个点,每个RS也都会落在环上的某一个点,所以就可以根据五元组的key和RS的key的距离来选择五元组。这样当一个RS掉线的时候,已有的五元组因为哈希算法保持不变,所以得到的key是不变的,已有的RS的key也是不变的,所以已有的连接也是不变的,只是落到掉线的那个节点的所有五元组都会被分配到前后的两个RS上。

这个方案完美的解决了哈希重算的问题,但是这个方案很容易的想到,有一个非常严重的问题,就是我们是期望多个RS是均匀间隔的分布到环上的,如果两个RS的key距离比较近,这就意味着严重的负载不均衡。

当一个RS掉线,就会在两个RS之间制造一个更大的key空洞,导致更加严重的负载不均衡。

所以,虽然这种最简单的一致性哈希使用广泛,但是我们也必须要意识到这个算法的局限性。

为了解决这个问题,谷歌在他的负载均衡设备中发明了Maglev算法。Maglev算法的定位就是要解决这个RS在哈希环上的不均衡的问题。这就要从不均衡的原理上进行分析。如果RS的数目足够多,例如有一万多个RS,可以想到,通过哈希算法之后,可以相对均匀的分布在整个哈希环上,也就是说,RS的数目越多,一致性哈希越均衡,丢失一个节点或者增加一个节点带来的不均衡的程度就会被弱化。但是实际上,在工程中,大部分的RS都会比较少,三五个是非常常见的配置模型。

Maglev的出发点就是把这三五个RS变成很多个,事实上不止是Maglav,libconn库使用的一致性哈希也是基于同样的思想,只是与maglev的技术手法不一样。Github的负载均衡设备中使用的score hash则是一次一致性哈希的革命,这个score hash的思想很早就在交换机中被使用,只是Github将其进一步优化。

我们先看Maglev,Magev的数据结构包括了两个层面的数组,一个是用来生成选择数组的中间数组,一个是数据通道直接使用的选择数组。假设配置了5个RS,Maglev首先给每个RS创建一个中间数组,这个数组的长度可以自己设定,越大随机性越好。然后,将五个RS的IP伪随机的填写到所有RS的中间数组中。统计上,5个RS的IP在5个RS的中间数组上出现的次数会一样,数组越大越一样,而且在空间上也是随机的分布的。由于是伪随机的,所以只要随机种子一样,RS一样,每个节点的生成的中间数组就会是一样的。只是带有了随机性。这一步相当于将有限个数的RS在大的空间上展开,均匀的分布在一个大的空间上得到的显然的好处就是随机性的增强,不会出现两个节点的距离过远的情况(过远指的是两个节点中间有远多于其他两个节点间可能的key的取值)。

生成了中间数组之后,再使用所有RS的中间数组生成选择数组。假设这个选择数组的长度是N,N也是越大随机性越好。这个选择数组的生成算法本质上可以是任意的,只要从中间数组中有规律的选择N个IP顺序的填入选择数组即可。例如甚至可以直接将5个RS的数组顺序合并成一个大数组的方法。只是合并的顺序一般是轮询式的,也就是从每个RS的中间数组中取一个值填到目标数组,如此的一遍一遍的遍历中间数组,直到填满最后的数组。

在使用的时候,五元组哈希之后mod N得到一个选择数组的索引,直接数组索引就可以得到要使用的RS,可以说查询RS的时候,没有算法能做到比Maglev更快。

这个算法可以做到一致性是因为虽然整个过程是随机的,但是是伪随机的。其他的节点也是用同样的RS也能得到同样的选择数组。只要选择数组相同,就是一致性哈希。

当然如果单纯的简单的这么实现,得到的结果在删除节点的时候不会有太大意料之外的重算(因为删除节点在算法上是简单的用其他的RS填充被删除的空间),但是在增加节点的时候,重算比例仍然可以达到20%。所以如果需要真正的一致性还行,还需要做很多优化。

Maglev算法是谷歌的算法,一经推出,迅速火爆市场。国内很多厂商都有在使用Maglev,例如美团(未开源不能证实)。爱奇艺的dpvs,虽然没有使用Maglev,但是他使用了libconn库,这个库也是实现了一个版本的一致性哈希。这个哈希在某些情况下,能够取得的效果并不亚于Maglev。

libconn使用了红黑树来组织RS。与Maglev一样,libconn的思路也是在空间上展开RS,将本来很少量的RS在数量上变成很多个,目的也是尽可能的减少各个RS在取值空间上的距离,以达到和Maglev类似的均衡效果。

只是libconn走了一条不同的道路,它将RS的IP地址直接从字符串的意义上展开,例如一个192.168.1.1的RS地址,会直接被展开成192.168.1.1_1,192.168.1.1_2等一连串的字符串地址。这一连串的地址组成一个庞大的RS集合,经过哈希组成一颗红黑树。其哈希的结果取值空间与五元组的哈希结果取值空间一致,这样就能让五元组哈希之后得到的key直接在这颗红黑树上搜索匹配的节点。无论是按照从大到小的排序还是按照从小到大的排序,原理都是一样的,结果是一个VIP一颗红黑树。没个五元组的连接都是直接查询红黑树。由于每个节点的RS一致,展开的方法也一样,所以每个节点得到的红黑树也是一样的,这样当一个节点掉线之后,其他节点就可以直接的复原已有的连接了。这个方案简单可行,Maglev没有可靠的参考实现,但是Maglev的思路在理论上是可以远比红黑树优秀的结果的,因为红黑树不能太大,但是数组可以很大。越大就意味着越均衡。当然,两个的最大差别并不是在均衡上,很多实际的应用,对于均衡程度的要求并不是那么的严格。主要是在增加删除节点的时候带来的不一致性的重算。

而这个影响的程度,不太取决于算法本身,而是取决于实现的人。因为算法的原理本质上是一样的,实现的人的实现能力才是决定结果的最大变量。

第三条路是一个一个类似WCMP的思路,我们知道ECMP是负载均衡的必备良器,是ECMP使得我们能够将不同的节点看成一个节点,也是ECMP让我们有必要让不同的节点的内部状态保持一致,也就是需要一致性哈希或者是流同步。但是ECMP有它固有的缺陷,很多交换机都有有针对这个问题的专门优化,就是ECMP下节点的奇数和偶数问题。思科的交换机称之为CEF Polarization。在奇数个节点的时候ECMP是均衡的,但是在偶数个数的节点的时候,ECMP的不均衡程度显著增大。例如四个节点的时候,均衡的程度是 20%-20%-20%-40% 而不是 25%-25%-25%-25%。

WCMP是一个带权重的ECMP,也就是每个节点并不是完全的等价的。WCMP使用了一个打分的机制,这个打分的机制代表的思想就是我们要说的第三种一致性哈希的思路。这个机制在Github的GLB系统中被采用,GLB是Github开源的负载均衡方案,其采用的打分机制经过比较多的优化。

一般的打分方法是将五元组和所有的RS一起计算哈希,然后直接选择结果最大的那个RS。这一句话可以概括整个过程,但是比较多的细节需要考虑。假设有五个RS,每一个五元组(代表一个流)到来的时候,都会分别与这五个RS计算得到一个哈希结果。无论是选择打分最低还是最高的那个,都是一个唯一选择的问题,只要所有的节点最后选择的节点是一样的,就能够得到一样的选择。这样就是一致性哈希了。如果说Maglev是同时依赖数学和数据,那打分的机制就是完全的依赖数学,无疑的,这个完全依赖数学的具有最高的准确性,但是同时,也有最大的使用开销。

因为每一个五元组都需要跟所有的RS做一次哈希,相当于一个五元组要做很多次哈希,这就是性能损耗的关键点。Github为了解决这个问题,使用了提前构造数组的方法,使用RS提前构造一个数组,然后直接从RSS(五元组由硬件计算的结果),选择几位直接从数组中选择得到RS。这种思路相当于Maglev和打分机制的混合,既有了数组的速度优势,又有了打分算法的构造优势。只要选择的RSS的比特位是相同的,各个节点就能做到一致性。Gitlab做了很多优化,详情可以看他的开源的源代码。

所以总体的思路有打分和RS空间打散两种思路。打分的思路偏向于纯代数算法,RS空间打散偏向于统计算法。并不是说不存在其他的一致性哈希算法,事实上还有很多。例如增强的一致性哈希算法(E-ECMP),是华三的专利技术,在ECMP下的节点变化的时候,粘滞已有的流。他粘滞的算法并不是完全的通过流表,而是通过哈希函数进行粘滞。是一个完全统计意义上的算法。比如当前有一千万个流,每个流被哈希到不同的RS,这样就相当于一个多对多的映射关系,这个映射关系是可以构造一个映射函数的,这个函数就是被选择的哈希函数。使用这个哈希函数,可以最大限度的描述这个映射,不在这个映射的其他流就可以使用流表,这样同时使用流表和哈希函数的选择,就可以显著的节省内存。因为流表最大的问题就是内存的巨大开销,这对于交换机设备是不太能承受的。

还有例如简单的哈希函数,但是做一个保持操作。也就是说就是使用普通的哈希算法,对每个五元组得到一个哈希值,然后mod现在的RS数目N,就得到RS的索引。但是当有RS掉线的时候,仍然先使用原来的RS数目N做mod,这样每个已有的流都可以不变的被哈希到原来的节点上,如果mod得到的索引恰好是已经掉线的那个节点,就重新用新的RS数目N-1做mod,就得到了新的索引结果。类似的思想可以用于添加。这种方案理解起来最简单,但是实现起来反而是最难的。因为需要维护各种各样的关系,而且有一个收敛时间,对频发的RS上下线不友好,所以采用的比较少。

一致性哈希是分布式哈希的一种实现,一致性哈希之所以单列是因为一致性哈希致力于解决无通信的情况下的哈希重新计算的最小化。虽然说是最小化损耗,但是当节点上下线的时候,必然依然会有哈希不命中的情况,一致性哈希只是尽可能的利用数学方法让这个不命中的概率最小。

一旦发生了不命中,仍然需要通过延展网络(就是找这个key到底在哪个节点上)的操作去找到对应的节点到底在哪里。或者是可以直接忍受查找丢失失败的结果。比较简单的一致性环形哈希的延展网络是通过环上相邻的节点进行查找,左右两侧基本就可以找到重新计算导致的丢失的key。也就是说简单的环形哈希的延展网络是一个环形的查找结构。但是其他的哈希算法的延展网络就各有千秋了,有的甚至根本不考虑这个问题。用在四层流表的一致性哈希,一般会选择忍受或者流同步的方式来弥补这个误差。流同步机制的实现本身就是一个延展网络的实现。之所以不太关注延展网络是因为四层负载均衡设备的哈希的执行者是交换机,大部分情况下你不能控制交换机的哈希算法,甚至都不能知道他的算法结构。

分布式哈希DHT是一个将整个哈希表分布在所有节点的技术。一个最容易想到的策略就是将key按照空间划分,一个节点会获得一个空间的key分配。这种查询和设置都是最快的,但是也是有一个最大的问题,就是节点上下线的时候的范围重新分配的问题。

这个问题是所有DHT算法的最核心要解决的问题。Redis中也实现了一个DHT,它并没有做到自动的哈希均衡,而是将这个责任甩给了用户。Redis将哈希空间分割为固定数目的槽,每个节点分配哪些槽是用户自己选择的。所以相当于是一个人肉版的分布式哈希系统。当发生节点上下线的时候,你可以自己设计策略让哪些槽转移到哪些节点。这个设计不能说不好,也是技术无法突破的一个折中的相对有效的办法。因为用户自己配置,可以让用户选择不同负载能力的节点来处理不同范围的节点数。并且使用主备策略让动态变化不成为常态问题。而需要扩展的时候大部分情况下也是需要调整配置,顺带着一起调整一下槽的分配也未尝不可。既然是手动配置的,那么扩展时候的数据丢失也是你自己的问题了。事实上Redis还没有完全就不负责任的甩锅,是扩容的时候,redis提供了迁移的机制。就是把槽的重新分配触发一个kv所在槽在不同节点间的转移的过程。虽然有点丑陋,但是也未尝不可。

这段描述,Redis作为一个一线的缓存系统,是不会认账的。所以Redis退出了Cluster模式,以前的Redis要么在客户端嵌入节点的槽分配信息,要么在整个集权前端多一个proxy,proxy知道每个key对应的是什么后端的节点。这两种都会有显然的问题。后来Redis退出了Cluster,Redis Cluster就是一个标准的DHT的实现了,因为它实现了延展网络。就是请求仍然会发到一个哈希得到的节点,如果这个节点中不存在这个kv,该节点是能够知道哪个节点有这个kv的,会直接重定位到那个节点去获取或者设置kv。这就是延展网络的概念了。只是Redis做到这个延展网络的方式比较巧妙,没有使用数学的方式,而是使用数据收敛的方式直接配置化同步。

意思是,整个集群一共就16384个槽,每个kv都是属于其中一个槽,每个槽都是属于某个节点。维护这个槽和节点的对应关系,这个数据量并不大。完全可以依靠网络同步配置来达到目的。而传统的延展网络的方式都是没有这个收敛到有限个槽的过程,而是想办法计算得到下个节点在哪。如果下个节点没有,就再去下个节点。当然这个下个节点并不会乱找,而是会在所有节点上定义一个顺序的概念,每个节点都会去问跟自己顺序比较近的节点。这个顺序就是每个节点ID之间的汉明距离(本质上还是哈希环的思想)。

环形的思路对应的业界DHT算法就是Chord,著名的kad算法使用的是二叉树的方式组织节点。Kad的这种组织方式叫做结构式的分布式网络,kad设计了一整套的节点的组织,更新和查询的流程。整个流程的设计就是为了确定下个节点是谁。与Chord的环形思想索要达到的目的类似。

延展网络的发展在目前的工业界应用还是比较简单的。但是很多公司有自己的协议实现,例如国内的迅雷和其他的大公司也会有自己的改进算法。

从本质上分析延展网络,其实就是为了在不存在的时候去查询。所以两个发展思路,一个是尽可能的让节点上下线导致的哈希变动最小,另外一个是让延展网络最容易到达。一致性哈希算法的发展致力于第一个目标,Redis的做法显然是第二个目标。我曾经实现过一个高性能的二层网络查询协议。节点都位于一个交换机二层,使用二层直接封包组包进行广播查询。由于每个节点都会有查询的需求,所以在高吞吐的场景,查询总是组包查询,配合一致性哈希让这个过程尽可能的少,并且通过一个巨祯和二层包的压缩技术让二层的流量尽可能的充分利用,也实现了一个能满足线上标准的千万级数据包处理系统的同步系统。本质上也是对延展网络的一种的实现方式,只是这里的距离,由于是自己的单机房节点集群,所以可以利用二层这一个特殊的广播渠道,就达到了非常可观的广播延展网络的收益。

编辑于 2018-12-10

文章被以下专栏收录