一、概述
本篇博文我们将会谈一谈一些的常见负载均衡算法,然后重点讨论一下 一致性哈希算法 的适用范围和算法思想,最后提供一个我自己写的 Golang 版本的实现(做 Lab 时顺手写)。
博客内所有文章和代码均为 原创,所有示意图均为 原创,若转载请附原文链接。
二、负载均衡算法
2.1 概述
在分布式系统中,为了避免对系统中的某一台服务器进行集中地访问而导致该服务器出现被流量压垮的现象,一般都会采用相关的负载均衡算法来对流量进行分散。而对于负载均衡算法的具体应用进一步细化的话,一般比较常见的是对外网访问的流量进行负载均衡,或者在我们经常接触的微服务当中,也经常会将服务注册到服务中心中,然后其他服务会通过服务中心来查询自己所需调用服务的地址后再进行调用,这样就通过服务中心对访问服务的流量进行了负载均衡,又或者在分布式存储系统中,我们为了提升存储系统的性能,经常会采用分片存储的方式来对数据进行存储,这样做最大意义在于当将数据按照不同类别进行分片存储后,就可以将不同片区的数据分散到不同的服务器上进行存储,一方面可以使后期的数据查询等操作并行进行,另一方面因为每个服务器仅存储部分片区的数据,所以查询数据的速度也会大大提升。
2.2 轮询算法
负载均衡的众多算法中最简单的就是 轮询算法 ,顾名思义这个算法就是通过轮询的方式将流量绝对平均的分发给每一台服务器,这种算法最后的效果就是集群中的所有服务器会接收到绝对相同量级的流量访问,比较适用于集群中所有机器的性能大致相同时。
2.3 随机算法
随机算法 跟上面的轮询算法实现难度差不多,实现方式也是顾名思义,就是通过随机的方式将流量进行分发,但是如果有学过概率论的话应该会有一些印象,这种方式最终的分发效果其实是类似于轮询算法的(当随机分配的数量趋于无穷大时,分配趋于平均分配)。
2.4 随机轮询算法
这个 随机轮询算法 我也是从网上了解到的,根据网上的描述它的实现方式其实跟轮询算法是大同小异的,它虽然将轮询法和随机法进行了结合,但也仅仅对每轮轮询的开始节点进行随机,而接下来的分发也还是按照轮询算法的方式进行。
2.5 加权轮询算法
加权轮询算法 是 Nginx 的默认负载均衡算法,上面提到的几种算法都适用于当集群中的机器性能大致相同时,而这种算法比较适合于当集群中的机器存在性能差异时进行使用,比如对于性能较低的机器我们可以将其权值设置的小一点,从而在轮询时少分给它一些流量,而对于性能较高的机器我们可以调大其权值,从而在轮询时多分发给它一些流量。这种算法相对于上面几种算法可以在集群中机器存在性能差异时,最大化发挥集群中机器的性能。
2.6 哈希算法
哈希算法 不只是在负载均衡领域,在一些编程领域我们也会经常接触到,它的主要思想就是对发送请求的 IP 地址或其它信息进行哈希,然后将求取的哈希值取模当前集群中的机器数量,最后根据取模值将其分发给特定的机器来处理,这样做的好处是可以保证发送请求的客户端的所有请求会被持续分发给同一个服务器进行处理,因此比较适用于缓存领域。
三、一致性哈希算法
3.1 适用场景
当我们在设计分布式缓存系统时经常需要考虑的一个问题就是如何才能提升系统的 容错性 和 可扩展性 以及 缓存命中率 ,怎么理解这三点呢,首先容错性是指当分布式缓存系统中一台或多台服务器不可用时,系统能够保持正常的运转,而可扩展性就是当我们为了提升系统性能而添加新的服务器时,系统是否在添加新服务器后正常工作且性能有所提升,最后的缓存命中率是建立在当我们持续的将同一个客户端的请求发送给同一台服务器进行处理时,该台服务器中会缓存该客户端近期所需的数据,所以缓存命中的几率会大大提升。
首先谈一些如何实现系统的 容错性 ,要提升系统的容错性我们就要保证当系统中一台或多台服务器发生故障后,发生故障的机器所负责的数据能够重新分配到无故障的机器中,从而让外部感受不到系统内部发生的故障。其次当我们要提升系统的 可扩展性 时,其实也就是在考虑如何能够在集群正常运行时动态加入机器,所谓动态加入是指集群能够保证当有新机器加入时,集群能够将属于原集群中机器的部分数据分配给新机器来进行处理,降低集群中每台机器所需处理的平均缓存数据量,从而达到提升系统性能的目的。最后,要提升系统的 缓存命中率 就要保证同一个客户端发送的请求能够持续的被分发给同一台服务器来进行处理。
容错性和可扩展性实现的思路大致如上,但是其实现才是至关重要的。如果使用上面的几种负载均衡算法来进行实现,可能会适得其反。首先就用性能较好的哈希算法来举例,因为相对于其它的几种算法哈希算法能够最大限度的提升缓存的命中率。我们知道对于哈希算法其每次对于请求的处理都是哈希地址等信息后取模,然后再根据取模值将其分配给特定的服务器来进行处理,那么当我们集群中移除(容错)或增加新(扩展)的机器时,所有客户端请求所对应的服务器都会被重新计算(因为集群中的机器数量发生变化,因此哈希取模值也会发生变化),因此会造成大量的缓存键被重新分配,从而导致大量的客户端请求 缓存无法命中 。
那么我们有没有什么好的方法来解决这个问题呢,这里就可以使用到 一致性哈希算法 ,也成 哈希环 算法。
3.2 算法思想
因为网上对一致性哈希算法思想的分析很多,所以这里我就不做赘述,但是总结起来其对于哈希算法优化的思想就是将原 哈希算法 点对点映射 的方式优化为 点对线的映射 的方式 ,核心思想就是将 客户端地址的哈希值和服务端地址的哈希值放到同一个哈希环上 。在哈希算法中我们对客户端的地址哈希后取余,然后将其映射到一个单一的服务器,而正是这种客户端点对服务端点的这种点对点映射方式,造成了当服务端机器数量变化时取模值发生变化,从而导致大量的客户端被重定向。但如果我们选择使用一致性哈希的客户端点对服务端线的方式进行映射,让每台服务器不再仅仅只映射单个哈希取模的值而是映射一段哈希取模值的区域,就会增加每台服务器的可控范围,这时因为每台服务器负责的是一个区域内的哈希取模值,因此当我们移除或添加新的服务器时,只需将原每台服务器所负责的部分哈希取模值区域划分出来,然后仅将该区域内的客户端进行重定向给新服务器就可以了。
在上面的叙述中我使用了一个词即每台服务器所负责的区域,如果你之前了解过一致性哈希算法肯定会反问,为什么会是每台服务器都要划分,不应该是仅将一台服务器所负责的范围进行重新划分就可以了。原因是这样的,仅将一台服务器所负责的区域进行重新划分是没错的,但这是在单节点的情况下,即每台服务仅对应于哈希环上的一个节点,但是这样单节点的划分方式会带来请求 分布不均衡 的问题,大大降低系统的平衡性。而采用 虚拟节点 节点的优化方式,我们可以为每台服务器都虚拟出多个节点,使每台服务器在哈希环上所负责的区域分布更加平均,所以在这种情况下当我们移除或添加节点时,就可能需要每一台服务器都划分部分其所负责的区域给新的服务器来达到新的平衡。
四、代码实现(Golang)
4.1 主要属性和函数
type HashRing struct {
replicateCount int // 每台服务所对应的节点数量(实际节点 + 虚拟节点)
nodes map[uint32]string // 键:节点哈希值 , 值:服务器地址
sortedNodes []uint32 // 从小到大排序后的所有节点哈希值切片,可以认为这个就是 哈希环
}
/*
* 作用:在哈希环上添加单个服务器节点(包含虚拟节点)的方法
* 入参:服务器地址
*/
func (hr *HashRing) addNode(masterNode string) {
// 为每台服务器生成数量为 replicateCount-1 个虚拟节点
// 并将其与服务器的实际节点一同添加到哈希环中
for i := 0; i < hr.replicateCount; i++ {
// 获取节点的哈希值,其中节点的字符串为 i+address
key := hr.hashKey(strconv.Itoa(i) + masterNode)
// 设置该节点所对应的服务器(建立节点与服务器地址的映射)
hr.nodes[key] = masterNode
// 将节点的哈希值添加到哈希环中
hr.sortedNodes = append(hr.sortedNodes, key)
}
// 按照值从大到小的排序函数
sort.Slice(hr.sortedNodes, func(i, j int) bool {
return hr.sortedNodes[i] < hr.sortedNodes[j]
})
}
/*
* 作用:添加多个服务器节点(包含虚拟节点)的方法
* 入参:服务器地址集合
*/
func (hr *HashRing) addNodes(masterNodes []string) {
if len(masterNodes) > 0 {
for _, node := range masterNodes {
// 调用 addNode 方法为每台服务器创建实际节点和虚拟节点并建立映射关系
// 最后将创建好的节点添加到哈希环中
hr.addNode(node)
}
}
}
/*
* 作用:从哈希环上移除单个服务器节点(包含虚拟节点)的方法
* 入参:服务器地址
*/
func (hr *HashRing) removeNode(masterNode string) {
// 移除时需要将服务器的实际节点和虚拟节点一同移除
for i := 0; i < hr.replicateCount; i++ {
// 计算节点的哈希值
key := hr.hashKey(strconv.Itoa(i) + masterNode)
// 移除映射关系
delete(hr.nodes, key)
// 从哈希环上移除实际节点和虚拟节点
if success, index := hr.getIndexForKey(key); success {
hr.sortedNodes = append(hr.sortedNodes[:index], hr.sortedNodes[index+1:]...)
}
}
}
/*
* 作用:给定一个客户端地址获取应当处理其请求的服务器的地址
* 入参:客户端地址
* 返回:应当处理该客户端请求的服务器的地址
*/
func (hr *HashRing) getNode(key string) string {
if len(hr.nodes) == 0 {
return ""
}
// 获取客户端地址的哈希值
hashKey := hr.hashKey(key)
nodes := hr.sortedNodes
// 当客户端地址的哈希值大于服务器上所有节点的哈希值时默认交给首个节点处理
masterNode := hr.nodes[nodes[0]]
for _, node := range nodes {
// 如果客户端地址的哈希值小于当前节点的哈希值
// 说明客户端的请求应当由该节点所对应的服务器来进行处理(逆时针)
if hashKey < node {
masterNode = hr.nodes[node]
break
}
}
return masterNode
}
/*
* 作用:哈希函数(这里使用 crc32 算法来实现,返回的是一个 uint32 整型)
* 入参:节点或客户端地址
* 返回:地址所对应的哈希值
*/
func (hr *HashRing) hashKey(key string) uint32 {
scratch := []byte(key)
return crc32.ChecksumIEEE(scratch)
}
4.2 使用方法
主要的使用方法就是通过 New 函数来初始化哈希环,然后通过 addNode 方法向哈希环上添加服务器节点( 实际节点 和 虚拟节点 ),然后通过 removeNode 方法移除哈希环上的服务器节点,当需要分发客户端请求时通过 getNode 方法,即可获取负责处理该客户端请求的服务器地址,后面我会补一些测试用例。
4.3 存在的问题
这里需要注意的是对于 虚拟节点地址 的构造,不同的虚拟节点地址构造方式会直接影响到请求分发的平均性,正常来说当虚拟节点地址字符串构建的越平均时,请求分发的会越平均。当然存在这样的问题也是因为我这里使用的是 crc32 算法,感兴趣的伙伴也可以尝试使用其他的加密算法,但需要注意的是 MD5 加密后的字符串是长度 128bit 的十六进制长整形 ,而 Golang 中最大的长整型为 64bit 的 int64 类型。
五、源码地址
https://github.com/TIYangFan/DistributedSystemBaseGolang
目前在 src/shardmaster/hash_ring.go 中,如果可以帮到你,Please Star ^ _ ^ ~
六、内容总结
这篇博文大致介绍了一下比较常见的几种 负载均衡算法 ,然后大概谈了一下 一致性哈希算法 的适用场景,并简单介绍了一下我自己实现的 Go 版本的一致性哈希算法。因为算法的代码是自己在做 Lab 时顺手写的,可能还存在一些小的问题,后面我会慢慢验证并写一些相关的测试用例。