本文转自 redis 的设计与实现
结构
Redis 的 Hash 类型键使用以下两种数据结构作为底层实现:
- 字典;
- 压缩列表;
因为压缩列表比字典更节省内存, 所以程序在创建新 Hash 键时, 默认使用压缩列表作为底层实现, 当有需要时, 程序才会将底层实现从压缩列表转换到字典。
Dict哈希表
Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:
1 | typedef struct dictht { |
-
table 属性是一个数组, 数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针, 每个 dictEntry 结构保存着一个键值对。
-
size 属性记录了哈希表的大小, 也即是 table 数组的大小, 而 used 属性则记录了哈希表目前已有节点(键值对)的数量。
-
sizemask 属性的值总是等于 size - 1 , 这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。
图 4-1 展示了一个大小为 4 的空哈希表 (没有包含任何键值对)。
Dict节点
哈希表节点使用 dictEntry 结构表示, 每个 dictEntry 结构都保存着一个键值对:
1 | typedef struct dictEntry { |
举个例子, 图 4-2 就展示了如何通过 next 指针, 将两个索引值相同的键 k1 和 k0 连接在一起。
Dict 结构
Redis 中的字典由 dict.h/dict 结构表示:
1 | typedef struct dict { |
type 属性和 privdata 属性是针对不同类型的键值对, 为创建多态字典而设置的:
type 属性是一个指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。
而 privdata 属性则保存了需要传给那些类型特定函数的可选参数。
1 | typedef struct dictType { |
-
ht 属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
-
除了 ht[1] 之外, 另一个和 rehash 有关的属性就是 rehashidx : 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。
图 4-3 展示了一个普通状态下(没有进行 rehash)的字典:
哈希算法以及解决冲突
- 哈希算法
1 | # 使用字典设置的哈希函数,计算键 key 的哈希值 |
-
解决冲突
在哈希表实现中, 当两个不同的键拥有相同的哈希值时, 称这两个键发生碰撞(collision), 而哈希表实现必须想办法对碰撞进行处理。
字典哈希表所使用的碰撞解决方法被称之为链地址法: 这种方法使用链表将多个哈希值相同的节点串连在一起, 从而解决冲突问题。
rehash
rehash 发生的原因
随着操作的不断执行, 哈希表保存的键值对会逐渐地增多或者减少, 为了让哈希表的负载因子(load factor)维持在一个合理的范围之内, 当哈希表保存的键值对数量太多或者太少时, 程序需要对哈希表的大小进行相应的扩展或者收缩。
其中哈希表的负载因子可以通过公式:
负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
计算得出。
比如说, 对于一个大小为 4 , 包含 4 个键值对的哈希表来说, 这个哈希表的负载因子为:
load_factor = 4 / 4 = 1
又比如说, 对于一个大小为 512 , 包含 256 个键值对的哈希表来说, 这个哈希表的负载因子为:
load_factor = 256 / 512 = 0.5
根据 BGSAVE
命令或 BGREWRITEAOF
命令是否正在执行, 服务器执行扩展操作所需的负载因子并不相同, 这是因为在执行 BGSAVE
命令或 BGREWRITEAOF
命令的过程中, Redis
需要创建当前服务器进程的子进程, 而大多数操作系统都采用写时复制(copy-on-write)
技术来优化子进程的使用效率, 所以在子进程存在期间, 服务器会提高执行扩展操作所需的负载因子, 从而尽可能地避免在子进程存在期间进行哈希表扩展操作, 这可以避免不必要的内存写入操作, 最大限度地节约内存。
另一方面, 当哈希表的负载因子小于 0.1 时, 程序自动开始对哈希表执行收缩操作。
rehash 过程
-
为字典的 ht[1] 哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及 ht[0] 当前包含的键值对数量 (也即是 ht[0].used 属性的值):
-
如果执行的是扩展操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2^n (2 的 n 次方幂);
如果执行的是收缩操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n 。 -
将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上。
-
当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。
举个例子, 假设程序要对图 4-8 所示字典的 ht[0] 进行扩展操作, 那么程序将执行以下步骤: -
ht[0].used 当前的值为 4 , 4 * 2 = 8 , 而 8 (2^3)恰好是第一个大于等于 4 的 2 的 n 次方, 所以程序会将 ht[1] 哈希表的大小设置为 8 。 图 4-9 展示了 ht[1] 在分配空间之后, 字典的样子。
-
将 ht[0] 包含的四个键值对都 rehash 到 ht[1] , 如图 4-10 所示。
-
释放 ht[0] ,并将 ht[1] 设置为 ht[0] ,然后为 ht[1] 分配一个空白哈希表,如图 4-11 所示。
至此, 对哈希表的扩展操作执行完毕, 程序成功将哈希表的大小从原来的 4 改为了现在的 8 。
渐进式rehash
在上一节,我们了解了字典的 rehash 过程, 需要特别指出的是, rehash 程序并不是在激活之后,就马上执行直到完成的, 而是分多次、渐进式地完成的。
假设这样一个场景:在一个有很多键值对的字典里, 某个用户在添加新键值对时触发了 rehash 过程, 如果这个 rehash 过程必须将所有键值对迁移完毕之后才将结果返回给用户, 这样的处理方式将是非常不友好的。
另一方面, 要求服务器必须阻塞直到 rehash 完成, 这对于 Redis 服务器本身也是不能接受的。
为了解决这个问题, Redis 使用了渐进式(incremental)的 rehash 方式: 通过将 rehash 分散到多个步骤中进行, 从而避免了集中式的计算。
渐进式 rehash 主要由 _dictRehashStep
和 dictRehashMilliseconds
两个函数进行:
_dictRehashStep
用于对数据库字典、以及哈希键的字典进行被动 rehash ;
dictRehashMilliseconds
则由 Redis 服务器常规任务程序(server cron job)执行,用于对数据库字典进行主动 rehash ;
_dictRehashStep
每次执行 _dictRehashStep
, ht[0]->table 哈希表第一个不为空的索引上的所有节点就会全部迁移到 ht[1]->table 。
在 rehash 开始进行之后(d->rehashidx 不为 -1), 每次执行一次添加、查找、删除操作, _dictRehashStep
都会被执行一
因为在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0] 和 ht[1] 两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。
另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。
字典的收缩
扩展 rehash 和收缩 rehash 执行完全相同的过程, 一个 rehash 是扩展还是收缩字典, 关键在于新分配的 ht[1]->table 的大小
1 | /* |
在默认情况下, REDIS_HT_MINFILL
的值为 10
, 也即是说, 当字典的填充率低于 10%
时, 程序就可以对这个字典进行收缩操作了。
字典收缩和字典扩展的一个区别是:
- 字典的扩展操作是自动触发的(不管是自动扩展还是强制扩展);
- 而字典的收缩操作则是由程序手动执行。
因此, 使用字典的程序可以决定何时对字典进行收缩:
- 当字典用于实现哈希键的时候, 每次从字典中删除一个键值对, 程序就会执行一次
htNeedsResize
函数, 如果字典达到了收缩的标准, 程序将立即对字典进行收缩; - 当字典用于实现数据库键空间(key space)的时候, 收缩的时机由 redis.c/tryResizeHashTables 函数决定。
字典的迭代
字典带有自己的迭代器实现 —— 对字典进行迭代实际上就是对字典所使用的哈希表进行迭代:
迭代器首先迭代字典的第一个哈希表,然后,如果 rehash 正在进行的话,就继续对第二个哈希表进行迭代。
- 当迭代哈希表时,找到第一个不为空的索引,然后迭代这个索引上的所有节点。
- 当这个索引迭代完了,继续查找下一个不为空的索引,如此反覆,直到整个哈希表都迭代完为止。
整个迭代过程可以用伪代码表示如下:
1 | def iter_dict(dict): |
字典的迭代器有两种:
- 安全迭代器:在迭代进行过程中,可以对字典进行修改。
- 不安全迭代器:在迭代进行过程中,不对字典进行修改。
以下是迭代器的数据结构定义
1 | /* |
API
函数 | 作用 | 时间复杂度 |
---|---|---|
dictCreate |
创建一个新的字典。 | O(1) |
dictAdd |
将给定的键值对添加到字典里面。 | O(1) |
dictReplace |
将给定的键值对添加到字典里面, 如果键已经存在于字典,那么用新值取代原有的值。 | O(1) |
dictFetchValue |
返回给定键的值。 | O(1) |
dictGetRandomKey |
从字典中随机返回一个键值对。 | O(1) |
dictDelete |
从字典中删除给定键所对应的键值对。 | O(1) |
dictRelease |
释放给定字典,以及字典中包含的所有键值对。 | O(N) ,
N 为字典包含的键值对数量。 |
skiplist与平衡树、哈希表的比较
-
skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
-
在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
-
平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
-
从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
-
查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。
从算法实现难度上来比较,skiplist比平衡树要简单得多。
赞赏一下