1

前置问题

  1. MD5算法会不会冲突?概率是多大?
  2. redis的分布式缓存是如何做到的?
  3. nginx的负载均衡有哪些实现方式?

蝉的哲学

蝉有好多种,有的3年期出土,有的5年期出土,本文更多讨论的是13年和17年出土的超长周期蝉。
t
周期蝉是蝉科周期蝉属(Magicicada)下7种蝉的统称,其中有3种是17年蝉,4种是13年蝉。它们只生活在北美东部,其中13年蝉主要分布在美国东南部,17年蝉主要分布在美国东北部及加拿大部分地区。

这7种蝉之所以被称为“周期蝉”,是因为它们会遵循某个周期集中出现。我们以2016年为例,2016年,有一种17年蝉将会在马里兰州出现,而在2016年之后的16年里,马里兰州再也不会出现这种周期蝉,直到2033年,马里兰州才会再次出现这批周期蝉的后代。

有趣的事情发现了,我们发现,不管蝉的生命周期长或者是短,它们都是质数。也就是说,它和任何一个数的最大公因数是1

为什么?

根据《费马大定理》里提到的,有一种理论假设蝉有一种生命周期也较长的天敌,蝉要设法避开这种天敌。如果这种天敌的生命周期比方说是2年,那么蝉就要避开能被2整除的生命周期,否则天敌和蝉就会定期相遇。类似地,如果天敌的生命周期是3年,那么蝉要避开能被3整除的生命周期,否则天敌和蝉又会定期相遇。所以最终为了避免遇到它的天敌,蝉的最佳策略是使它的生命周期的年数延长为一个质数。由于没有数能整除17,十七年蝉将很难得遇上它的天敌。如果天敌的生命周期为2年,那么它们每隔34年才遇上一次;倘若天敌的生命周期更长一些,比方说16年,那么它们每隔272(16×17)年才遇上一次。

为了回击,天敌只有选择两种生命周期可以增加相遇的频率——1年期的生命周期以及与蝉同样的17年期的生命周期。然而,天敌不可能活着接连重新出现达17年之久,因为在前16次出现时没有蝉供它们寄生。另一方面,为了达到为期17年的生命周期,一代代的天敌在16年的生命周期中首先必须得到进化,这意味着在进化的某个阶段,天敌和蝉会有272年之久不相遇!无论哪一种情形,蝉的漫长的、年数为质数的生命周期都保护了它。
这或许解释了为什么这种假设的天敌从未被发现!在为了跟上蝉而进行的赛跑中,天敌很可能不断延长它的生命周期直至到达16年这个难关。然后它将有272年的时间遇不到蝉,而在此之前,由于无法与蝉相遇它已被赶上了绝路。剩下的是生命周期为17年的蝉,其实它已不再需要这么长的生命周期了,因为它的天敌已不复存在。

那么这个蝉,和我们今天要说的哈希有啥关系呢?

哈希(hashing)

哈希这个概念,对于前端来说,其实不是很容易理解,为什么,因为我们时时刻刻都在用这个东西,这个对我们来说,就是理所当然的。

那么抛开这些,假设我们不是一个前端,我们来看。

假设我们要给一个公司的工号做一个工号本,可以根据工号找到这个工号是谁。
考虑到这个公司不大,不可能出现十万人。假设工号的格式是xXXXX,99999是最大工号。由于公司的流动性,虽然只有100人,但是工号也已经上万了。

那么我们可以直接把对应的人的信息,存到数组里。这样访问这个号码的时候,就是O(1)的时间复杂度。

 var arr = new Array(99999);
 arr[num] = info;

空间 = O(9999)
利用的效率 = 100/99999 = 千分之一。
那利用率是不是太低了?

那我们就需要设计一种东西,可以提高它的利用率,那这个就是哈希表

哈希表

哈希表,又称散列表,英文名为Hash Table。实质上是一种对数组进行了扩展的数据结构,可以说哈希表是在数组支持下标直接索引数据(value)的基础上进行优化并尽可能在常数时间内对数据进行增、删、改、查等操作。

也就是说,人数100人,假设我提供一个容量为199的哈希表,我只需要把工号,通过某个方式,转换为200以内的数字,最终同样把所有人的信息,存储进来,那么是不是这样的效率会更高呢?效率就是100/199 ~= 1/2,远远大于千分之一。这也是装载因子的定义
而这个方式,也就是所设计出来的函数,就叫哈希函数

哈希函数

比如MD5算法,就是一个比较常见的哈希函数,它的原理都是公开的,有兴趣可以去查查。
它就是把任意长度的字符串,转化为一个128位的2进制数,也就是32位的16进制数。

而像上述的这个工号转换的哈希函数,很简单。

function hashNumber(num) {
    return num % 199;
}

假设我需要存储工号1999的员工的信息

const key = hashNumber(1999);
arr[key] = {name: '张三', phone: 18888 ,sex: '男' };

那么假设有个同事的工号是2198,结果发现通过这个算法生成的key也是9,这就造成了冲突。

而要想设计一个优秀的哈希算法并不容易,一般情况下,它需要满足的几点要求:

  1. 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法);
  2. 对输入数据非常敏感,哪怕原始数据只修改了一个Bit,最后得到的哈希值也大不相同;
  3. 哈希冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;
  4. 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。

这些定义和要求都比较理论,可能还是不好理解,我拿MD5这种哈希算法来具体说明一下。

我们分别对“优秀的哈希算法不是我能写出来的”和“aklgod”这两个文本,计算MD5哈希值,得到两串看起来毫无规律的字符串。可以看出来,无论要哈希的文本有多长、多短,通过MD5哈希之后,得到的哈希值的长度都是相同的,而且得到的哈希值看起来像一堆随机数,完全没有规律。

MD5("优秀的哈希算法不是我能写出来的") = 15215cac3a04b7735eadd30b2c384420
MD5("aklgod") = 0135ae01cc770c5b6c46c788a46106ca

我们再来看两个非常相似的文本,“每天都想一夜暴富”和“每天都想一夜暴富!”。这两个文本只有一个感叹号的区别。如果用MD5哈希算法分别计算它们的哈希值,你会发现,尽管只有一字之差,得到的哈希值也是完全不同的。

MD5("每天都想一夜暴富") = fc63ff05adebc9f02a5fdf101c571db5
MD5("每天都想一夜暴富!") = 2b9647855f3879e0cff8d4ea2dd8c92b

前面也说了,通过哈希算法得到的哈希值,很难反向推导出原始数据。比如上面的例子中,我们就很难通过哈希值“2b9647855f3879e0cff8d4ea2dd8c92b”反推出对应的文本“每天都想一夜暴富!”。

哈希算法要处理的文本可能是各种各样的。比如,对于非常长的文本,如果哈希算法的计算时间很长,那就只能停留在理论研究的层面,很难应用到实际的软件开发中。比如,我们把今天这篇文章,用MD5计算哈希值,用不了1ms的时间。

哈希冲突

有些论断看似没有道理,比如“一个班级里肯定有两个人生日在同一个月份”、“世界上肯定有两个人头发数一样多”等等,但是仔细一想还真是对的。
人类的头发根据种族和发色的不同,数量略有不同,最多在12万根左右;目前世界上的总人口超过了74亿;
再举一个例子,现在有十只鸽子但只有九个笼子,那么必定有一个笼子里至少有两只鸽子。因为九个鸽子先各占一个笼子,那么第十只就只能和其他鸽子挤挤了。

而上述看似显然的一个结论就是“鸽笼原理”(The Pigeonhole Principle),也叫“抽屉原理”

那么我刚刚设计的一个工号,它的冲突的概率就是199分之1。概率还是很高的,而MD5算法呢,它是128位的2进制数,也就说冲突的概率在2^128分之1,这是一个难以想象的概率,那么对于一个才百分级的图库,大可不必担心使用MD5算法,而比特币这样的,就用了更厉害的SHA256算法。

冲突的解决方案

目前来说,哈希冲突的解决方法有两大类`链表寻址法(Linking Addressing)`和`开放寻址法(Opening Addressing)`。
  1. 开放寻址法
    开放寻址法思想比较简单:如果出现一个key2进行hash(key)后映射的位置与key1进行hash(key)后映射的位置一致(即发生了哈希冲突),那么从该位置往下寻找到第一个空位时将key2的数据放入。而从该位置往下寻找空闲位置的过程又称为探测。
    忽略,不是很重要。
  2. 链表寻址法

啥意思呢?就是我存储数据的时候,使用链表的方式。当冲突数据进来的时候,采用头插或尾插的方式,把冲突数据插入到对应的位置即可。

举个例子
```
    1999这个工号的人,位置是arr[9] = {xxx}
    结果我插入2198的时候,发现还是9,冲突了,采用头插法就是
    let temp = arr[9].next;
    arr[9].next = 2198的info。
    2198info.next = temp;
    采用尾插法,就是
    let head = arr[9];
    let next = head.next
    // 假设尾插的时候已经有2个以上了,我不判断了。
    while(next.next) {
        next = next.next;
    }
    next.next = 2198info。
    
``` 

在java8以前使用的是头插法,java8之后改为了尾部插入,有兴趣的可以去查查为什么
死循环,效率,红黑树。

疑问

上述说了半天,其实可以发现,我最开始为啥不用对象呢?
直接

var obj = {};
obj[工号] = info

这样不就结束了吗?
是的
js中的对象,本质上来说,就是服务端后台外挂版本的HashMap
而js中的对象或数组,其实就已经是一个优质的散列表了。
这是成品的应用,而哈希函数的思想,还是非常值得去思考的。

应用

  1. 安全加密

类似MD5,SHA都是属于安全加密的应用。它们都没有单独的说去解决这个冲突。

如果我们拿到一个MD5哈希值,希望通过毫无规律的穷举的方法,找到跟这个MD5值相同的另一个数据,那耗费的时间应该是个天文数字。所以,即便哈希算法存在冲突,但是在有限的时间和资源下,哈希算法还是被很难破解的。
比如

d131dd02c5e6eec4693d9a0698aff95c
2fcab58712467eab4004583eb8fb7f89
55ad340609f4b30283e488832571415a
085125e8f7cdc99fd91dbdf280373c5b
d8823e3156348f5bae6dacd436c919c6
dd53e2b487da03fd02396306d248cda0
e99f33420f577ee8ce54b67080a80d1e
c69821bcb6a8839396f9652b6ff72a70

d131dd02c5e6eec4693d9a0698aff95c
2fcab50712467eab4004583eb8fb7f89
55ad340609f4b30283e4888325f1415a
085125e8f7cdc99fd91dbd7280373c5b
d8823e3156348f5bae6dacd436c919c6
dd53e23487da03fd02396306d248cda0
e99f33420f577ee8ce54b67080280d1e
c69821bcb6a8839396f965ab6ff72a70

image.png

image.png

这两段出来的是一个MD5 79054025255fb1a26e4bc422aef54eb4

但是,可以通过蛮力的方式,比如常用的生日,常用的123456,12345678。这样的密码的MD5值,存起来,那就知道它是谁了。
所以需要有加盐的这样的操作。
就是123456这样的密码,比如你固定在第三位开始,加一个niubi。这样的。别人就很难破解了。

2.唯一标识
比如我们的图片,它最后出来的tfskey,那就是一个hash算法后的key,保证了图片的唯一性。可以直接通过这个key,访问到对应的图片
比如说我们在发送接口请求的时候,会带一个_sg,就是sign,给服务端,服务端通过验证这个签,就可以判断我们是不是那个对的人。

以上,都通过了各种算法,把庞大的图片信息,用户信息,变成了简短的一个key,来达到目的。

3.数据校验
利用哈希算法对输入数据敏感的特点,可以对数据取哈希值,从而高效校验数据是否被篡改过。比如你再下载文件的时候,文件很大,需要分成很多个数据块,然后都下载完了后,再组装起来,但是一旦中间下载出错了,怎么办呢?可以对分块的文件取hash值,这样下载完一块后,和种子文件里的基本信息做对比,如果不同,就说明出错了,需要重新下载。

4.负载均衡

我们知道,负载均衡算法有很多,比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞(session sticky)的负载均衡算法呢?也就是说,我们需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上。

最直接的方法就是,维护一张映射关系表,这张表的内容是客户端IP地址或者会话ID与服务器编号的映射关系。客户端发出的每次请求,都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。这种方法简单直观,但也有几个弊端:

如果客户端很多,映射表可能会很大,比较浪费内存空间;

客户端下线、上线,服务器扩容、缩容都会导致映射失效,这样维护映射表的成本就会很大;

如果借助哈希算法,这些问题都可以非常完美地解决。我们可以通过哈希算法,对客户端IP地址或者会话ID计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。 这样,我们就可以把同一个IP过来的所有请求,都路由到同一个后端服务器上。

这其实也是nginx负载均衡中的一个算法。

5.分布式存储
就以分布式缓存为例吧,我们每天接受海量的数据,需要缓存,并不想让他们去频繁的访问数据库,因为到了我的数据库,就需要I/O了,而缓存是在我的内存中的,谁更快大家都懂把。
但是一个缓存机器肯定是不够的。于是,我们就需要将数据分布在多台机器上。

该如何决定将哪个数据放到哪个机器上呢?我们可以通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号。

但是,如果数据增多,原来的10个机器已经无法承受了,我们就需要扩容了,比如扩到11个机器,这时候麻烦就来了。因为,这里并不是简单地加个机器就可以了。

原来的数据是通过与10来取模的。比如13这个数据,存储在编号为3这台机器上。但是新加了一台机器中,我们对数据按照11取模,原来13这个数据就被分配到2号这台机器上了。

因此,所有的数据都要重新计算哈希值,然后重新搬移到正确的机器上。这样就相当于,缓存中的数据一下子就都失效了。所有的数据请求都会穿透缓存,直接去请求数据库。这样就可能发生雪崩效应,压垮数据库。

这里,就会说到一致性哈希了。

一致性哈希

一致哈希 是一种特殊的哈希算法。在使用一致哈希算法后,哈希表槽位数(大小)的改变平均只需要对 K/n 个关键字重新映射,其中K是关键字的数量, n是槽位数量。然而在传统的哈希表中,添加或删除一个槽位的几乎需要对所有关键字进行重新映射。

image.png

Redis Cluster 就是类似于一致性哈希的原理。

例子

1.两数之和
2.购物车,我们知道,购物车里的数据,是服务端吐的数组集合,但是购物车需要频繁的去勾选,去反选,去删除,去加减数量。
那么找到这个数据,就需要find,频繁的去find到对应的sku,而我们的sku,通常又会藏在store的下面,spu的下面。这样对于查找的时间,就会偏长。

那么假如通过本节所学,创建一个前端hash表,把sku的id作为key,是不是空间换时间的完美策略,这样我再找到选中的那个人的时候,O(1)的时间就可以找到了。

后续

后续可能要讲讲平衡二叉树系列,更好的去理解链表这样的数据结构,对前端本身也是一种提高。


jansen
130 声望16 粉丝

学习不能止步,学习就是兴趣!终生学习是目标