redis dict 设计

429 阅读3分钟

这是我参与8月更文挑战的第20天,活动详情查看:8月更文挑战

redis 提供了经典的 hashtable 设计:

  1. hash 冲突 ⇒ 拉链法
  2. 扩容 ⇒ rehash 扩容。准备两个 hash,交替工作

其中扩容这个,在 go map 改造中可以借用,可以有效防止 delete map key 造成的内存泄漏。

hash 冲突

先说说 redis 存储 dict 的结构:

typedef struct dictht {
    dictEntry **table;      // table array 每一个都指向 dictEntry
    unsigned long size;     // Hash表大小
    unsigned long sizemask;
    unsigned long used;
} dictht;

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;
  1. dict 中含有若干个桶 → dictEntry
  2. 每一个桶的指针,带有 next 指针 → 实现链式hash的关键

hash 冲突就是使用 next 将node指针串联在一个桶中。那么当链表长度变长,查询效率变慢,有什么方法可以减少对 hashtable 的查询影响呢?

rehash

一句话:就是扩大 Hash 表空间。基本思路如下:

  1. 准备两个 hashtable,在 rehash 中交替存储数据
  2. 正常响应请求 → 写入 hash[0]
  3. rehash → hash[0] ⇒ hash[1]
  4. reahash 迁移完毕 → hash[0] 空间释放,然后 hash[1] ⇒ hash[0] (指针指向转换),hash[1] 大小空间置为 0

而 redis 的 rehash 方式是:渐进式hash,这也是性能的体现。那为什么要实现渐进式 rehash?

键拷贝过程中,redis 主线程无法执行其他要求,阻塞主线程,加大 rehash 的开销

简单来说:把很大块迁移数据的开销,平摊到多次小的操作中,目的是降低主线程的性能影响

具体操作就是:

每次的键拷贝,只拷贝 hashtable 中的一个 bucket 对应的 dictEntry

关键函数:dictRehash && _dictRehashStep

先来看看 dictRehash

int dictRehash(dict *d, int n) {
    int empty_visits = n*10;
    ...
    // 主循环,根据要拷贝的bucket数量n,循环n次后停止或ht[0]中的数据迁移完停止
    while(n-- && d->ht[0].used != 0) {
        // 如果当前要迁移的bucket中没有元素
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
                    ...
        // 获得哈希表中哈希项
        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++;
    }
    // ht[0] 是否迁移完毕
    if (d->ht[0].used == 0) {
        // 迁移完毕,释放空间
        zfree(d->ht[0].table);
        // 让ht[0]指向ht[1],ht[0] 继续接受正常请求
        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;
}

核心:迁移某一个 bucket 下的全部 hash entry,步骤如下:

  1. 根据 rehashidx (当前的迁移进度) 确定到 bucket
  2. 如果 bucket 还有 next hash entry,继续迁移
  3. 先保存在 ht[0] 中找到的 bucket 中的第一个 hash entry→next,作为下一次循环的开始 (其实是要保存在 ht[0] 中的迁移的指针,不然下一个需要迁移的 entry 哪里找)
  4. 根据扩容,找到在 ht[1] 中新的 bucket 所在位置
  5. 链表插入
  6. ht[0] - 1,ht[1] + 1
  7. 指向下一个哈希项 (ht[0] 的next )