字典是一种用来保存键值对的数据结构。
在字典中,一个key与一个value相对应,字典中的key是唯一的。
在Redis中字典使用哈希表作为底层实现,用数组来表示一个哈希表,每个元素都是一对key-value
同样,在Redis中字典由三部分组成:
- 哈希节点,保存一对key-value
- 哈希表,用来爆粗整个哈希表以及相关信息
- 字典用来封装哈希表
字典的实现
哈希节点
typeof struct dictEntry{
void *key; // 键key
union v ; // 值value
struct dictEntry *next; // 指向下一个哈希节点,形成链表
} dictEntry;
哈希节点中的这三个属性没啥说的,看过HashMap源码的,应该都懂。
哈希表
哈希表的结构体:
typeof struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; //哈希表大小
unsigned long sizemask; // 掩码,用来计算索引
unsigned long used; // 哈希表中已使用的节点数量
} dictht;
还是来说一下这个结构:
- table属性是一个数组,数组中的每个元素都是指向dictEntry哈希表节点的指针
- sizemask用来快速计算一个哈希节点的索引
字典
字典的底层实现是哈希表,字典结构体还保存着整体的信息,还字典的结构体:
typeof struct dict {
// 哈希表
dictht ht[2];
// 类型特定函数,针对不同类型的key-value操作的一系列函数,不必关心
dictType *type;
// 私有数据,不必关心
void *private;
// rehash索引
int rehashidx;
} dict;
在字典的结构体中,有两个属性比较重要:
dictht ht[2]
,这是两个哈希表。我们前面讲解了哈希表结构体dictht,一个字典中竟然封装了两个哈希表,主要用来rehash,接下来说。int rehashidx
在rehas时指向旧哈希表的一个指针,关于rehash接下来就说。
关于哈希表节点、哈希表、字典的关系,看这张图梳理一下:
hash冲突
谈到哈希表,那么hash冲突是绕不开的一个点。
哈希冲突:当两个不同的key,通过哈希运算,被分配到了统一索引上,这就是哈希冲突。
那么Redis中是如何解决哈希冲突的呢?
Redis中的哈希表与Java中的HashMap的原理相似,都是通过链表法来解决key冲突。
我们知道每个链表节点dictEntry中,都有一个next属性,这个属性用来构成单向链表,把分配到同一个索引上的节点通过此属性连接起来
因为在dictEntry中只有一个next指针,所以为了性能考虑,Redis哈希表采用了头插法,时间复杂度是O(1),排在其已有节点的前面。
Redis中使用了MurmurHash算法,即使对于相同的输入,也能够保证很好的随机分布性,而且计算速度非常快。
rehash
rehash也是哈希表绕不开的一个点。
随着操作的不断进行,哈希表中的键值对数量会逐渐增多或减少,为了让哈希表的负载因子load factor(负载因子,用来衡量哈希表满的程度)维持在一个合理的范围,当哈希表中保存的键值对的数量太多或太少时,就会触发rehash,来对哈希表进行适应的扩容或收缩。
负载因子 = 哈希表中已保存的数量 / 哈希表容量
我们前文提到,在字典中定义了两个哈希表,即dictht ht[2]
,在正常使用时,我们是把键值对分配到第一个哈希表中,也就是ht[0]
中,当rehash时,第二个哈希表ht[1]
就发挥作用了。
rehash的具体过程:
- 首先为
ht[1]
分配空间,ht[1]
的大小取决于原先的ht[0]
中键值对的数量:- 如果是扩容操作,那么
ht[1]
的大小是第一个大于等于ht[0].used*2
的2^n。 - 如果是收缩操作,那么
ht[1]
的大小是第一个等于ht[0].used
的2^n
- 如果是扩容操作,那么
- 为
ht[1]
分配空间完成后,就是键值对迁移的过程了。将ht[0]
中的所有键值对再次hash放到ht[1]
中,rehash就是重新计算哈希值和索引值,然后放到ht[1]
的指定位置上。 - 当
ht[0]
中的键值对全部迁移到了ht[1]
之后,释放ht[0]
,将ht[1]
作为ht[0]
,并在ht[1]
创建一个新的空白的哈希表,留着下一次rehash使用
何时对哈希表进行扩容操作?满足以下任意一个条件
- redis服务器目前没有执行BGSAVE(rdb持久化)命令或BGREWRITEAOF(AOF文件重写)命令,并且此时哈希表的复杂因子大于等于1.
- 服务器正在执行BGSAVE(rdb持久化)命令或BGREWRITEAOF(AOF文件重写)命令,并且哈希表的复杂因子大于等于5
当负载因子小于0.1时,会自动进行收缩操作
渐进式rehash
刚才说了rehash的基本流程,对于第二个过程,将ht[0]
中的键值对rehash到ht[1]
上这一个过程,并不是一次完成的。
因为考虑到哈希表中的键值对数量过多,一次性完成可能会占用非常长的时间,由于redis是单线程的,同时还要对外提供服务,如果rehash是一次性完成的,那么在rehash期间,Redis必须停掉服务。
因此,为了避免rehash对服务器性能造成的影响,服务器并不是一次性将ht[0]
中的键值对迁移到ht[1]
中的,而是分多次、渐进式、慢慢地rehash到ht[1]
上的
渐进式rehash的详细过程:
- 为
ht[1]
分配空间,字典同时持有ht[0]
和ht[1]
两个哈希表 - 在字典内部维护一个索引计数器变量,就是前文提到的
rehashidx
,将它设置为0(即ht[0]
中第一个键值对的索引),表示rehash工作开始。 - 在rehash期间,每次对字典进行增、删、改、查操作时,还会顺带着将
ht[0]
哈希表在rehashidx
指向的键值对rehash到ht[1]
中,当这一次操作完成后,rehashidx + 1
,指向下一个需要rehash的键值对。 - 随着对字典的操作不断执行,最终
ht[0]
中所有的键值对都会迁移到ht[1]
中,这时rehashidx
为-1,表示rehash已经全部完成。
在渐进式rehash的过程中,对字典的新增操作,是直接在ht[1]
新的哈希表上进行,而删除、查找、更新操作是在这两个哈希表上进行的
例如,当查找一个元素时,先去ht[0]
上查找,如果没有找到,再去ht[1]
上查找。
随着rehash的进行,ht[0]
最终变成空表。
总结
在这一篇中,说了很多,大致总结一下:
- 字典是一种存储键值对数据的结构
- 在Redis中,字典是由哈希表实现的,字典相关的结构体有三个:
- dictEntry哈希表中的一个节点,就是一个键值对
- dictht哈希表,有多个dictEntry组成的数组来表示哈希表
- dict字典,其中保存了两个哈希表
ht[2]
,在rehash时使用
- 哈希表的rehash操作并不是一次性完成的,而是渐进式地,伴随着每次对哈希表的操作,顺带迁移一个键值对,直至完成。
参考文章
- 《Redis设计与实现》
- Redis数据结构——字典 - 随心所于 - 博客园