[置顶] Redis源码阅读笔记(3)– 字典dict

字典是Redis的一种非常重要的底层数据结构,其应用非常广泛。Redis的数据库就是使用字典作为底层实现的,对数据库的增删查改也都构建在对字典的操作之上;字典也是hash键的底层实现之一,当一个哈希键包含的键值对比较多时,或者键值对中的元素都是比较长的字符串时,redis就会使用字典作为底层实现;此外,redis还使用dict和skiplist共同维护一个sorted set。

dict本质上是为了解决算法中的查找问题(Searching),一般查找问题的解法分为两个大类:一个是基于各种平衡树,一个是基于哈希表。我们平常使用的各种Map或dictionary,大都是基于哈希表实现的。在不要求数据有序存储,且能保持较低的哈希值冲突概率的前提下,基于哈希表的查找性能能做到非常高效,接近O(1),而且实现简单。

数据结构

我们先来看看dict的数据结构,有四部分组成:dict、dictht、dictType和dictEntry–与字典有关的源代码都在dict.c/dict.h中。

哈希表–dictht

typedef struct dictht {
    // 哈希表数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;
    // 该哈希表已有节点的数量
    unsigned long used;
} dictht;

其中,table属性是一个数组,数组中的每一个元素都是一个指向哈希表节点的指针,而每一个哈希表节点都保存着一个键值对。

哈希表节点–dictEntry

typedef struct dictEntry {
    void *key;    // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;   // 值
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

哈希表节点使用dictEntry结构表示,每一个dictEntry结构都保存着一个键值对。其中需要注意的是,next属性是指向另外一个哈希表节点的指针,通过这样一个指针,可以将索引值相同的键值对连接在一起,以此解决键冲突的问题。

字典–dict

typedef struct dict {
    dictType *type; 
    void *privdata; // 私有数据,由调用者在创建dict的时候传进来
    dictht ht[2];  
    long rehashidx; 
    int iterators; // 当前正在使用的迭代器数量,暂不讨论,忽略
} dict;

其中,a、type是一个指向dictType结构的指针,保存一些用于操作特定类型键值对的函数;b、ht[2], 一个字典结构包括两个哈希表,只有在重哈希的过程中,ht[0]和ht[1]才都有效。而在平常情况下,只有ht[0]有效,ht[1]里面没有任何数据;c、rehashidx属性,当前重哈希索引,如果rehashidx = -1,表示当前没有在重哈希过程中;否则,表示当前正在进行重哈希,且它的值记录当前需要rehash的索引值

字典类型特定函数–dictType

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;

来一张普通状态下(没有rehash)的字典结构图,帮助大家理解

图一

hash算法

将一个新的键值对添加到字典里面时,我们需要先计算这个键值对构成的哈希表节点在哈希表数组中的位置(即索引值),程序先根据键计算出哈希值和索引值,再根据索引值,将键值对构成的哈希表节点放到哈希表数组相应的索引上。
hash值和索引值得计算方法:
使用字典设置的hash函数,计算key的哈希值

//计算hash值
hash = dict->type->hashFunction(key)     
//使用哈希表的sizemask属性和哈希值,计算索引值
//根据情况的不同,ht[x]可能为ht[0]或者ht[1]
index = hash & d->ht[x].sizemask;

每个key先经过hashFunction计算得到一个哈希值,然后计算(哈希值 & sizemask)得到在table上的位置。相当于计算取余(哈希值 % size)(当size不为0时)。

键冲突

当两个或以上数量的键分配到哈希表数组的同一个索引上时,我们称这些键发生了冲突。Redis中的hash表使用链地址法来解决键冲突的问题,每一个哈希表节点都有一个next指针,被分配到同一索引的多个哈希表节点使用next指针构成一条单向链表,这样便解决了键冲突的问题。
因为dictEntry节点组成的链表没有指向链表末尾的指针,所以为了速度考虑,总是将新节点添加到链表的表头位置,排在其他已有节点之前。


图二:一个包含两个键值对的哈希表

图三:使用链地址法解决k1和k2键冲突

rehash算法

rehash指的是重新计算键的hash值和索引值,然后将键值对放到ht[1]哈希表指定的位置上。
首先指出一个概念:哈希表的负载因子-load_factor
load_factor = ht[0].used / ht[0].size
在操作哈希表的过程中,表中的键值对会增多或者减少。我们知道hash表采用链地址法解决键冲突的问题,那么当键值对增多时,势必会造成冲突链表越来越长,导致hash表查询效率的下降,这样就需要对hash表进行扩容;另外,当hash表的键值对过少时,就需要对hash表收缩以节省内存。扩容和收缩操作就需要通过rehash实现。
Redis对hash表执行rehash的步骤如下:
1)为字典的ht[1]哈希表分配内存空间,大小取决于要执行的操作和ht[0]哈希表中键值对的数量(即ht[0].used属性的值);

  • 若负载因子大于5时,执行的是扩容操作,ht[1]哈希表的大小为第一个大于等于ht[0].used*2的2的n次方;
  • 负载因子小于0.1时,执行的收缩操作,ht[1]哈希表的大小为第一个大于等于ht[0].used的2的n次方;

2)将保存在ht[0]中的所有键值对rehash到hr[1]上面;
3)当ht[0]包含的多有键值对全部都迁移至ht[1]之后(ht[0]变为空表),释放ht[0],并将ht[1]置为ht[0]、在ht[1]新创建一个空白的哈希表,为下一次rehash做准备;
rehash算法源码:

//执行 N 步渐进式 rehash 。
//返回 1 表示仍有键需要从 0 号哈希表移动到 1 号哈希表,
// 返回 0 则表示所有键都已经迁移完毕。
//注意,每步 rehash 都是以一个哈希表索引(桶)作为单位的,一个桶里可能会有多个节点,被 rehash 的桶里的所有节点都会被移动到新哈希表。
int dictRehash(dict *d, int n) {
    // 只可以在 rehash 进行中时执行
    if (!dictIsRehashing(d)) return 0;
    // 进行 N 步迁移
    // T = O(N)
    while(n--) {
        dictEntry *de, *nextde;
        // 如果 0 号哈希表为空,那么表示 rehash 执行完毕
        // T = O(1)
        if (d->ht[0].used == 0) {
            // 释放 0 号哈希表
            zfree(d->ht[0].table);
            // 将原来的 1 号哈希表设置为新的 0 号哈希表
            d->ht[0] = d->ht[1];
            // 重置旧的 1 号哈希表
            _dictReset(&d->ht[1]);
            // 关闭 rehash 标识
            d->rehashidx = -1;
            // 返回 0 ,向调用者表示 rehash 已经完成
            return 0;
        }
        // 确保 rehashidx 没有越界
        assert(d->ht[0].size > (unsigned)d->rehashidx);
        // 略过数组中为空的索引,找到下一个非空索引
        while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
        // 指向该索引的链表表头节点
        de = d->ht[0].table[d->rehashidx];
        // 将链表中的所有节点迁移到新哈希表
        // T = O(1)
        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;
        // 更新 rehash 索引
        d->rehashidx++;
    }
    return 1;
}

Redis中rehash的操作不是一次完成,而是渐进式完成,每次只移动若干个索引下的键值对到新表(在ht[0]中采用rehashidx参数来记录当前需要rehash的索引值)。为此,Redis提供了两种渐进式的操作来进行rehash。
1)按索引值,每一次只移动一个索引值下面的键值对到行的hash表中;

// 在执行查询和更新操作时,如果符合rehash条件就会触发一次rehash操作,每次执行一步
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

2)按照时间,每次执行一段固定的时间

// 获取当前的时间戳(以毫秒为单位)
long long timeInMilliseconds(void) {
    struct timeval tv;
    gettimeofday(&tv,NULL);
    return (((long long)tv.tv_sec)*1000)+(tv.tv_usec/1000);
}
// rehash操作每次执行ms时间就退出
int dictRehashMilliseconds(dict *d, int ms) {
    long long start = timeInMilliseconds();
    int rehashes = 0;
    while(dictRehash(d,100)) {  // 每次执行100步
        rehashes += 100;
        if (timeInMilliseconds()-start > ms) break; // 如果时间超过ms就退出
    }
    return rehashes;
}

rehash算法就讲到这里,接下去要分析的大家应该可以想到,对了,就是字典的创建、插入、查找和删除。

dict的操作

字典的创建–dictCreate()

//创建一个新的字典
dict *dictCreate(dictType *type,void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));
    _dictInit(d,type,privDataPtr);
    return d;
}
//初始化hash表
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;
}

字典的查找–dictFind()

字典中包含键 key 的节点,找到返回节点,找不到返回 NULL, T = O(1)

dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    unsigned int h, idx, table;
    // 字典(的哈希表)为空
    if (d->ht[0].size == 0) return NULL;
    // 如果条件允许的话,进行单步 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;
}

dict的插入–dictAdd()和dictReplace()

dictAdd()插入新的一对key和value,如果key已经存在,则插入失败。
dictReplace()也是插入一对key和value,不过在key存在的时候,它会更新value。
添加键值对时,还需要考虑的是字典是否在rehash:

  • 如果此时没有进行rehash操作,直接计算出索引添加到ht[0]中
  • 如果此刻正在进行rehash操作,则根据ht[1]的参数计算出索引值,添加到ht[1]中
//尝试将给定键值对添加到字典中
//只有给定键 key 不存在于字典时,添加操作才会成功
//添加成功返回 DICT_OK ,失败返回 DICT_ERR
//最坏 T = O(N) ,平均 O(1) 
int dictAdd(dict *d, void *key, void *val)
{
    // 尝试添加键到字典,并返回包含了这个键的新哈希节点,T = O(N)
    dictEntry *entry = dictAddRaw(d,key);
    // 键已存在,添加失败
    if (!entry) return DICT_ERR;
    // 键不存在,设置节点的值,T = O(1)
    dictSetVal(d, entry, val);
    // 添加成功
    return DICT_OK;
}
/*
 * 如果键已经在字典存在,那么返回 NULL
 * 如果键不存在,那么程序创建新的哈希节点,
 * 将节点和键关联,并插入到字典,然后返回节点本身。
 * T = O(N)
 */
 dictEntry *dictAddRaw(dict *d, void *key)
{
    int index;
    dictEntry *entry;
    dictht *ht;
    // 如果条件允许的话,进行单步 rehash,T = O(1)
    if (dictIsRehashing(d)) _dictRehashStep(d);
    // 计算键在哈希表中的索引值
    // 如果值为 -1 ,那么表示键已经存在,T = O(N)
    if ((index = _dictKeyIndex(d, key)) == -1)
        return NULL;
    // 如果字典正在 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++;
    // 设置新节点的键
    // T = O(1)
    dictSetKey(d, entry, key);
    return entry;
}
/* 
 * 将给定的键值对添加到字典中,如果键已经存在,那么删除旧有的键值对。
 * 如果键值对为全新添加,那么返回 1 。
 * 如果键值对是通过对原有的键值对更新得来的,那么返回 0 。
 * T = O(N)
 */
 int dictReplace(dict *d, void *key, void *val)
{
    dictEntry *entry, auxentry;
    // 尝试直接将键值对添加到字典
    // 如果键 key 不存在的话,添加会成功,T = O(N)
    if (dictAdd(d, key, val) == DICT_OK)
        return 1;
    // 运行到这里,说明键 key 已经存在,那么找出包含这个 key 的节点T = O(1)
    entry = dictFind(d, key);
    // 先保存原有的值的指针
    auxentry = *entry;
    // 然后设置新的值,T = O(1)
    dictSetVal(d, entry, val);
    // 然后释放旧值,T = O(1)
    dictFreeVal(d, &auxentry);
    return 0;
}
//_dictKeyIndex():在dict中寻找插入位置,如果不在rehash过程中,它只查找ht[0];否则查找ht[0]和ht[1];
//_dictKeyIndex()可能触发dict内存扩展--_dictExpandIfNeeded(),详见dict.c中源码)。
static int _dictKeyIndex(dict *d, const void *key)
{
    unsigned int h, idx, table;
    dictEntry *he;
    // 单步 rehash
    if (_dictExpandIfNeeded(d) == DICT_ERR)
        return -1;
    // 计算 key 的哈希值
    h = dictHashKey(d, key);
    for (table = 0; table <= 1; table++) {
        // 计算索引值
        idx = h & d->ht[table].sizemask;
        // 查找 key 是否存在
        he = d->ht[table].table[idx];
        while(he) {
            if (dictCompareKeys(d, key, he->key))
                return -1;
            he = he->next;
        }
        // 如果运行到这里时,说明 0 号哈希表中所有节点都不包含 key
        // 如果这时 rehahs 正在进行,那么继续对 1 号哈希表进行 rehash
        if (!dictIsRehashing(d)) break;
    }
    // 返回索引值
    return idx;
}

dict的删除

dict的删除就交给大家自己去分析了,跟简单的,有问题可以回帖,大家一起讨论,加油!

IT文库 » [置顶] Redis源码阅读笔记(3)– 字典dict
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址