1.普通的哈希算法
1.1概述
了解一致性哈希,首先我们必须了解传统的哈希及其在大规模分布式系统中的局限性。简单地说,哈希就是一个键值对存储,在给定键的情况下,可以非常高效地找到所关联的值.
在分布式情况下,当数据太大而无法存储在一个节点或者机器上时,我们一般都需要做一个集群,将数据分别存储在不同的机器上。那么如何确定哪个key存储在哪个节点上呢?最简单的解决方案就是使用哈希取模来确定
,给定一个key,先对key进行哈希运算,然后取模节点个数N得到改key存放的具体节点,同样获取key时也相同。
示意图:
1.2出现的问题
分布式下使用这种算法出现的问题也是显而易见的,比如说我们集群中某一台机器出现了故障宕机,或者双十一等大流量场景,我们需要在集群中增加节点,由于我们的寻址算法是(hash(key) % N)
,此时N发生了变化,那么会导致我们的大部分节点进行重新进行寻址后存储位置发生了变化,即出现了大部分缓存失效,则可能出现缓存雪崩
,这将会造成灾难性的问题.
要解决此问题,我们必须在其余节点上重新分配所有现有键,这可能是非常昂贵的操作,并且可能对正在运行的系统产生不利影响。当然除了重新分配所有现有键的方案之外,还有另一种更好的方案即使用一致性哈希算法。
2.一致性哈希
2.1概述
一致性哈希算法在1997年由麻省理工学院提出,是一种特殊的哈希算法,目的是解决分布式缓存的问题
。在移除或者添加一个服务器时,能够尽可能小地改变已存在的服务请求与处理请求服务器之间的映射关系。一致性哈希解决了简单哈希算法在分布式哈希表( Distributed Hash Table,DHT) 中存在的动态伸缩等问题。
2.2一致性哈希算法原理
一致性哈希算法通过一个叫做一致性哈希环
的数据结构实现,这个环的起点是0,终点是2^32 -1,并且起点与中点相连,故这个环的整数分布范围是 [0, 2 ^ 32 - 1],如下图所示
2.3工作流程
2.3.1数据存储过程
假设现在有三个Key,(KA, KB, KC)
,我们分别计算其哈希值(就是在环上的位置)
,然后将其存储到环上
2.3.2节点的存储
节点跟数据的存储的方式相同,也是计算出节点的hash值,然后将其存储到环上,假设现在有三台节点,分别计算其hash值后映射到环上。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G5Zy1BVC-1653887376420)(image/哈希环_节点存储.png)]
2.3.3数据的存储过程
我们将节点和数据映射到哈希环上后,此时每个数据进行顺时针寻找距离自己最近的一个节点
,然后将数据存储到此节点上.
2.3.4模拟节点宕机的情况
假设S2节点宕机,此时我们看一下会出现什么情况
当S2宕机时,只有S1 - S2节点之间的这些数据会被顺延存储到S0节点中,其他的节点存储位置都不发生改变(在本例子中KC这个数据会被顺延存储到S0中). 即缓存只有一小部分失效,
2.3.5模拟新增节点的情况
我们新增一个节点S4,假设它的位置如下,那么它对于整个哈希环的影响只有原S1-S4之间的数据数据发生变化存储到了S4中(例如KC存放到了S4中)
,其他位置的数据都不发生改变
2.4普通一致性哈希的优点与不足
优点
从2.3.4和2.3.5中我们可以看出,当我们的集群中节点个数发生改变时,只有小部分数据存储位置发生了改变
,这对比我们使用普通的哈希算法有了非常大的提升,可以在一定程度上避免使用普通哈希算法出现的缓存雪崩现象
不足
可以看到,当节点被删除时,其余节点在环上的映射不会发生改变,只是原来打在对应节点上的Key现在会转移到顺时针方向的下一个节点上去。增加一个节点也是同样的,最终都只有少部分的Key发生了失效。不过发生节点变动后,整体系统的压力已经不是均衡的了
数据倾斜问题
如果节点的数量很少,而hash环空间很大(一般是 0 ~ 2^32),直接进行一致性hash上去,大部分情况下节点在环上的位置会很不均匀,挤在某个很小的区域。最终对分布式缓存造成的影响就是,集群的每个实例上储存的缓存数据量不一致,会发生严重的数据倾斜(一些节点存储很多的数据,而一些节点存储很少的数据)
。
缓存雪崩
如果每个节点在环上只有一个节点,那么可以想象,当某一集群从环中消失时,它原本所负责的任务将全部交由顺时针方向的下一个集群处理。例如,当S2退出时,它原本所负责的缓存将全部交给S0处理。这就意味着S0的访问压力会瞬间增大。设想一下,如果S0因为压力过大而崩溃,那么更大的压力又会向S1压过去,最终服务压力就像滚雪球一样越滚越大,最终导致雪崩
。
2.5解决方案-引入虚拟节点
解决上述两个问题最好的办法就是扩展整个环上的节点数量
,因此我们引入了虚拟节点的概念。一个实际节点将会映射多个虚拟节点,这样Hash环上的空间分割就会变得均匀。
同时,引入虚拟节点还会使得节点在Hash环上的顺序随机化,这意味着当一个真实节点失效退出后,它原来所承载的压力将会均匀地分散到其他节点上去。
数据定位算法不变,只是多了一步虚拟节点到实际节点的映射,例如定位到S0#1、S0#1 两个虚拟节点的数据均定位到S0上。这样就解决了服务节点少时数据倾斜的问题。
3.优雅扩缩容
缓存服务器对于性能有着较高的要求,因此我们希望在扩容时新的集群能够较快的填充好数据并工作。但是从一个集群启动,到真正加入并可以提供服务之间还存在着不小的时间延迟
,要实现更优雅的扩容,我们可以从两个方面出发:
3.1高频Key预热
负载均衡器作为路由层,是可以收集并统计每个缓存Key的访问频率的,如果能够维护一份高频访问Key的列表,新的集群在启动时根据这个列表提前拉取对应Key的缓存值进行预热,便可以大大减少因为新增集群而导致的Key失效。
具体的设计可以通过缓存来实现,如下:
不过这个方案在实际使用时有一个很大的限制,那就是高频Key本身的缓存失效时间可能很短,预热时储存的Value在实际被访问到时可能已经被更新或者失效
,处理不当会导致出现脏数据,因此实现难度还是有一些大的。
3.2历史hash环保留
回顾一致性Hash的扩容,不难发现新增节点后,它所对应的Key在原来的节点还会保留一段时间。因此在扩容的延迟时间段,如果对应的Key缓存在新节点上还没有被加载,可以去原有的节点上尝试读取。
举例,假设我们原有3个集群,现在要扩展到6个集群,这就意味着原有50%的Key都会失效(被转移到新节点上),如果我们维护扩容前和扩容后的两个Hash环,在扩容后的Hash环上找不到Key的储存时,先转向扩容前的Hash环寻找一波
,如果能够找到就返回对应的值并将该缓存写入新的节点上,找不到时再透过缓存,如下图:
这样做的缺点是增加了缓存读取的时间,但相比于直接击穿缓存而言还是要好很多的。优点则是可以随意扩容多台机器,而不会产生大面积的缓存失效。
3.3熔断机制
缩容后,剩余各个节点上的访问压力都会有所增加,此时如果某个节点因为压力过大而宕机,就可能会引发连锁反应。因此作为兜底方案,应当给每个集群设立对应熔断机制来保护服务的稳定性
多集群LB(负载均衡)的更新延迟
这个问题在缩容时比较严重,如果你使用一个集群来作为负载均衡,并使用一个配置服务器比如ConfigServer来推送集群状态以构建Hash环,那么在某个集群退出时这个状态并不一定会被立刻同步到所有的LB上,这就可能会导致一个暂时的调度不一致,如下图:
如果某台LB错误地将请求打到了已经退出的集群上,就会导致缓存击穿。解决这个问题主要有以下几种思路:
- 缓慢缩容,等到Hash环完全同步后再操作。可以通过监听退出集群的访问QPS来实现这一点,等到该集群几乎没有QPS时再将其撤下。
- 手动删除,如果Hash环上对应的节点找不到了,就手动将其从Hash环上删除,然后重新进行调度,这个方式有一定的风险,对于网络抖动等异常情况兼容的不是很好。
- 主动拉取和重试,当Hash环上节点失效时,主动从ZK上重新拉取集群状态来构建新Hash环,在一定次数内可以进行多次重试。
总结
了解了一致性Hash算法的特点后,我们也不难发现一些不尽人意的地方:
- 整个分布式缓存需要一个路由服务来做负载均衡,存在单点问题(如果路由服务挂了,整个缓存也就凉了)
- Hash环上的节点非常多或者更新频繁时,查找性能会比较低下
- 针对这些问题,Redis在实现自己的分布式集群方案时,设计了全新的思路:
基于P2P结构的
4.Redis集群方案
4.1HashSlot
类似于Hash环,Redis Cluster采用HashSlot
来实现Key值的均匀分布和实例的增删管理。
首先默认分配了16384个Slot(这个大小正好可以使用2kb的空间保存),每个Slot相当于一致性Hash环上的一个节点。接入集群的所有实例将均匀地占有这些Slot,而最终当我们Set一个Key时,使用CRC16(Key) % 16384
来计算出这个Key属于哪个Slot,并最终映射到对应的实例上去。
增删节点时
举个例子,原本有3个节点A,B,C,那么一开始创建集群时Slot的覆盖情况是:
节点A 0-5460
节点B 5461-10922
节点C 10923-16383
现在假设要增加一个节点D,RedisCluster的做法是将之前每台机器上的一部分Slot移动到D上(注意这个过程也意味着要对节点D写入的KV储存)
,成功接入后Slot的覆盖情况将变为如下情况:
节点A 1365-5460
节点B 6827-10922
节点C 12288-16383
节点D 0-1364,5461-6826,10923-12287
同理删除一个节点,就是将其原来占有的Slot以及对应的KV储存均匀地归还给其他节点
4.2P2P节点寻找
现在我们考虑如何实现去中心化的访问,也就是说无论访问集群中的哪个节点,你都能够拿到想要的数据
。其实这有点类似于路由器的路由表,具体说来就是:
- 每个节点都保存有完整的HashSlot - 节点映射表,也就是说,每个节点都知道自己拥有哪些Slot,以及某个确定的Slot究竟对应着哪个节点
- 无论向哪个节点发出寻找Key的请求,该节点都会通过
CRC(Key) % 16384
计算该Key究竟存在于哪个Slot,并将请求转发至该Slot所在的节点。
总结一下就是两个要点:映射表和内部转发
,这是通过著名的Gossip协议来实现的。
最后我们可以给出Redis Cluster的系统结构图,和一致性Hash环还是有着很明显的区别的:
对比一下,HashSlot + P2P的方案解决了去中心化的问题,同时也提供了更好的动态扩展性。但相比于一致性Hash而言,其结构更加复杂,实现上也更加困难。
而在之前的分析中我们也能看出,一致性Hash方案整体上还是有着不错的表现的,因此在实际的系统应用中,可以根据开发成本和性能要求合理地选择最适合的方案。
4.3拓展如何保证集群在线扩容的安全性?
例如:集群已经对外提供服务,原来有3节点,准备新增2个节点,怎么在不下线的情况下,保证正常的访问.
Redis 使用了 ASK 错误来保证在线扩容的安全性
在槽的迁移过程中若有客户端访问,依旧先访问源节点,源节点会先在自己的数据库里面査找指定的键,如果找到的话,就直接执行客户端发送的命令。
如果没找到,说明该键可能已经被迁移到目标节点了,源节点将向客户端返回一个 ASK 错误,该错误会指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令,从而获取到结果。
ASK错误
在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。
当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时。源节点会先在自己的数据库里面査找指定的键,如果找到的话,就直接执行客户端发送的命令。
否则,这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个 ASK 错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令,从而获取到结果。