-
引言
redis中的字典应该是我们最为熟悉的一个结构,因为redis就可以看做是一个大的内存字典。在很多变成语言中都有字典的实现,如java中的HashMap,但是redis是使用c语言实现的,c语言中没有提供字典的实现,因此redis编写了自己的字典实现。 -
哈希表
Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:typedef struct dictht { // 哈希表数组 dictEntry **table; // 哈希表大小 unsigned long size; // 哈希表大小掩码,用于计算索引值 // 总是等于 size - 1 unsigned long sizemask; // 该哈希表已有节点的数量 unsigned long used; } dictht;
table 数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针, 每个 dictEntry 结构保存着一个键值对
typedef struct dictEntry { // 键 void *key; // 值 union { void *val; uint64_t u64; int64_t s64; } v; // 指向下个哈希表节点,形成链表 struct dictEntry *next; } dictEntry; key 保存着键值对中的键, v 保存着键值对中的值, 值可以是指针, 或者是uint64_t 整数, 又或是int64_t 整数 next 下一个哈希表节点(dictEntry)的指针, 多个hash值相同的节点会通过next链接在一起形成链表,也就是常说的链地址法解决hash冲突
size 属性记录了哈希表的大小, 也即是 table 数组的大小, 而 used 属性则记录了哈希表目前已有节点(键值对)的数量
sizemask 属性的值总是等于 size - 1 , 这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面
哈希表的图形结构如下所示:(熟悉java的hashmap的同学看到以下结构应该不会感到陌生,这个结构和hashmap极为相相似)
-
字典
redis 中的字典dict.h/dict 结构如下:typedef struct dict { // 类型特定函数 dictType *type; // 私有数据 void *privdata; // 哈希表 dictht ht[2]; // rehash 索引 // 当 rehash 不在进行时,值为 -1 int rehashidx; /* rehashing not in progress if rehashidx == -1 */ } dict;
type 和 privdata 是针对不同类型的键值对, 为创建多态字典而设置的
type 是一个指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, redis 会为用途不同的字典设置不同的类型特定函数。 privdata 则保存了需要传给那些类型特定函数的可选参数。typedef struct dictType { // 计算哈希值的函数 unsigned int (*hashFunction)(const void *key); // 复制键的函数 void *(*keyDup)(void *privdata, const void *key); // 复制值的函数 void *(*valDup)(void *privdata, const void *obj); // 对比键的函数 int (*keyCompare)(void *privdata, const void *key1, const void *key2); // 销毁键的函数 void (*keyDestructor)(void *privdata, void *key); // 销毁值的函数 void (*valDestructor)(void *privdata, void *obj); } dictType;
重点:
ht 是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
除了 ht[1] 之外, 另一个和 rehash 有关的属性就是 rehashidx : 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。
字典的结构(没有进行rehash的状态下)如下图所示: -
渐进式rehash
ht 是一个包含两个项的数组,即有两个哈希表,其实但一个数组也可以实现字典的,或者说哈希表就可以当做一个字典的实现,为什么redis却使用了两个哈希表。
这两个哈希表主要是在字典扩容或者缩容的时候使用的,也就是rehash的时候使用。
我们如果熟悉java的HashMap,会知道HashMap在不断插入数据的时候,当HashMap的中键值的数量超过负载因子的时候,HashMap则会进行扩容操作,此时会出现rehash。HashMap的rehash操作是一次性完成的(我使用的jdk是1.8版本的)。
redis的rehash则不是一次性完成的,是渐进式的,我觉得原因应该是,redis中保存了大量的key,redis中保存百万级或者说千万级的key太常见了,如果redis的rehash的时候一次性完成,这么多的key进行rehash则耗时十分严重,会导致redis阻塞。
rehash的过程:- 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
- 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
- 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
- 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。
rehash的过程图如下(rehashidx是在不断的变化):
1.rehash准备开始(为ht[1]分配内存)
2.将h[0]中的第一个槽的k2 rehash到ht[1]中,此时rehashidx为0
3.将h[0]中的第二个槽的k0 rehash到ht[1]中,此时rehashidx为1
4.将h[0]中的第三个槽的k3 rehash到ht[1]中,此时rehashidx为2
5.将h[0]中的第三个槽的k1 rehash到ht[1]中,此时rehashidx为3
6.释放ht[0] ,将 ht[1]设置为ht[0] ,在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备,此时rehashidx为-1,表示没有在进行rehash操作
补充:-
扩容或缩容每次为h[1]分配多大的空间?
扩容, ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2^n (2 的 n 次方幂)
如:used=10,10*2=20,那么h[1]的大小为2^5 = 32
缩容: ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n -
什么时候进行扩容或者缩容?
扩容:
服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 1 ;
服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 5 ;# 负载因子 = 哈希表已保存节点数量 / 哈希表大小 load_factor = ht[0].used / ht[0].size
缩容
当哈希表的负载因子小于 0.1 时, 程序自动开始对哈希表执行收缩操作 -
渐进式rehash进行中,k0原本在h[0]中,rehash到了h[1],此时访问k0,是否会访问不到呢?
可以正常访问k0,当程序在h[0]中不能找到k0时,会访问h[1],从而获取到k0的值 -
渐进式rehash只在查询、更新、删除key的时候进行吗,如果某一个key一直没被访问,rehash岂不是久久不能完成?
redis的渐进式rehash有两种模式:
lazy模式,就是文中提到的查询、更新、删除的时候进行。
active模式,redis有time event,如果redis正在进行渐进式rehash操作,则会花费1毫秒的时间,帮助一起进行渐进式rehash操作 -
渐进式rehash是否会带来什么问题?
- 如果redis的内存已经十分紧张,如果此时进行渐进式rehash,有可能导致部分key会被淘汰掉,因为rehash的时候需要为h[1]分配内存,如果此时redis内存不足,则会淘汰一部分key,使满足rehash的进行
- scan命令可能会返回重复key,或者返回的key不足。返回重复key的情况,比如k0在h[0]的1号槽中,此时被scan返回了,于此同时k0被rehash到了h[1]的8号槽中,当scan命令scan了8号槽的时候k0会被再次返回。
- rehash时会导致内存增长到某一个稳定值(因为给h[1]分配了内存),然后一直处于该值一段时间后,内存又下降了(rehash完成h[0]释放)。
-
总结
一直以为redis的字典结构如同java的hashmap一样,但是实际并非如此,redis的字典很好的利用了两个哈希表实现了渐进式rehash,这种分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。
参考资料: