简介
本节简单讨论一下 Hash (哈希) 算法以及它的常见应用场景,之所以写此篇,是因为在群里看见相关的讨论。
Hash 算法与一致性 Hash 其使用范围是很广泛的,本文抛砖引玉一下。
Hash 算法
什么是 Hash 算法?
一句话定义,将任意长度的二进制数据映射成固定长度的二进制值串,这种映射规则就是哈希算法。
想重头设计一个优秀 Hash 算法并不容易,它需要满足一些基本条件
1. 不可逆,无法通过 Hash 算法处理过的二进制值 (哈希值) 串反推出原始值
2. 数据敏感,原始数据有一些微小的变化都会让 Hash 后的二进制值出现较大变化
3. 冲突尽量小,根据「鸽巢原理」,任何 Hash 算法都不可能完全没有冲突,但优秀的 Hash 算法会让冲突的概率很小
4. 效率高,Hash 算法要可以比较高效的计算出哈希值
什么是鸽巢原理?
很简单,总共只有 10 个巢,但却有 11 只鸽子,那么肯定会出现两个鸽子在同一个巢的情况。
引申到 Hash 算法中,因为 Hash 算法要将任意长度的二进制值都映射成固定长度的哈希值,固定长度哈希值其变化是有限的,而任意长度的原始数据是无限的,相当于有限的鸽巢与无限的鸽子,所以任意 Hash 算法在理论上都是无法避免冲突的,但 Hash 算法生成的哈希值越长,冲突的概率越小,但要生成越长的哈希值,需要的运算时间也就越长。
常见的 Hash 算法有很多,如 MD5、SHA、AES 等等
Hash 算法有很多应用场景,如作为唯一标识、给密码安全加密等等,但为了配合文章主题,这里主要从分布式这个方向来讨论。
一个经典的问题:现在图库中有 1 亿张图片,你怎么可以快速判断某张图片是否存在于图库中?
取模应用
思考一下 1 亿图片图库是否存在某图片的问题。
1 亿张图片,单台物理机没戏,所以需要多台物理机配合才能处理这种规模的数据。
具体怎么做?
先准备 n 台物理机,每台物理机只维护部分图片对应的散列表 (利用散列算法,通过 key 可以快速找到 value 的一种数据结构),我们每次从图库中读取一张图片,都利用 Hash 算法计算唯一标识,并利用这个唯一表示构建散列表的 key,但问题是,一张图片的信息放在哪个物理机中呢?
搭建一个 redis,如根据图片名称,如图片名称以 1 结尾的,放到 1 号物理机,其他的类推?这种方式并不好,最好的方式就是利用哈希与取模,具体做法如下:
我们每次从图库中取一张图片,利用 Hash 算法获得哈希值,这相当于图片的唯一标识,利用它与机器的个数 n 进行求余运算,取其模作为作为要操作物理机的编号,假设取模获得的值是 x,则将图片的唯一标识与路径存放在第 x 个物理机中。
而查询一种图片是否存在图库中,其过程也类似,先对图片做 Hash,获得哈希值后,求余取模获得对应的物理机编号,再去这台物理机,通过散列表判断这个哈希值是否存在,从而就可以判断图片是否存在。
通过上面的结构,就可以快速判断 1 亿图片图库是否存在某图片了。
这里再讨论一下 1 亿张图片大概需要多少物理机。
假设我们通过 MD5 处理,获得图片的 MD5 值,这个值会占 128bit,即 16 字节,而文件路径长度上限为 256 字节,因为散列表会出现冲突的可能,所以还需要利用链表来解决冲突,而列表的指针预估占 8 个字节,这里对文件路径占的字节数取平均值来估算,一张图片构建散列表元素的大小大约需要 152 字节 (256/2+16+8)。
一台物理机,内存如果为 2GB,那么大概可以处理 1400 万张图片 (2GB/152 字节),那处理 1 亿张图片,需要十几台物理机,这里还没有涉及装载因子的概念,所谓装载因子是指散列表中的数据超过装载因子定义的值,就需要进行扩容了。
散列表:可以通过数组的方式来简单理解散列表,数组可以通过下标找到对应的值,其时间复杂度为 O (1),散列表也是如此,散列表会申请一段内存连续内存空间,然后通过散列函数获得下标,这个下标就可以定位出该内存空间的某个位置,其时间复杂度也为 O (1),散列函数没有 Hash 函数那么复杂,它只要求算法可以将对应的值比较平均的分配到对应的内存空间则可。
一致性 Hash
如果加多或减少一个物理机呢?
比如图库应用上线后,效果不错,图库图片增多到了 1.1 亿张了,此时就要增加物理机了,只是简单的增加一台物理机吗?
为了描述清楚,这里将情况简化一下,比如有 2000 万张图片在图库中,它们通过 Hash 函数求余取模的方式在两台物理机上构建了散列表,但此时图片增加到了 3000w 张,要增加多一台,此时就会出现取余求模的值发生变化。
原本,2 台物理机时:Hash (a.jpg)%2 = 1
现在,3 台物理机时:Hash (a.jpg)%3 = ???
可以发现,增加一台物理机后,原本构建的散列表也都失效了,需要对所有的 3000w 张图片进行 Hash 计算,重新构建散列表,这是非常不现实的。
这也是造成缓存雪崩的原因之一。
所谓缓存雪崩,当服务器增加时,此前的缓存全部失效,导致请求直接到达底层数据库,请求量一大,会让底层数据库崩溃,出现灾难性后果。
那怎么办?
使用一致性 Hash 则可。
解释一下一致性 Hash,比如某种 Hash 算法,它映射出的哈希值有 2^32 种取值可能 (比如 MD5,有 2^128 种取值可能),此时我们就可以构建出一个虚拟的哈希环
哈希环从 0 开始,顺时针方向增大,0 的右侧第一个点表示 1,以此类推,最后一个点位 2^32-1,在 0 左侧第一个,哈希环上就表示了当前这种 Hash 算法所有可能的值。
接着利用 Hash 算法对服务器的特征值进行哈希运算,获得唯一的哈希值,这里可以选择服务器的 IP 或主机名等关键字段,此时,服务器对应的哈希值就会出现到哈希环上。
回答一开始的图片问题,当一张图片来后,先计算其哈希值,然后将其放置到哈希环上,并沿着哈希环顺时针行走,其遇到的第一台服务器就是它要存储的服务器。
这就是一致性 Hash 的所有过程。
当某个服务器宕机了,此时,不需要对所有图像数据都重新进行 Hash 运算并构建新的散列表,而只需对宕机服务器中的数据进行上述操作。
如果数据量增大,需要新增服务器,此时也不需要操作所有的数据。
一致性 Hash 算法不像此前的求余取模方法,服务器数量的变动只会影响到部分服务器的数据,而不会影响全局数据。
虚拟节点
接着讨论一下虚拟节点的概念。
进行一致性 Hash 时,如果服务器太少,比如只有两台服务器,此时就容易出现数据倾斜,即大部分数据都让其中某一台服务器处理了。
为了避免这种情况,就提出了虚拟节点的概念。
简单而言,就是为一台服务器构建多个逻辑上的节点,比如在服务器的 IP 后引入编号,从而获得不同的哈希值,这些哈希值都落在哈希环上构成虚拟节点,然后维护一张简单的映射表,保存虚拟节点与真实服务器之间的关系。
真实数据依旧以同样的操作获得哈希值,然后落在哈希环上,只是它们遇到虚拟节点就停下来了,并将具体的数据交由虚拟节点对应的真实服务器去处理。
在实际应用中,虚拟节点设置的比较大,从而让少量的服务器也可以做相对均衡的数据分布处理。
尾
本人最近在恶补算法、计算机组成原理方面的知识以及在学习 C++ 语言,所以更新的并不频繁。
希望在写公众号的这段时间里,与大家一同学习进步。
最后,如果内容对你有帮助,麻烦点一下「好看」,那是可以点击的,叩谢豪恩。