【数据结构与算法】与哈希函数有关的结构

1,222 阅读25分钟

哈希函数

日常听说过的MD5算法,SHA1算法都是哈希函数的模型。

本文不会阐述哈希函数的实现原理,因为当前市面上流行的哈希函数至少有上百种,每一种哈希函数的原理都不一样,但是它们所要维持的特性都是一样的。

1. 性质

经典Hash函数模型:out = f(in)

  • 性质一:in的输入域无穷,比方说可以传入任意长度的字符串。但是在有些工程中也会给输入域规定范围。

  • 性质二:out的输出域相对有限,比如MD5算法的输出域是 [ 0,(2^64) - 1 ];SHA1算法的输出域是 [ 0,(2^128) - 1 ];在Java中规定Hash算法的输出域是 [ 0,(2^32) - 1 ]。可以认为输出域很大,但是一定是有穷尽的。

    为什么Hash函数需要具备以上两种性质,就是因为在实际工程中面临很多这样类似的问题,比如:我们需要一个函数,无论传递给函数传入什么信息,最终都能被函数处理得到一个十六进制数。因此,如果我们使用MD5算法,那么最终得到的结果是一个长度为16的十六进制数;如果我们使用SHA1算法,那么最终得到的结果是一个长度为32的十六进制数。

  • 性质三:输入相同的in,输出的out也相同(same in ——> same out)。比如多次将"abc"传入Hash函数,那么Hash函数返回的值都是一样的。表示Hash函数中没有任何随机的成分。

  • 性质四:因为in的输入域无穷,out的输出域有限,所以一定会有不同的in,输出相同的out(dif in ——> same out)。这种情况叫做Hash碰撞,Hash碰撞的几率特别低,但是是存在的。

  • 性质五:无论有多少个in传入Hash函数,最终输出的out在输出域上都是均匀离散的。从而保证了均匀性和离散性。

    均匀性:设定多个等规模的out输出域的真子集,使用这些真子集映射到输出域中,会发现每一个真子集中out的数量都是一样的。

    离散性:in对应的out在输出域中是没有规律的。即使in非常像,out都不会有相似。

    如果离散性不存在,那么可以通过输入相似的in,让输出域上出现某一个区域的集中,这样均匀性也不会存在。所以均匀性和离散型是同时存在,相辅相成的。

    如果一个Hash函数的离散型和均匀性越好,该Hash函数就越优秀。

    20211004103101.png

2. 推广

哈希函数有如下推广:

假设输入样本为in1,in2,in3...,通过Hash函数得到out1,out2,out3...(假设没有碰撞),然后我们给每一个out添加一个去摸%m的操作,得到m1,m2,m3... 。由于性质五,我们可知out在输出域上是均匀离散的,那么%m之后,可以保证m在 [ 0,m-1 ] 这个范围上也是均匀离散分布的。

20211004111713.png

根据推广,我们可以解决一些典型的工程问题。

题目:假设有一个大文件,该文件中存储了40亿个无符号整数,已知每一个无符号整数的范围是:[ 0,(2^32) - 1 ],转化成10进制是 [ 0,42亿多 ]。如果只有1G的内存,如何得到文件中出现次数最多的那个数?

分析

这道题,如果使用Java来解,我们通常想到的经典解法是利用Java中的HashMap。Key是Integer类型,表示文件中的数;Value是Integer类型,表示该数出现的次数。然后我们从头到尾遍历该文件,遍历结束后,HashMap中最大Value对应的Key就是出现次数最多的那个数。

但是,该题目只给了1G的内存,那么使用HashMap来做够不够用呢?

已知HashMap中的一条记录有两个空间,包括Key的空间和Value的空间,Key和Value都是int类型都是4个字节,因此HashMap中一条记录最起码需要8个字节。除此之外,HashMap内部存储索引还需要一部分空间,我们假设索引没有占用空间。40亿个数,最差情况是40亿个数都不一样,这样HashMap中就要存储40亿条记录。每一条记录8个字节,40亿条记录一共需要320亿字节,折合32G的内存空间。因此,不能使用Java中提供的HashMap来做本题。

我们发现,HashMap空间的占用,只和Key的种类数有关,在本题中就是和文件中数字的种类数有关,相同种的数多次出现是不会增加HashMap的占用空间的,修改一下对应的Value就可以实现(HashMap的词频压缩)。所以在本题中,我们并不害怕某个数出现很多次,而是害怕有很多个不一样的数字。

解决方案

将文件中所有的数传入Hash函数,计算出40亿个out,将out依次%100,得到一个0 ~ 99范围的数。创建0 ~ 99号文件,将%100后的out存储到对应号的文件中。假设最坏情况有40亿个不相同的数字,我们也可以保证0 ~ 99号文件中每个文件存储的数字数量和种类分布都是差不多的。

然后,对每一号文件单独使用HashMap进行处理。如果40亿条记录放在一个文件中使用HashMap处理需要32G的内存,而40亿条记录均匀分布在一百个文件中,对其中一个文件使用HashMap处理则仅需要320M的内存。我们一号一号文件进行处理,这样就会保证内存不会被占满。每一号文件在HashMap处理后都会有一个出现次数最多的那个数,同时相同的数肯定只会出现在同一号文件里。因此在所有文件处理完之后,就会得到100个出现次数最多的数,而且100个数都不相同。最后在这100个数中找出出现次数最多的那个数就是最终的答案。

哈希表

在日常刷题过程中,只涉及到哈希表的使用,知道哈希表的增删改查的性能都是O(1)。

本文只讲述哈希表最经典的结构原理,优化方面不会深究。

1. 经典结构

哈希表基于Hash函数实现。

假设设置初始区域容量为17,就会有0~16号区域。

Java中最常用的哈希表莫过于HashMap和HashSet了,两者的结构一致,原理一致,唯独区别在于HashMap有伴随数据Value而HashSet没有。本文详解结构时以HashMap为例。

如果要将Key="abc",Value=1存入哈希表,首先哈希表会使用哈希函数对key进行计算得到out,然后out再%17得到一个0~16的数,假设为1。然后哈希表遍历到1号区域,如果1号区域为空,就在1号区域后创建一个单向链表,将Key="abc",Value=1作为一个节点连接到单项链表中。多加几对Key-Value与上述同理,最终构成一个经典的哈希表。

20211004182827.png

由于Hash函数的性质,我们可以知道哈希表每个区域连接的单项链表基本上都是均匀变长的。

如果我们想通过Key找到对应的Value,就把存储Key-Value的流程再走一遍即可。假如当前Key="abc",那么哈希表先使用哈希函数对Key进行计算得到out,然后out再%17得到1,然后再遍历1号区域的单向链表最终找到对应节点,得到Value=1。

2. 扩容

如果在上面的哈希表中加入大量的Key-Value,假设加入N对Key-Value,那么哈希表遍历的平均长度就是N / 17。如果哈希表容量一直都是17,那么哈希表操作的性能远远达不到O(1)。

因此,我们需要统计哈希表中每一个单向链表的长度,一旦某一个单向链表的长度超过一个阈值,那么就要触发哈希表的扩容机制。

假设每一次扩容都是原来的一倍,那么触发了扩容机制后,区域容量就成了34。原本所有单向链表中的节点都需要被哈希函数重新计算,重新取模,重新挂载到新的哈希表中。原来所有节点由17个区域均分,现在由34个区域均分,因此我们可以将原哈希表中的所有单向链表长度减半。

当扩容后,假设我们需要在哈希表中进行查询操作,哈希函数对Key计算的时间复杂度为O(1),取模的时间复杂度为O(1),遍历单向链表的时间复杂度为O(K)(假设链中由K个节点,如果可以保证单向链表不会过长,那么在单向链表中增删改查的时间复杂度都为O(1))。

扩容复杂度的计算比较复杂,假设我们加入了N对Key-Value且只要单向链表长度超过2就扩容一倍,那最多需要经历logN次扩容。如果单项链表扩容阈值大于2,那么扩容的次数就会小于logN,但是复杂度也是O(logN)级别的,只是常数项不同。每一次扩容完之后,需要把N个节点重新计算哈希值,重新取模,重新挂载到心的哈希表中,综合起来时间复杂度为O(N)。因此,当经历若干次扩容到容纳N对Key-Value时,扩容的时总代价为O(NlogN),单次扩容的代价为O(NlogN / N) 。因此可以认为在哈希表中单次的增删改查平均代价为O(logN)。

虽然哈希函数计算的时间复杂度为O(1),但是实际上常数是比较大的,但是指标为O(1)。

为什么说在使用哈希表时时间复杂度为O(1)呢?

因为我们可以将单向链表扩容的阈值定的很长,假设定为10,遍历10个节点的速度依旧非常快,但是可以极大减少扩容代价。因此O(logN)就变成了一个特别小的常数,可以说逼近于O(1)。

3. 离线扩容技术

该技术像C++这种及时申请内存的语言做不了,但是Java和一些虚拟机托管的语言能够实现。

该技术能够在上述描述的扩容基础上,继续给扩容加速。

假设用户在使用哈希表A,哈希表A中的单向链表已经很长了,虽然操作的时间复杂度仍然可以达到O(1),但是常数比较大。因此用户想给哈希表A扩容。

因为该哈希表A被JVM托管,所以即使用户不用,它也会在内存中一直存在。那么我们就可以在内存别处给哈希表A做扩容生成哈希表B,在扩容过程中是不妨碍用户使用哈希表A的。等扩容成功后,就把哈希表A的指针指向哈希表B,将哈希表A销毁即可。这样就进一步降低了哈希表的扩容代价。

这就是为什么可以说在使用层面上哈希表的时间复杂度为O(1),而在理论上不是,理论上就是O(logN)。

4. 不同语言

哈希表在不同语言中的具体实现有所不同,因为不同语言可能会使用其他数据结构来对哈希表做再次优化。

Java就把哈希表中的单链表改成了红黑树,但是C++还是保持了最经典的哈希表结构。

RandomPool结构

题目

设计一种结构,在该结构中有如下三个功能:

  1. insert(key):将某个Key加入到该结构,做到不重复加入。
  2. delete(key):将原本在结构中的某个Key移除。
  3. getRandom():等概率随机返回结构中的任何一个Key。

要求是以上三个方法的时间复杂度为O(1)。

分析

本道题是一个使用哈希表层面的数据结构设计,不会使用到哈希表的原理。

本体采用两个哈希表相互辅助实现RandomPool结构,其中一个哈希表中存储的是 key—index,另一个哈希表中存储的是 index—key。两个表之间通过index实现数据的关联,可以完成双向数据查找的操作。

insert和getRandom操作没有什么特别之处,关键在于delete操作。如果直接删除结构中某一个Key和其对应的Index,那么势必会造成Index的不连续从而产生漏洞。这样在Random时就会出现多次随机生成Index,却击中不了当前存储在哈希表中的Index。因此在删除时,我们会将最后存入哈希表的Key覆盖掉目标Key,复用目标Key的Index,然后物理删除最后存入哈希表的Key。这样逻辑上既删除了目标Key,又不会造成Index的漏洞。

20211005105225.png

代码

public class RandomPool {

    private int size;

    private final HashMap<K, Integer> keyIndexMap;

    private final HashMap<Integer, K> indexKeyMap;

    public RandomPool() {
        size = 0;
        keyIndexMap = new HashMap<K, Integer>();
        indexKeyMap = new HashMap<Integer, K>();
    }

    // insert(key):将某个Key加入到该结构,做到不重复加入。
    public void insert(K key) {
        // 如果Key不存在,执行insert操作
        if (!keyIndexMap.containsKey(key)) {
            keyIndexMap.put(key, size);
            indexKeyMap.put(size ++, key);
        }
    }

    // delete(key):将原本在结构中的某个Key移除。
    public void delete(String key) {
        // 如果Key存在,执行delete操作
        if (keyIndexMap.containsKey(key)) {
            // keyIndexMap中最后Key覆盖目标Key
            keyIndexMap.put(indexKeyMap.get(size), keyIndexMap.get(key));

            // indexKeyMap中最后Key覆盖目标Key
            indexKeyMap.put(keyIndexMap.get(key), indexKeyMap.get(size));

            // keyIndexMap中删除目标Key
            keyIndexMap.remove(key);

            // indexKeyMap中删除最后一个Key
            indexKeyMap.remove(size);
            
            size --;
        }
    }

    // getRandom():等概率随机返回结构中的任何一个Key。
    public K getRandom() {
        if (size == 0) {
            return null;
        }

        int randomIndex = (int) (Math.random() * size);

        return indexKeyMap.get(randomIndex);
    }

}

布隆过滤器

1. 引入

布隆过滤器主要作用就是用来解决 "黑名单系统" 或者 "爬虫去重问题" 之类的问题。

我们以黑名单系统举例,比方说,现在有1000万个URL组成的大文件,这些URL都是黑名单,每个URL最大64字节,公司不希望用户在使用我们开发的浏览器服务的时候能够访问它们。

因此,我们需要构建一个黑名单系统,该系统使用一种数据结构能够将大文件中所有URL组织起来,并且能够快速查询。当用户输入一个URL要去访问时,我们能够通过查询该数据结构快速判断该URL是否在黑名单中。 该数据结构没有删除某个黑名单的需求,只有将某个URL加入黑名单和查询某个URL是否在黑名单中这两个重点需求。

经典解决方案就是构建一个HashSet,将1000万个URL全部存入HashSet,总共占用内存空间6亿4000万字节,约5GB,内存的空间开销将会非常大。

如果使用布隆过滤器实现该系统,将会极大减少内存的开销,但是将会出现一定概率的失误。

布隆过滤器专门用来替代内存中存储大量数据的集合,且该集合只有增和查的需求,没有删除的需求。布隆过滤器只占用很小的内存空间,但是会有一定概率出现失误,且失误无法避免。

失误有两种:

  1. 在黑名单中的URL被判断成白名单。

  2. 不在黑名单中的URL被判断成黑名单。

布隆过滤器不会出现第一种失误,只可能会有第二种类型的失误。但是布隆过滤器可以通过人为的设计,让第二种类型失误的触发率很低,低到万分之一。

布隆过滤器在工程中是完全能被接收的,即使是极度敏感的黑名单,要求不能误判任何一个黑名单,布隆过滤器也能做到 "即使错杀一百,也不放过一个",更何况错杀的概率很低。

2. 位图

在详解布隆过滤器的实现之前,需要了解位图。

位图,又叫 Bit Array 或者 Bit Map。

我们都熟悉Java中的数组,有int类型的数组int[],如果初始容量为100,那么int[]会有0~99个单元空间,每一个空间占4个字节。从概念上来说,位图就是bit类型的数组bit[],和int[]一样,只是bit[]每一个空间只占1bit。

我们知道Java中,内存是按照字节计算的,而位图的占用空间是按照bit来计算的,那么我们在Java中如何实现一个位图呢?

使用基础类型数组来拼凑,意思就是可以使用基础类型来保存bit的整体信息,然后通过位运算来操作每一个bit。

容量为10的int类型数组可以表示容量为320的bit类型数组。

int[] arr = new int[10]

arr[0],arr[1],arr[2] ... 都是独立的数字,如果把每一个数字每一位拆开来看,arr[0] 能表示 0~31 bit,arr[1] 能表示 32 ~ 63 bit ... 以此类推。

现在需要获取第178 bit的信息,如果是1返回整数1,如果是0返回整数0,那么该如何获取?

// 定位第178 bit在int[]中的哪一个单元空间
int numIndex = 178 / 32;

// 定位该单元空间中的哪一个bit
int bitIndex = 178 % 32;

// 获取该bit上的状态
int status = (arr[numIndex] >> bitIndex) & 1;

如果我们要修改第178 bit的信息,如何修改?

// 将第178bit改成1 并保存
arr[numIndex] = (1 << bitIndex) | arr[numIndex];
// 将第178bit改成0 并保存
arr[numIndex] = (~(1 << bitIndex)) & arr[numIndex];

3. 设计思想

布隆过滤器就是一个大位图,容量为m的bit[],实际占用空间为m/8字节。

往布隆过滤器中添加第一个黑名单URL1,那么URL1会先被哈希函数1计算所得结果out1,再将out1%m,就可以算出一个0~(m-1)的数字,这样就可以对应到bit[]中的某一个单元空间,将该位bit置1;然后URL1再被哈希函数2计算所得结果out2,再将out2%m,又可以对应的bit[]中的某一个单元空间,将该位bit置1 ... 以此类推,假设有K个哈希函数,URL1就会去依次调用K个哈希函数,得到K个out,模完m后对应到bit[]中K个单元空间,将K位bit置1(有可能单元空间有重叠,重叠的bit保持1,也有可能K个单元空间都独立不相同)。当K个哈希函数都调用完,URL1就算存入布隆过滤器中了。

URL2、URL3 ... URL1000万以此类推,每一个URL都需要调用K个哈希函数,得到K个out,模完m后对应到bit[]中K个单元空间,将K位bit置1。

现在有一个URL,需要在布隆过滤器中查找该URL是否是黑名单,那么该URL需要调用K个哈希函数,得到K个out,摸完m后对应到bit[]中K个单元空间,如果所有位bit都是1,那么说明该URL是黑名单;只要有1位bit不是1,那么说明该URL是白名单。

这里有两个参数是不确定的:

  1. K个哈希函数到底设置多少个?
  2. 位图容量m到底开多大?

为什么在这个问题中布隆过滤器中不会出现 "黑名单误判成白名单" 这种失误呢?

机制决定的,如果该URL是黑名单,那么在查的时候,可以保证该URL对应的bit位必然全都是1。

为什么在这个问题中布隆过滤器中会出现 "白名单误判成黑名单" 这种失误呢?

如果容量m定的很小,URL数量又很多,那么每一位bit必然会出现大量重叠,最终每一位bit都是1,就必然会出现误判。

什么决定失误率?

最重要的因素就是位图的容量m,如果m很大,那么失误率就能压的很小;如果m很小,失误率就会上升,最高可能高达100%。

哈希函数的个数K是什么地位?

K实际上是根据位图容量m和样本量共同决定的。这种机制直观起来理解实际上就是采集指纹,K个哈希函数就相当于在一个指纹样本上采集K个特征点。如果只有1个哈希函数,相当于只采集了一个特征点,失误率就不能控制的很低。如果有3个哈希函数,那么失误率就会比只有1个哈希函数要低一些。但是不能把特征点数定的非常大,如果每一个指纹需要采集100个特征点,即使m设置的很合理,那么在处理非常大量的样本后,每位bit有相当大的概率也都置1了。

m和K的确定流程是什么?

先根据样本量n和失误率P先确定位图容量m,然后再根据样本量n、失误率P和位图容量m计算出一个最合适的哈希函数个数K。

20211006100528.png

在样本量n确定的情况下,随着位图容量m的增长,失误率P逐渐下降,但是下降速度会越来越缓慢。根据预期的失误率,可以选择一个较为合适的位图容量m = v。

在样本量n和位图容量m都确定的情况下,随着哈希函数个数K的增长,失误率P逐渐下降,但是当K很大时,m也会被逐渐填满,失误率P最终会逐渐上升,最终逼近到1。因此我们能够找到失误率P下降和回升的临界点,该点对应的k值就是最合适的哈希函数个数。

4. 具体实现

判断一个系统是否需要使用布隆过滤器来设计的标准:

  • 确定模型,类似于黑名单业务这样的系统,并且系统中没有删除业务。

  • 该系统允许有一定的失误,并且有一个明确的预期失误率。

单样本的大小和设计布隆过滤器没有一点关系,不会决定布隆过滤设计的任何细节,例如上述例子中 "每个URL最大占64字节"。只要我设置的哈希函数可以接收64字节的URL作为参数,并能够对其进行计算就可以了。

设计布隆过滤器只需要以下三个公式:

20211006155056.png

公式一和公式二计算出来的是理论位图容量m和理论哈希函数个数K,在实际生产中会做出相应调整。

比如理论位图计算出来容量m=26G,实际上可能会给m‘分配32G,这样实际失误率P’就会比理论失误率P进一步降低。

如果要计算实际失误率P’,需要使用先使用公式一和公式二计算出m和K,再使用公式三:

20211006154808.png

一致性哈希

1. 引入

一致性哈希是用来讨论 "如何组织多台数据服务器" 这类问题。

数据服务器通常是指MySQL,Oracle这类的数据库服务器。

如果是经典的Web架构,那么通常只有一台数据服务器,不存在数据组织的问题。如果是分布式架构,那么就会有多台数据服务器,每台服务器存储哪些数据?这就涉及到多台数据服务器数据如何组织的问题。

经典组织方法是:

有三台数据服务器,分别为0号、1号和2号数据服务器,每一台数据服务器都维护着自己的专属数据。

如果现在需要存储 { "name": "John", "pass": "123" } 该条数据,那么应用服务器会先将该条数据的Key(唯一标识) "John" 放入哈希函数中进行计算得到out,再将out%3得到一个0~2的数字,然后存储到集群中对应的数据服务器中去。

如果现在要通过name = "John"查询pass,那么应用服务器也同样会将 "John" 放入哈希函数中计算再模3最终能够确定该数据条目归属于集群中哪一号数据服务器,从而从中取出 "123"。

20211006170508.png

这种经典的数据服务器组织数据的方式已经能够很好的做到数据在每一台数据服务器上的均匀分配,实现负载均衡。

那么拿数据条目中的什么Key来做这种数据的专属划分呢?

选择条目中种类非常多、不会重复的字段作为Key。比如说ID、用户名等。这样能够让哈希函数更好的做均分。

这种经典组织方法有一个致命的问题,就是增加数据服务器和减少数据服务器数据迁移的代价是全量的

如果在某一个时刻数据量非常大,数据服务器不够用了,我想扩充数据服务器的数量,那么数据迁移的代价将会非常大,我们需要将原所有数据服务器中的数据提取出来,然后进行哈希计算再取模,重新将数据均匀存储到添加了新数据服务器的集群中。

那么我们就需要想如何不用 "模" 的方式来实现数据的均匀存储,让数据迁移的代价不那么高呢?

一致性哈希。

2. 原理

一致性哈希中是没有 "模" 这个操作的,那么具体是如何完成数据的均匀存储呢?

假设使用MD5算法作为哈希函数,计算出来的哈希值范围是 [ 0,(2^64) - 1 ],我们将整个哈希值域 [ 0,(2^64) - 1 ] 抽象成一个闭环。

假设当前有三台数据服务器,分别为:0号、1号和2号数据服务器。

每一台数据服务器的机器信息(IP,Host name,MAC地址)肯定都不一样,只要能够选择一种机器信息能够将每一台数据服务器都区分开,该机器信息就可以作为每一台数据服务器的传入哈希函数的参数。例如使用MAC地址作为区分服务器的机器信息,那么哈希函数就会计算每一台数据服务器的MAC地址,最后得到不同的out,对应到闭环中。

如果现在需要存储 { "name": "John", "pass": "123" } 该条数据,那么应用服务器会先将该条数据的Key(唯一标识) "John" 放入哈希函数中进行计算得到out,out一定能对应到 [ 0,(2^64) - 1 ] 中的某一个位置,然后将该条数据存储到对应位置顺时针距离最近的数据服务器上

20211006185657.png

那么该机制如何实现?

在每个应用服务器上维护所有数据服务器通过MD5(MAC)计算得到的哈希值的一个有序排列,同时每个应用服务器还需要记录每一个哈希值对应哪一个数据服务器。上述例子的有序排列就是 [ ( 0 — 10亿),( 1 — 5000亿 ),( 2 — 70000亿 ) ]。

如果现在要通过name = "John"查询pass,那么在接收到该请求的应用服务器中,先将 "John" 放入哈希函数中计算得到4500亿,然后拿着计算出来的4500亿在该排序做二分查找,就能找到排序中大于4500亿且距离最近的一个哈希值为5000亿,因此应用服务器就会去哈希值为5000亿对应的1号数据服务器中检索该数据条目。

如果有数据服务器的哈希值正好等于4500亿,则应用服务器就直接去该数据服务器中检索数据条目,而不需要顺时针再去找数据服务器。

如果哈希函数计算出的哈希值在排序中没有找到大于等于该值的,那么对应的就是有序排列中最小的哈希值对应的那个数据服务器(因为是一个闭环)。

这个机制有什么好处?

如下图哈希环所示,在没有添加新数据服务器时,0号数据服务器存储哈希值落在C区域的数据,1号数据服务器存储哈希值落在A区域的数据,2号数据服务器存储哈希值落在B区域的数据。当集群中添加一个新数据服务器3号,那么仅需要0号数据服务器将C区域的数据迁移到3号数据服务器上即可,大大降低了添加 / 减少数据服务器时数据迁移的代价。

20211006211254.png

但是,此时还存在两个潜在问题:

  • 在数据服务器数量很少时,一开始可能做不到数据的均匀存储。(因为哈希函数的特征是当数据多起来时落在每一段的数据数量差不多,而不是只有三个数据的时候三个数据必须分别落在三个段上)
  • 即便在数据服务器很少的情况下可以把哈希环均分,当增加 / 减少一台数据服务器时,马上负载不均衡。(比方说本来三台数据服务器各占哈希环的 1 / 3,当加入一台新的数据服务器时,其中两台还占 1 / 3,但是另外两台却各自占 1 / 6)

如果解决了这两个问题,一致性哈希就非常好用了。

这两个潜在问题可以使用一个技术来解决,虚拟节点技术。

虚拟节点技术

1. 引入

假设在上述例子的基础上,给0号数据服务器分配1000个虚拟节点(a1,a2,...,a1000),给1号数据服务器也分配1000个虚拟节点(b1,b2,...,b1000),给2号数据服务器也分配1000个虚拟节点(c1,c2,...,c1000)。

不是0号、1号和2号数据服务器去争抢哈希环了,而是这些数据服务器下的虚拟节点去抢环。每个虚拟节点中存储一个代表字符串,通过将字符串传入哈希函数,计算得到的哈希值落在哈希环的对应位置上。

写一个简单的底层逻辑就可以实现虚拟节点之间的数据迁移。例如将a10的数据迁移到b500,通过路由表,a10去0号数据服务器上找数据,然后传输到b500虚拟节点对应的1号数据服务器中。

当前,整个哈希环被3000个虚拟节点所争抢。其中,哈希环中有1000个落点属于0号数据服务器,1000个落点属于1号数据服务器,1000个落点属于2号数据服务器。由于哈希函数的特性,这3000个落点在每一段位置中都分布均衡。因此随便取一段,a、b和c虚拟节点的个数都是差不多的,基本上保持1 / 3属于0号数据服务器,1 / 3属于1号数据服务器,1 / 3属于2号数据服务器。

按照比例去争抢数据,就能解决初始数据存储不均和增加 / 减少数据服务器导致数据存储不均的问题。

当增加一台数据服务器时,那么就为该数据服务器再创建1000个虚拟节点,再将1000个落点打到哈希环中。那么此时哈希环中每台数据服务器都各占1 / 4的落点,此时新数据服务器一定会从0号、1号和2号数据服务器夺取等量的数据到自身中。

当减少一台数据服务器时,该数据服务器也会将自身数据等量的分给其他三台数据服务器,给完之后其他三台数据服务器的数据也能够保持均衡。

2. 管理负载

如果按照比例去争抢数据,那么一致性哈希还有一个妙用,就是可以通过实际机器的状况来管理负载。

比如在上面的例子中,0号数据服务器的性能比1号、2号数据服务器的性能都强。那么我们可以给0号数据服务器分配2000个虚拟节点,给1号、2号数据服务器各分配500个虚拟节点。

当今,各大公司构建的分布式数据库底层使用的都是这个原理,被誉为 "谷歌改变世界技术的三驾马车之一"(GFS、MapReduce和BigTable) 。