
分享老师:齐雯
Redis中都有哪里用到了哈希表?
哈希表在 Redis 中作为“字典”数据结构的底层实现,在Redis 中应用广泛,例如,Redis 数据库本身的就是利用“字典” 这种数据结构来实现的。另外,Redis 有五种数据类型,其中 Hash(哈希)和 ZSet(有序集合)的底层实现也使用了 “字典” 数据结构。
注:以下代码来自于 Redis 5.0.4版本的源码。
1. Redis数据库
我们都知道 Redis 是 Key- Value 型的内存数据库,它的底层数据结构就是“字典”。
我们在 src/server.h 中可以看到,Redis 服务器用 redisServer 结构来表示,每个 redisServer 包括多个 redisDb。每个 redisDb 都使用了 dict 字典数据结构来实现(一个 dict 包含多个键值对):

由此可见,redisServer、redisDb 和 dict 字典的关系可以用下图来表示(dict 的结构在后面会具体介绍,这里先了解一下 redisDb 与 dict 的关系即可):
2. 哈希对象
先说下 Redis 中的对象。在 Redis 中并没有直接使用 “字典”、“压缩列表”、“字符串” 等数据结构来实现 Key - Value 数据库,而是采用了对象,包括 “字符串对象”、“哈希对象”、“列表对象”、“集合对象” 和 “有序集合对象”(对应我们常用的 5 种 Redis 数据类型),每种对象的底层实现都至少包含了2种数据结构。
哈希对象指的就是我们常用的 hash 数据类型,它的底层数据结构包括两种:“压缩列表”和“字典”。
先来看一下 redis 对象的定义:

这里我们主要看一下 ptr 指针,它是指向该对象底层实现的数据结构,也就是说,对于哈希对象(hash数据类型),ptr 可能指向一个压缩列表,也可能指向一个字典。
当我们使用哈希对象存储数据时,它会根据我们存储数据的大小是否达到某个阈值来判断其底层使用哪种数据结构,其在 server.h 中的定义如下:

以上两行代码说明了使用 ziplist 作为底层实现的哈希对象需要满足的两个条件,如果其中一个不满足,那么哈希对象就会使用字典 作为底层实现:
①保存的键值对(ENTRY)最大为 512 个;
②保存的所有键值对的键和值的字符串长度都小于 64 字节。
下图是当哈希对象使用字典作为底层实现时的结构:(redisObject 的类型为 REDIS_HASH)
3. 有序集合对象
有序集合对象指的就是我们常用的 zset数据类型,它的底层数据结构是“压缩列表”和“跳表”,其中“跳表”使用一个 zset 结构体实现,这个zset 结构体中同时包含一个“跳表”和一个“字典”。

Redis中字典的实现
1. 字典
字典的定义在 dict.h 中,由 dict 结构体表示 :

type 和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置的:
type 是一个指向 dictType 结构的指针,每个 dictType 保存了一组用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
privdata 保存了需要传给类型特定函数的可选参数
dictType 结构实现如下:

字典和哈希表的结构图如下:
2. 哈希表
字典中所使用的哈希表,定义在dict.h 中,由 dictht 结构体表示。

下图是一个大小为 4 的空哈希表:
3. 哈希表节点
哈希表节点定义在 dict.h 中,由 dictEntry 结构体表示。一个哈希表包含多个节点,每个节点保存着一个键值对。

dictEntry 中的定义说明:
key 是一个指针,通常指向 stringObject,就像我们在 Redis 数据库中看到的那样;
v 是一个 union 联合体,也是就是键值对中的值可以是整数、浮点型数、或指针,指针同样可以指向其他对象(例如 stringObject、listObject 等);
next 指向下一个哈希表节点(dictEntry),形成一个链表,用来解决哈希键冲突。
下图是一个大小为 4 的哈希表,节点数为 2,通过 next 指针链接,形成链表。其中 k0 和 k1 是产生哈希冲突的节点,这里先简单了解冲突时采用链表法的,后面会讲到解决哈希冲突的具体操作。

由以上 3 个结构体的定义,我们可以得出字典、哈希表、哈希节点的结构,大致就是下图的样子(以普通状态的字典为例):
由于没有正在进行rehash,所以 rehashidx 值为 -1;
ht[1] 的大小是 0,没有在使用;
ht[0] 的大小是 4,表示该哈希表有 4 个节点,实际存储的键值对数量为 2。
4. 哈希算法
当要将一个新的节点(键值对)添加到字典中时,需要先根据键值对的键计算出哈希值和索引值,然后将这个新节点放到哈希表数组的索引值对应的位置。
Redis 计算哈希值和索引值的方法如下:
Redis字典如何解决键冲突
当两个或以上数量的键计算出的索引值相同时,就会发生键冲突。
Redis 哈希表使用拉链法解决键冲突,每个哈希表节点(dictEntry)都有一个 next 指针,多个冲突的节点通过 next 构成一个单链表(Bucket),被分配到哈希表的同一个索引上。
每次发生建冲突的新节点会被添加到链表的表头位置,可能是出于以下两点的考虑:
从添加角度:由于dictEntry 节点组成的链表没有指向链表尾部的指针,如果插入到尾部,需要从头到尾遍历一遍;
从查找角度:基于最新插入的节点更有可能被频繁使用的假设(见 dict.c 文件中 dictAddRaw 方法里的注释);
下图是链表法解决 k1 和 k2 的键冲突:
先向字典插入 k1 - v1,计算出 k1 的 index = 2;
又向字典插入 k2 - v2,计算出 k2 的index = 2,与 k1 冲突,则将 k2 插入到索引值为 2 的Bucket的链表的头部。
字典的 rehash(重新散列)
1. 字典的 rehash
负载因子 = 已使用节点数 / 哈希表大小。
为了让负载因子维持在一个合理范围内,当哈希表保存的键值对数量太多或者太少时,程序会对哈希表的大小进行扩容或缩容。扩容或缩容都可以通过 rehash 操作来完成。
Redis 对字典的哈希表执行 rehash 的步骤如下:
1)为 ht[1] 哈希表分配空间,大小取决于要执行什么操作,以及当前的ht[0].used 值;
如果是扩容操作,ht[1] 的大小是:ht[0].used * 2 的的值,再向上取第一个 2 的 n 次幂;
如果是缩容操作,ht[1] 的大小是:第一个大于等于 ht[0].used 的 2 的 n 次幂。
2)将保存在 ht[0] 中的所有节点(键值对)搬移到 ht[1] 上,搬移时需要重新计算键的哈希值和索引值,将节点放到ht[1] 的新的索引值位置上;
3)当 ht[0] 所有的节点都搬到了 ht[1] 后,释放 ht[0].table,将 ht[1] 设置成 ht[0],将 ht[1] 重置为空白哈希表,为下次 rehash 做准备。
下面为字典 rehash 的过程:
0)字典初始状态,rehashidx = -1,ht[0] 表中有 4 个节点。

1)为 ht[1] 分配空间,size = 2 * 4 = 8(刚好是 2 的3 次幂):

2)为 ht[0] 中每个节点重新计算哈希值和索引值,并搬移到 ht[1] 相应的索引值位置上:

3)将ht[1] 变成 ht[0],ht[1]变成空白表,rehashidx 置为-1,rehash 完成:
2. 渐进式 rehash
为了避免 rehash 操作对服务器性能造成影响,Redis 采用渐进式 rehash 方式,分多次将 ht[0] 中的 bucket 慢慢搬移到 ht[1] 中。每次对字典进行添加、查找、删除或更新时,都会执行一步 rehash 操作。
下面为当向字典添加节点时,执行一步rehash 的代码实现:
向字典添加 key – value,其中调用了 dictAddRaw:

dictAddRaw 的实现:

执行一步 rehash 操作:

字典 rehash 操作,n 表示执行几步(每次搬几个 bucket):

Redis 渐进式 rehash 的步骤总结:
1)为 ht[1] 哈希表分配空间(这一步是在_dictKeyIndex 计算 key 的索引值时实现的,上边的代码没有体现出来);
2)使用字典中的 rehashidx 变量(可以看做一个计数器),将其设置为 0,表示 rehash 开始,也表示 ht[0] 中第一个搬移的节点的 index 值;
3)在 rehash 进行时,每次对字典执行添加、删除、查找或更新操作时,程序都会将 ht[0] 中索引值为 rehashidx 的节点搬移到ht[1] 上,并将 rehashidx 加 1;
4)当所有节点搬移完成,rehashidx 重新被置为 -1,表示rehash 操作已经完成。
渐进式 rehash 期间的哈希表操作:
在 rehash 期间,字典会同时维护ht[0] 和 ht[1] 两张表;
字典的删除、查找、更新等操作会在两个哈希表上进行;
字典的添加操作只会在 ht[1] 上进行。
过程图示:
1)为 ht[1] 分配了空间,准备进行 rehash:

2)rehashidx= 0,表示从 ht[0] 的第 0 个 bucket(对应k2 - v2)开始搬移,计算k2在ht[1] 中新的索引值为4,将其搬到 ht[1] 中 index 为 4 的bucket。

3)rehashidx= 1,继续搬移ht[0] 中第 1 个bucket(k0 - v0),计算出 k0 在 ht[1] 中新的索引值为5,将其搬到 ht[1] 中 index 为 5 的bucket。

4)rehashidx = 2,继续搬移 ht[0] 中第 2 个 bucket(k3 - v3),计算出 k3 在 ht[1] 中新的索引值为 1,将其搬到ht[1] 中 index 为 1 的 bucket(图略)。
5)rehashidx = 3,继续搬移 ht[0] 中第 3 个(也是最后一个) bucket(k1 - v1),计算出 k1 在 ht[1] 中新的索引值为 7,将其搬到ht[1] 中 index 为 7 的 bucket。

6)所有 bucket 已经全部搬移到 ht[1],释放 ht[0],将ht[1] 变成 ht[0],为 ht[1] 重新生成空表,将 rehashidx 重新置为-1,为下次rehash 做准备。

除了在对字典进行添加、删除、查找或更新操作时会执行一步 rehash 以外,Redis 还会使用定时任务在空闲时根据时间范围来执行 rehash:
字典的遍历 - 迭代器
字典的遍历(例如 Redis 的keys 命令)是利用迭代器实现的,Redis字典的迭代器分为两种:安全迭代器和非安全迭代器。
字典迭代器的结构定义如下:
1.非安全迭代器
当字典使用非安全迭代器时,只能对字典进行 dictNext() 操作,即遍历下一个节点,但允许单步 rehash。
下面是获取非安全迭代器的方法,其实就是对迭代器进行初始化,并将 safe 设置为 0:
2. 安全迭代器
当字典使用了安全迭代器时,我们仍可以对字典进行添加、删除、查找以及其他对字典进行修改的一些操作,但 rehash 单步操作除外,也就是说,如果字典使用了安全迭代器,则不能对字典进行下一步rehash,这一点我们在前面 rehash 部分的代码中可以看到。

其中 iterators 就是我们在前面的字典结构定义中看到的 “迭代器数量”,当dictIterator 中的 safe 被设置为 1,则每有一个遍历字典的操作,iterators 就会加 1,表示正在使用安全迭代器,此时不能进行单步 rehash。这样是为了防止在使用安全迭代器时由于 rehash 造成的重复遍历(假设字典在遍历过程中执行了一步 rehash,如果迭代器当前遍历 ht[0],rehash 操作会使 ht[0] 的bucket 都搬移到 ht[1],随着迭代的不断进行,迭代器进行到 ht[1] 时,就会遍历到之前在 ht[0] 已经遍历过的元素,导致重复遍历)。
获取安全迭代器的方法如下,其实就是调用了dictGetIterator 方法,然后将 safe 标记为1,表示使用安全迭代器:

注:Redis 中的 keys 命令使用的就是用安全迭代器,具体可见源码 src/db.c 中的 keysCommand 方法。
3. 字典的迭代 - dictNext
dictNext 就是获取下一个要遍历的节点,也就是迭代器中的nextEntry。但某些情况下,字典当前迭代的节点可能是NULL,此时是取不到迭代器 nextEntry 的,因此,字典的迭代操作分为两种情况:
1)当前遍历的节点是 NULL,导致这种情况有两种可能:
①本次迭代是字典的第一次迭代,迭代器 entry 的初始值为 NULL;
②上一次迭代刚好到了某个 bucket 链表的最后一个节点,那么最后一个节点的 next 节点就为 NULL。
在这种情况下,字典的迭代过程如下:
①判断当前正在遍历的哈希表是否为第一次迭代,如果是,则需要给迭代器设置标记(如果是安全迭代器,将迭代器数量 +1;如果是非安全迭代器,则计算指纹值)。
②将迭代器的 index 值 +1,向后迭代。如果是上述情况①,即第一次迭代,则 index 从 -1 变为 0,表示从当前哈希表的第 0 个 bucket 开始,正式进行第一次迭代;如果是情况②,即已经遍历到某个bucket 链表的尾部,那么 index 值 +1 以为着继续迭代下一个bucket 中的节点。
③将迭代器的 entry 设置为哈希表中 index 对应的 bucket,表示迭代器当前遍历到的节点,同时记录当前节点的下一个结点,保存在迭代器的 nextEntry 中。
注:在此过程中,index 值 +1 后需要对其进行超容的判断,并且如果字典正在进行 rehash,当 ht[0] 迭代完后还要继续在 ht[1] 中进行迭代,具体可以参见代码。
2)当前遍历的节点不是 NULL,这时就直接将迭代器中的 nextEntry 返回即可,因为在每次迭代的过程中,都会记录当前节点的next节点,保存到 nextEntry 中。这样做是为了防止在迭代过程中,一旦哈希表中的节点被删除(安全迭代器是允许对字典的节点进行删除操作的),造成节点的 next 发生改变,可能导致无法找到下一个要迭代的节点。
dictNext 的具体实现代码如下:

总结
哈希表在 Redis 中是非常重要的一种数据结构,本文首先介绍了 Redis 中都有哪些地方用到了哈希表,以及字典在 Redis 中是如何使用哈希表来实现的,接着介绍了字典如何解决键冲突、如何进行渐进式Rehash等,最后简单介绍了字典中迭代器的实现。
以上内容都是笔者通过阅读 Redis 源码,并参考《Redis实现与设计》一书中的讲解后,把自己对这一部分的理解归纳总结的结果,如有不对的地方,欢迎大家一起讨论~