redis设计与实现——字典
在字典中,一个key和一个value进行关联,从而组成键值对,但C语言并没有内置了这种数据结构,因为redis自行构建了。字典是哈希键的底层实现之一。
字典的实现
Redis的字典使用了哈希表作为底层实现,每个表内部含有多个哈希表节点。
哈希表节点
首先是键值对的定义,即每个dictEntry结构保存着一个键值对。
1 | typedef struct dictEntry { |
key-value中的值可以是一个指针,也可能是一个整数或者double。next则是用来以链表的形式解决哈希冲突的问题。
哈希表
1 | /* This is our hash table structure. Every dictionary has two of this as we |
字典
为了使用方便,redis的字典在上面哈希表再多封装一层。
1 | typedef struct dict { |
这里的type属性保存了一个指向dictType结构的指针,而每个dictType结构都包含了一组用于操作特定类型键值对的函数。而private属性则保存了需要传递给那些类型特定函数的可选参数:
1 | typedef struct dictType { |
至此,Redis的封装层级就是dict->dictht->dictEntry
哈希算法
当需要添加一个新的键值对到dict里面,Redis会根据key计算出哈希值和索引值,再把包含键值对的哈希节点放到哈希表数组的指定索引上。
先是调用dictAdd
1 | int dictAdd(dict *d, void *key, void *val) |
其中dictAddRaw会增加一个dictEntry,但不会设置value值,而是由用户自行决定如何设置。同时这个API也直接暴露给用户,用户可以自行调用,比如设置非指针值。
1 | entry = dictAddRaw(dict,mykey,NULL); |
1 | dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing) |
这里的关键是获取index,这样才能新增一个entry,并设置对应的key。准确来说,是先获取对应的hash值,再利用该hash值计算索引index。
1 | // 使用dict设置的哈希函数,计算key的哈希值 |
计算index的过程,首先是判断是否需要扩展dict,然后遍历两个哈希表,在dictEnrty数组中遍历,确保不含有相同的key。
解决键冲突
假设Redis计算得出k1和k2的索引值相同,则这就是发生了冲突。Redis使用链地址法解决冲突。每个哈希表节点都有一个next指针,在发生冲突时就使用next指针将k1和k2所在节点连接起来。同时,由于dictEntry节点组成链表没有指向尾部的指针,为了速度考虑,直接将新节点插入到头部。如上代码所示。
Rehash
首先来看扩展:正在rehashing的直接返回;第一次增加键值对的,直接扩展哈希表的大小到4;接下来是判断在什么情况下,redis会对哈希表进行扩展:
- 服务器没有执行BGSAVE或者BGREWRITEAOF命令时,哈希表的负载因子大于或等于1;
- 服务器正在执行BGSAVE或者BGREWRITEAOF命令时,哈希表的负载因子大于5;
之所以这样设计,是因为在子进程存在期间,操作系统是采用写时复制的技术,此时如果进行哈希扩展,就可能产生大量内存写入操作。
1 | // 至于扩展dict也比较直接 |
至于收缩,当哈希表的负载因子小于0.1时,Redis会进行收缩操作。这一个操作是在server.c里进行的。
1 |
|
渐进式rehash
由前面的结构体可以看到dict中有ht[0]和ht[1],这样的设计可以保证在扩展或者收缩哈希表的时候可以将ht[0]里面的所有键值对rehash到ht[1]。而这一个步骤是分多次、渐进式地完成的,避免过大的计算量导致服务器在一段时间内停止服务。
在_dictExpandIfNeeded中,当满足扩展条件时会调用dictExpand。
1 | int dictExpand(dict *d, unsigned long size) |
在分配完dict之后,就可以进行rehash的操作了。在redis中,有两种rehash方式。
- dictRehashMilliseconds:按照ms计时的rehash操作,是databasesCron中针对redis的DB进行rehash。serverCron后台定时任务会每次调用databasesCron()进而调用incrementallyRehash,每隔一段时间就会执行。
1 | /* Rehash for an amount of time between ms milliseconds and ms+1 milliseconds */ |
在server.c里会调用这个函数,使用了CPU时间的1ms。该函数的作用,是对正在rehash的字典,每次执行1毫秒,每次循环100次的哈希表数据迁移。
1 | int incrementallyRehash(int dbid) { |
- _dictRehashStep:这是一个单步的rehasing,在执行对dict的增删改查中都会被调用
1 | static void _dictRehashStep(dict *d) { |
比如前面的添加键值对,或者查找操作。但相对其它的操作会在两个哈希表中进行,字典的添加只会在ht[1]中直接新增
1 | dictEntry *dictAddRaw(dict *d, void *key) // 新增一个entry |
上面两个函数都调用了通用的rehash函数
1 | int dictRehash(dict *d, int n) { |
因为哈希表可能存在大量的空节点,redis的做法是每次遍历10n个节点,如果还没找到非空节点即返回,这里的n是step数。这样就可以避免rehash的时候,阻塞太久。
另外,结合incrementallyRehash该函数来看,考虑到渐进式rehash在服务器比较空闲的时候将会长时间存在使用两个哈希表的时候。因此在redis的周期函数中,会花费1ms来辅助rehash。具体可以参考server.c/databasesCron():
1 | void databasesCron(void) { |
总结rehash的详细步骤:
- 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表;
- 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始;
- 在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] ,当 rehash 工作完成之后,程序将 rehashidx 属性的值增一。另外,每一步都会遍历至多十个节点,以找到非空节点;
- 随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 rehash 至 ht[1],这时程序将 rehashidx 属性的值设为 -1 ,表示 rehash 操作已完成。
通过将rehash键值对的计算工作分摊到增删改查的操作中,避免了对服务器性能造成影响。
关于rehash,推荐一篇文章,主要讲的是线上遇到的在rehash期间,同时有两个hash表在使用,会使得redis内存使用量瞬间突增的问题:美团针对Redis Rehash机制的探索和实践