0. 为什么写本文
有个朋友是做分布式存储的,有一次聊天他问我一些问题:什么是一致性哈希?一般如何实现?有什么优点?
对于这个问题,我的脑海中只是闪现几个词汇:md5、hash函数、哈希环。
在我看来,哈希就是一种算法。一句话概括就是:把无限的数据映射到有限的集合中的一种算法。
朋友说:你这段话很官方,但是等于没说。
哈哈,身为某厂高级开发工程师的我,此刻无地自容。惭愧、惭愧,请允许我做一个悲伤的表情。
1. 哈希
对于哈希,日常开发中在很多场景都会用到,比如:
- md5 之类的哈希函数
- 分库、分表时,使用某个字段的 hash 值对固定数值取模,来确定对应库表
- 一个大量数据的集合,根据某个字段作为拆分键,对数据进行打散处理
- PHP的 HashTable、Go的 map、Python 的 dict 等数据结构实现
- Redis 分片时使用 crc16 对key进行哈希,然后对 16384 取模来确定分片
- 等等 ......
除上面场景,还有很多地方会用到 hash,而他们都是哈希的一种实现方式。
hash函数
1.1 哈希碰撞
无限个原始数据在经过Hash函数运算之后,得到的哈希结果会有一定的概率相同。那么,这些不同的原始数据得到相同哈希值的情况,就是哈希碰撞。
例如下图所示:c、d在经过某个哈希函数计算之后得到相同的哈希值10,那么c、d 就发生了哈希碰撞。
哈希碰撞
需要了解的:
哈希碰撞无法避免 (因为哈希结果值域是有限的,原始数据是无限的)
哈希值域越大,碰撞概率一般越低
好的哈希函数除了运算速度外,还需要尽量小的哈希碰撞概率
1.2 针对哈希碰撞的两种方案
在出现哈希碰撞情况下常用的方案有:
- 开放地址法
- 拉链法
开放地址法一般不常使用,读者可以自行查阅相关资料。
拉链法则在很多场景、甚至开源系统都会用到。
例如:PHP的 HashTable(PHP5使用双向链表、PHP7使用数组)、以及Go的map底层实现。
拉链法
如图所示,c、d的哈希结果都为10,在存储的时候使用链表来把他们串在一起(就像拉了一条锁链一样)。
其中,bucket 为在某一时刻大小固定的数组,下标为哈希值对固定数值取模之后得到。bucket 的大小一般会在某种临界状态下进行自动的扩容、缩容。
查找的时候,根据计算的哈希值先定位到bucket对应位置,然后再遍历链表查找对应数据。
注意:
原始数据经过Hash计算一般会得到比较大的哈希值,此时需用哈希值对bucket大小取模来确定数据存储位置
理想情况下,不会有哈希碰撞,数值落在bucket的不同位置,查找时间复杂度为 O(1)
糟糕情况下,数据全部哈希碰撞,数值都落在bucket同一个位置,查找时间复杂度为 O(n)
1.3 为什么用哈希
笔者以前接手过一个项目,每天数据量2亿多条,这些数据需要落盘。建表的话,如果存在一张表里面,那将会是一个灾难。当时笔者建了10张表,使用用户uid对10取模来确定当前数据落在哪一张表里面。
取模
其中,uid%10 相当于hash算法,这样的话就把2亿多条的数量分拆在不同的表里面,减少了单表数据量,好处的话:可以提升查询速度、数据在同步时效率提升等等。
在这种情况之下,使用哈希对大量数据进行拆分再合适不过了。
1.4 普通哈希的缺点
优点说了一堆,那么接下来说一下不好的地方。
假设有这样一种场景:原来使用了10张表存储数据,完全没有问题。突然有一天,业务要求现在使用20张表或者5张表存储数据,那该怎么办?
由于表的个数发生变化,此时的hash函数 uid%10 就应该变为 uid%20 或者 uid%5。
此时老的数据就需要进行处理,怎么办?rehash!
对全量数据进行rehash,使用新的hash函数重新计算所有数据,再把这些数据存储在新的表中。
实际开发中出现 rehash 的场景会非常多,所以就需要提前做一些预案。
如果数据量非常大的话,一般有两种方案:
- 停服维护,在维护期间进行数据 rehash 迁移
- 异步迁移,写数据的时候,使用新的哈希函数确认落在哪一张表里面。查询的时候,如果发现数据没有迁移完成,则需要同时使用多个hash函数,从多张表中读取数据(假设还牵扯分页,则会更加麻烦)。在提供服务的同时,对老数据进行rehash迁移。
方案1需要停服,这就要看产品、公司业务是否允许。允许的情况之下,是最优方案。
方案2不停服进行迁移,相当于边开飞机边换轮胎。风险高、逻辑处理复杂。
还有就是,对于数据量大的情况之下,rehash可能会是个漫长的过程
那么,有没有其他好的解决办法呢?
办法是有,解决问题的角度从迁移全量数据变成了迁移部分数据。它就是:一致性哈希。
2. 一致性哈希
维基百科告诉我们:
一致哈希 是一种特殊的哈希算法。在使用一致哈希算法后,哈希表槽位数(大小)的改变平均只需要对K/n 个关键字重新映射,其中 K是关键字的数量,n是槽位数量。然而在传统的哈希表中,添加或删除一个槽位的几乎需要对所有关键字进行重新映射。
一致哈希由MIT的Karger及其合作者提出,现在这一思想已经扩展到其它领域。在这篇1997年发表的学术论文中介绍了“一致哈希”如何应用于用户易变的分布式Web服务中。哈希表中的每一个代表分布式系统中一个节点,在系统添加或删除节点只需要移动 K/n项。
一致哈希也可用于实现健壮缓存来减少大型Web应用中系统部分失效带来的负面影响。
一致哈希的概念还被应用于分布式散列表(DHT)的设计。DHT使用一致哈希来划分分布式系统的节点。所有关键字都可以通过一个连接所有节点的覆盖网络高效地定位到某个节点。
David Karger及其合作者列出了使得一致哈希在互联网分布式缓存中非常有用的几个特性:
- 冗余少
- 负载均衡
- 过渡平滑
- 存储均衡
- 关键词单调
2.1 实现方式 - 哈希环
一致哈希将每个对象映射到圆环边上的一个点,系统再将可用的节点机器映射到圆环的不同位置。查找某个对象对应的机器时,需要用一致哈希算法计算得到对象对应圆环边上位置,沿着圆环边上查找直到遇到某个节点机器,这台机器即为对象应该保存的位置。
当删除一台节点机器时,这台机器上保存的所有对象都要移动到下一台机器。
添加一台机器到圆环边上某个点时,这个点的下一台机器需要将这个节点前对应的对象移动到新机器上。
更改对象在节点机器上的分布可以通过调整节点机器的位置来实现。
假设有一个环形结构,上面有很多节点,一般为 2的32次方。
哈希环
我们需要做的事情大致如下:
对不同节点服务器的某些参数(mac地址、IP地址等)进行hash计算,用hash值对2^32取模,确定当前服务器落在环某一个节点上
数据存储时,对指定的key进行hash计算,然后用hash值对2^32取模,确定数据落在环的哪一个节点上,得到环的节点值之后,顺时针方向找到遇到的第一台服务器,这台服务器就是存储当前数据的地方。
普通哈希环
从图中可以看到,有三台服务器分别落在哈希环的不同节点位置。数据A、B、C、D、E也落在环的不同位置。根据一致性哈希要求,数据在计算得到自己的环中节点之后,顺时针找到第一个服务器节点,那台服务器就是数据的存储位置。
那样的话,可知:
- 数据D、E、A存储在服务器1
- 数据B、C存储在服务器2
- 没有数据存储在服务器3
2.2 场景复现
场景1(缩容)
假设,服务器2发生故障,存在上面的数据都需要迁移
那么,此时只需要迁移服务器1与服务器2之间的数据B、C到服务器3即可。
场景2(扩容)
假设,在数据B、C之间添加服务器4,那么只需要迁移存储在服务器2上的数据B到服务器4即可。
通过上面两个场景可以看出,无论是扩容还是缩容,相对于传统的hash方式,在发生扩、缩容时,只需要迁移一部分数据。大大简化了数据的迁移量,也会大大降低发生问题的概率。
2.3 优化版本的哈希环
通过上面例子可以看出:
数据D、E、A存储在服务器1
数据B、C存储在服务器2
没有数据存储在服务器3
不知道你发现没有:服务器3没有存储数据,服务器1却存储最多的数据,此时就发生了数据倾斜。
那么,有什么办法来解决数据倾斜吗?
办法就是需要对负载策略进行优化,引入虚拟服务器节点。
原来的一台服务器,在哈希环上只能拥有一个节点。那么,此时我们对每一台服务器进行虚拟。例如:原来的服务器1,现在虚拟为2台,服务器1-A、服务器1-B,此时这2台虚拟服务器会在哈希环上拥有不同的2个节点(但是它们实际映射到同一台真实的服务器上)。此时,哈希环就发生了变化。
具有虚拟节点的哈希环
此时,服务器节点由原来的3个节点变为了6个节点。
根据一致性哈希要求,数据存储的位置变为:
- 数据A存储在服务器1-A
- 数据B存储在服务器3-A
- 数据C存储在服务器2-A
- 数据D存储在服务器1-B
- 数据E存储在服务器2-B
由于,上面的服务器节点为虚拟服务器节点,最终数据存储在的真实位置:
- 数据A、D存储在服务器1
- 数据C、E存储在服务器2
- 数据 B 存储在服务器3
由此可见,通过引入服务器虚拟节点,数据的存储变得比较均衡。
3. 总结
通过一系列的场景分析,我们认识了哈希、哈希碰撞、哈希碰撞的解决办法,并抛出了普通哈希存在的数据全量迁移问题。
同时,也找到了解决全量数据迁移的办法——一致性哈希,通过对一致性哈希的认识,了解到它所拥有的巨大潜力。但是,面对大数据量存储的场景,可能会出现数据倾斜,造成某些服务器的高负载。在引入了服务器虚拟节点之后,对一致性哈希的负载进行了优化,从而达到了一种各个服务器均衡的状态。
实际场景中,面对不同的业务或许会有些许差异。但是,大致逻辑类似。