最近刚刚看过了redis源码的字典(dict)部分,结合《redis设计与实现》一书中的关于字典数据结构的介绍,写下自己对于redis中使用的字典数据结构的理解。如果有不正确的地方,欢迎各位及时指正。
dict是redis中关键的一种数据结构,数据库和哈希键的实现都依赖于dict结构。根据看过的资料,个人认为要掌握redis中的dict数据结构需要掌握以下几方面。
1.redis中字典的实现所用的数据结构以及他们彼此之间的关系;
2.redis中rehash的实现;
3.redis中dict的遍历操作dictScan的实现;
本文重点关注第1、2部分,关于dictScan、的精妙设计思想。请参考博客,讲解的通俗易懂;另一篇博客中的图配合的比较好。自认为不能比这两位博主写出更好的关于dictScan的理解,所以在建议读者直接去阅读这两篇文章。
下面,按照以上列出的几个问题,介绍redis的实现和源码。
1、redis中字典的实现所用的数据结构以及他们彼此之间的关系
redis的实现中包含了struct dict、struct dictht、struct dictEntry、struct dictType以及struct dictIterator五种数据结构,其代码实现分别如下:
typedef struct dict {
//每个dictype结构保存了一簇用于操作特定类型键值对的函数,redis为用途不同的字典设置不同类型的特定函数;
dictType *type;
//保存用于传递给特定类型函数的可选参数;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
//???iterators是什么???
unsigned long iterators; /* number of iterators currently running */
} dict;
type 和 privatedata 属性是针对不同类型的的键值对,为创建多态字典而存在的。type是一个指向dictType类型的指针,每个dictType保存了一簇用户操作特定类型的键值对的函数(有针对key的哈希函数、key的销毁函数、val的销毁函数、key的比较函数、key的复制函数和val的复制函数),redis会为不同类型的键值对设置不同类型的特定函数;privatedata属性则保存了需要传递给那些特定类型函数的可选参数。
ht[2]中包含了两个dictht结构。每个dictht都是一个哈希表,其中ht[0]一般是默认的哈希表,ht[1]只有在redis执行rehash的时候才会使用。
rehashidx是redis执行 rehash 的过程中,保存的当前需要执行的rehash在ht[1]中的索引。在不执行rehash的时候,rehashidx为-1,dict.h中判断dict是否处于rehash的宏定义就是使用了rehashidx是否为-1。(#define dictIsRehashing(d) ((d)->rehashidx == -1)。
iterator中代表是否是处于安全的迭代过程中。
/*哈希表结构,每个字典中含有两个这样的哈希表结构,
* 存在两个的原因是因为我们为了控制哈希表的负载因子在合理的范围内时,
* 可能需要rehash,rehash采用的"渐进式哈希"*/
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
//指向指针的指针,代码的实现中table指向了包含有size个dictEntry*结构的数组的首地址;
dictEntry **table;
//数组的总的大小;
unsigned long size;
//sizemask和哈希值一起决定一个节点应该放到哈希表的哪个索引上面;
unsigned long sizemask;
//数组中已有的元素的大小;因为使用了链式冲突分解方案,因此有可能used的值大于size的值;
unsigned long used;
} dictht;
sizemask 的大小为 size - 1,其主要作用是针对给定的key的哈希值,确定该key在table中的索引(idx = (d->type->hashfunciton(key)) & sizemask)以保证key所对应的节点的索引不会溢出而超过size-1。
used为dictht中已经存储的元素的个数。根据这个used/size的值以及负载因子的大小,确定是否应该执行rehash;根据used元素的值确定rehash的时候新的dictht的大小为第一个大于或者等于ht[0].used * 2大小的2的n次方。
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
从next可以看出,redis使用了链式冲突分解方案解决键冲突,哈希值的低log2(ht[0].size)位相同的键在同一张哈希表上具有相同的索引。当索引产生冲突的时候,将新增的键值对增加到已有索引的链表的头部,这样可以减少遍历该索引上整个链表的不必要的麻烦,以O(1)的时间复杂度进行插入。
key 是一个指针,源码中使用Murmurhash()方法计算出该key对应的哈希值,然后与dictht中的sizemask做"位与",保证索引的值始终处于哈希表的 0 ~ size - 1范围之内。
typedef struct dictType {
uint64_t (*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;
hashFunction 为对key进行哈希所使用的哈希函数,redis中使用了Murmurhash()函数。该hash算法具有较好的分布均匀性;
keyDup 为复制键key的函数,如果是key是一个对象,则可以是该对象的复制构造函数;
valDup 为复制值的函数,如果val是一个对象,那么可以是该值的复制构造函数;
keyCompare 为对比两个键值是否相等的函数;
keyDesstructor和valDestructor分别为key和val的析构函数,如果key和val是动态分配的对象的话,那么这两个函数将是对应的释放函数。这在删除一个dictEntry的时候是非常有用的,例如dict.h文件中定义了两个宏,分别用于释放key和val。
#define dictFreeKey(d, entry) \
if ((d)->type->keyDestructor) \
(d)->type->keyDestructor((d)->privdata, (entry)->key)
#define dictFreeVal(d, entry) \
if ((d)->type->valDestructor) \
(d)->type->valDestructor((d)->privdata, (entry)->v.val)
typedef struct dictIterator {
dict *d;
long index;
/** safe = 0 表示是一种非安全的迭代器;safe = 1 表示是一种安全的迭代器 **/
int table, safe;
dictEntry *entry, *nextEntry;
/* unsafe iterator fingerprint for misuse detection. */
long long fingerprint;
} dictIterator;
redis的迭代器分为安全的迭代器和非安全的迭代器。所谓的安全迭代器就是在执行的过程中可以执行增、删、查等操作;但是非安全的迭代器只能执行迭代操作,迭代的过程中通过dictFingerPrint验证是否对dict进行了修改。
d 为指向迭代的dict的指针,这个在dictNext中获取指定的dict;
table为dict中ht的下标,取值为0或者1,index为需要遍历的ht中成员table的下标;
safe代表是否是安全的迭代;
entry为当前迭代器指向的entry,nextEntry为当前entry的下一个entry。之所以保留nextEntry是因为当前的entry极有可能被调用者删除,因此使用nextEntry记录下一个entry,供下次迭代使用。
fingerprint 相当于指纹,在进行不安全的迭代的时候,首先记录该dict的指纹(关于ht[0]和ht[1]的size和used的一些散列计算,等迭代结束的时候再次验证指纹,如果前后的指纹一致,表明没有对dict做什么插入和删除操作)。
dictIterator主要用在对某个字典进行迭代的过程中。
上面叙述了几种组成redis中的dict的重要数据结构,下面我们给出他们之间的组成关系图。
2、redis中rehash的实现
当初始化一个dict之后,随着操作的进行,dict中dictht的size以及used都将不断地变化。如果不及时调整dict中dictht的大小,那么随着其中元素的增加,将造成每个索引的链表长度变得很长,查找速度变慢;随着元素的减少,可能存在很多的空闲的slot,造成了内存空间的浪费。为了最优化redis的性能,redis可以动态调整dict中ht[0]的大小,这个过程称之为rehash。总之,随着redis的使用,redis可以动态扩容或者缩容dict。
在redis中的rehash是通过使用空表ht[1]完成的。当redis没有执行BGSAVE和BGREWRITEAOF,且负载因子大于等于1时,会执行rehash或者是当redis中正在执行BGSAVE或者BGREWRITEAOF,但是负载因子大于等于5时会执行rehash(负载因子 = ht[0].used/ht[0].size),且redis为了避免在执行rehash的过程会过长的阻塞进程从而无法接受其他的请求,redis对dict的rehash操作采用了“分而治之”的策略---渐进式哈希,在redis中执行增、删、改、查的时候将在当前rehashidx上的链表rehash到ht[1]。这个过程的基本单位是一个index上的整个链表中的元素, 因此不存在一个index上的部分dictEntry被rehash,而其他部分的dictEntry没有被rehash的情况。除了在每次的增、删、查的过程中执行rehash,redis在databaseCron中同样会执行rehash。
在增、删、改、查的过程中rehash的执行都是通过_dictRehashStep(dict* d)在执行的,其定义如下。
/** 只执行一步的哈希 **/
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
_dictRehashStep表明是要单步执行rehash,rehash执行的就是d->rehashidx索引上的整条链表。真正的执行操作是在dictRehash中进行的,其定义如下。
int dictRehash(dict *d, int n) {
/** 防止过多的遍历ht中相应的index位置链表为NULL的slots,对遍历空白index的数量做一个限制 **/
int empty_visits = n*10;
if (!dictIsRehashing(d)) return 0;
/** ht[0].used != 0 表示当前肯定没有rehash完;while中的n-- 和 d->ht[0].used != 0既保证了rehash的index的数量不会超过n,也保证了rehash的dictEntry数量不会超过当前d->ht[0]中已有的数量 **/
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
assert(d->ht[0].size > (unsigned long)d->rehashidx);
/** 跳过 hash table 中指针为空的槽 **/
while(d->ht[0].table[d->rehashidx] == NULL) {
/** 此处不用担心d->rehashidx会在已有的尺寸中溢出,因为该循环只是跳过指针为空的槽,ht[0].used已经保证了还没有哈希完 **/
d->rehashidx++;
/** 已经遍历的slot的数量达到了空slot的数量,返回 1 表示还没有rehash完 **/
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
/* 将该索引上链表上的所有dictEntry转移到ht[1]中 */
while(de) {
unsigned int h;
nextde = de->next;
/* 获取de->key的哈希值,并与新的hash table中的sizemask做位与获取新的索引值;这里需要注意的一点的是,key不变,一般hash值也不会发生
变化,但是由于sizemask发生了变化也会造成最终的索引值发生变化。关于这一点可以参考上面两片博客中关于dictScan的介绍 */
/** 在进行rehash的过程中,并不会释放掉旧的dictEntry再重新分配dictEntry结构体,只是改变指向该dictEntry的的指针而已 **/
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;
}
/** rehash 完一个索引上的dictEntry之后,将旧ht中索引为rehashidx的位置指向NULL **/
d->ht[0].table[d->rehashidx] = NULL;
/** 递增d->rehashidx为下次rehash做准备 **/
d->rehashidx++;
}
/* Check if we already rehashed the whole table... */
/** 结束再哈希 **/
if (d->ht[0].used == 0) {
/** 只有 hash table 中的table是动态分配的,所以必须手动释放掉 **/
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
/* More to rehash... */
return 1;
}
前面我们说redis采用了渐进式rehash的方式,将整个rehash的过程分摊到每次对dict的增、删、改、查中,在redis的源码中每次执行dictEntry *dictAddRow(dict *d,void *key,dictEntry **existing),static dictEntry *dictGenericDelete(dict *d, const void *key,int nofree),dictEntry *dictFind(dict *d, const void *key),dictEntry *dictGetRandomKey(dict *d),unsigned int dictGetSomeKeys(dict *d, dictEntry **des,unsigned int count)的时候如果当前处于rehash的状态,那么就要执行dictRehashStep(dict *d)操作。
在执行rehash的过程中如果对dict执行增加操作,那么新增加的元素将全部增加到redis中的ht[1]表中,而不是默认的ht[0]表,这就保证了新增加的元素只会增加到ht[1]中,而ht[0]中的元素只会减少不会增加。那么在某一个时刻,ht[0]表中的元素将会全部转移到ht[1]中,完成rehash的过程。下面我们看一下,向dict中增加元素的时候,redis是如何做的。
int dictAdd(dict *d, void *key, void *val)
{
dictEntry *entry = dictAddRaw(d,key,NULL);
if (!entry) return DICT_ERR;
/** 将值添加到 entry 中 **/
/** 将键和值与entry的结合分为两个不同的地方,看似不符合《代码大全》的要求,但是方便与对于set实现。set的实现中只需要使用dictAddRaw,没有val **/
dictSetVal(d, entry, val);
return DICT_OK;
}
/** dictEntry ** existing 如果不为null, 则当key已经存在的时候,existing是指向已经存在的 dictEntry* 的指针,如果existing为null, 那么即使key已经存在,也不指向已经存在的dictEntry* ,此时该函数的返回值是null;
* 如果 key不存在,那么直接返回新添加的dictEntry指针 **/
/** 反映在命令行上就是,如果key已经存在,那么直接返回0,如果key不存在那么返回1; **/
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
int index;
dictEntry *entry;
dictht *ht;
/** rehash过程是一个渐进式哈希的过程,将整个再哈希过程分摊到每次对redis字典的增、删、改、查操作中 **/
if (dictIsRehashing(d)) _dictRehashStep(d);
/* 获取要新增加的元素的key对应的index,如果不能成功获取key对应的index,那么返回 -1,表示key已经存在或者是需要扩展hash table操作没有成功 */
if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
return NULL;
/** 如果正在处于哈希的过程中,那么直接取dict中的ht[1]而不是默认的ht[0]。这就符合上面所说的,如果实在哈希的过程中,那么增加元素的时候将直> 接在ht[1]中增加元素,ht[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++;
/** 将 key 与新分配额entry相结合 **/
dictSetKey(d, entry, key);
return entry;
}
既然,在rehash的过程中,将新增的元素只会添加到ht[1]哈希表中,那么当rehash时在获取哈希表中entry所在的索引时必须获取ht[1]中的索引,而不是ht[0]中的索引。下面还是看redis中的代码实现。
static int _dictKeyIndex(dict *d, const void *key, unsigned int hash, dictEntry **existing)
{
unsigned int idx, table;
dictEntry *he;
if (existing) *existing = NULL;
/* 如果满足要求,则扩展redis中的hash table */
if (_dictExpandIfNeeded(d) == DICT_ERR)
return -1;
/** 对整个dict中的两个hash table进行遍历,如果key存在于任何一个hash table中,那么证明key不能被再次添加 **/
for (table = 0; table <= 1; table++) {
/* 获取redis中ht[table]中key所在的索引 */
idx = hash & d->ht[table].sizemask;
/* key是否已经存在 */
he = d->ht[table].table[idx];
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key)) {
if (existing) *existing = he;
return -1;
}
he = he->next;
}
/* 如果dict没有正在处于rehash中,那么不必要再次遍历dict中的ht[1],因为此时它为空 */
if (!dictIsRehashing(d)) break;
}
return idx;
}
static dictEntry *dictGenericDelete(dict *d, const void *key, int nofree) {
unsigned int h, idx;
dictEntry *he, *prevHe;
int table;
if (d->ht[0].used == 0 && d->ht[1].used == 0) return NULL;
/* 渐进式rehash过程,每次执行增、删、改、查都要执行单步的rehash */
if (dictIsRehashing(d)) _dictRehashStep(d);
h = dictHashKey(d, key);
/* 遍历两个hash table */
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
prevHe = NULL;
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key)) {
/* Unlink the element from the list */
if (prevHe)
prevHe->next = he->next;
else
/** 处理找到的 key 位于链表头的情况,此时preHe为NULL **/
d->ht[table].table[idx] = he->next;
/** 判断是否是直接释放掉找到的dictEntry所占用的空间,如果是则直接释放掉dictEntry所占用的内存空间 **/
if (!nofree) {
dictFreeKey(d, he);
dictFreeVal(d, he);
zfree(he);
}
d->ht[table].used--;
return he;
}
prevHe = he;
he = he->next;
}
/** 哈希表没有处在rehash过程中,ht[1]为空表 */
if (!dictIsRehashing(d)) break;
}
return NULL; /* not found */
}
在查找一个给定的键的时候,也是在dict的两个表中进行遍历,当然了,如果是处于非再哈希的过程中,那么直接在ht[0]中遍历就可以了。上代码。
dictEntry *dictFind(dict *d, const void *key)
{
dictEntry *he;
unsigned int h, idx, table;
if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
if (dictIsRehashing(d)) _dictRehashStep(d);
h = dictHashKey(d, key);
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key))
return he;
he = he->next;
}
if (!dictIsRehashing(d)) return NULL;
}
return NULL;
}
当然,dictGetSomeKeys和dictGetRandomKey两个函数执行的时候,如果dict处于rehash的过程中,那么都是在两个hash table中执行查找过程。
关于redis中dict的介绍,本文到此就结束了。但是还是非常建议读者去阅读开头提到的两篇博客中关于dictScan中使用的算法和执行rehash时redis对于两个hash table的遍历方法,实在是非常的巧妙。