You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

26 KiB

03 | 如何实现一个性能优异的Hash表

你好我是蒋德钧。今天我们来聊聊Redis中的Hash。

我们知道Hash表是一种非常关键的数据结构在计算机系统中发挥着重要作用。比如在Memcached中Hash表被用来索引数据在数据库系统中Hash表被用来辅助SQL查询。而对于Redis键值数据库来说Hash表既是键值对中的一种值类型同时Redis也使用一个全局Hash表来保存所有的键值对从而既满足应用存取Hash结构数据需求又能提供快速查询功能。

那么Hash表应用如此广泛的一个重要原因就是从理论上来说它能以O(1)的复杂度快速查询数据。Hash表通过Hash函数的计算就能定位数据在表中的位置紧接着可以对数据进行操作这就使得数据操作非常快速。

Hash表这个结构也并不难理解但是在实际应用Hash表时当数据量不断增加它的性能就经常会受到哈希冲突rehash开销的影响。而这两个问题的核心其实都来自于Hash表要保存的数据量超过了当前Hash表能容纳的数据量。

那么要如何应对这两个问题呢?事实上,这也是在大厂面试中,面试官经常会考核的问题。所以你现在可以先想想,如果你在面试中遇到了这两个问题,你会怎么回答呢?

OK思考先到这里现在我来告诉你Redis是怎么很好地解决这两个问题的。

Redis为我们提供了一个经典的Hash表实现方案。针对哈希冲突Redis采用了链式哈希在不扩容哈希表的前提下将具有相同哈希值的数据链接起来以便这些数据在表中仍然可以被查询到对于rehash开销Redis实现了渐进式rehash设计进而缓解了rehash操作带来的额外开销对系统的性能影响。

所以这节课我就带你来学习Redis中针对Hash表的设计思路和实现方法帮助你掌握应对哈希冲突和优化rehash操作性能的能力并以此支撑你在实际使用Hash表保存大量数据的场景中可以实现高性能的Hash表。

好了,接下来,我们就先来聊聊链式哈希的设计与实现。

Redis如何实现链式哈希

不过在开始学习链式哈希的设计实现之前我们还需要明白Redis中Hash表的结构设计是啥样的以及为何会在数据量增加时产生哈希冲突这样也更容易帮助我们理解链式哈希应对哈希冲突的解决思路。

什么是哈希冲突?

实际上一个最简单的Hash表就是一个数组数组里的每个元素是一个哈希桶也叫做Bucket第一个数组元素被编为哈希桶0以此类推。当一个键值对的键经过Hash函数计算后再对数组元素个数取模就能得到该键值对对应的数组元素位置也就是第几个哈希桶。

如下图所示key1经过哈希计算和哈希值取模后就对应哈希桶1类似的key3和key16分别对应哈希桶7和桶4。

从图上我们还可以看到需要写入Hash表的键空间一共有16个键而Hash表的空间大小只有8个元素这样就会导致有些键会对应到相同的哈希桶中。

我们在实际应用Hash表时其实一般很难预估要保存的数据量如果我们一开始就创建一个非常大的哈希表当数据量较小时就会造成空间浪费。所以我们通常会给哈希表设定一个初始大小而当数据量增加时键空间的大小就会大于Hash表空间大小了。

也正是由于键空间会大于Hash表空间这就导致在用Hash函数把键映射到Hash表空间时不可避免地会出现不同的键被映射到数组的同一个位置上。而如果同一个位置只能保存一个键值对就会导致Hash表保存的数据非常有限这就是我们常说的哈希冲突

比如下图中key3和key100都被映射到了Hash表的桶5中这样当桶5只能保存一个key时key3和key100就会有一个key无法保存到哈希表中了。

那么我们该如何解决哈希冲突呢?可以考虑使用以下两种解决方案:

  • 第一种方案,就是我接下来要给你介绍的链式哈希。这里你需要先知道链式哈希的链不能太长否则会降低Hash表性能。
  • 第二种方案,就是当链式哈希的链长达到一定长度时,我们可以使用rehash。不过执行rehash本身开销比较大所以就需要采用我稍后会给你介绍的渐进式rehash设计。

这里,我们先来了解链式哈希的设计和实现。

链式哈希如何设计与实现?

所谓的链式哈希,就是用一个链表把映射到Hash表同一桶中的键给连接起来。下面我们就来看看Redis是如何实现链式哈希的以及为何链式哈希能够帮助解决哈希冲突。

首先我们需要了解Redis源码中对Hash表的实现。Redis中和Hash表实现相关的文件主要是dict.hdict.c。其中dict.h文件定义了Hash表的结构、哈希项以及Hash表的各种操作函数而dict.c文件包含了Hash表各种操作的具体实现代码。

在dict.h文件中Hash表被定义为一个二维数组dictEntry **table这个数组的每个元素是一个指向哈希项dictEntry的指针。下面的代码展示的就是在dict.h文件中对Hash表的定义你可以看下

typedef struct dictht {
    dictEntry **table; //二维数组
    unsigned long size; //Hash表大小
    unsigned long sizemask;
    unsigned long used;
} dictht;

那么为了实现链式哈希, Redis在每个dictEntry的结构设计中除了包含指向键和值的指针,还包含了指向下一个哈希项的指针。如下面的代码所示dictEntry结构体中包含了指向另一个dictEntry结构的指针*next,这就是用来实现链式哈希的:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

除了用于实现链式哈希的指针外这里还有一个值得注意的地方就是在dictEntry结构体中键值对的值是由一个联合体v定义的。这个联合体v中包含了指向实际值的指针*val还包含了无符号的64位整数、有符号的64位整数以及double类的值。

我之所以要提醒你注意这里,其实是为了说明,这种实现方法是一种节省内存的开发小技巧非常值得学习。因为当值为整数或双精度浮点数时由于其本身就是64位就可以不用指针指向了而是可以直接存在键值对的结构体中这样就避免了再用一个指针从而节省了内存空间。

好了那么到这里你应该就了解了Redis中链式哈希的实现不过现在你可能还是不太明白为什么这种链式哈希可以帮助解决哈希冲突呢

别着急我就拿刚才的例子来说明一下key3和key100都被映射到了Hash表的桶5中。而当使用了链式哈希桶5就不会只保存key3或key100而是会用一个链表把key3和key100连接起来如下图所示。当有更多的key被映射到桶5时这些key都可以用链表串接起来以应对哈希冲突。

这样当我们要查询key100时可以先通过哈希函数计算得到key100的哈希值被映射到了桶5中。然后我们再逐一比较桶5中串接的key直到查找到key100。如此一来我们就能在链式哈希中找到所查的哈希项了。

不过链式哈希也存在局限性那就是随着链表长度的增加Hash表在一个位置上查询哈希项的耗时就会增加从而增加了Hash表的整体查询时间这样也会导致Hash表的性能下降。

那么有没有什么其他的方法可以减少对Hash表性能的影响呢当然是有的这就是接下来我要给你介绍的rehash的设计与实现了。

Redis如何实现rehash

rehash操作其实就是指扩大Hash表空间。而Redis实现rehash的基本思路是这样的

  • 首先Redis准备了两个哈希表用于rehash时交替保存数据。

我在前面给你介绍过Redis在dict.h文件中使用dictht结构体定义了Hash表。不过在实际使用Hash表时Redis又在dict.h文件中定义了一个dict结构体。这个结构体中有一个数组ht[2]包含了两个Hash表ht[0]和ht[1]。dict结构体的代码定义如下所示

typedef struct dict {
    …
    dictht ht[2]; //两个Hash表交替使用用于rehash操作
    long rehashidx; //Hash表是否在进行rehash的标识-1表示没有进行rehash
    …
} dict;

  • 其次在正常服务请求阶段所有的键值对写入哈希表ht[0]。
  • 接着当进行rehash时键值对被迁移到哈希表ht[1]中。
  • 最后当迁移完成后ht[0]的空间会被释放并把ht[1]的地址赋值给ht[0]ht[1]的表大小设置为0。这样一来又回到了正常服务请求的阶段ht[0]接收和服务请求ht[1]作为下一次rehash时的迁移表。

这里我画了一张图以便于你理解ht[0]和ht[1]交替使用的过程。

那么在了解了Redis交替使用两个Hash表实现rehash的基本思路后我们还需要明确的是在实现rehash时都需要解决哪些问题我认为主要有以下三点

  • 什么时候触发rehash
  • rehash扩容扩多大
  • rehash如何执行

所以下面我就带你来逐一学习Redis对这三个问题的代码实现通过代码实现你就能明晰Redis针对这三个问题的设计思想了。

什么时候触发rehash

首先要知道Redis用来判断是否触发rehash的函数是 _dictExpandIfNeeded。所以接下来我们就先看看_dictExpandIfNeeded函数中进行扩容的触发条件然后我们再来了解下_dictExpandIfNeeded又是在哪些函数中被调用的。

实际上_dictExpandIfNeeded函数中定义了三个扩容条件。

  • 条件一ht[0]的大小为0。
  • 条件二ht[0]承载的元素个数已经超过了ht[0]的大小同时Hash表可以进行扩容。
  • 条件三ht[0]承载的元素个数是ht[0]的大小的dict_force_resize_ratio倍其中dict_force_resize_ratio的默认值是5。

下面的代码就展示了_dictExpandIfNeeded函数对这三个条件的定义你可以看下。

//如果Hash表为空将Hash表扩为初始大小
if (d->ht[0].size == 0) 
   return dictExpand(d, DICT_HT_INITIAL_SIZE);
 
//如果Hash表承载的元素个数超过其当前大小并且可以进行扩容或者Hash表承载的元素个数已是当前大小的5倍
if (d->ht[0].used >= d->ht[0].size &&(dict_can_resize ||
              d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
    return dictExpand(d, d->ht[0].used*2);
}

那么对于条件一来说此时Hash表是空的所以Redis就需要将Hash表空间设置为初始大小而这是初始化的工作并不属于rehash操作。

而条件二和三就对应了rehash的场景。因为在这两个条件中都比较了Hash表当前承载的元素个数d->ht[0].used和Hash表当前设定的大小d->ht[0].size这两个值的比值一般称为负载因子load factor。也就是说Redis判断是否进行rehash的条件就是看load factor是否大于等于1和是否大于5。

实际上当load factor大于5时就表明Hash表已经过载比较严重了需要立刻进行库扩容。而当load factor大于等于1时Redis还会再判断dict_can_resize这个变量值查看当前是否可以进行扩容。

你可能要问了这里的dict_can_resize变量值是啥呀其实这个变量值是在dictEnableResize和dictDisableResize两个函数中设置的它们的作用分别是启用和禁止哈希表执行rehash功能如下所示

void dictEnableResize(void) {
    dict_can_resize = 1;
}
 
void dictDisableResize(void) {
    dict_can_resize = 0;
}

然后这两个函数又被封装在了updateDictResizePolicy函数中。

updateDictResizePolicy函数是用来启用或禁用rehash扩容功能的这个函数调用dictEnableResize函数启用扩容功能的条件是**当前没有RDB子进程并且也没有AOF子进程。**这就对应了Redis没有执行RDB快照和没有进行AOF重写的场景。你可以参考下面给出的代码

void updateDictResizePolicy(void) {
    if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
        dictEnableResize();
    else
        dictDisableResize();
}

到这里我们就了解了_dictExpandIfNeeded对rehash的判断触发条件那么现在我们再来看下Redis会在哪些函数中调用_dictExpandIfNeeded进行判断。

首先,通过在dict.c文件中查看_dictExpandIfNeeded的被调用关系我们可以发现_dictExpandIfNeeded是被_dictKeyIndex函数调用的而_dictKeyIndex函数又会被dictAddRaw函数调用然后dictAddRaw会被以下三个函数调用。

  • dictAdd用来往Hash表中添加一个键值对。
  • dictRelace用来往Hash表中添加一个键值对或者键值对存在时修改键值对。
  • dictAddorFind直接调用dictAddRaw。

因此当我们往Redis中写入新的键值对或是修改键值对时Redis都会判断下是否需要进行rehash。这里你可以参考下面给出的示意图其中就展示了_dictExpandIfNeeded被调用的关系。

好了简而言之Redis中触发rehash操作的关键就是_dictExpandIfNeeded函数和updateDictResizePolicy函数。_dictExpandIfNeeded函数会根据Hash表的负载因子以及能否进行rehash的标识判断是否进行rehashupdateDictResizePolicy函数会根据RDB和AOF的执行情况启用或禁用rehash。

接下来我们继续探讨Redis在实现rehash时要解决的第二个问题rehash扩容扩多大

rehash扩容扩多大

在Redis中rehash对Hash表空间的扩容是通过调用dictExpand函数来完成的。dictExpand函数的参数有两个一个是要扩容的Hash表另一个是要扩到的容量下面的代码就展示了dictExpand函数的原型定义

int dictExpand(dict *d, unsigned long size);

那么对于一个Hash表来说我们就可以根据前面提到的_dictExpandIfNeeded函数来判断是否要对其进行扩容。而一旦判断要扩容Redis在执行rehash操作时对Hash表扩容的思路也很简单就是如果当前表的已用空间大小为size那么就将表扩容到size*2的大小。

如下所示当_dictExpandIfNeeded函数在判断了需要进行rehash后就调用dictExpand进行扩容。这里你可以看到rehash的扩容大小是当前ht[0]已使用大小的2倍。

dictExpand(d, d->ht[0].used*2);

而在dictExpand函数中具体执行是由_dictNextPower函数完成的以下代码显示的Hash表扩容的操作就是从Hash表的初始大小DICT_HT_INITIAL_SIZE不停地乘以2直到达到目标大小。

static unsigned long _dictNextPower(unsigned long size)
{
    //哈希表的初始大小
    unsigned long i = DICT_HT_INITIAL_SIZE;
    //如果要扩容的大小已经超过最大值则返回最大值加1
    if (size >= LONG_MAX) return LONG_MAX + 1LU;
    //扩容大小没有超过最大值
    while(1) {
        //如果扩容大小大于等于最大值,就返回截至当前扩到的大小
        if (i >= size)
            return i;
        //每一步扩容都在现有大小基础上乘以2
        i *= 2;
    }
}

下面我们再来看看Redis要解决的第三个问题即rehash要如何执行而这个问题本质上就是Redis要如何实现渐进式rehash设计。

渐进式rehash如何实现

那么这里,我们要先搞清楚一个问题,就是为什么要实现渐进式rehash

其实这是因为Hash表在执行rehash时由于Hash表空间扩大原本映射到某一位置的键可能会被映射到一个新的位置上因此很多键就需要从原来的位置拷贝到新的位置。而在键拷贝时由于Redis主线程无法执行其他请求所以键拷贝会阻塞主线程这样就会产生rehash开销

而为了降低rehash开销Redis就提出了渐进式rehash的方法。

简单来说渐进式rehash的意思就是Redis并不会一次性把当前Hash表中的所有键都拷贝到新位置而是会分批拷贝每次的键拷贝只拷贝Hash表中一个bucket中的哈希项。这样一来每次键拷贝的时长有限对主线程的影响也就有限了。

**那么渐进式rehash在代码层面是如何实现的呢**这里有两个关键函数dictRehash和_dictRehashStep。

我们先来看dictRehash函数这个函数实际执行键拷贝它的输入参数有两个分别是全局哈希表即前面提到的dict结构体包含了ht[0]和ht[1]和需要进行键拷贝的桶数量bucket数量

dictRehash函数的整体逻辑包括两部分

  • 首先该函数会执行一个循环根据要进行键拷贝的bucket数量n依次完成这些bucket内部所有键的迁移。当然如果ht[0]哈希表中的数据已经都迁移完成了,键拷贝的循环也会停止执行。
  • 其次在完成了n个bucket拷贝后dictRehash函数的第二部分逻辑就是判断ht[0]表中数据是否都已迁移完。如果都迁移完了那么ht[0]的空间会被释放。因为Redis在处理请求时代码逻辑中都是使用ht[0]所以当rehash执行完成后虽然数据都在ht[1]中了但Redis仍然会把ht[1]赋值给ht[0],以便其他部分的代码逻辑正常使用。
  • 而在ht[1]赋值给ht[0]后它的大小就会被重置为0等待下一次rehash。与此同时全局哈希表中的rehashidx变量会被标为-1表示rehash结束了这里的rehashidx变量用来表示rehash的进度稍后我会给你具体解释

我画了下面这张图展示了dictRehash的主要执行流程你可以看下。

同时你也可以通过下面代码来了解dictRehash函数的主要执行逻辑。

int dictRehash(dict *d, int n) {
    int empty_visits = n*10;
    ...
    //主循环根据要拷贝的bucket数量n循环n次后停止或ht[0]中的数据迁移完停止
    while(n-- && d->ht[0].used != 0) {
       ...
    }
    //判断ht[0]的数据是否迁移完成
    if (d->ht[0].used == 0) {
        //ht[0]迁移完后释放ht[0]内存空间
        zfree(d->ht[0].table);
        //让ht[0]指向ht[1],以便接受正常的请求
        d->ht[0] = d->ht[1];
        //重置ht[1]的大小为0
        _dictReset(&d->ht[1]);
        //设置全局哈希表的rehashidx标识为-1表示rehash结束
        d->rehashidx = -1;
        //返回0表示ht[0]中所有元素都迁移完
        return 0;
    }
    //返回1表示ht[0]中仍然有元素没有迁移完
    return 1;
}

在了解了dictRehash函数的主体逻辑后我们再看下渐进式rehash是如何按照bucket粒度拷贝数据的这其实就和全局哈希表dict结构中的rehashidx变量相关了。

rehashidx变量表示的是当前rehash在对哪个bucket做数据迁移。比如当rehashidx等于0时表示对ht[0]中的第一个bucket进行数据迁移当rehashidx等于1时表示对ht[0]中的第二个bucket进行数据迁移以此类推。

而dictRehash函数的主循环首先会判断rehashidx指向的bucket是否为空如果为空那就将rehashidx的值加1检查下一个bucket。

**那么有没有可能连续几个bucket都为空呢**其实是有可能的在这种情况下渐进式rehash不会一直递增rehashidx进行检查。这是因为一旦执行了rehashRedis主线程就无法处理其他请求了。

所以渐进式rehash在执行时设置了一个变量empty_visits用来表示已经检查过的空bucket当检查了一定数量的空bucket后这一轮的rehash就停止执行转而继续处理外来请求避免了对Redis性能的影响。下面的代码显示了这部分逻辑你可以看下。

while(n-- && d->ht[0].used != 0) {
    //如果当前要迁移的bucket中没有元素
    while(d->ht[0].table[d->rehashidx] == NULL) {
        //
        d->rehashidx++;
        if (--empty_visits == 0) return 1;
    }
    ...
}

而如果rehashidx指向的bucket有数据可以迁移那么Redis就会把这个bucket中的哈希项依次取出来并根据ht[1]的表空间大小重新计算哈希项在ht[1]中的bucket位置然后把这个哈希项赋值到ht[1]对应bucket中。

这样每做完一个哈希项的迁移ht[0]和ht[1]用来表示承载哈希项多少的变量used就会分别减一和加一。当然如果当前rehashidx指向的bucket中数据都迁移完了rehashidx就会递增加1指向下一个bucket。下面的代码显示了这一迁移过程。

while(n-- && d->ht[0].used != 0) {
    ...
    //获得哈希表中哈希项
    de = d->ht[0].table[d->rehashidx];
    //如果rehashidx指向的bucket不为空
    while(de) {
        uint64_t h;
        //获得同一个bucket中下一个哈希项
        nextde = de->next;
        //根据扩容后的哈希表ht[1]大小计算当前哈希项在扩容后哈希表中的bucket位置
        h = dictHashKey(d, de->key) & d->ht[1].sizemask;
        //将当前哈希项添加到扩容后的哈希表ht[1]中
        de->next = d->ht[1].table[h];
        d->ht[1].table[h] = de;
        //减少当前哈希表的哈希项个数
        d->ht[0].used--;
        //增加扩容后哈希表的哈希项个数
        d->ht[1].used++;
        //指向下一个哈希项
        de = nextde;
    }
    //如果当前bucket中已经没有哈希项了将该bucket置为NULL
    d->ht[0].table[d->rehashidx] = NULL;
    //将rehash加1下一次将迁移下一个bucket中的元素
    d->rehashidx++;
}

好了到这里我们就已经基本了解了dictRehash函数的全部逻辑。

现在我们知道dictRehash函数本身是按照bucket粒度执行哈希项迁移的它内部执行的bucket迁移个数主要由传入的循环次数变量n来决定。但凡Redis要进行rehash操作最终都会调用dictRehash函数。

接下来我们来学习和渐进式rehash相关的第二个关键函数 _dictRehashStep这个函数实现了每次只对一个bucket执行rehash。

从Redis的源码中我们可以看到一共会有5个函数通过调用_dictRehashStep函数进而调用dictRehash函数来执行rehash它们分别是dictAddRawdictGenericDeletedictFinddictGetRandomKeydictGetSomeKeys。

其中dictAddRaw和dictGenericDelete函数分别对应了往Redis中增加和删除键值对而后三个函数则对应了在Redis中进行查询操作。下图展示了这些函数间的调用关系

但你要注意不管是增删查哪种操作这5个函数调用的_dictRehashStep函数给dictRehash传入的循环次数变量n的值都为1下面的代码就显示了这一传参的情况。

static void _dictRehashStep(dict *d) {
//给dictRehash传入的循环次数参数为1表明每迁移完一个bucket ,就执行正常操作
    if (d->iterators == 0) dictRehash(d,1);
}

这样一来每次迁移完一个bucketHash表就会执行正常的增删查请求操作这就是在代码层面实现渐进式rehash的方法。

小结

实现一个高性能的Hash表不仅是Redis的需求也是很多计算机系统开发过程中的重要目标。而要想实现一个性能优异的Hash表就需要重点解决哈希冲突和rehash开销这两个问题。

今天这节课我带你学习了Redis中Hash表的结构设计、链式哈希方法的实现以及渐进式rehash方法的设计实现。Redis中Hash表的结构设计很特别它的每个哈希项都包含了一个指针用于实现链式哈希。同时Redis在全局哈希表中还包含了两个Hash表这种设计思路也是为了在实现rehash时帮助数据从一个表迁移到另一个表。

此外Redis实现的渐进式rehash是一个用于Hash表扩容的通用方法非常值得我们学习。这个设计方法的关键是每次仅迁移有限个数的bucket避免一次性迁移给所有bucket带来的性能影响。当你掌握了渐进式rehash这个设计思想和实现方法你就可以把它应用到自己的Hash表实现场景中。

每课一问

Hash函数会影响Hash表的查询效率及哈希冲突情况那么你能从Redis的源码中找到Hash表使用的是哪一种Hash函数吗

欢迎在留言区分享你的答案,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。