Redis源码学习-字典类型

152 阅读8分钟

本篇文章介绍字典(Map/Dict)类型在Redis中的实现。上来第一个问题依旧是为什么Redis要自己实现字典类型?原因和链表类似,C语言没有提供字典类型。

字典类型对于Redis而言非常重要,Redis被称为KV数据库,因此其数据库在底层就是通过字典实现的。我们在Redis中插入一条键值对后,这个键值对就是保存在代表数据库的字典中。此外字典还是哈希键的底层实现之一

1. 字典的实现

typedef struct dictht {
    
    // 哈希表数组
    dictEntry **table;

    // 哈希表大小
    unsigned long size;
    
    // 哈希表大小掩码,用于计算索引值,总是等于 size - 1
    unsigned long sizemask;

    // 该哈希表已有节点的数量
    unsigned long used;

} dictht;

我们知道字典的底层实现就是哈希表,在Redis中对应的数据结构为dicthttable属性类型为保存指向dictEntry的指针的数组,size表示哈希表的大小,sizemask表示哈希表掩码,总是等于size - 1used表示哈希表中已有节点的数量。

typedef struct dictEntry {
    
    // 键
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;

} dictEntry;

键值对在Redis通过dictEntry数据结构表示。key属性指向键,v属性存储或指向值,由于Redis中解决哈希冲突是通过链地址法,因此哈希值相同的键值对通过链表保存,next指针用于指向链表中的下一个键值对节点。

typedef struct dict {

    // 类型特定函数
    dictType *type;

    // 私有数据
    void *privdata;

    // 哈希表
    dictht ht[2];

    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */

    // 目前正在运行的安全迭代器的数量
    int iterators; /* number of iterators currently running */

} dict;

字典在Redis中通过dict数据结构表示。typeprivadata属性是针对不同类型的键值对,为创建多态字段而设计的。ht[2]属性用于执行哈希表,至于为什么需要维护两个哈希表指针以及rehashidx的用途在后面介绍渐进式ReHash时进行讲解。iterators表示安全迭代器的数量。

typedef struct dictType {

    // 计算哈希值的函数
    unsigned int (*hashFunction)(const void *key);

    // 复制键的函数
    void *(*keyDup)(void *privdata, const void *key);

    // 复制值的函数
    void *(*valDup)(void *privdata, const void *obj);

    // 对比键的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);

    // 销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);
    
    // 销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);

} dictType;

2. 哈希算法

Redis计算在哈希表中索引的算法如下:

// 计算哈希值
hash = dict->type->hashFunction(k0);
// 计算在哈希表中的索引
idx = hash & dict->ht[0].sizemask;

3. ReHash

3.1 负载因子

负载因子的计算公式如下:

// 节点数 / 哈希表长度
load_factor = ht[0].used / ht[0].size;

3.2 哈希表的扩展与收缩

  • 没有执行BGSAVEBGREWRITEAOF命令时,如果load_factor >= 1,哈希表大小扩展为第一个大于等于ht[0].used * 22^n
  • 正在执行BGSAVEBGREWRITEAOF命令时,如果load_factor >= 5,哈希表大小扩展为第一个大于等于ht[0].used * 22^n。。
  • 如果load_factor < 0.1,哈希表大小收缩为第一个大于等于ht[0].used2^n

3.3 ReHash

前面提高dict结构中维护这两个指向dictht的指针,ht[0]用于存储节点,ht[1]指向空哈希表。当哈希表进行扩展或收缩时,ht[1]会指向一个新分配的指定大小的哈希表,然后将ht[0]指向的哈希表中的所有节点重新计算哈希值和索引值将其存放到ht[1]指向的哈希表中,然后释放ht[0]的空间,然后ht[0]指向重新哈希的哈希表,ht[1]指向空哈希表,为下一次ReHash做准备。

3.4 渐进式ReHash

当哈希表中存放的节点数量过大时,如果一次性将所有的键值对全部ReHashht[1]的话,庞大的计算量会让服务器无法对外提供服务。为了避免ReHash对服务器性能的影响,服务器采用分多次、渐进式地将ht[0]里面的键值对慢慢地ReHashht[1]

步骤如下:

  • ht[1]分配空间,让字典同时拥有ht[0]ht[1]两个哈希表。
  • 设置rehashidx为0表示ReHash工作正式开始。
  • ReHash期间,每次操作都会附带将操作的数据从ht[0]ReHash到ht[1]rehashidx + 1
  • ht[0]中的所有节点ReHash到ht[1]后,rehashidx = -1

3.5 ReHash执行期间的哈希表操作

期间删除、查询和更新操作会在两个哈希表上进行。而插入操作只在ht[1]上进行。

4. API实现

4.1 创建新字典

dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    // 分配空间
    dict *d = zmalloc(sizeof(*d));
    // 字典初始化
    _dictInit(d,type,privDataPtr);

    return d;
}

int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    // 初始化两个哈希表的各项属性值,但暂时还不分配内存给哈希表数组
    _dictReset(&d->ht[0]);
    _dictReset(&d->ht[1]);

    // 设置类型特定函数
    d->type = type;

    // 设置私有数据
    d->privdata = privDataPtr;

    // 设置哈希表 rehash 状态
    d->rehashidx = -1;

    // 设置字典的安全迭代器数量
    d->iterators = 0;

    return DICT_OK;
}

static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

新建字典时需要传递字典类型和私有数据指针作为入参,新建的dictht[0]ht[1]都空哈希表,rehasdhidx设置-1,表示未进行ReHash

4.2 将给定的键值对添加到字典中

int dictAdd(dict *d, void *key, void *val)
{
    // 尝试添加键到字典,并返回包含了这个键的新哈希节点
    dictEntry *entry = dictAddRaw(d,key);

    // 键已存在,添加失败
    if (!entry) return DICT_ERR;

    // 键不存在,设置节点的值
    dictSetVal(d, entry, val);

    // 添加成功
    return DICT_OK;
}

// 尝试将键值添加到哈希表中,如果成功,返回对应的节点指针,反之返回NULL
dictEntry *dictAddRaw(dict *d, void *key)
{
    int index;
    dictEntry *entry;
    dictht *ht;

    // 如果条件允许的话,进行单步 rehash,即将当前键值计算得到的哈希值对应哈希表中桶中所有的节点进行ReHash
    if (dictIsRehashing(d)) _dictRehashStep(d);

    /* Get the index of the new element, or -1 if
     * the element already exists. */
    // 计算键在哈希表中的索引值
    // 如果值为 -1 ,那么表示键已经存在
    // T = O(N)
    if ((index = _dictKeyIndex(d, key)) == -1)
        return NULL;

    // T = O(1)
    /* Allocate the memory and store the new entry */
    // 如果字典正在 rehash ,那么将新键添加到 1 号哈希表
    // 否则,将新键添加到 0 号哈希表
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    // 为新节点分配空间
    entry = zmalloc(sizeof(*entry));
    // 将新节点插入到链表表头
    entry->next = ht->table[index];
    ht->table[index] = entry;
    // 更新哈希表已使用节点数量
    ht->used++;

    /* Set the hash entry fields. */
    // 设置新节点的键
    // T = O(1)
    dictSetKey(d, entry, key);

    return entry;
}

// 查看字典是否正在 rehash
#define dictIsRehashing(ht) ((ht)->rehashidx != -1)


/* 
 * 在字典不存在安全迭代器的情况下,对字典进行单步 rehash 。
 *
 * 字典有安全迭代器的情况下不能进行 rehash ,因为两种不同的迭代和修改操作可能会弄乱字典。
 *
 * 这个函数被多个通用的查找、更新操作调用,它可以让字典在被使用的同时进行 rehash 。
 */
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

步骤总结如下:

  • 如果当前在正在ReHash,将Key值对应的哈希表中的桶的所有节点ReHash到ht[1]
  • 判断该键值是否已经存在,如果存在返回NULL
  • 如果正在ReHash将新建的节点添加到ht[1]中,反之添加到ht[0]中。
  • Value值设置到对应的节点对象中。

4.3 更新键值

int dictReplace(dict *d, void *key, void *val)
{
    dictEntry *entry, auxentry;

    /* Try to add the element. If the key
     * does not exists dictAdd will suceed. */
    // 尝试直接将键值对添加到字典
    // 如果键 key 不存在的话,添加会成功
    // T = O(N)
    if (dictAdd(d, key, val) == DICT_OK)
        return 1;

    /* It already exists, get the entry */
    // 运行到这里,说明键 key 已经存在,那么找出包含这个 key 的节点
    entry = dictFind(d, key);
    // 先保存原有的值的指针
    auxentry = *entry;
    // 然后设置新的值
    // T = O(1)
    dictSetVal(d, entry, val);
    // 然后释放旧值
    // T = O(1)
    dictFreeVal(d, &auxentry);

    return 0;
}

替换指定的键对应的值,如果键不存在,则添加。

4.4 查询

dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    unsigned int h, idx, table;

    // 字典(的哈希表)为空
    if (d->ht[0].size == 0) return NULL; /* We don't have a table at all */

    // 如果条件允许的话,进行单步 rehash
    if (dictIsRehashing(d)) _dictRehashStep(d);

    // 计算键的哈希值
    h = dictHashKey(d, key);
    // 在字典的哈希表中查找这个键
    // T = O(1)
    for (table = 0; table <= 1; table++) {

        // 计算索引值
        idx = h & d->ht[table].sizemask;

        // 遍历给定索引上的链表的所有节点,查找 key
        he = d->ht[table].table[idx];
        // T = O(1)
        while(he) {

            if (dictCompareKeys(d, key, he->key))
                return he;

            he = he->next;
        }

        // 如果程序遍历完 0 号哈希表,仍然没找到指定的键的节点
        // 那么程序会检查字典是否在进行 rehash ,
        // 然后才决定是直接返回 NULL ,还是继续查找 1 号哈希表
        if (!dictIsRehashing(d)) return NULL;
    }

    // 进行到这里时,说明两个哈希表都没找到
    return NULL;
}

4.5 获取指定键的值

void *dictFetchValue(dict *d, const void *key) {
    dictEntry *he;

    // T = O(1)
    he = dictFind(d,key);

    return he ? dictGetVal(he) : NULL;
}

4.6 删除给定的键值

int dictDelete(dict *ht, const void *key) {
    return dictGenericDelete(ht,key,0);
}

static int dictGenericDelete(dict *d, const void *key, int nofree)
{
    unsigned int h, idx;
    dictEntry *he, *prevHe;
    int table;

    // 字典(的哈希表)为空
    if (d->ht[0].size == 0) return DICT_ERR; /* d->ht[0].table is NULL */

    // 进行单步 rehash ,T = O(1)
    if (dictIsRehashing(d)) _dictRehashStep(d);

    // 计算哈希值
    h = dictHashKey(d, key);

    // 遍历哈希表
    // T = O(1)
    for (table = 0; table <= 1; table++) {

        // 计算索引值 
        idx = h & d->ht[table].sizemask;
        // 指向该索引上的链表
        he = d->ht[table].table[idx];
        prevHe = NULL;
        // 遍历链表上的所有节点
        // T = O(1)
        while(he) {
        
            if (dictCompareKeys(d, key, he->key)) {
                // 超找目标节点

                /* Unlink the element from the list */
                // 从链表中删除
                if (prevHe)
                    prevHe->next = he->next;
                else
                    d->ht[table].table[idx] = he->next;

                // 释放调用键和值的释放函数?
                if (!nofree) {
                    dictFreeKey(d, he);
                    dictFreeVal(d, he);
                }
                
                // 释放节点本身
                zfree(he);

                // 更新已使用节点数量
                d->ht[table].used--;

                // 返回已找到信号
                return DICT_OK;
            }

            prevHe = he;
            he = he->next;
        }

        // 如果执行到这里,说明在 0 号哈希表中找不到给定键
        // 那么根据字典是否正在进行 rehash ,决定要不要查找 1 号哈希表
        if (!dictIsRehashing(d)) break;
    }

    // 没找到
    return DICT_ERR; /* not found */
}

4.7 释放给定的字典

void dictRelease(dict *d)
{
    // 删除并清空两个哈希表
    _dictClear(d,&d->ht[0],NULL);
    _dictClear(d,&d->ht[1],NULL);
    // 释放节点结构
    zfree(d);
}

5. 最后

关于Redis字典还有一些知识点没有提到,就是关于Scan的实现,这部分内容比较复杂,后面会在单独在一篇文章中讨论。