Redis源码阅读【5-字典】

Redis源码阅读【1-简单动态字符串】
Redis源码阅读【2-跳跃表】
Redis源码阅读【3-Redis编译与GDB调试】
Redis源码阅读【4-压缩列表】
Redis源码阅读【5-字典】
Redis源码阅读【6-整数集合】
Redis源码阅读【7-quicklist】
Redis源码阅读【8-命令处理生命周期-1】
Redis源码阅读【8-命令处理生命周期-2】
Redis源码阅读【8-命令处理生命周期-3】
Redis源码阅读【8-命令处理生命周期-4】
Redis源码阅读【番外篇-Redis的多线程】
Redis源码阅读【9-持久化】
Redis源码阅读【10-事务与Lua】
建议搭配源码阅读源码地址

在前面介绍完Redis的【动态字符串sds】,以空间换时间的【跳跃表】,和极限压缩内存使用的【压缩列表】后,现在介绍Redis下一个重量级数据结构——【字典】,这一章对于许多读过Java-JDK中HashMap或者HashTable源码的同学来说应该并不陌生,甚至相似度很高。同时没读过HashMap或者HashTable的同学,希望看完这章后续对你理解HashMap或者HashTable有所帮助

1、什么是字典

字典又叫做散列表,是用来存储健值对(key-value)的一种数据结构,基本上在许多高级语言中都有这样一种结构,而Redis作为一个几乎是使用key-value来存储的内存形数据库,必然是非常重要的,对Redis数据库进行任何的增,删,改,查,实际就是对字典的增,删,改,查。但是C语言本身没有散列这一种数据结构,所以Redis的实现了自己的字典。

在开始了解字典之前,我们先了解一下,Redis具有的特点:

1、可以存储海量数据,健值是映射关系,可以在O(1)的时间复杂度取出或者插入关联值
2、健值对中健的类型可以是字符串,整型,浮点型等,且健是唯一的。
3、健值对中值的类型可以为String,Hash,List,Set,SortedSet

根据上面三个特征,我们想一下在C语言中,【字典】这个数据结构我们要怎么实现呢?都需要哪些字段呢?Redis的底层是C语言写的,要实现第一个特征:可以存储海量数据,所以此时我们需要一个根节点,或者一个数据节点,我们称为data,用于存储海量数据的内存地址。
但是现在又引出另一个难题了,海量数据如何保证以相近O(1)的时间复杂度去操作数据呢?我们前面使用的【跳跃表】也只是几乎接近O(logN),而且跳表过于浪费空间不适合作为数据库的主要数据结构,那么我们想,曾经有什么样的数据结构能达到O(1)的时间复杂度呢?这个时候我们想起了一个数据结构:【数组】

1.1、数组

有限个类型相同的对象的集合称为数组。组成数据的各个对象称为数组的元素,用于区分数据的各个元素的数字编号称为数组下标。元素的类型可为:数值,字符,指针,结构体等等。其中指针和结构体类型就保证了,我们的字典的value可以是多种类型的数据结构,同时数组是连续的一段内存空间,通过偏移量就能在O(1)时间复杂度内找到相应的元素,假设一个int类型并且长度为10的数组,内存结构如下图:
在这里插入图片描述
当需要对数组a中的元素进行操作时,C语言需通过下标找到其对应的内存地址,然后才能对这块内存进行相应的操作。例如,读取 a[9] 的值,在C语言中是会转换成 *(a+9) 的形式,a[9] 和 *(a+9)是等价的,也就是说,要得到a[9]的地址,可以通过对数组a的首地址偏移9个元素就行。

当一个数组中的数据非常海量的时候,通过头指针 + 偏移量的方式也能以O(1)的时间复杂度定位到数据所在的内存地址,然后进行操作,这也满足了我们前面提到的第一个条件,以O(1)的时间复杂度操作数据

2、字典的结构

通过前面对字典特征的定义和数组的介绍,我们大概能先得到一个大概的Redis数组结构的示意图如下:
在这里插入图片描述
这个结构看上去很完美,没毛病,但是有一个严重的问题就是:我们存储的key是字符串,而数组的下标却是有序的数字索引,我们怎么样才能将不规则的key匹配到这个数组中去呢?

2.1、Hash函数

Hash称之位散列,做用是把任意长度的输入通过Hash散列算法转换成固定类型,固定长度的散列值,换句话说,Hash函数可以把不同健转换成唯一的整型数据。散列函数一般有以下特征:

	1、相同的输入经Hash计算后得出相同的输出
	2、不同的输入经Hash计算后一般得出不同的值,但也可能相同

所以,好的Hash算法是经过Hash计算后得出的值具有强随机分布性。这里Redis使用的是times33散列算法 ,其核心算法是:hash(i)=hash(i-1)*33+str[i]

注:与Java中JDKHash算法不同,HashMap是使用HashCode的高16位和低16位异或的出来的

hash的实现代码如下(dict.c:53):

static unsigned int dictGenHashFunction(const unsigned char *buf, int len) {
    unsigned int hash = 5381;

    while (len--)
        hash = ((hash << 5) + hash) + (*buf++); /* hash * 33 + c */
    return hash;
}

dictGenHashFunction 函数的主要做用是,入参是任意长度的字符串,通过Hash计算后返回无符号整数。因此,我们可以通过Hash函数,将任意输入的键转换为整型数据,使其可以当作数组的下标使用。但是,如果直接使用Hash值作为数组下标也不合理,hash本身可以是一个long类型的整数,未免太大了,或者我们无法出类似于如此大的数组,去存储。于是,redis这里使用了一种方式:下标取余,通过使用hash % 数组长度来得出数组的下标。最终结构如下图:

2.2、Hash冲突

由前面得知,Hash函数是根据Hash算法算出来的,Hash本身是可能产生冲突的,即:多个不同的输入具有相同的输出,也有可能是取余操作的时候导致的hash冲突。hash冲突的本质就是,不同的key,具有相同的数组下标。
为了解决Hash冲突,所以数组中的元素除了应把健值对中的存储外,还应该存储一个next指针,next指针可以把冲突的键值对串成一个单链表,信息用于判断是否为当前要查找的键。此时数组中元素的字段也明确了,字典数据结构示意图如下图:
在这里插入图片描述
当根据键去找值时,分如下几步:

第1步:键通过Hash,取余等操作得到索引值,根据索引值找到对应元素。
第2步:判断元素中键与查找的值是否相等,相等则读取元素中的值返回,否则判断next指针是否有值,如有值则继续取next值,直到找到或者没找到对应的键,没找到返回null

3、Redis字典

Redis的字典也是通过Hash函数来实现的,由于Redis需要考虑的因素更多,所以字典的实现会比上面的更加复杂,但是原理是一样的。Redis的字典实现主要分为三个部分:字典Hash表Hash表节点,字典嵌入了两个Hash表,Hash表中的table字段存放着Hash表节点,Hash表节点对应存储健值对。

3.1、Hash表

Hash表结构,与前面介绍的结构类似,在Redis源码中取名为Hash表,其数据结构如下:

//Redis 字典 Hash表的定义
typedef struct dictht {
    dictEntry **table;     //指针数组,用于存储键值对
    unsigned long size;    //table数组大小
    unsigned long sizemask;//掩码 = size - 1
    unsigned long used;    //table数组已存元素个数,包含next单链表的数据
} dictht;

Hash表的结构整体占用32个字节,其中table字段是数组,做用是存储健值对,该数组中的元素指向的是dictEntry的结构体,每个dictEntry里面存有健值对。size表示table数组的总大小。used字段记录着table数组以存健值对个数

sizemask字段用来计算键的索引值,sizemask的值恒等于size -1。我们知道,索引值是键Hash值与数组总容量取余之后的值,而Redis为提高性能对这个计算进行了优化,具体计算步骤如下:

第1步:人为设定Hash表的数组容量初始值为4,随着健值对存储量的增加,就需对Hash表扩容,新扩容的容量大小设定为当前容量大小的一倍,也就是说,Hash表的容量大小只能为4,8,16,32…。而sizemask掩码值就只能为3,7,15,31…,对应的二进制为11,111,1111,11111…,因此掩码值的二进制肯定是每一位都为1。(这里其实有点像HashMap的位移)
第2步:索引值 = Hash值 & 掩码值,对应Redis源码为:idx = hash & d->ht[table].sizemask,其计算结果等同Hash值与Hash表容量取余,而计算机的位运算要比取余运算快的多。

结构如下图:
在这里插入图片描述

3.2、Hash表节点

Hash表中的元素是用dictEntry结构体来封装的,主要作用是存储健值对,具体结构如下:

//Hash表元素结构体
typedef struct dictEntry {
    void *key;      //存储键
    union {
        void *val;    //db.dict 中的val
        uint64_t u64; 
        int64_t s64;  //db.expires 中存储过期时间
        double d;
    } v;              // 值,是个联合体
    struct dictEntry *next; //用于解决冲突的next指针
} dictEntry;

Hash表中元素结构体和我们前面定义的元素结构体类似,整体占24字节,key字端存储的是键值对中的键。v联合体存储的是健值对中的值,在不同场景下使用不同字段。例如,用字典存储整个Redis数据库所有的健值对时,用的是*val字段,可以指向不同类型的值,再比如,字典被用记录键的过期时间时,用的是s64字段存储,当出现Hash冲突时,next字段用来指向冲突的元素,通过头插法,形成单链表。如下图:
在这里插入图片描述

3.3、字典结构

Redis字典实现除了包含前面介绍的两个结构体Hash表以及Hash表节点外,还在最外面层封装了一个叫字典的结构体。其主要作用是对散列表再进行一层封装,当字典需要进行一些特殊操作时要用到里面的辅助字段。具体结构如下:

//字典结构体的定义
typedef struct dict {
    dictType *type;  //该字典对应的特定操作函数
    void *privdata;  //该字典依赖的数据
    dictht ht[2];   //Hash表,健值对存储在这里,(为什么是两个呢?这个和rehash有关)
    long rehashidx; //用来标记字典当前是否在rehash,存储的值代表当前rehash的位置,-1表示当前没有进行rehash
    unsigned long iterators; //当前运行的迭代器数量 
} dict;

字典整体结构占用96字节,其中type指向dictType结构体,dictType定义如下:

//该字典对应的特定操作函数
typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);               //该字典对应的Hash函数
    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;

Redis字典这个数据结构,除了主数据库的K-V数据存储外,还有很多其他地方会用到。例如,Redis的哨兵模式,就使用字典管理所有master节点以及slave节点,再如数据库中健值对的值为Hash类型时,存储这个Hash类型的值也是用字典。在不同的应用中,字典中的健值形态可能不同,而dictType结构体,则是为了实现各种形态的字典而抽象出来的一组操作函数。

1、[privdata 字段]: 私有数据,配合type字段指向的函数一起使用。
2、[ht 字段]:是个大小为2的数组,该数组存储的元素类型为dictht,虽然有两个元素,但一般情况下只会使用ht[0],只有当字典扩容,缩容需要进行rehash的时候,才会使用ht[1]。
3、[rehashidx字段]:用来标记字典是否正在rehash,没进行rehash的时,值为-1,否则该值用来表示Hash表ht[0]执行rehash到了哪个元素,并记录该元素的数组下标值 
4、[iterators字段]:用来记录当前运行的安全迭代器数,当有安全迭代器绑定到该字典时,会暂停rehash操作。Redis很多场景下都会用到迭代器,例如:执行keys命令会创建一个安全的迭代器,此时iterators会加1,命令执行完时候数量会-1,而执行sort命令时会创建普通迭代器,该字段不会改变

完整的字典结构如下图:
在这里插入图片描述

4、Redis字典操作

前面介绍了字典原理,Hash表以及字典的结构,那么单纯了解结构是不够的,我们也知道Redis是一个支持 增,删,改,查 的数据库,那么下面我们将介绍Redis的字典生成销毁以及操作。

4.1、字典初始化

在redis-serve启动中,整个数据库会先初始化一个空的字典用于存储整个数据库的健值对。初始化一个空字典,调用的是dict.h中的dictCreate函数,实现如下:

dict *dictCreate(dictType *type,void *privDataPtr){
    dict *d = zmalloc(sizeof(*d)); //分配96字节
    _dictInit(d,type,privDataPtr); //初始化结构体
    return d;
}
int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    _dictReset(&d->ht[0]); //初始化 ht[0]
    _dictReset(&d->ht[1]); //初始化 ht[1]
    d->type = type;  //该字典对应的特定操作函数
    d->privdata = privDataPtr; //初始化该字典依赖的数据
    d->rehashidx = -1; //设置rehash值
    d->iterators = 0; //设置迭代器数量
    return DICT_OK;
}
static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

dictCreate函数初始化一个空字典的主要步骤为:申请空间调用_dictInit给字典的各个字段赋值。初始化后字段的如下图所示
在这里插入图片描述

4.2、添加元素

redis-server启动完毕后,再启动redis-cli连上Server,执行命令 set k1 v1 ,最终会执行到setKey(redisDb *db,robj *key,robj *val) 函数,前文介绍字典的特性时提到过,每个键必须是唯一的,所以元素添加需要经过以下几步来完成:先判断该键是否存在,存在则执行修改,否则添加健值对。而setKey函数的主要逻辑也是如此,其主要流程如下。

第一步:调用dictFind函数查找键是否存在,是则调用dbOverwrite函数修改键值对,否则调用dbAdd函数添加元素
第二步:dbAdd 最终调用dict.h文件中的dictAdd函数插入键值对。

dictAdd函数的插入代码如下:

//字典添加元素(在这个函数调用之前已经调用了dictFind)
int dictAdd(dict *d, void *key, void *val)
{
    dictEntry *entry = dictAddRaw(d,key,NULL); //创建待添加的元素

    if (!entry) return DICT_ERR;
    dictSetVal(d, entry, val); //设置值
    return DICT_OK;
}

完整代码:

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;
    
    //判断当前是否正在rehash,如果是调用一次rehash
    if (dictIsRehashing(d)) _dictRehashStep(d);

	//定位对应的table index
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1) 
     	//查找键,找到则直接返回-1,并把老节点存入existing字段,否则把新节点的索引值返回。如果遇到Hash表容量不足,则进行扩容
        return NULL;
        
    //判断是否rehash 如果是插入 ht[1] 否则插入 ht[0]  ,因为上面结束后可能rehash已经结束
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; 
    
    entry = zmalloc(sizeof(*entry)); //分配空间
    entry->next = ht->table[index]; 
    ht->table[index] = entry;
    ht->used++;

    dictSetKey(d, entry, key); //设置键
    return entry;
}

//查找键,找到则直接返回-1,并把老节点存入existing字段,否则把新节点的索引值返回。如果遇到Hash表容量不足,则进行扩容
static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing)
{
    unsigned long idx, table;
    dictEntry *he;
    if (existing) *existing = NULL;
	//判断是否需要拓展Hash表
    if (_dictExpandIfNeeded(d) == DICT_ERR)
        return -1;
    for (table = 0; table <= 1; table++) {
    	//定位到相应hash表的index
        idx = hash & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        while(he) {
        	//判断是否找到已经存在的键
            if (key==he->key || dictCompareKeys(d, key, he->key)) {
            	//将已经存在的键存储在existing中
                if (existing) *existing = he;
                return -1;
            }
            he = he->next;
        }
        //如果正在rehash 还要取 ht[1]里面去找
        if (!dictIsRehashing(d)) break;
    }
    return idx;
}

//拓展Hash表
static int _dictExpandIfNeeded(dict *d)
{
    if (dictIsRehashing(d)) return DICT_OK;

    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize ||
         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
    {
        return dictExpand(d, d->ht[0].used*2);
    }
    return DICT_OK;
}

//拓展或者创建hash表
int dictExpand(dict *d, unsigned long size){
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

    dictht n; 
    //重新计算扩容后的值,必须为2的幂
    unsigned long realsize = _dictNextPower(size);

    if (realsize == d->ht[0].size) return DICT_ERR;

    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
    
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }

    d->ht[1] = n;
    d->rehashidx = 0; //刚刚开始扩容设置为0
    return DICT_OK;
}

//调用rehash入口
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

//字典rehash
int dictRehash(dict *d, int n) {
	//当前rehash的最大数量 buckets 为单位 每个buckets rehash 10个
    int empty_visits = n*10;  
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;
        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];      
        while(de) {
            uint64_t h;

            nextde = de->next;       
            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++;
    }

    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;
    }

    return 1;
}

dictAddRaw 函数主要作用是添加或查找键,添加成功返回新节点,查找成功返回NULL并把老节点存入existing 字段。该函数中比较核心的是调用 _dictKeyIndex 函数,作用是得到键的索引值,索引值获取与前文介绍的函数类似,主要有这么两步:

//第一步:调用该字典的Hash函数得到键的Hash值
dictHashKey(d,key)
//第二步:用健的Hash值与字典掩码取与,得到索引值
idx = hash & d->ht[table].sizemask;

dictAddRaw函数拿到键的索引值后则可直接定位健值对要存入的位置,新创建一个节点存入即可。

4.2.1、字典扩容

上面的代码也列出来,当添加元素的时候,当元素达到容量的上限时或者容量不足的时候,字典本身是有可能会进行扩容的,扩容的代码就是上面的 dictExpand

//拓展或者创建hash表
int dictExpand(dict *d, unsigned long size){
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

    dictht n; 
    //重新计算扩容后的值,必须为2的幂
    unsigned long realsize = _dictNextPower(size);

    if (realsize == d->ht[0].size) return DICT_ERR;

    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
    
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }

    d->ht[1] = n; //扩容后新内存放入到 ht[1]中
    d->rehashidx = 0; //刚刚开始扩容设置为0
    return DICT_OK;
}

扩容的主要流程为:

1、申请一块新内存,默认大小为4,每次扩展一倍
2、把新申请的内存地址赋值给ht[1],并把rehashidx 设置为 0

扩容后,字典容量及掩码都会发生改变,同一个键与掩码经过位运算后得到的索引值就会发生改变,从而导致根据健查找不到值的情况。解决这个问题的办法是,新扩容的Hash表为ht[1],原本ht[0]的数据不受到影响,直到ht[0]的数据都被迁移一份到ht[1]中后,才切换为扩容后的表。

4.2.2、渐进式rehash

rehash除了扩容时会触发,缩容时也会触发。Redis整个rehash的实现,主要分为以下几步完成。

1、给Hash表ht[1]申请足够的空间,扩容时空间大小为当前容量*2,当使用空间不足10%时,进行缩容,缩容的大小为,刚好为能容纳used*2的整数

2、进行rehash操作调用的是dictRehash函数,重新计算ht[0]中每个健的Hash值与索引值,并且依次添加到ht[1]中,并把旧值删除,把字典rehashidx修改为ht[0]正在rehash操作节点的索引值

3、rehash操作后,情况ht[0],然后对调ht[1]和ht[0]的索引,并把rehashidx值设置为*-1	

我们知道,Redis为了提供高性能的线上服务,而且是单进程模式,当数据库中的数据达到百万,千万,亿级别的时候,整个rehash的过程会非常缓慢,这里redis用了一个巧妙的方式,利用分治的思想进行rehash操作,大致步骤如下:

执行 插入 删除 查找 修改 等操作前,都判断一下当前字典是否正在进行 rehash ,如果是则调用dictRehashStep 函数进行 rehash 操作(每次只对一个节点进行 rehash 操作,共执行1次)。除了这种情况外,当服务器空闲的时候,也会进行 rehash 操作,则会调用incrementallyRehash函数进行批量rehash操作(每次对100个节点进行rehash,共耗时1毫秒)。再经历N次rehash后,整个ht[0]的数据都会迁移到ht[1]中。

dictRehashStep 代码如下:

static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

//字典rehash
int dictRehash(dict *d, int n) {
	//当前rehash的最大数量 buckets 为单位 每个buckets rehash 10个
    int empty_visits = n*10;  
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;
        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];      
        while(de) {
            uint64_t h;

            nextde = de->next;       
            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++;
    }

    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;
    }

    return 1;
}

incrementallyRehash 代码如下:

//批量rehash
int incrementallyRehash(int dbid) {
    if (dictIsRehashing(server.db[dbid].dict)) {
        dictRehashMilliseconds(server.db[dbid].dict,1);
        return 1;
    }
  
    if (dictIsRehashing(server.db[dbid].expires)) {
        dictRehashMilliseconds(server.db[dbid].expires,1);
        return 1; 
    }
    return 0;
}

// d 是字典 ms 是期望耗时
int dictRehashMilliseconds(dict *d, int ms) {
    long long start = timeInMilliseconds(); //获取当前时间
    int rehashes = 0;

    while(dictRehash(d,100)) {
        rehashes += 100;
        //超时就结束
        if (timeInMilliseconds()-start > ms) break;
    }
    return rehashes;
}

4.3 、查找元素

查找元素例如使用 get 指令的时候,Server最终回去调用dict.h文件中的dictFind函数,源码如下:

//查找元素
dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    uint64_t h, idx, table;

    if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
    if (dictIsRehashing(d)) _dictRehashStep(d); //判断是否正在rehash 如果是调用rehash
    h = dictHashKey(d, key); // 获取键对应的hash值
    for (table = 0; table <= 1; table++) { //在 ht[0] 和 ht[1] 中查找
        idx = h & d->ht[table].sizemask; //找到对应的hash 表的index
        he = d->ht[table].table[idx];
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key))
                //查找到键直接返回
                return he;
            he = he->next;
        }
        //如果没有在rehash直接结束
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}

查找键的过程比较简单,过程基本如下:

1、根据键调用Hash函数取得相应的hash值
2、根据Hash值取到索引值
3、遍历字典的两个Hash表,读取索引对应的元素
4、遍历该元素单链表,如找到了与自身键匹配的键,则返回该元素
5、找不到返回NULL

4.4、修改元素

Server收到set 命令后,会查询键是否存在,如果已经存在会调用db.c文件中的dbOverwrite函数,其源码如下:

//更新数据
void dbOverwrite(redisDb *db, robj *key, robj *val) {
    dictEntry *de = dictFind(db->dict,key->ptr); //查找该元素

    serverAssertWithInfo(NULL,key,de != NULL); //不存在则中断执行
    dictEntry auxentry = *de;
    robj *old = dictGetVal(de); //获取老节点的val字段值
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { //过期策略(后面会讲)
        val->lru = old->lru;
    }
    dictSetVal(db->dict, de, val); //给节点设置新的值

    if (server.lazyfree_lazy_server_del) { //缓慢删除(后面会讲)
        freeObjAsync(old);
        dictSetVal(db->dict, &auxentry, NULL);
    }

    dictFreeVal(db->dict, &auxentry);//释放旧的val内存
}

修改键的源码,虽然没有调用dict.h中的方法去修改字典中的元素,但修改过程基本类似,Redis修改键值对,整个过程主要分如下几个步骤:

1、调用dictFind查找健值对是否存在
2、不存在则中断执行
3、修改节点键值对中的值为新值
4、释放旧值内存

4.5、删除元素

继续跟进删除字段中的健值对,例如执行 del 指令,Server收到 del命令后,最终删除键值对会调用dict.h文件中的dictDelete函数,其主要执行过程为:

1、查找该健是否存在当前字典中
2、存在则把该节点从单链表中删除
3、释放该节点对应键占用的内存,值占用的内存,以及本身dictEntry占用的内存
4、给对应的Hash表的used字段值减1

当字典操作后,使用量不到总空间的10%的时候,就会进行缩容操作,缩容核心代码又两个分别为tryResizeHashTablesdictResize

代码如下:

//字典元素删除
static int dictDelete(dict *ht, const void *key) {
    unsigned int h;
    dictEntry *de, *prevde;

    if (ht->size == 0)
        return DICT_ERR;
    h = dictHashKey(ht, key) & ht->sizemask;//查找hash表对应的index
    de = ht->table[h];

    prevde = NULL;
    while(de) {
        //查找到键
        if (dictCompareHashKeys(ht,key,de->key)) {            
            //由于是单链表删除节点的时候需要记录下上一个节点
            if (prevde)
                prevde->next = de->next;
            else
                ht->table[h] = de->next;

            dictFreeEntryKey(ht,de); //释放键空间
            dictFreeEntryVal(ht,de); //释放值空间
            free(de); //释放本身dictEntry空间
            ht->used--;
            return DICT_OK;
        }
        prevde = de;
        de = de->next;
    }
    return DICT_ERR; 
}

//缩容入口
void tryResizeHashTables(int dbid) {
	//redisDb字典缩容(后面介绍redisDb对象的时候会讲)
    if (htNeedsResize(server.db[dbid].dict))
        dictResize(server.db[dbid].dict);
     //redisDb字典过期时间缩容(后面介绍redisDb对象的时候会讲)
    if (htNeedsResize(server.db[dbid].expires))
        dictResize(server.db[dbid].expires);
}

//hash表缩容
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); //和前面扩容的 dictExpand 是一样的
}

5、字典遍历

前面讲解了字典的基本概念,基本操作,本节讲解字典的遍历操作,遍历的原则有以下几点。

1、不重复出现数据
2、不遗漏任何数据

目前遍历Redis整个数据库主要有两种方式:【全遍历】【间断遍历】

【全遍历】:一次命令执行就遍历整个数据库
【间断遍历】:每次执行命令,只取部分数据,多次遍历

5.1、迭代器遍历

迭代器-可在容器上遍访的接口,设计人员无须关心容器的内容,调用迭代器固定的接口就可遍历数据,在很多高级语音中都有实现。
字典迭代器的主要用于迭代字典这个数据结构中的数据,既然是迭代字典中的数据,必然会出现一个问题,迭代过程中,如果发生了数据增删,则可能导致字典触发rehash操作,或迭代开始时字典正在rehash操作,从而导致一条数据可能被多次遍历的情况。那Redis如何解决这个问题呢?我们首先来看看字典迭代器结构体的实现吧:

//redis 字典迭代器结构体
typedef struct dictIterator {
    dict *d;   //迭代的目标字典
    long index;  // 当前迭代到Hash表中哪个索引
    int table, safe; // table用于表示当前正在迭代的Hash表,即ht[0]与ht[1],safe用于表示当前创建的是否为安全迭代器
    dictEntry *entry, *nextEntry; //当前节点,下一个节点
    long long fingerprint; // 字典的指纹,当字典未发生改变的时候,该值不变,发生改变的时候则值也随之改变
} dictIterator;

整结构体占了48字节,其中d字段指向需要迭代的字典,index字段代表当前读取到Hash表中的哪个值,table字段表示当前正在迭代的Hash表(即ht[0]和ht[1]中的0和1),safe字段表示当前创建的迭代器是否为安全模式,entry字段表示正在读取的节点数据,nextEntry字段表示entry节点中的next字段所指向的数据。
fingerprint字段是一个64位的整数,表示在给定时间内字段的状态。在这里称其为字典的指纹,因为该字段的值为字典(dict结构体)中所有字段值组合在一起生成的Hash值,所以当字典中数据发生任何变化时,其值都会不同,生成算法不做过多解读,读者可参见源码dict.c文件中的dictFingerPrint函数
为了让迭代过程变的简单,Redis也提供了迭代相关的API函数,主要为:

dictIterator *dictGetIterator(dict *d); //初始化迭代器
dictIterator *dictGetSafeIterator(dict *d); //初始化安全的迭代器
dictEntry *dictNext(dictIterator *iter); //通过迭代器获取下一个元素
void dictReleaseIterator(dictIterator *iter); //释放迭代器

简单介绍完迭代器的基本结构,字段含义以及API,我们来看一下Redis如何解决增删数据的同时不出现读取数据重复的问题。Redis为单进程单线程模式。不存在两个命令同时指向的情况,因此只有当指向执行的命令在遍历的同时删除了数据,才会触发前面的问题。我们把迭代器遍历数据分为两类

1、普通迭代器,只遍历数据
2、安全迭代器,遍历的同时删除数据

5.1.1、普通迭代器

普通迭代器迭代字典中数据时,会对迭代器中fingerprint字段的值作严格的校验,来保证迭代过程中字典结构不发生任何变化,确保读取出的数据不出现重复。
当Redis执行部分命令时会使用普通迭代器迭代字典数据,例如sort命令。sort命令主要作用是对给定列表,集合,有序集合的元素进行排序,如果给定的是有序集合、其成员名存储用的是字典,分值存储用的是跳跃表,则执行sort命令读取数据的时候会去用到迭代器来遍历整个字典。
普通迭代器迭代数据的过程比较简单,主要分为如下几个步骤。

1、调用dictGetIterator函数初始化一个普通迭代器,此时会把iter->safe值置为0,表示初始化的迭代器为普通迭代器,初始化后的结构示意图如下:
在这里插入图片描述

2、循环调用dictNext函数依次遍历字典中Hash表的节点,首次遍历时会通过dictFingerprint 函数拿到当前字典的指纹值,此时结构示意图如图所示:
在这里插入图片描述

3、当调用dictNext函数遍历完字典Hash表中节点数据后,释放迭代器时会继续调用dictFingerprint 函数计算字典的指纹值,并与首次拿到的指纹对比,不相等则输出异常"===ASSERTION FAILED ===",且退出程序执行。
普通迭代器通过步骤1,步骤3的指纹对比,来限制整个迭代过程中只能进行迭代操作,即迭代过程中字典数据的修改、添加、删除、查找等操作都不能进行,只能调用dictNext函数迭代整个字典,否则就报异常,由此来保证这次迭代器取出数据的准确性。

5.1.2、安全迭代器

安全迭代器和普通迭代器迭代数据原理类似,也是通过循环调用dictNext函数依次遍历字典中Hash表的节点。安全迭代器确保数据的准确性,不是通过限制字典的部分操作来实现的而是通过限制rehash的进行来确保数据的准确性,因此迭代过程中可以对字典进行增、删、改、查等操作。
我们知道,对字典的增、删、改、查操作会调用dictRehashStep函数进行渐进式rehash操作,那如何对rehash操作进行限制呢,我们一起看下dictRehashStep函数源码实现:

//渐进式rehash入口
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1); //字典正在运行迭代操作的安全迭代器个数
}

原理很简单,如果当前有安全迭代器正在运行,则不进行渐进式rehash操作,rehash操作暂停,字典中的数据不会被重复遍历,由此确保读取数据的准确性。
当Redis执行部分命令时会使用安全迭代器迭代字典数据,例如keys命令。keys命令主要作用是通过模式匹配,返回给定模式的所有key列表,遇到过期键则会进行删除操作。Redis数据健值对存储在字典中,因此keys命令会通过安全迭代器来遍历整个字典。安全迭代器整个迭代过程也比较简单,主要分如下几个步骤:

第一步、调用dictGetSafeIterator 函数初始化一个安全迭代器,此时会把iter->safe值置为1,表示初始化的迭代器为安全迭代器。(结构参考普通迭代器结构)
第二步、循环调用dictNext函数依次遍历字典中Hash表的节点,首次遍历时会把字典中iterators字段进行加1操作,确保暂停rehash
第三步、当调用dictNext函数遍历完字段Hash表中节点数据后,释放迭代器时会把字典中iterators字段进行减1,步骤3中对字典的iterators字段进行修改,使得迭代过程中渐进式rehash操作被中断,由此来保证迭代器读取数据的准确性。

5.2、间断遍历

前文讲解了全遍历字典的实现,但有一个问题凸显出来,当数据库中有海量数据的时候,执行keys命令进行一次数据库全遍历,耗时肯定不短,会造成短暂的Redis不可用,所以在Redis在2.8.0版本后增加了scan操作,也就是间断遍历。而dictScan间断遍历中的一种实现,主要在迭代字典中数据时使用,例如hscan命令迭代整个数据库中的key,以及zscan命令迭代有序集合所有成员与值时,都是通过dictScan函数来实现的字典遍历。dictScan遍历字典过程中是可以进行rehash操作的,通过算法来保证所有的数据能被遍历到。
下面我们来看一下dictScan函数定义:

//字典间断遍历
unsigned long dictScan(dict *d,   //需要遍历的字典
                       unsigned long v, //变量标识迭代开始的游标(整个遍历围绕游标值的改动进行)
                       dictScanFunction *fn, // 函数指针,遍历一个节点则调用该函数处理
                       dictScanBucketFunction* bucketfn, //用来整理碎片时调用
                       void *privdata) // 调用函数fn所需要的参数

变量d是当前迭代的字典,变量v标识迭代开始的游标(即Hash表中数组索引),每次遍历后回返回新的游标值,整个遍历过程都是围绕这个游标值的改动进行的,来保证所有的数据都能被遍历到,fn是函数指针,每遍历一个节点则调用该函数处理,bucketfn函数在整理碎片时调用privdata是回调函数fn所需参数。
执行hscan命令时外层调用dictScan函数示例:

		//count为hscan命令传入的count值,代表获取数据的个数
  		long maxiterations = count*10; 
        do {
        	//调用dictScan函数迭代字典数据,cursor字段初始值为hscan传入值,代表迭代Hash数组的游标起点值 
            cursor = dictScan(ht, cursor, scanCallback, NULL, privdata);
        } while (cursor &&
              maxiterations-- &&
              listLength(keys) < (unsigned long)count);

dictScan函数间断遍历字典过程中会遇到如下3种情况:

1、从迭代开始到结束,散列表没有进行rehash
2、从迭代开始到结束,散列表进行了扩容或缩容操作,且恰好为两次迭代间隔期间完成了rehash操作
3、从迭代开始到结束,某次或者某几次迭代时散列表正在进行rehash操作

5.2.1、遍历中始终未rehash

每次迭代都没有遇到rehash操作,也就是遍历字典只遇到第一种或,第二种情况。其实第一种情况,只要依次按照顺序遍历Hash表ht[0]中节的点即可,第2种情况因为在遍历的整个过程中,期间字典可能发生了扩容或缩容操作,间断遍历期间 (并且已经完成),如果依次按照顺序遍历,则可能会出现数据重复读取的现象,于是redis技巧性的使用了一种方式实现避免重复读取,如下所示:
在这里插入图片描述
dictScan函数中:

  		t0 = &(d->ht[0]);
        m0 = t0->sizemask; //当前掩码
        if (bucketfn) bucketfn(privdata, &t0->table[v & m0]); //整理碎片使用的函数
        de = t0->table[v & m0]; //避免遍历后游标超出最大值
        while (de) {
            next = de->next;
            fn(privdata, de); // 依次将节点中健值对存入privdata字段中的单链表
            de = next;
        }    

整个迭代过程强依赖游标值v变量,根据v找到当前需要读取的Hash表元素,然后遍历该元素单链表上所有的健值对,依次执行fn函数指针执行的函数,对健值对进行读取操作。
为了兼容迭代期间可能发生的缩容与扩容操作,每次迭代时都会对v变量(游标值)进行修改,以确保迭代出的数据无遗漏,游标具体变更算法为:

		// 游变变更算法
        v |= ~m0;       
        v = rev(v); //二进制逆转
        v++;
        v = rev(v); //二进制逆转

为了更好的立即这个算法下面列出了两个表描述整个过程:
假设1:Hash表大小为4,迭代从始至终未进行扩容缩容操作
掩码m0=0x11 ~m0=0x100,游标值的变化如下所示:

输入初始值v |= 0x100v=rev(v)v++v=rev(v)最终结果
第1次游标为00x0000x1000x0010x0100x0102
第2次游标为20x0100x1100x0110x1000x0011
第3次游标为10x0010x1010x1010x1100x0113
第4次游标为30x0110x1110x1110x0000x0000

刚好把整个Hash表遍历完成顺序是:2 1 3 0

假设2:Hash表大小为4,进行3次迭代时,Hash表扩容为8
掩码m0=0x11 ~m0=0x100

输入初始值v |= 0x100v=rev(v)v++v=rev(v)最终结果
第1次游标为00x0000x1000x0010x0100x0102
第2次游标为20x0100x1100x0110x1000x0011

进行第三次迭代的时候,表扩容到了8,数组的掩码m0=0x111 ~m0=0x1000 ,接下来的游标如下所示:

输入初始值v |= 0x100v=rev(v)v++v=rev(v)最终结果
第3次游标为10x00010x10010x10010x10100x01015
第4次游标为50x01010x11010x10110x11000x00113
第5次游标为30x00110x10110x11010x11100x01117
第6次游标为10x01110x11110x11110x00000x0000

此时我们发现,迭代只进行6次就完成了,顺序为0 2 1 5 3 7 少遍历 4 6,因为游标为0 2 的数据已经遍历过
0|4 和 2|6是一样的数据,所以无需再遍历

假设3:Hash表大小为8 迭代到第5次的时候缩容,Hash表大小变为4
掩码:m0=0x111 ~m0=0x1000 游标值的变化顺序如下

输入初始值v |= 0x100v=rev(v)v++v=rev(v)最终结果
第1次游标为00x00000x10000x00010x00100x01004
第2次游标为40x01000x11000x00110x01000x00102
第3次游标为20x00100x10100x01010x01100x01106
第4次游标为60x01100x11100x01110x10000x00011

进行第5次迭代的时候Hash表大小缩容到4 掩码::m0=0x11 ~m0=0x100

输入初始值v |= 0x100v=rev(v)v++v=rev(v)最终结果
第5次游标为00x0010x1010x1010x1100x0113
第6次游标为40x0110x1110x1110x0000x0000

同样,迭代只进行6次结束,顺序为 0 4 2 6 1 3 少遍历了0 2

总结:只要遍历过程中没有遇到rehash恰好在执行,通过游标变更算法可以保证无论扩容还是缩容都不会遍历重复数据。

5.2.2、遍历中遇到rehash

从迭代开始到结束,某次或某几次迭代时散列表正在进行rehash操作,rehash操作中会同时并存两个Hash表,一张为扩容或缩容后的表ht[1],一张为老表ht[0]ht[0]的数据会通过渐进式rehash会逐步迁移到ht[1]中,最终完成整个迁移过程。
因为两张表并存,所以需要从ht[0]ht[1]中都取出数据,整个遍历过程为:先找到两个散列表中的小表,先对小表遍历,然后对大的Hash表遍历,代码如下dictScan

	    t0 = &(d->ht[0]);
        m0 = t0->sizemask;   
        //判断哪个Hash表小
        if (t0->size > t1->size) {
            t0 = &d->ht[1];
            t1 = &d->ht[0];
        }
        m0 = t0->sizemask;
        m1 = t1->sizemask;

		de = t0->table[v & m0];
		//迭代第一张小表
        while (de) {
            next = de->next;
            fn(privdata, de);
            de = next;
        }
        //迭代第二张大表
        do {            
            if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
            de = t1->table[v & m1];
            while (de) {
                next = de->next;
                fn(privdata, de);
                de = next;
            } 
            // 游标算法          
            v |= ~m1;
            v = rev(v);
            v++;
            v = rev(v);          
        } while (v & (m0 ^ m1));

结合rehash中的游标算法,这样能保证不会重复遍历相同的节点
其实大家可以这样理解,游标算法的主要作用就是:保证无论扩容还是缩容,都不会遍历到已经遍历过的bucket index,例如 遍历过2 就不会遍历 6 ,遍历过 0 就不会遍历4

6 、API列表

这里顺便介绍一下剩余的API 主要在文件 src/dict.h

dict *dictCreate(dictType *type, void *privDataPtr);  //初始化字典
int dictExpand(dict *d, unsigned long size); 		//字典扩容
int dictAdd(dict *d, void *key, void *val); 		//添加键值对,已存在则不添加 
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing);//添加key,并返回新添加key对应的节点指针。若存在,则存入 existing 并返回-1
dictEntry *dictAddOrFind(dict *d, void *key);	//添加或查找key
int dictReplace(dict *d, void *key, void *val);  //添加健值对,若存在则修改,否则添加
int dictDelete(dict *d, const void *key);		//删除节点
dictEntry *dictUnlink(dict *ht, const void *key); //删除key但不释放内存
void dictFreeUnlinkedEntry(dict *d, dictEntry *he);//释放dictUnlink函数删除key的内存
void dictRelease(dict *d);						//释放字典
dictEntry * dictFind(dict *d, const void *key); //根据键查找元素
void *dictFetchValue(dict *d, const void *key); //根据键查找出值
int dictResize(dict *d);						//将字典表的大小调整为包含所有元素的最小值,即收缩字典
dictIterator *dictGetIterator(dict *d);			//初始化迭代器
dictIterator *dictGetSafeIterator(dict *d);		//初始化安全迭代器
dictEntry *dictNext(dictIterator *iter);		//通过迭代器获取下一个节点
void dictReleaseIterator(dictIterator *iter);	//释放迭代器
dictEntry *dictGetRandomKey(dict *d);			//随机得到一个键
dictEntry *dictGetFairRandomKey(dict *d);		//随机得到一个公平键
unsigned int dictGetSomeKeys(dict *d, dictEntry **des, unsigned int count);//随机得到几个键
void dictGetStats(char *buf, size_t bufsize, dict *d);	//读取字典的状态,使用情况等
uint64_t dictGenHashFunction(const void *key, int len);	//hash函数(字母大小写敏感)
uint64_t dictGenCaseHashFunction(const unsigned char *buf, int len);//hash函数(字母大小写不敏感)
void dictEmpty(dict *d, void(callback)(void*));	//清空字典
void dictEnableResize(void);					//开启Resize(调整)
void dictDisableResize(void);					//关闭Resize(调整)
int dictRehash(dict *d, int n);					//渐进式rehash n 为几步进行
int dictRehashMilliseconds(dict *d, int ms);	//持续式rehash ms 为持续时间
void dictSetHashFunctionSeed(uint8_t *seed);	//设置新的散列种子
uint8_t *dictGetHashFunctionSeed(void);			//获取当前散列种子
unsigned long dictScan(dict *d, unsigned long v, dictScanFunction *fn, dictScanBucketFunction *bucketfn, void *privdata); //间断迭代数据
uint64_t dictGetHash(dict *d, const void *key);		//得到键的hash值
dictEntry **dictFindEntryRefByPtrAndHash(dict *d, const void *oldptr, uint64_t hash);//使用指针 + hash值取查找元素

文章内容参考来源:《Redis5涉及与源码分析》

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值