MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Redis字典的底层实现机制

2021-08-253.0k 阅读

Redis字典概述

Redis是一个开源的基于键值对的内存数据库,广泛应用于缓存、消息队列、分布式锁等场景。字典(dict)作为Redis中最核心的数据结构之一,用于实现数据库的键值对映射以及哈希(Hash)数据类型。理解Redis字典的底层实现机制,对于深入掌握Redis的工作原理、优化性能以及排查问题至关重要。

字典在Redis中的应用场景

  1. 数据库键值对存储:Redis数据库本质上就是一个大型的字典,其中每个键都是唯一的,通过键可以快速定位到对应的值。这种键值对存储方式使得Redis能够高效地执行诸如SET、GET、DEL等基本操作。
  2. 哈希数据类型实现:Redis的哈希数据类型是一种包含多个键值对的无序集合。在内部,哈希数据类型同样是基于字典实现的。这使得Redis能够提供诸如HSET、HGET、HDEL等操作,方便用户对哈希表进行管理。

字典的底层数据结构

Redis字典的底层实现依赖于两个主要的数据结构:哈希表(dict.h/dictht)和字典(dict.h/dict)。

哈希表(dictht)

哈希表是一种基于哈希算法的数据结构,它能够在平均情况下以O(1)的时间复杂度进行插入、删除和查找操作。在Redis中,哈希表的定义如下:

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;
  1. table:这是一个指向数组的指针,数组中的每个元素都是一个指向dictEntry结构的指针。dictEntry结构用于存储具体的键值对。
  2. size:哈希表的大小,即table数组的长度。
  3. sizemasksize - 1,用于计算哈希值在table数组中的索引位置。
  4. used:哈希表中已使用的槽位数量。

字典(dict)

字典结构包含了两个哈希表,用于实现渐进式 rehash。其定义如下:

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;
  1. type:指向一个dictType结构的指针,该结构定义了针对特定数据类型的操作函数,例如哈希函数、比较函数等。
  2. privdata:私有数据指针,用于传递一些与特定数据类型相关的参数。
  3. ht:一个包含两个哈希表的数组,通常情况下,只有ht[0]用于存储数据,ht[1]在 rehash 过程中发挥作用。
  4. rehashidx:记录 rehash 进度的索引。当rehashidx == -1时,表示当前没有进行 rehash 操作。
  5. iterators:当前正在运行的迭代器数量。

哈希表节点(dictEntry)

哈希表节点dictEntry用于存储具体的键值对,其定义如下:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;
  1. key:键的指针,指向具体的键。
  2. v:一个联合体,用于存储值。根据值的类型不同,可以是指针、整数或浮点数。
  3. next:指向下一个哈希表节点的指针,用于解决哈希冲突。

哈希算法与哈希冲突解决

哈希算法

Redis使用MurmurHash2算法来计算键的哈希值。MurmurHash2是一种快速、高效的哈希算法,具有良好的雪崩效应和较低的碰撞率。在Redis中,哈希函数的实现位于dict.c文件中:

static unsigned int dictGenHashFunction(const void *key, int len) {
    return dictGenMurmurHash2(key, len);
}
unsigned int dictGenMurmurHash2(const void *key, int len) {
    /* 'm' and 'r' are mixing constants generated offline.
     They're not really 'magic', they just happen to work well.  */
    const uint32_t m = 0x5bd1e995;
    const int r = 24;

    /* Initialize the hash to a 'random' value */
    uint32_t h = 0x12345678 ^ len;
    const unsigned char *data = (const unsigned char *)key;

    while (len >= 4) {
        uint32_t k = *(uint32_t *)data;

        k *= m;
        k ^= k >> r;
        k *= m;

        h *= m;
        h ^= k;

        data += 4;
        len -= 4;
    }

    switch (len) {
    case 3:
        h ^= data[2] << 16;
    case 2:
        h ^= data[1] << 8;
    case 1:
        h ^= data[0];
        h *= m;
    };

    /* Do a few final mixes of the hash to ensure the last few
     * bytes are well-incorporated. */
    h ^= h >> 13;
    h *= m;
    h ^= h >> 15;

    return h;
}

该函数接受键和键的长度作为参数,返回一个32位的哈希值。

哈希冲突解决

由于哈希算法的特性,不同的键可能会计算出相同的哈希值,这就产生了哈希冲突。Redis采用链地址法(separate chaining)来解决哈希冲突。在链地址法中,当多个键映射到同一个哈希槽时,这些键值对会通过链表的方式连接起来。例如,假设有两个键key1key2,它们计算出的哈希值相同,都会映射到哈希表的第n个槽位。此时,key1对应的dictEntry会被插入到该槽位,而key2对应的dictEntry会通过next指针连接到key1dictEntry之后,形成一个链表。这种方式使得在发生哈希冲突时,仍然能够高效地查找、插入和删除键值对。

字典的操作实现

插入操作

当向Redis字典中插入一个新的键值对时,首先会计算键的哈希值,然后根据哈希值和哈希表的大小计算出应该插入的槽位。如果该槽位为空,则直接将新的dictEntry插入到该槽位;如果该槽位已经有其他键值对,则通过链表将新的dictEntry连接到链表末尾。以下是简化的插入操作伪代码:

def insert(dict, key, value):
    hash_value = calculate_hash(key)
    index = hash_value & dict.ht[0].sizemask
    entry = dict.ht[0].table[index]
    if entry is None:
        new_entry = create_dict_entry(key, value)
        dict.ht[0].table[index] = new_entry
        dict.ht[0].used += 1
    else:
        while entry.next is not None:
            if entry.key == key:
                entry.v = value
                return
            entry = entry.next
        if entry.key == key:
            entry.v = value
        else:
            new_entry = create_dict_entry(key, value)
            entry.next = new_entry
            dict.ht[0].used += 1
    # 检查是否需要 rehash
    if dict.ht[0].used >= dict.ht[0].size * dict_force_resize_ratio:
        rehash(dict)

查找操作

查找操作同样先计算键的哈希值,然后根据哈希值找到对应的槽位。接着在该槽位的链表中遍历,查找与目标键匹配的dictEntry。如果找到,则返回对应的值;如果遍历完链表仍未找到,则表示键不存在。以下是简化的查找操作伪代码:

def find(dict, key):
    hash_value = calculate_hash(key)
    index = hash_value & dict.ht[0].sizemask
    entry = dict.ht[0].table[index]
    while entry is not None:
        if entry.key == key:
            return entry.v
        entry = entry.next
    return None

删除操作

删除操作也需要先计算键的哈希值,找到对应的槽位和链表。然后在链表中查找要删除的dictEntry,并将其从链表中移除。在移除dictEntry时,需要处理链表的指针调整,确保链表的连续性。以下是简化的删除操作伪代码:

def delete(dict, key):
    hash_value = calculate_hash(key)
    index = hash_value & dict.ht[0].sizemask
    prev = None
    entry = dict.ht[0].table[index]
    while entry is not None:
        if entry.key == key:
            if prev is None:
                dict.ht[0].table[index] = entry.next
            else:
                prev.next = entry.next
            dict.ht[0].used -= 1
            return
        prev = entry
        entry = entry.next
    return

渐进式rehash

rehash的原因

随着数据的不断插入和删除,哈希表的负载因子(used / size)会发生变化。当负载因子过高时,哈希冲突的概率会增加,从而降低字典操作的性能。为了保证字典的性能,Redis会在适当的时候对哈希表进行rehash。rehash的主要操作是重新分配哈希表的空间,通常将哈希表的大小扩大为原来的两倍,然后将旧哈希表中的所有键值对重新计算哈希值并插入到新的哈希表中。

渐进式rehash的实现

由于Redis是单线程模型,如果一次性将大量键值对从旧哈希表迁移到新哈希表,可能会导致主线程阻塞,影响Redis的性能。为了解决这个问题,Redis采用渐进式rehash。在渐进式rehash过程中,Redis会在每次字典操作(如插入、查找、删除)时,逐步将一部分键值对从旧哈希表迁移到新哈希表。具体实现如下:

  1. 初始化阶段:当决定进行rehash时,Redis会为ht[1]分配空间,其大小通常是ht[0]的两倍。同时,将rehashidx设置为0,表示rehash开始。
  2. 迁移阶段:在每次字典操作时,Redis会检查rehashidx是否大于等于0。如果是,则从ht[0]rehashidx位置开始,将该位置及其链表上的所有键值对迁移到ht[1]。迁移完成后,rehashidx自增1。
  3. 完成阶段:当rehashidx等于ht[0].size时,表示所有键值对都已迁移完毕。此时,将ht[0]释放,将ht[1]赋值给ht[0],并将rehashidx设置为 -1,表示rehash结束。

以下是渐进式rehash的简化伪代码:

def rehash(dict):
    new_size = dict.ht[0].size * 2
    dict.ht[1].table = allocate_memory(new_size * sizeof(dictEntry *))
    dict.ht[1].size = new_size
    dict.ht[1].sizemask = new_size - 1
    dict.ht[1].used = 0
    dict.rehashidx = 0
    while dict.rehashidx < dict.ht[0].size:
        index = dict.rehashidx
        entry = dict.ht[0].table[index]
        while entry is not None:
            next_entry = entry.next
            hash_value = calculate_hash(entry.key)
            new_index = hash_value & dict.ht[1].sizemask
            entry.next = dict.ht[1].table[new_index]
            dict.ht[1].table[new_index] = entry
            dict.ht[1].used += 1
            dict.ht[0].used -= 1
            entry = next_entry
        dict.rehashidx += 1
    free(dict.ht[0].table)
    dict.ht[0] = dict.ht[1]
    dict.rehashidx = -1

rehash过程中的操作处理

在渐进式rehash过程中,字典的插入、查找和删除操作需要同时在ht[0]ht[1]上进行。具体来说:

  1. 插入操作:新的键值对会被插入到ht[1]中,这样可以保证新插入的数据都在新的哈希表中。
  2. 查找操作:首先在ht[0]中查找,如果未找到,则在ht[1]中查找。
  3. 删除操作:同样先在ht[0]中查找并删除,如果未找到,则在ht[1]中查找并删除。

字典的迭代器

迭代器的类型

Redis字典提供了两种类型的迭代器:安全迭代器(safe iterator)和非安全迭代器(non - safe iterator)。

  1. 安全迭代器:安全迭代器允许在迭代字典的同时进行插入、删除等修改操作。在迭代过程中,即使字典发生了rehash,安全迭代器也能正确地遍历所有键值对。安全迭代器通过记录当前遍历的位置和状态来实现这一点。
  2. 非安全迭代器:非安全迭代器不允许在迭代过程中对字典进行修改操作。如果在迭代过程中字典发生了修改(如插入、删除、rehash等),非安全迭代器可能会出现错误的结果。非安全迭代器实现相对简单,它直接按照哈希表的结构顺序进行遍历。

迭代器的实现

  1. 安全迭代器实现:安全迭代器在创建时,会记录当前字典的状态,包括rehashidx的值。在迭代过程中,根据rehashidx的值,从ht[0]ht[1]中依次获取键值对。当字典发生rehash时,安全迭代器能够根据rehashidx的变化,正确地从新的哈希表中获取剩余的键值对。
  2. 非安全迭代器实现:非安全迭代器直接从ht[0]的第一个槽位开始,依次遍历每个槽位及其链表。如果在遍历过程中字典发生了rehash,由于哈希表结构发生了变化,非安全迭代器可能会遗漏某些键值对或者重复遍历某些键值对。

以下是一个简单的使用迭代器遍历字典的C语言示例代码:

#include <stdio.h>
#include "redis.h"
#include "dict.h"

int main() {
    dict *d = dictCreate(&redisDictType, NULL);
    dictAdd(d, "key1", "value1");
    dictAdd(d, "key2", "value2");
    dictAdd(d, "key3", "value3");

    dictIterator *di = dictGetSafeIterator(d);
    dictEntry *de;
    while ((de = dictNext(di)) != NULL) {
        printf("Key: %s, Value: %s\n", (char *)dictGetKey(de), (char *)dictGetVal(de));
    }
    dictReleaseIterator(di);

    dictRelease(d);
    return 0;
}

在上述代码中,首先创建了一个字典,并向其中添加了三个键值对。然后使用安全迭代器遍历字典,并打印出每个键值对。最后释放迭代器和字典。

总结与优化建议

通过深入了解Redis字典的底层实现机制,我们可以更好地理解Redis的性能特点和行为。在使用Redis时,为了充分发挥字典的性能优势,可以考虑以下优化建议:

  1. 合理预估数据量:在设计应用程序时,尽量提前预估需要存储在Redis中的数据量,以便合理设置哈希表的初始大小,减少rehash的发生频率。
  2. 选择合适的键:选择具有良好哈希分布特性的键,避免大量键集中在少数哈希槽位,从而减少哈希冲突的概率。
  3. 避免频繁的大字典操作:尽量避免在短时间内对大字典进行大量的插入、删除操作,以减少渐进式rehash对性能的影响。
  4. 正确使用迭代器:根据实际需求选择合适的迭代器类型。如果需要在迭代过程中进行修改操作,务必使用安全迭代器。

深入理解Redis字典的底层实现机制,不仅有助于我们优化Redis的性能,还能让我们在面对复杂的应用场景时,做出更合理的设计决策。