字典是 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
我们一直都在努力坚持原创.......请不要一声不吭,就悄悄拿走。
我原创,你原创,我们的内容世界才会更加精彩!
【所有原创内容版权均属TechTarget,欢迎大家转发分享。但未经授权,严禁任何媒体(平面媒体、网络媒体、自媒体等)以及微信公众号复制、转载、摘编或以其他方式进行使用。】
微信公众号
TechTarget
官方微博
TechTarget中国
作者
相关推荐
-
OpenWorld18大会:Ellison宣布数据库的搜寻和破坏任务
在旧金山举行的甲骨文OpenWorld 2018大会中,甲骨文首席技术官(CTO)兼创始人Larry Elli […]
-
ObjectRocket着力发展Azure MongoDB服务
MongoDB吸引了微软公司的注意力,微软公司计划针对运行于该公司2017年发布的Azure Cosmos D […]
-
数据库和数据仓库的区别在哪儿?
目前,大部分数据仓库还是用数据库进行管理。数据库是整个数据仓库环境的核心,是数据存放的地方和提供对数据检索的支持。
-
如何使用服务来平衡Oracle RAC 数据库工作负载
为不同的应用程序配置不同的服务,DBA可以更有效地平衡集群工作负载,在Oracle RAC数据库环境下实现更好的应用程序性能。