问题

  1. redis 怎么解决的hash冲突问题 ?
  2. redis 对于扩容rehash有什么优秀的设计?

hash

目标是解决hash冲突,那什么是hash冲突呢?

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

redis hash 有并发问题吗 redis hash 数量_算法

上图所示,写入16个键,那么对应的桶只有8个(想一下如果一个桶只能保存一个元素,那么势必会存在数据覆盖),如果写入的key值过多,我们的hash表要怎么处理呢? 事先声明一个很大的hash表嘛,这种肯定是不现实的,不说大小怎么确定,资源也会存在浪费。

那么回过来,我们看下hash冲突,key1 和 key9 都被映射到了 Hash 表的桶 1 中,这样,当桶 5 只能保存一个 key 时,key1 和 key3 就会有一个 key 无法保存到哈希表中了。

redis hash 有并发问题吗 redis hash 数量_哈希算法_02

看下redis怎么解决hash冲突:总体来说一个是链式hash和渐进式rehash。

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

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

在 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;

再看dictEntry,一定是会一个当前自己的指针,一个next指针

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

下面还是拿key1 和 key9 来举例,还是相同的映射流程,采用链式hash的方式画个图就都清楚了 

redis hash 有并发问题吗 redis hash 数量_redis_03

可以看出,当我们查询key9的时候,会先hash(key9)/8 的结果确定桶的位置,再根据链表中的next指针遍历要得到的结果。

想一下,这样会有什么不足?(链表过长的时候,查询复杂度上升)

rehash

hash表的缺点是链表过长,查询效果会下降,那么就要想办法让它的链表存储变短一些。在Redis 中准备了两个哈希表,用于 rehash 时交替保存数据。如图定义:

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 时的迁移表。

redis hash 有并发问题吗 redis hash 数量_算法_04

到这里应该了解怎么进行rehash,保证我们使用的空间足够了,那么有两个问题: 什么时候触发 rehash? rehash 扩容扩多大? rehash 如何执行?

什么时候触发 rehash?

判断是否触发的函数:dictExpandIfNeeded,在里面找一下触发条件

变量值是在 dictEnableResize 和 dictDisableResize作用分别是启用和禁止哈希表执行 rehash 功能

//如果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);
}

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

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

剩下的就是看下这个dictExpandIfNeeded方法是谁在使用了 ,dictAdd:用来往 Hash 表中添加一个键值对。 dictRelace:用来往 Hash 表中添加一个键值对,或者键值对存在时,修改键值对。 dictAddorFind:直接调用 dictAddRaw。

rehash 扩容扩多大?

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

// 当前表的已用空间大小为 size,那么就将表扩容到 size2 的大小。
dictExpand(d, d->ht[0].used*2);

在 Redis 中,rehash 对 Hash 表空间的扩容是通过调用 dictExpand 函数 来完成的。dictExpand 函数的参数有两个,一个是要扩容的 Hash 表,另一个是要扩到的容量

        在 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;
    }
}

为什么要实现渐进式 rehash?

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

简单的说,就是分批来迁移桶内数据,并不会一次性把当前 Hash 表中的所有键,都拷贝到新位置,而是会分批拷贝,每次的键拷贝只拷贝 Hash 表中一个 bucket 中的哈希项。

dictRehash 的主要执行流程:

redis hash 有并发问题吗 redis hash 数量_哈希算法_05

整理了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;
}

需要关注个核心参数:全局哈希表 dict 结构中的 rehashidx 变量相关了。 rehashidx 变量表示的是当前 rehash 在对哪个 bucket 做数据迁移。比如,当 rehashidx 等于 0 时,表示对 ht[0]中的第一个 bucket 进行数据迁移;当 rehashidx 等于 1 时,表示对 ht[0]中的第二个 bucket 进行数据迁移,以此类推。