字典是 Redis 的核心数据结构之一,在 Redis 中,每个数据库本身也是一个字典,而且字典也是 Redis 的 Hash 类型的底层实现。
本文通过分析 Redis 源码里的 dict.h 和 dict.c 文件,了解字典结构的详细实现,籍此加深对 Redis 的理解。
由于字典(哈希表)是一种非常常见的数据结构,而 dict.c 中使用的 separate chaining 哈希表实现可以在任何一本算法书上找到,因此,在本文中没有对字典的查找和增删等操作做过多的着墨,而是将重点放到整个字典结构的运作流程,以及哈希表的渐增式 rehash 操作上。
字典实现的数据结构
dict.h 文件里定义了字典实现的数据结构,比如 dict 、 dictht 和 dictEntry 等,它们之间的关系可以用下图来描述:
点击放大
其中, dict 结构的定义如下:
typedef struct dict {
dictType *type; // 为哈希表中不同类型的值所使用的一族操作函数
void *privdata;
dictht ht[2]; // 每个字典使用两个哈希表(用于渐增式 rehash)
int rehashidx; // 指示 rehash 是否正在进行,如果不是则为 -1
int iterators; // 当前正在使用的 iterator 的数量
} dict;
|
代码中的注释基本说明相关属性的作用了,需要补充的一些是:
为了实现渐增式 rehash ,每个字典使用两个哈希表,分别为 ht[0] 和 ht[1] 。当 rehash 开始进行的时候, Redis 会逐个逐个地将 ht[0] 哈希表中的元素移动到 ht[1] 哈希表,直到 ht[0] 哈希表被清空为止。文章后面会给出 rehash 的相关细节。
另一方面, rehashidx 则是 rehash 操作的计数器,这方面的相关细节也会后面给出。
接下来是哈希表结构 dictht ,这个哈希表是一个典型的 separate chaining hash table 实现,它通过将哈希值相同的元素放到同一个链表中来解决冲突问题:
typedef struct dictht {
dictEntry **table; // 节点指针数组
unsigned long size; // 桶的大小(最多可容纳多少节点)
unsigned long sizemask; // mask 码,用于地址索引计算
unsigned long used; // 已有节点数量
} dictht;
|
最后要介绍的是链表的节点结构 dictEntry :
typedef struct dictEntry {
void *key; // 键
union {
void *val;
uint64_t u64;
int64_t s64;
} v; // 值(可以有几种不同类型)
struct dictEntry *next; // 指向下一个哈希节点(形成链表)
} dictEntry;
|
dictEntry 中的 key 属性保存字典的键,而 v 属性则保存字典的值, next 保存一个指向dictEntry 自身的指针,用于构成链表,解决哈希值的冲突问题。
创建字典
在初步了解了字典实现所使用的结构之后,现在是时候来看看相关的函数是怎样来操作这些结构的了。让我们从创建字典开始,一步步研究字典以及哈希表的运作流程。
使用字典的第一步就是创建字典,创建新字典执行这样一个调用链:
dictCreate 函数创建一个新的 dict 结构,然后将它传给 _dictInit 函数:
dict *dictCreate(dictType *type, void *privDataPtr)
{
dict *d = zmalloc(sizeof(*d));
_dictInit(d,type,privDataPtr);
return d;
}
|
_dictInit 函数为 dict 结构的各个属性设置默认值,并调用 _dictReset 函数为两个哈希表进行初始化设置:
int _dictInit(dict *d, dictType *type, void *privDataPtr)
{
_dictReset(&d->ht[0]); // 初始化字典内的两个哈希表
_dictReset(&d->ht[1]);
d->type = type; // 设置函数指针
d->privdata = privDataPtr;
d->rehashidx = -1; // -1 表示没有在进行 rehash
d->iterators = 0; // 0 表示没有迭代器在进行迭代
return DICT_OK; // 返回成功信号
}
_dictReset 函数为字典的几个属性值赋值,但并不为这两个哈希表的链表数组分配空间:
static void _dictReset(dictht *ht)
{
ht->table = NULL; // 未分配空间
ht->size = 0;
ht->sizemask = 0;
ht->used = 0;
}
|
哈希表链表的创建流程
每个 dict 结构都使用两个哈希表,分别是 dict->h1[0] 和 dict->ht[1] ,为了称呼方便,从现在开始,我们将它们分别叫做 0 号哈希表和 1 号哈希表。
从上一节的介绍可以知道,创建一个新的字典并不为哈希表的链表数组分配内存,也即是dict->ht[0]->table 和 dict->ht[1]->table 都被设为 NULL 。
只有当首次调用 dictAdd 向字典中加入元素的时候, 0 号哈希表的链表数组才会被创建,dictAdd 执行这样一个调用序列:
dictAddRaw 是向字典加入元素这一动作的底层实现,为了计算新加入元素的 index 值,它会调用 _dictKeyIndex :
dictEntry *dictAddRaw(dict *d, void *key)
{
// 被省略的代码...
// 计算 key 的 index 值
// 如果 key 已经存在,_dictKeyIndex 返回 -1
if ((index = _dictKeyIndex(d, key)) == -1)
return NULL;
// 被省略的代码...
}
|
_dictKeyIndex 会在计算 index 值之前,先调用 _dictExpandIfNeeded ,检查两个哈希表是否有足够的空间容纳新元素:
static int _dictKeyIndex(dict *d, const void *key)
{
// 被省略的代码...
/* Expand the hashtable if needed */
if (_dictExpandIfNeeded(d) == DICT_ERR)
return -1;
// 被省略的代码...
}
|
进行到 _dictExpandIfNeeded 这一步,一些有趣的事情就开始发生了, _dictExpandIfNeeded会检测到 0 号哈希表还没有分配任何空间,于是它调用 dictExpand ,传入DICT_HT_INITIAL_SIZE 常量,作为哈希表链表数组的初始大小(在当前版本中,DICT_HT_INITIAL_SIZE 的默认值为 4 ):
static int _dictExpandIfNeeded(dict *d)
{
// 被省略的代码...
/* If the hash table is empty expand it to the intial size. */
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
// 被省略的代码...
}
|
dictExpand 会创建一个分配了链表数组的新哈希表,然后进行判断,决定是应该将新哈希表赋值给 0 号哈希表,还是 1 号哈希表:
int dictExpand(dict *d, unsigned long size)
{
// 创建带链表数组的新哈希表
dictht n; /* the new hash table */
unsigned long realsize = _dictNextPower(size);
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*));
n.used = 0;
/* Is this the first initialization? If so it's not really a rehashing
* we just set the first hash table so that it can accept keys. */
if (d->ht[0].table == NULL) {
d->ht[0] = n; // 将新哈希表赋值给 0 号哈希表
return DICT_OK; // 然后返回
}
// 被省略的代码 ...
}
|
到了这一步, 0 号哈希表已经从无到有被创建出来了。
字典的扩展,以及 1 号哈希表的创建
在 0 号哈希表创建之后,字典就可以支持增加、删除和查找等操作了。
唯一的问题是,这个最初创建的 0 号哈希表非常小,它很快就会被添加进来的元素填满,这时候,字典的扩展(expand)机制就会被激活,它执行一系列动作,为字典分配更多空间,从而使得字典可以继续正常运作。
因为字典的的底层实现是哈希表,所以对字典的扩展,实际上就是对(字典的)哈希表做扩展。这个过程可以分为两步进行:
1) 创建一个比现有的 0 号哈希表更大的 1 号哈希表
2) 将 0 号哈希表的所有元素移动到 1 号哈希表去
_dictExpandIfNeeded 函数检查字典是否需要扩展,每次往字典里添加新元素之前,这个函数都会被执行:
static int _dictExpandIfNeeded(dict *d)
{
// 被省略的代码...
// 当 0 号哈希表的已用节点数大于等于它的桶数量,
// 且以下两个条件的其中之一被满足时,执行 expand 操作:
// 1) dict_can_resize 变量为真,正常 expand
// 2) 已用节点数除以桶数量的比率超过变量 dict_force_resize_ratio ,强制 expand
// (目前版本中 dict_force_resize_ratio = 5)
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].size > d->ht[0].used) ?
d->ht[0].size : d->ht[0].used)*2);
}
// 被省略的代码...
}
|
可以看到,当代码注释中所说的两种情况的其中一种被满足的时候, dictExpand 函数就会被调用: 0 号哈希表的桶数量和节点数量两个数值之间的较大者乘以 2 ,就会被作为第二个参数传入dictExpand 函数。
这次调用 dictExpand 函数执行的是和之前创建 0 号哈希表时不同的路径 —— 这一次,程序执行的是 else case —— 它将新哈希表赋值给 1 号哈希表,并将字典的 rehashidx 属性从 -1 改为0:
int dictExpand(dict *d, unsigned long size)
{
// 创建带链表数组的新哈希表
dictht n; /* the new hash table */
unsigned long realsize = _dictNextPower(size);
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*));
n.used = 0;
/* Is this the first initialization? If so it's not really a rehashing
* we just set the first hash table so that it can accept keys. */
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
/* Prepare a second hash table for incremental rehashing */
// 这次执行这个动作
d->ht[1] = n; // 赋值新哈希表到 d->ht[1]
d->rehashidx = 0; // 将 rehashidx 设置为 0
return DICT_OK;
}
|
渐进式 rehash ,以及平摊操作
在前一节的最后, dictExpand 的代码中,当字典扩展完毕之后,字典会同时使用两个哈希表(d->ht[0] 和 d->ht[1] 都不为 NULL ),并且字典 rehash 属性的值为 0 。这意味着,可以开始对 0 号哈希表进行 rehash 操作了。
Redis 对字典的 rehash 操作是通过将 0 号哈希表中的所有数据移动到 1 号哈希表来完成的,当移动完成, 0 号哈希表的数据被清空之后, 0 号哈希表的空间就会被释放,接着 Redis 会将原来的 1 号哈希表设置为新的 0 号哈希表。如果将来这个 0 号哈希表也不能满足储存需要,那么就再次执行 rehash 过程。
需要说明的是,对字典的 rehash 并不是一次性地完成的,因为 0 号哈希表中的数据可能非常多,而一次性移动大量的数据必定对系统的性能产生严重影响。
为此, Redis 采取了一种更平滑的 rehash 机制,Redis 文档里称之为渐增式 rehash (incremental rehashing):它将 rehash 操作平摊到 dictAddRaw 、 dictGetRandomKey 、 dictFind 和dictGenericDelete 这四个函数里面,每当上述这些函数执行的时候(或者其他函数调用它们的时候), _dictRehashStep 函数就会被执行,它每次将 1 个元素从 0 号哈希表移动到 1 号哈希表:
点击放大
作为展示渐增式 rehash 的一个例子,以下是 dictFind 函数的定义:
dictEntry *dictFind(dict *d, const void *key)
{
// 被省略的代码...
// 检查字典(的哈希表)能否执行 rehash 操作
// 如果可以的话,执行平摊 rehash 操作
if (dictIsRehashing(d)) _dictRehashStep(d);
// 被省略的代码...
}
|
其中 dictIsRehashing 是一个宏,它检查字典的 rehashidx 属性是否不为 -1 :
#define dictIsRehashing(ht) ((ht)->rehashidx != -1)
|
如果条件成立成立的话, _dictRehashStep 就会被执行,将一个元素从 0 号哈希表转移到 1 号哈希表:
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
|
_dictRehashStep 定义中的 iterators == 0 检查表示,当有迭代器在处理字典的时候,不能进行 rehash ,因为迭代器可能会修改字典中的元素,从而造成 rehash 错误。
就这样,如同愚公移山一般, 0 号哈希表的元素被逐个逐个地移动到 1 号哈希表,最终整个 0 号哈希表被清空,当 _dictRehashStep 再调用 dictRehash 时,被清空的 0 号哈希表就会被删除,然后原来的 1 号哈希表成为新的 0 号哈希表。
当有需要再次进行 rehash 的时候,这个循环就会再次开始。
以下是 dictRehash 函数的完整实现,它清晰地说明了如何轮换 0 号哈希表和 1 号哈希表,以及,如何将 0 号哈希表的元素 rehash 到 1 号哈希表:
/* Performs N steps of incremental rehashing. Returns 1 if there are still
* keys to move from the old to the new hash table, otherwise 0 is returned.
* Note that a rehashing step consists in moving a bucket (that may have more
* thank one key as we use chaining) from the old to the new hash table. */
int dictRehash(dict *d, int n) {
if (!dictIsRehashing(d)) return 0;
while(n--) {
dictEntry *de, *nextde;
// 如果 0 号哈希表为空,使用 1 号哈希表代替它
/* Check if we already rehashed the whole table... */
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;
}
// 进行 rehash
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
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];
/* Move all the keys in this bucket from the old to the new hash HT */
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;
d->rehashidx++;
}
return 1;
}
|
另外,还有一个确保 rehash 得以最终完成的重要条件,那就是 —— 当 rehashidx 不等于 -1 ,也即是 dictIsRehashing 为真时,所有新添加的元素都会直接被加到 1 号数据库,这样 0 号哈希表的大小就会只减不增,最终 rehash 总会有完成的一刻(假如新加入的元素还继续被放进 0 号哈希表,那么尽管平摊 rehash 一直在努力地进行,但说不定 rehash 还是永远也完成不了):
dictEntry *dictAddRaw(dict *d, void *key)
{
// 被省略的代码...
// 如果字典正在进行 rehash ,那么将新元素添加到 1 号哈希表,
// 否则,使用 0 号哈希表
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
// 被省略的代码...
}
|
另外,除了 _dictRehashStep 以及 dictAddRaw 的特殊处理之外,Redis 还会在每次事件中断器运行的时候,执行一个为时一毫秒的 rehash 操作,在文件 redis.c 中的 serverCron 函数中记录了这一点。
哈希表的大小
在介绍完哈希表的使用流程和 rehash 机制之后,最后一个需要探索的地方就是哈希表的大小了。
我们知道哈希表最初的大小是由 DICT_HT_INITIAL_SIZE 常量决定的,而当 rehash 开始之后,根据给定的条件,哈希表的大小就会发生变动:
static int _dictExpandIfNeeded(dict *d)
{
// 被省略的代码...
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].size > d->ht[0].used) ?
d->ht[0].size : d->ht[0].used)*2);
}
// 被省略的代码...
}
|
可以看到, d->ht[0].size 和 d->ht[0].used 两个数之间的较大者乘以 2 ,会作为 size 参数的值被传入 dictExpand 函数。
但是,尽管如此,这个数值仍然还不是哈希表的最终大小,因为在 dictExpand 里面,真正的哈希表大小需要 _dictNextPower 函数根据传入的 size 参数计算之后才能得出:
int dictExpand(dict *d, unsigned long size)
{
// 被省略的代码...
// 计算哈希表的(真正)大小
unsigned long realsize = _dictNextPower(size);
// 被省略的代码...
}
|
_dictNextPower 不断计算 2 的乘幂,直到遇到大于等于 size 参数的乘幂,就返回这个乘幂作为哈希表的大小:
static unsigned long _dictNextPower(unsigned long size)
{
unsigned long i = DICT_HT_INITIAL_SIZE;
if (size >= LONG_MAX) return LONG_MAX;
while(1) {
if (i >= size)
return i;
i *= 2;
}
}
|
虽然桶的元素个数 d->ht[0].size 刚开始是固定的( DICT_HT_INITIAL_SIZE ),但是,因为我们没有办法预知 d->ht[0].used 的值,所以我们没有办法准确预估新哈希表的大小,不过,我们可以确定以下两个关于哈希表大小的性质:
1) 哈希表的大小总是 2 的乘幂(也即是 2^N,此处 N 未知)
2) 1 号哈希表的大小总比 0 号哈希表大
小结
以上就是 Redis 字典结构的实现分析了,因为边幅所限,这里展示的函数多数都只贴出了主要部分的代码,如果对所有代码的细节感兴趣,可以到我的 GITHUB 上去找带有完整注释的代码:https://github.com/huangz1990/reading_redis_source