今天要介绍的数据结构,是Redis中的哈希表,这种数据结构是Redis中非常重要的一种数据类型,可以方便的处理很多复杂场景的业务需求。
哈希表 的结构定义在 dict.h 文件中,我们抽取代码查看一下:

如图所示, 哈希表是一个结构体类型,包含四个成员属性:
-
table 是一个数组,数组的每个元素都是一个指向 dict.h/dictEntry 结构的指针;
-
size 记录哈希表的大小,即 table 数组的大小,且一定是2的幂;
-
used 记录哈希表中已有结点的数量;
-
sizemask 用于对哈希过的键进行映射,索引到 table 的下标中,且值永远等于 size-1。具体映射方法很简单,就是对 哈希值 和 sizemask 进行位与操作,由于 size 一定是2的幂,所以 sizemask=size-1,自然它的二进制表示的每一个位(bit)都是1,等同于取模;
由上图我们已经知道,哈希表的定义中包含一个table数组,它的每一个元素都是一个指向dictEntry结构类型的指针,其实这里的dictEntry便是哈希表的节点。
哈希表节点 的结构定义在 dict.h 文件中的较前位置,我们查看一下代码:

由上图可知每一个dictEntry结构都是一个健值对,且有一个next指针,来维持节点之间的链表形态。下面我们详细看下每个字段的具体含义:
-
key 是键值对中的键;
-
v 是键值对中的值,它是一个联合类型,方便存储各种结构;
-
next 是链表指针,指向下一个哈希表节点,他将多个哈希值相同的键值对串联在一起,用于解决键冲突;
至此,我们可以看到一个空的哈希结构会是下面这个样子:

我们可以看到,在结构中存有指向dictEntry 数组的指针,而我们用来存储数据的空间即是dictEntry。
哈希冲突
我们知道在哈希数据结构中,key 是唯一的,但是我们存入里面的key 并不是直接的字符串,而是一个hash 值,通过hash 算法将字符串转换成对应的hash 值,是一个数字,然后在dictEntry 中找到对应的位置。
这时候我们会发现一个问题,如果出现hash 值相同的情况怎么办?
Redis 中采用了连地址法(separate chaining)来解决键冲突。每个哈希表节点都有一个next 指针,多个哈希表节点可以使用next 构成一个单向链表,被分配到同一个索引上的多个节点可以使用这个单向链表连接起来解决hash值冲突的问题。
举个栗子,现在哈希表中有以下的数据:k0 和k1

我们现在要插入k2,通过hash 算法计算到k2 的hash 值为2,即我们需要将k2 插入到dictEntry[2]中:

在插入后我们可以看到,dictEntry指向了k2,k2的next 指向了k1,从而完成了一次插入操作(这里选择表头插入是因为哈希表节点中没有记录链表尾节点位置)。
字典
字典用于保存键值对,可以方便的根据key值操作对应的value值。Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点保存了具体的在键值对。Redis的源码dict.h/dict可以看到字典的结构体定义如下:

由上图可以知道,字典包含以下几个字段,我们分别看下其中的含义:
-
type 是一个指向 dict.h/dictType 结构的指针,保存了一系列用于操作特定类型键值对的函数;
-
privdata 保存了需要传给上述特定函数的可选参数;
-
ht 是两个哈希表,一般情况下,只使用ht[0],只有当哈希表的键值对数量超过负载(元素过多)时,才会将键值对迁移到ht[1],这一步迁移被称为 rehash (重哈希),rehash 会在下文进行详细介绍;
-
rehashidx 由于哈希表键值对有可能很多很多,所以 rehash 不是瞬间完成的,需要按部就班,那么 rehashidx 就记录了当前 rehash 的进度,当 rehash 完毕后,将 rehashidx 置为-1;
通过以上的学习,我们可以知道,一个普通状态下的字典大致如下图所示:

Rehash
随着字典操作的不断执行,哈希表保存的键值对会不断增多(或者减少),为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,需要对哈希表大小进行扩展或者收缩(Rehash)。首先有几个概念我们需要理解:
负载因子
这里提到了一个负载因子,其实就是当前已使用结点数量除上哈希表的大小,即:
load_factor = ht[0].used / ht[0].size
哈希表扩展
-
当哈希表的负载因子大于5时,为 ht[1] 分配空间,大小为第一个大于等于 ht[0].used * 2 的 2 的幂;
-
将保存在 ht[0] 上的键值对 rehash 到 ht[1] 上,rehash 就是重新计算哈希值和索引,并且重新插入到 ht[1] 中,插入一个删除一个;
-
当 ht[0] 包含的所有键值对全部 rehash 到 ht[1] 上后,释放 ht[0] 的控件, 将 ht[1] 设置为 ht[0],并且在 ht[1] 上新创件一个空的哈希表,为下一次 rehash 做准备;
哈希表收缩
哈希表的收缩,同样是为 ht[1] 分配空间, 大小等于 max( ht[0].used, DICT_HT_INITIAL_SIZE ),然后和扩展做同样的处理即可。
下面我们看一个rehash的完整过程:
分配空间
因此这里我们为ht[1] 分配 空间为8。

数据转移
将ht[0]中的数据转移到ht[1]中,在转移的过程中,需要对哈希表节点的数据重新进行哈希值计算;数据转移后的结果:

释放ht[0]
将ht[0]释放,然后将ht[1]设置成ht[0],最后为ht[1]分配一个空白哈希表:

自此,一个rehash的过程,全部完成。
上面我们说到,在进行拓展或者压缩的时候,可以直接将所有的键值对rehash 到ht[1]中,这是因为数据量比较小。在实际开发过程中,这个rehash 操作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。
渐进式 rehash
渐进式rehash 的详细步骤:
-
为ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;
-
在几点钟维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash 开始
-
在rehash 进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将ht[0]中的数据rehash 到ht[1]表中,并且将rehashidx的值+1;
-
当ht[0]中所有数据转移到ht[1]中时,将rehashidx 设置成-1,表示rehash 结束;
采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。
我们了解了字典结构的方方面面,那么redis的哈希类型是不是一定用字典来存储呢?为了节省空间,存储有两种可能:压缩列表 和 字典。
哈希对象存储的编码转换,当哈希对象可以同时满足以下2个条件时, 哈希对象使用压缩列表编码:
-
哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
-
哈希对象保存的键值对数量小于 512 个;
不能满足这两个条件的哈希对象需要使用 hashtable 编码。
在以后的文章中会详细介绍压缩列表到底是什么?为什么在有些情况会采用压缩列表来存储对象。
最后面我们看下哈希类型键可能会应用在哪些方面:
应用场景:
我们简单举个实例来描述下Hash的应用场景,比如我们要存储一个用户信息对象数据,包含以下信息:
-
用户ID,为查找的key
-
存储的value用户对象包含姓名、年龄、生日等信息
另外一种可能的场景是购物车,key是用户的ID,field是不同的商品标识,值是商品的数量。这样可以很方便的维护一个用户的购物车信息。当然还有很多其他的场景,在此我们不再赘述,欢迎读者自己探索发现。
自此,我们已经比较详细的了解了哈希的设计与实现,了解PHP的同学有没有意识到这里的哈希表与PHP底层强大的哈希表有什么区别呢?
先留个悬念,后面的文章我们将会详细对比,一一分析。

欢迎扫码,关注我的微信公众号

本文深入探讨Redis中的哈希表数据结构,包括其内部结构、哈希冲突处理、字典和rehash机制,以及应用场景。
1177

被折叠的 条评论
为什么被折叠?



