Redis学习记录:字典(dict)源码分析
- 最近在看《redis设计与实现》这本书,对于其中一些部分内容我还是很感兴趣的,由此抽空会切进去学习一下实现源码,并且在CSDN上记录一下吧。
- 个人的《redis设计与实现》读书笔记记录:https://github.com/zgg2001/redis_learning
一、前言
字典是目前使用率蛮高的一种KV存储数据结构,简单说就是一个key对应一个value,并且可以确保通过key可以快速的获取value。
字典的具体实现还是有很大的差异的。在C++中一般字典结构被称为map,目前stl常用的字典结构有std::map
和std::unordered_map
,前者的底层实现是红黑树,后者的底层实现为哈希表,两者都可以实现高效率的查询/插入操作。
回归到redis上,redis中字典的出场率很高,毕竟redis本来就是一个KV内存数据库。可以说其底层就是使用字典来实现的,而常规的增删查改也是基于字典来进行的。但是C语言中是没有内置字典数据结构的,所以redis自己构建了字典实现。
二、redis字典的实现思路
redis的字典底层实现采用哈希表,总体思路和C++里的std::unordered_map
思路差不多,简单来说都是哈希表+开链法。但是由于C语言里也没有自己的std::vector
,所以redis的字典存储空间也是需要自己通过malloc/free
管理。
从具体实现上来看,redis字典主要由三部分组成:字典部分、哈希表部分、哈希表节点部分。从关系上来看,字典部分含有两张哈希表,而哈希表中含有若干哈希表节点。
redis的字典思路其实还算是简单,但是其中很多策略和细节的实现我都蛮感兴趣的,所以还是会细看一下。其中个人比较感兴趣的部分有开链法的代码部分以及渐进式rehash的实现部分。
三、实现源码分析
1. 哈希表节点数据结构
1 | typedef struct dictEntry { |
如上为哈希表节点的数据结构,可以说是字典结构里的最底层数据结构。简而言之一个哈希表节点由常规的key
、value
以及一个next
指针组成,还是比较清晰的。
key
的类型为void
指针,来实现存各式各样的内容。redis字典的键部分一般都是字符串对象,所以就没有像值部分那样使用联合体来特化内容。
value
部分使用一个联合体,其中有void*
类型和有/无符号64位int
类型,另外新版本的redis中好像也添加了高精度浮点数double
类型。这里使用联合体主要还是为了节省内存,避免存数值内容时的内存浪费。
next
指针部分算是开链法的具体实现,即通过这个指针来把哈希冲突的节点给连接起来,来解决哈希冲突问题。
2. 哈希表数据结构
1 | typedef struct dictht { |
如上为哈希表的数据结构,dictht
通过一个dictEntry
指针数组来建表。除了基础的表结构,此数据结构中也存放了已使用大小、总大小和大小掩码。
首先是二级指针table
,用于指向一个哈希表节点数组,来保存哈希表节点。所以这部分内存实际上是由字典直接进行管理的,不像是STL里使用std::vector
当底层。
然后说一下sizemask
这个属性,其值总是等于size - 1
,其实也就是指针数组的下标范围(0 ~ size - 1),相关公式为index = hash & sizemask
,来避免索引值超出数组范围。redis字典的扩容策略要求哈希表大小全部为2的n次方,所以这里进行减一操作就可以获取范围掩码了,还是挺巧妙的。
剩下的两个属性size
和used
就比较常规了,没什么好说的。
3. 字典数据结构
1 | typedef struct dict { |
如上为字典的数据结构。首先看最重要的,属性ht
用于存放哈希表,而且可以看到是用大小为2的数组存的,主要就是为了方便进行渐进式rehash操作,而下面的rehashidx
属性用来配合记录rehash进度,rehash部分下面会进行具体分析。
其次privdata
属性是一个void*
指针,指向一些特定参数,这些参数用于配合dictType
里的回调函数。
接下来来看type
属性,其类型为dictType
指针。具体来看内容,dictType
结构体里存了一组回调函数,其结构体内容如下:
1 | typedef struct dictType { |
可以看到是一组用于操作指定类型键值对的函数,不同的字典配备函数也可能不同,算是为了方便进行泛型编程吧。
4. 渐进式rehash部分
首先讲一下redis字典的rehash策略,redis中为了防止在进行扩展/收缩的rehash时,由于数据过多造成服务器停止服务,采用了渐进式rehash思路。即rehash不一次性进行完毕,而是分多次、渐进式的对键值对进行rehash操作。
首先实现渐进式rehash的基础就是得有可记录的、独立的两张新表,对此redis数据结构dict
以dictht
数组的形式存放哈希表,数组大小为2。日常使用时使用[0]
表,在扩展/收缩时,为[1]
分配新表,并渐进式将[0]
表上的键值对rehash分配到[1]
表上。当rehash完成时,[1]
表拥有所有键值对而[0]
表为空,此时释放[0]
表,并将[1]
表设置[0]
表,再将[1]
表置空,此时渐进式rehash流程完全完成。
还有一个值得一提的重点就是渐进式rehash的具体过程:redis字典数据结构dict
中有一个rehash索引rehashidx
,每当对rehash过程中的字典进行增删查改操作时,程序除了进行指定操作外,还会将[0]
表在rehashidx
索引上的所有键值对rehash到[1]
表上,随后索引rehashidx
值自增一。当渐进式rehash过程结束后,索引rehashidx
置为-1,意为未在rehash过程中。
此外,还需要注意的一个细节是渐进式rehash过程中增删查改的操作。在这个过程中,字典是同时使用[0]
和[1]
两张表的,所以删、查、改操作会同时在两张表上进行,来保证不会漏数据。例如查找一个键时,会先在[0]
表上找,如果没有则会去[1]
表上找,找不到再返回。而增操作则会直接将新键值对放在[1]
表上,[0]
表不进行任何添加操作,这主要是为了保证[0]
表数据只减不增,并随着rehash操作最终变成空表。
然后来看一下具体的实现源码:
1 | int dictRehash(dict *d, int n) { |
可以看到在渐进式rehash的过程里,碰到空索引会跳过并且会自动找下一个非空索引,直至完成指定步数或者[0]
表为空。当[0]
表为空时,进行释放、交换、重置三连,并标记渐进式rehash结束。
渐进式rehash的每一步都会把一个索引处内的一条链表rehash至[1]
表上,其中涉及到索引更新、计数器更新和置空等操作。
最后看一下各种情况下进行rehash的源码部分:
1 | static void _dictRehashStep(dict *d) { |
首先是封装好的进行单次rehash操作的函数。
1 | dictEntry *dictAddRaw(dict *d, void *key) |
尝试插入键会触发单步rehash。
1 | static int dictGenericDelete(dict *d, const void *key, int nofree) |
查找删除节点会触发单步rehash。
1 | dictEntry *dictFind(dict *d, const void *key) |
只进行节点查找也会触发单步rehash。
1 | dictEntry *dictGetRandomKey(dict *d) |
随机返回任一节点也会触发单步rehash。
5. 扩容部分
首先来讲一下redis字典的空间分配策略,总结哈希表空间变化规律如下:
- 当执行扩展操作时:新的大小为第一个大于等于当前已使用大小二倍的2的n次方。例如当前已使用大小为4,执行扩容操作,扩容后哈希表总大小为8(8 >= 4 * 2)。
- 当执行收缩操作时:新的大小为第一个大于等于当前已使用大小的2的n次方。例如当前已使用大小为3,执行收缩操作,收缩后哈希表总大小为4(4 >= 3)。
哈希表扩展和收缩的情况如下:(负载因子:已使用/总量)
- 当负载因子小于0.1时,进行收缩操作。
- 当未执行BGSAVE/BGREWRITEAOF命令时,负载因子大于等于1时,进行扩展操作。
- 当在执行BGSAVE/BGREWRITEAOF命令时,负载因子大于等于5时,进行扩展操作。
当执行BGSAVE/BGREWRITEAOF时提高扩展所需负载因子,主要是因为执行这两个命令时都需要redis创建子进程,而此时进行rehash操作可能会触发子进程的”写时复制”机制。所以此时减少rehash操作即可避免不必要的内存写入操作,最大限度的节约内存。
然后来看一下具体的实现源码:
1 | int dictExpand(dict *d, unsigned long size): |
可以看到这部分在获取下一个大小后进行了内存申请,申请了realsize
个dictEntry
指针大小的内存。
1 | // 如果 0 号哈希表为空,那么这是一次初始化: |
这部分主要是为了进入rehash状态,当然如果是初始化就不用进rehash状态。
6. 开链法解决哈希冲突部分
最后我决定看一下开链法的具体实现,毕竟这么有名的思路,看一下redis源码里是怎么写的。首先看一下新增节点的源码部分:
1 | dictEntry *dictAddRaw(dict *d, void *key) |
太长不看,截取出关键部分:
1 | //hash取下标 |
所以实际上redis的开链法实现就是配合着数据结构里的next
指针,简单的把每个新节点头插在对应下标的链表上,非常的简单,也就几行代码,完全没有进行任何哈希冲突方面的判定。
四、总结
redis的字典设计的还是非常巧妙的,尤其是其中的渐进式rehash实现。通过这次看具体代码我也是学到了很多新思路,尤其是看源码前完全没想到开链法实现竟然这么简单。
就个人的感觉来看,redis的代码写的还是蛮清晰简便的,不像C++标准库套来套去非常的恶心。我感觉可能主要是因为C语言比较灵活,没有搞C++强类型那一套,所以通过void*
就可以非常简便的实现泛型编程。其次可能就是redis的代码风格比较精简,确实读起来蛮舒服的(当然也是huangz大佬的书写的好)。