字典 dict

@[toc] --- Redis 所使用的 C 语言并没有内置字典结构,因此 Redis 构建了自己的字典实现(底层其实就是数组 + 链表) ## dict 的定义 **字典 dict**: <img src="https://cs-wiki.oss-cn-shanghai.aliyuncs.com/img/20220609152550.png" style="zoom: 50%;" /> 重点是 dictht ht[2] 和 rehashidx 属性,rehashidx 主要是用在 rehash 过程中 ht 属性是一个包含两个项的数组,数组中的每个项都是一个 dictht 哈希表(每个字典都使用两个哈希表) 一般情况下,字典只使用 ht[0] 哈希表,ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用 **哈希表 dictht**: <img src="https://cs-wiki.oss-cn-shanghai.aliyuncs.com/img/20220609152626.png" style="zoom:50%;" /> 重点是 dictEntry 和 sizemask,sizemask 用来计算 key 的索引值,总是等于 size - 1 > key 对应的哈希表数组中索引值的计算方法: > > 1. 先计算 key 的哈希值:hash = dict.type.`hashFunction(key)` > 2. 再根据哈希值计算索引值:index = `hash & dict.ht[x].sizemask` **哈希表节点 dictEntry** <img src="https://cs-wiki.oss-cn-shanghai.aliyuncs.com/img/20220609152806.png" style="zoom:50%;" /> 每个 dictEntry 结构都保存着一个键值对 next 属性是指向另一个哈希表节点的指针,这个指针用来将多个哈希值相同的键值对连接在一次,以此来解决**键冲突**(collision)的问题 (**链地址法**)。 另外,因为 dictEntry 节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(头插法),复杂度为 O(1) ![](https://cs-wiki.oss-cn-shanghai.aliyuncs.com/img/20220609152833.png) ## 重新散列 rehash ### 什么是 rehash? 随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的**负载因子(load factor)**维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩,而这个过程又伴随着键值对的迁移。 **什么是负载因子?** 负载因子 = 哈希表已保存节点数量 / 哈希表大小 - 负载因子越大说明这个哈希表越拥挤 - 负载因子越小说明这个哈希表越稀疏 **rehash 的具体步骤**: 步骤 1:为字典的 ht[1] 哈希表分配空间。这个哈希表的空间大小取决于要执行的操作,以及 ht[0] 当前已有的键值对数量(也即是 `ht[0].used` 属性的值) - 如果执行的是扩展操作,那么 ht[1] 的大小为第一个 >= `ht[0].used * 2` 的 $2^n$ > 举个例子,ht[0].used = 6,6 * 2 = 12,比 12 大的第一个 $2^n$ 的数是 16,所以如果执行的是扩展操作,那么 ht[1] 的大小就是 16 - 如果执行的是收缩操作,那么 ht[1] 的大小为第一个 >= `ht[0].used` 的 $2^n$ 步骤 2:将 ht[0] 的键值对迁移(rehash)到 ht[1] 中,当然,由于数组大小发生变化,每个 key 对应的哈希值和索引值也相应地会发生变化 步骤 3:当 ht[0] 包含的所有键值对都迁移到了 ht[1]之后(ht[0] 变为空表),释放 ht[0],将 ht[1] 设置为 ht[0],并新创建一个空白哈希表作为新的 ht[1],为下一次 rehash 做准备 ### 渐进式 rehash **什么是渐进式 rehash?** 扩展或收缩哈希表需要将 ht[0] 里面的所有键值对 rehash 到 ht[1] 里面,但是,这个 rehash 动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的 **为什么需要渐进式?** 如果 ht[0] 里只保存着少数几个键值对,那么服务器可以在瞬间就将这些键值对全部 rehash 到 ht[1] 但是,如果哈希表里保存的键值对数量不是四个,而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部 rehash 到 ht[1] 的话,<u>庞大的计算量可能会导致服务器在一段时间内停止服务</u> **渐进式 rehash 的具体步骤**: 步骤 1:为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表(这一步和 rehash 一样) 步骤 2:在字典中维持一个索引计数器变量 `rehashidx`(dict 里面的属性,上面我提到过让大家额外注意),初始化为 0,表示 rehash 工作正式开始 步骤 3:在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],当 rehash 工作完成之后,程序将 rehashidx 属性的值增一 > 简单来说,就是<u>将所有键值对的 rehash 工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 而带来的庞大计算量</u> 在渐进式 rehash 进行期间,字典的删除、查找、更新等操作会在两个哈希表上进行 - 例如,<u>要在字典里面查找一个键的话,程序会先在 ht[0] 里面进行查找,如果没找到的话,就会继续到 ht[1] 里面进行查找</u> - 另外,<u>在渐进式 rehash 执行期间,新添加到字典的键值对一律会被保存到 ht[1] 里面</u>,而 ht[0] 则不再进行任何添加操作,这一措施保证了 ht[0] 包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表 步骤 4:最终在某个时间点上,ht[0] 的所有键值对都会被 rehash 至 ht[1],这时程序<u>将 rehashidx 属性的值设为 -1,表示 rehash 操作已完成</u>