Redis哈希表的内部实现

111 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第14天,点击查看活动详情

Redis无需介绍。它是一个非常流行的键值存储,提供了各种内存存储(数据也可以持久化在磁盘上-有后台保存和仅追加文件选项来实现相同的功能),如列表,Set, Sorted Set, String, HyperLogLog, Geospatial结构等。Redis利用内存实现速度,它在一个线程中执行所有操作,尽管AOF操作在一个单独的后台线程中运行。

这篇文章打算解释内部redis哈希表是如何设计的&如何调整它们的大小。这篇文章没有解释如何计算键的哈希值或桶索引。

每个redis数据库实例(数据库的索引从0到最大配置)都有一个与之相关的键空间,它只是哈希表实现的包装器。无论redis存储什么数据,无论是字符串,redis集还是redis哈希,所有数据都保存在哈希表中。下面的代码片段取自redis github存储库,展示了如何定义dict数据类型。结构体dict包含一个包含2个dictht实例的数组。我们一会儿再讲为什么数组中有2个实例。dictht,哈希表实现,其中包含dictEntry的数组(在代码中称为table)。dictEntry是一个链表节点的表示,它包含键、值和指向下一个节点的指针。Dictht还有其他成员,比如- size:哈希表中桶的总数,大小是在创建或扩展哈希表时提供的,sizemask与key的哈希值一起使用,以确定键的正确索引,通常sizemask≤size,使用的成员实际上跟踪哈希表中当前存在的元素总数。

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;
​
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    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;

哈希表dictht的初始大小为4。随着越来越多的密钥进入系统,哈希表的大小也会增加。什么时候redis调整哈希表的大小?Redis可以在以下2种情况下调整哈希表的大小或简单地rehash:

Total_elements / total_buckets = 1并且启用了dict resize。启用或禁用字典大小调整由redis内部处理。Redis试图避免重散列当一些后台进程运行做一些重操作,如保存数据库到磁盘,因为重散列涉及大量的内存页的移动。因此,简单地说,当后台进程运行时,字典大小调整通常是禁用的,否则启用。

Total_elements / total_buckets > 5(强制调整大小比例,强制调整大小完成)

现在有趣的部分来了。正如我已经提到的,redis是单线程的。所以它必须执行像哈希表大小调整和重哈希这样的操作,这样它就不会被阻塞。因为重哈希一个大哈希表总是会阻塞redis,这是不可接受的。

为了实现这一点,redis以增量方式执行重散列操作。对于每个操作,如GET, SET等,redis检查是否需要重新散列。如果需要重新散列,redis会检查是否需要首先扩展散列表。Expand只是调整哈希表的大小。正如我在上面的代码片段中提到的,struct dict包含一个包含2个dictht实例的数组ht,在这里它们出现在画面中。Redis通常将数据存储在第一个dictht实例中(ht[0])。在重哈希时,它会创建一个大小为2次幂的扩展哈希表,其大小仅大于或等于当前哈希表(ht[0])的大小,并且新的哈希表实际上存储在ht[1]实例中。因此,在增量重哈希过程中,redis不断将桶(实际上是一个链表,因为redis在发生碰撞时使用单独的链)从ht[0]移动到ht[1],而不是一次移动所有内容。通过计算当前哈希表中键的位置来确定要移动哪个桶(链接的节点)。结构体dict的成员rehashindex在重散列期间将≥0。Rehashindex只是一个变量,用于遍历哈希表,以确定索引位置是否包含要移动的桶。它从0开始,直到哈希表中的所有索引都被覆盖。读取该成员的值,redis知道是否正在进行重散列。因此,为了在rehashing期间为GET请求服务,redis必须读取这两个哈希表以找出数据。就速度而言,这有点不利。当重哈希完成时,rehashindex被设置为-1,并且驻留在ht[1]实例中的新哈希表被分配回ht[0]。

你可以参考下面的代码片段,它显示了redis表展开和rehash:

/* Resize the table to the minimal size that contains all the elements,
 * but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
{
    int minimal;
​
    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    minimal = d->ht[0].used;
    if (minimal < DICT_HT_INITIAL_SIZE)
        minimal = DICT_HT_INITIAL_SIZE;
    return dictExpand(d, minimal);
}
​
/* Expand or create the hash table */
int dictExpand(dict *d, unsigned long size)
{
    dictht n; /* the new hash table */
    unsigned long realsize = _dictNextPower(size);
​
    /* the size is invalid if it is smaller than the number of
     * elements already inside the hash table */
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;
​
    /* Rehashing to the same table size is not useful. */
    if (realsize == d->ht[0].size) return DICT_ERR;
​
    /* Allocate the new hash table and initialize all pointers to NULL */
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
​
    /* Is this the first initialization? If so it's not really a rehashing
     * we just set the first hash table so that it can accept keys. */
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }
​
    /* Prepare a second hash table for incremental rehashing */
    d->ht[1] = n;
    d->rehashidx = 0;
    return DICT_OK;
}
​
/* Performs N steps of incremental rehashing. Returns 1 if there are still
 * keys to move from the old to the new hash table, otherwise 0 is returned.
 *
 * Note that a rehashing step consists in moving a bucket (that may have more
 * than one key as we use chaining) from the old to the new hash table, however
 * since part of the hash table may be composed of empty spaces, it is not
 * guaranteed that this function will rehash even a single bucket, since it
 * will visit at max N*10 empty buckets in total, otherwise the amount of
 * work it does would be unbound and the function may block for a long time. */
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */
    if (!dictIsRehashing(d)) return 0;
​
    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;
​
        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        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) {
            unsigned int 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++;
    }
​
    /* Check if we already rehashed the whole table... */
    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;
    }
​
    /* More to rehash... */
    return 1;
}