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%的时候,就会进行缩容操作,缩容核心代码又两个分别为tryResizeHashTables
和 dictResize
代码如下:
//字典元素删除
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 |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最终结果 |
---|---|---|---|---|---|---|
第1次游标为0 | 0x000 | 0x100 | 0x001 | 0x010 | 0x010 | 2 |
第2次游标为2 | 0x010 | 0x110 | 0x011 | 0x100 | 0x001 | 1 |
第3次游标为1 | 0x001 | 0x101 | 0x101 | 0x110 | 0x011 | 3 |
第4次游标为3 | 0x011 | 0x111 | 0x111 | 0x000 | 0x000 | 0 |
刚好把整个Hash表遍历完成顺序是:2 1 3 0
假设2:Hash表大小为4,进行3次迭代时,Hash表扩容为8
掩码m0=0x11
~m0=0x100
输入 | 初始值 | v |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最终结果 |
---|---|---|---|---|---|---|
第1次游标为0 | 0x000 | 0x100 | 0x001 | 0x010 | 0x010 | 2 |
第2次游标为2 | 0x010 | 0x110 | 0x011 | 0x100 | 0x001 | 1 |
进行第三次迭代的时候,表扩容到了8,数组的掩码m0=0x111
~m0=0x1000
,接下来的游标如下所示:
输入 | 初始值 | v |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最终结果 |
---|---|---|---|---|---|---|
第3次游标为1 | 0x0001 | 0x1001 | 0x1001 | 0x1010 | 0x0101 | 5 |
第4次游标为5 | 0x0101 | 0x1101 | 0x1011 | 0x1100 | 0x0011 | 3 |
第5次游标为3 | 0x0011 | 0x1011 | 0x1101 | 0x1110 | 0x0111 | 7 |
第6次游标为1 | 0x0111 | 0x1111 | 0x1111 | 0x0000 | 0x000 | 0 |
此时我们发现,迭代只进行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 |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最终结果 |
---|---|---|---|---|---|---|
第1次游标为0 | 0x0000 | 0x1000 | 0x0001 | 0x0010 | 0x0100 | 4 |
第2次游标为4 | 0x0100 | 0x1100 | 0x0011 | 0x0100 | 0x0010 | 2 |
第3次游标为2 | 0x0010 | 0x1010 | 0x0101 | 0x0110 | 0x0110 | 6 |
第4次游标为6 | 0x0110 | 0x1110 | 0x0111 | 0x1000 | 0x0001 | 1 |
进行第5次迭代的时候Hash表大小缩容到4 掩码::m0=0x11
~m0=0x100
输入 | 初始值 | v |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最终结果 |
---|---|---|---|---|---|---|
第5次游标为0 | 0x001 | 0x101 | 0x101 | 0x110 | 0x011 | 3 |
第6次游标为4 | 0x011 | 0x111 | 0x111 | 0x000 | 0x000 | 0 |
同样,迭代只进行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涉及与源码分析》