Redis-0x0B-数据结构dict
首先我认为数据结构是算法的附着点,数据结构又是对内存布局的描述,一切的行为方法都是围绕内存布局展开的,所以学习数据结构最重要的就是搞清楚内存布局。
其次,数据结构的目的一定是为了解决数据的存取问题,但是为了实现高效的存取操作,一定会为此搭建纷繁复杂的基建。其核心一定是get和put,所以在搞明白数据结构的内存布局之后,我一般只关注数据是怎么存进去的和怎么取出来的。
1 内存布局
1.1 布局
dict的底层结构是数组+链表的组织形式,链表中每个脚标处称为hash桶,每个hash桶上放置一条单链表,并且该单链表是通过头插法解决的hash冲突。
1.2 键值对
c
1 |
|
在hash桶中存放的键值对,在dict中数据的最小粒度就是dictEntry。
2 存数据
假设我现在要往dict中添加一个键值对,涉及的流程如下
- 计算key的hash值是多少
- hash值在数组上的脚标
- 初始化一个键值对实例
- 此时hash桶已经被占了就说明发生了hash冲突,头插到链表
2.1 dictAdd
c
1 |
|
2.2 dictAddRaw
c
1 |
|
3 hash函数
- hash函数的作用是啥,计算key的hash值,这个hash值的作用是在作为key在数组hash桶位置的依据。
- 一个好的hash函数设计是为了尽可能减少hash碰撞,尽可能平衡键值对在数组上的分布,平衡空间和时间复杂度。
- 再者,只知道key的地址,dict本身是没办法对hash函数一概而论的,因此hash函数属于dict的基础设施,有用户定义好告诉dict,因为dict看到的仅仅是key的地址,用户是知道key的数据类型的。
3.1 用户指定key的hash函数
c
1 |
|
3.2 计算hash值
c
1 |
|
4 扩容
假使持续往dict中添加键值对,随着存储的膨胀,意味着平均情况下每个hash桶里面的链表都有可能无限增长,而链表的检索时间复杂度是O(N)。当效率不够的时候基本会用空间换时间,所以出发扩容,水平方向的扩容重新打散链表从而减少链表的长度。
因此,跟扩容有关值的关注的点为
- 扩容时机,什么样的数据规模应该扩容,用什么指标衡量数据规模
- 怎么扩容
- 扩容之后当前的键值对怎么处理
4.1 扩容时机
c
1 |
|
4.2 扩容方式
满足了上面的扩容阈值,或者说达到了扩容时机,就可以进行数组扩容,那么扩容的方式是什么呢?
- 以扩容前键值对数量为基数,比这个大就行,也就是在最优情况下让hash表的结构退化成数组,加快检索的效率,降低时间复杂度
- 为了替代取模运算%,用上位运算,人为保证数组的长度是2的幂次方
- 在上面2个前提之下,将数组长度约束到最小,因为一次扩容直接过大,也会存在极大概率导致空间浪费
因此,上述3点的综合就是扩容的方式
4.2.1 dictExpand
c
1 |
|
4.2.2 _dictExpand
c
1 |
|
4.3 重哈希方式
将hash数组增加容量仅仅是扩容的开始,因为扩容一定意味着键值对对数组容量的使用率达到了阈值,因此后续的工作是将所有已存的键值对迁移到新的数组上。
那么,这时候就有两个策略
- 扩容过程一气呵成,hash表实例只有1个
- 依然是空间换时间的方式,维护2张hash表,将键值对的迁移动作后置,分散到之后的工作线程上
4.3.1 迁移消耗分散到每个工作线程
几乎就是每个前台工作线程都会先考察一下dict是不是要rehash,然后分担一点迁移工作。
c
1 |
|
4.3.2 迁移
c
1 |
|
5 取数据
根据key查询value,思路就是
- 计算key的hash值
- hash值定位到数组脚标
- 找到hash桶里面的链表
- 遍历链表比较key
c
1 |
|
Redis-0x0B-数据结构dict
https://bannirui.github.io/2024/04/15/Redis/Redis-0x0B-数据结构dict/