Redis数据结构之HashTable

前言

哈希表是一种非常常用的数据结构,它具备 O(1) 时间复杂度的查找效率,常被用来索引数据。
哈希表在 Redis 中也承担着重要角色,例如:数据类型 Hash 底层就是用的哈希表实现的,数据库所有的键值对也是用一个全局哈希表来存储的。
Redis 是如何实现哈希表的呢?

哈希表

哈希表(Hash Table),也称作散列表,是根据键(Key)直接访问在记忆体储存位置的数据结构。
哈希表由若干个键值对组成,键值对也被称作 Entry。
数组就是最简单的哈希表,我们可以把数组的每一项看作是一个桶 Bucket,数据写入时先对 Key 计算一个哈希值,然后对数组的长度取模得到结果下标,此时我们就可以定位到一个具体的 Bucket,然后把 Entry 写入该 Bucket 即可,查找的过程类似。

在实现哈希表时,需要考虑几个关键问题:

  • 什么是哈希冲突?
  • 数组的容量设置多大?
  • 怎么解决哈希冲突?
  • rehash的策略

什么是哈希冲突?
数组的大小是有限的,也就是 Bucket 数量是有限的,但是往哈希表里写入的 Entry 理论上可以是无限的,把无限数量的 Entry 映射到有限的 Bucket 上,就一定会出现这样一种情况:两个不同的 Key 计算的结果下标相同,即映射到同一个 Bucket 上,这就是哈希冲突。

数组的容量设置多大?
数组就是最简单的哈希表,那数组的容量要如何设置呢?容量太小,发生哈希冲突的概率就会越大;容量太大,没用充分使用就会浪费内存空间。常见做法是:如果你能预先知道写入的数据量,那么就创建一个差不多容量数组;如果你无法预计数据量,就先创建一个较小的数组以节约内存,等到写入的数据量真的变大时再进行扩容操作。

怎么解决哈希冲突?
解决哈希冲突有很多种方式,例如:

  • 链地址法:冲突的 Entry 形成链表
  • 二次哈希:用另一种哈希函数重新计算一次哈希值,尝试映射到其它 Bucket
  • 公共溢出区:冲突的 Entry 统一存放到一个公共溢出区域
  • 开放定址法:冲突的 Entry 按照一定的规则(线性探测、平方探测)在哈希表中找到下一个空闲的 Bucket 存入

rehash的策略
当数组容量过小,写入的数据过大,极大概率会发生哈希冲突。一旦发生哈希冲突,或多或少都会影响哈希表的查找性能,例如链地址法,哈希冲突后只能扫描链表,时间复杂度降到 O(N)。
因此,当 Entry 数量 和 数组容量的比值到达一定阈值时,就要考虑对哈希表进行扩容了,这个阈值也被称作负载因子。哈希表的扩容就是新申请一块更大的数组空间,然后把元素重新映射到新数组上,减少哈希冲突的概率。因为扩容的过程需要对所有 Key 重新执行一次哈希计算,所以扩容过程也被称作 rehash。

Redis Hash

Redis 哈希表采用的是链式哈希的设计理念,通过链表来解决哈希冲突。Redis 哈希表对应的结构体是 dictht,二维数组 table 存储元素,size 记录了哈希表的容量,used 记录了元素的数量。

typedef struct dictht {
    dictEntry **table; // 哈希表
    unsigned long size; // 哈希表容量
    unsigned long sizemask; // 容量掩码 size-1
    unsigned long used; // 元素数量
} dictht;

哈希表中的键值对 Entry 对应的结构体是 dictEntry,next 指针指向下一个 Entry。

typedef struct dictEntry {
    void *key;
    union {
        void *val; // 指向 Value 的指针
        uint64_t u64; // 如果 Value 本身就是 整型或浮点型,直接存,无需指针,节省内存
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; // 链式哈希 指向下一个Entry
} dictEntry;

Redis 这里有一个小技巧,使用联合体(union)来定义 Value。正常键值对的 Value 可以是任意类型,使用指针 val 来指向它,但是如果 Value 本身就是整型或浮点型数值,就可以直接存储,无需再使用一个指针了。这种方式不仅节省内存,还少了一次寻址的开销。

Redis 是怎么实现 rehash 的呢?
首先我们要知道,Redis 读写DB只使用一个线程,也被称作主线程,主线程一旦发生阻塞,就无法处理其它请求了。Redis 作为一个高性能的键值对数据库,是无法容忍阻塞的。
偏偏 rehash 操作本身极有可能是个阻塞操作,首先 Redis 需要申请一块大的连续内存空间,然后把所有 Entry 做二次哈希操作,迁移到新的哈希表里,迁移的时长取决于哈希表中元素的数量。
为了避免 rehash 带来的阻塞问题,Redis 采用了渐进式 rehash 的做法。

所谓“渐进式 rehash”,意思是:不要一次迁移完所有 Entry,而是分批次,一点一点完成迁移。
为了实现渐进式 rehash,Redis 准备了两个哈希表 ht[0] 和 ht[1],在 dictht 外套了一层 dict 结构体:

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2]; // 两个哈希表 交替使用 rehash
    long rehashidx; // rehash索引号 迁移的下标 -1代表没在rehash
    int16_t pauserehash;
} dict;

rehash 的步骤:

  • 正常情况下,所有数据写入 ht[0]
  • rehash 时数据慢慢迁移到 ht[1],同时用 rehashidx 记录迁移的下标位置
  • ht[0] 迁移完成后,把 ht[0] 空间释放,再把 ht[1] 赋值给 ht[0]
  • 最后重置 ht[1] 和 rehashidx

什么时候会触发 rehash 呢?
判断是否需要 rehash 的方法是_dictExpandIfNeeded(),满足任一条件即可:

  • 哈希表是空的,此时要初始化
  • 哈希表的元素数量超过了容量大小,且此时允许 rehash
  • 哈希表的元素数量超过了容量dict_force_resize_ratio倍大小,此时会立即 rehash
static int _dictExpandIfNeeded(dict *d)
{
    /* 正在rehash 直接返回 条件:rehashidx!=-1 */
    if (dictIsRehashing(d)) return DICT_OK;

    /* 初始化 默认大小4 */
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
    if (!dictTypeExpandAllowed(d))
        return DICT_OK;
    /*
     * 正常情况下 负载因子>=1 就rehash
     * 有RDB AOF子进程时,为了避免写时复制带来的影响,负载因子>=5才会rehash
     * 扩容后的大小:_dictNextPower(size) 大于等于元素大小的且最接近的的2的幂次方数
     */
    if ((dict_can_resize == DICT_RESIZE_ENABLE &&
         d->ht[0].used >= d->ht[0].size) ||
        (dict_can_resize != DICT_RESIZE_FORBID &&
         d->ht[0].used / d->ht[0].size > dict_force_resize_ratio))
    {
        return dictExpand(d, d->ht[0].used + 1);
    }
    return DICT_OK;
}

dict_force_resize_ratio的值默认为 5,当哈希表的元素数量超过了容量的 5 倍大小,说明哈希表的负载已经非常高了,此时会立即扩容。
哈希表是否被允许 rehash,Redis 定义了枚举值:

typedef enum {
    DICT_RESIZE_ENABLE, // 启用rehash
    DICT_RESIZE_AVOID, // 避免rehash 提高负载因子
    DICT_RESIZE_FORBID, // 禁止rehash
} dictResizeEnable;

如果不进行 rehash 操作,带来的影响就是哈希表的性能会退化,为什么 Redis 还允许设置避免 rehash 呢?
我们先看看,哪些情况下 Redis 会设置避免 rehash 操作。
dict_can_resize变量的设置方法是dictSetResizeEnabled()

void dictSetResizeEnabled(dictResizeEnable enable) {
    dict_can_resize = enable;
}

设置的时机是updateDictResizePolicy()

void updateDictResizePolicy(void) {
    if (server.in_fork_child != CHILD_TYPE_NONE)
        dictSetResizeEnabled(DICT_RESIZE_FORBID);
    else if (hasActiveChildProcess())// 有子进程在 rdb_dump aof_rewrite 时,尽量避免rehash
        dictSetResizeEnabled(DICT_RESIZE_AVOID);
    else
        dictSetResizeEnabled(DICT_RESIZE_ENABLE);
}

我们发现,正常情况下,只要哈希表元素超过了容量大小就会触发 rehash,只有在子进程执行 rdb_dump 或 aof_rewrite 时才会设置尽量避免 rehash 操作,这又是为什么呢?

Redis 在做数据持久化和 AOF 文件重写时,因为这个操作相当耗时,为了避免阻塞主线程,Redis 会 fork 一个子进程异步处理。子进程和父进程共享同一块虚拟内存空间,所以子进程才能针对内存数据做全量持久化。但是为了节省资源,Linux 并不会真的拷贝一份内存,而是用到叫 “写时复制”(Copy On Write)技术,如果双方都不修改内存数据,就不会发生拷贝,此时基本没什么消耗。当任一方要修改数据时,因为内存页已经被 Linux 标记为 “写保护”所以该操作会报错,此时 Linux 会基于原始内存页复制一个新的内存页用于数据修改,而不至于影响到对方。大量的缺页报错会影响性能,所以在有 fork 子进程时,应该要尽量避免写数据,而 rehash 操作就是要一直写的,所以 Redis 在这种情况下才要尽量避免 rehash,手段就是调高负载因子,延迟 rehash 的时机。

哪些操作会触发 rehash 判断呢?通过源码发现有三个方法:

  • dictAdd:向哈希表中添加键值对
  • dictReplace:添加或替换键值对
  • dictAddOrFind

Redis rehash 的扩容策略是什么?
一句话总结:扩容后的大小必须大于等于元素数量且最小的2的幂次方数。

return dictExpand(d, d->ht[0].used + 1);

你可能好奇,为啥不直接 2 倍扩容呢?早期的版本确实是按照 2 倍容量扩容的,但是现在如果有 fork 子进程的话,Redis 会延迟扩容,就会导致元素大小远超过容量,此时就算 2 倍扩容也无济于事。
扩容方法是dictExpand(),注意 size 并不是扩容后的大小,而是元素数量,Redis 会自动计算一个最接近的 2 的幂次方数作为新容量来扩容:

int dictExpand(dict *d, unsigned long size) {
    return _dictExpand(d, size, NULL);
}

计算新容量的方法是_dictNextPower(),它会从初始容量开始,一直乘以 2,直到超过元素大小。

static unsigned long _dictNextPower(unsigned long size)
{
    unsigned long i = DICT_HT_INITIAL_SIZE;
    if (size >= LONG_MAX) return LONG_MAX + 1LU;
    while(1) {
        if (i >= size)
            return i;
        i *= 2;
    }
}

知道了扩容的策略,接下来看看 rehash 操作具体是如何实现的。
rehash 被拆分成两个函数:

  • _dictRehashStep:指定要迁移的 Bucket 数量,默认 1
  • dictRehash:按给定 Bucket 数量做数据迁移

默认情况下,Redis 每次只会迁移一个 Bucket,这样就保证了阻塞的时间很少。真正执行 rehash 操作的函数是dictRehash()

int dictRehash(dict *d, int n) {
    /* 访问的最大空桶数量 */
    int empty_visits = n*10;
    unsigned long s0 = d->ht[0].size;
    unsigned long s1 = d->ht[1].size;
    if (dict_can_resize == DICT_RESIZE_FORBID || !dictIsRehashing(d)) return 0;
    if (dict_can_resize == DICT_RESIZE_AVOID && 
        ((s1 > s0 && s1 / s0 < dict_force_resize_ratio) ||
         (s1 < s0 && s0 / s1 < dict_force_resize_ratio)))
    {
        return 0;
    }
    // 循环迁移
    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht[0].table[d->rehashidx];
        /* Move all the keys in this bucket from the old to the new hash HT */
        while(de) {
            uint64_t h;
            nextde = de->next;
            /* Get the index in the new hash table */
            h = dictHashKey(d, de->key) & d->ht[1].sizemask;
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }
    /*
     * 如果已经迁移完了,释放ht[0],ht[0] 指向 ht[1]
     * 重置ht[1],重置rehashidx
     */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }
    return 1;
}

Redis 会按照 rehashidx 顺序迁移 Bucket 里的数据,默认每次只会迁移 1 个 Bucket。如果访问的 Bucket 为空,则会继续扫描下一个,但是不排除极端情况下连续访问的 Bucket 都是空,此时为了不阻塞操作,Redis 不会一直扫描下去,最多扫描 10 个空桶,就会停止 rehash 操作。
如果把 ht[0] 数据都迁移完了,就会释放 ht[0],然后把 ht[1] 赋值给 ht[0],然后重置 ht[1] 和 rehashidx,以便下次 rehash。

为了避免全量 rehash 带来的阻塞问题,Redis 把 rehash 操作带来的影响分摊到多次执行,每次只迁移一个 Bucket,这样阻塞的时间就可以控制在可接受的范围内。那么哪些操作会触发 rehash 操作呢?通过源码发现有五个方法:

  • dictAddRaw
  • dictGenericDelete
  • dictFind
  • dictGetRandomKey
  • dictGetSomeKeys

尾巴

Redis 采用链式哈希的方式设计了哈希表,同时为了避免全量 rehash 操作带来的阻塞问题,Redis 使用渐进式 rehash,把数据的迁移操作分摊到多次执行,默认每次只迁移一个 Bucket 数据,这样阻塞的时间就可以控制在可接受的范围内。如果有子进程在执行 rdb_dump 或 aof_rewrite,考虑到主线程 rehash 会导致大量的写时复制,Redis 此时会尽量避免 rehash,延迟 rehash 的时机,但是如果哈希表负载过高,元素数量超过了容量的5倍大小,此时也不得不 rehash 了,否则哈希表的性能将会受到严重影响。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员小潘

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值