Redis 阅读笔记-字典
字典
字典,又称为符号表、关联数组或映射,是一种保存键值对的抽象数据结构。字典中每个键都是独一无二的,可以根据键查找与之关键的值,或根据键来更新值。
字典的实现
Redis的字典使用哈希表作为底层实现,一个哈希表里可以有多个哈希节点,每个哈希节点就保存了字典的一个键值对。
哈希表
哈希表有dict.h/dictht结构定义
typeof struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希值大小掩码,用于计算索引值,总等于size-1
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dicth;
下图1表示了一个空的哈希表:
哈希表的节点dictEntry
typeof struct dictEntry {
//键
void *key;
//值
union {
void * val;
uint64_tu64;
int64_ts64;
}
//指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
下图2展示如何通过哈希节点next指针,将两个索引相同的键链接在一起
字典
Redis的字典有dict.h/dict结构表示
typeof struct dict {
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dicth ht[2];
//rehash索引,当rehash不在进行时,值为-1
int rehashidx;
} dict;
type和privdata属性是针对不同类型的键值对,为创建多态字典而设置的。type属性指向dictType的指针,每个dictType结构保存了一簇用于操作特定类型的键值对的函数,Redis会为用途不同的字典设置不同类型的函数。而privdata属性则保存了需要传给哪些特定类型的函数的可选参数。
下面是dictType的定义结构
typeof 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时使用。另一个与rehash有关的属性是rehashidx,记录rehash当前的进度,如果当前没有进行的rehash,那么rehashidx的值是-1。
下图3是普通状态下(没有进行rehash)的字典:
哈希算法
当将一对新的键值对添加到字典中,程序先根据键值对计算出哈希值和索引值,在根据索引值,将包含新键值对的哈希节点放到哈希表数组的指定索引上。
Redis哈希值和索引值的计算方法:
# 首先用字典设置的hash函数,计算key的hash值
hash = dict->type->hashFunction(key);
# 使用sizemask的属性和哈希值,计算出索引值。根据不同的情况,ht[x]可以是ht[0]或ht[1]
index = hash & ht[x]->sizemask;
举个例子:对图3所示的空字典来说,如果将一个键值对k0和v0添加到字典中,那么程序会先使用语句:
hash = dict->type->hashFunction(k0);
计算出键k0的hash值。假设计算的hash值为8,那么程序会继续使用语句:
index = hash & ht[0].sizemask = 8 & 3 = 0
计算出键k0的索引值0,这表示k0和v0的节点应被放到哈希表数组的索引0位置上,如图4所示:
键冲突处理
当有两个或两个以上数量的键被分配到哈希表数组的同一索引上面时,称这些键发生了冲突。
Redis的哈希表使用链地址法来解决键冲突, 每个hash表节点都有一个next指针,多个hash表节点可以用next指针构成一个单向列表,被分配到同一索引上的多个节点可以用这个单向链表链接起来,这就解决了键冲突的问题。
举个例子,假设要将键值对k2和v2添加到下图5所示的哈希表上,此时计算得出k2的索引值为2,那么键k1和k2将会产生冲突,解决冲突的办法就是使用next指针将k2和k1所在的节点连接起来,如下图6所示
图5 一个包含两个键值对的哈希表
图6 使用链表解决k2和k1的冲突
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表表头位置(复杂度为O(1)), 排在其他已知节点之前。
rehash
随着操作的不断执行,哈希表保存的键值对会逐渐的增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围内,当哈希表保存的键值对数量太多或者太少时,程序需对哈希表的大小进行扩展或收缩。
扩展或收缩哈希表的工作可以通过rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:
1、为字典的ht[1]哈希表分配空间,这个哈希表的空间取决于要执行的操作,和ht[0]当前包含的键值对数量(即是ht[0].used属性的值):
1)如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n(2的n次方幂)
2) 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2^n。
2、将保存在ht[0]中所有键值对rehash到ht[1]上面,rehash指的是重新计算键的哈希值和索引值,然后将键值对放到ht[1]哈希表指定的位置上。
3、当ht[0]所包含的所有键值对都迁移到ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下次rehash做准备。
举个例子,假设程序需要对图7所示的字典的ht[0]进行扩展操作,那么程序执行如下步骤:
图7 执行rehash之前的字典
(1)、ht[0].used当前值是4, 4*2 = 8,而8是(2^3)恰好是第一个大于等于4的2的n次方,所以ht[1]哈希表的大小设置为8。图8表示ht[1]分配空间后的字典。
图8 为字典ht[1]哈希表分配空间
(2)、将ht[0]包含的4个键值对都rehash到ht[1],如图9所示:
图9 将ht[0]的索引键值对都迁移到ht[1]
(3)、释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白的哈希表,如图10所示:
图10 完成rehash后的字典
哈希表的扩展与收缩
当一下条件中的任意一个被满足,程序会自动执行扩展操作:
1、服务器目前没有执行bgsave命令或bgrewriteaof命令,并且哈希表的负载因子大于等于1
2、服务器目前正在执行bgsave命令或bgrewriteaof命令,并且哈希表的负载因子大于等于5
哈希表的负载因子计算公式:
# 负载因子 = 哈希表已保存的节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
3、当哈希表的负载因子小于0.1时,程序自动开始对哈希表进行收缩操作。
渐进式rehash
扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里,但是这个rehash动作不是一次性的、集中式的完成的,而是分多次、渐进式的完成。
这样做的原因是:如果键值对数量很大,一次性将这些键值全部rehash到ht[1]上,庞大的计算量可能会导致服务器在一段时间内停止服务。为了避免rehash对服务器性能的影响,而是分多次的、渐进式的将ht[0]的键值对慢慢的rehash到ht[1]上。
rehash渐进式的详细步骤:
1、为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
2、在字典中维持一个索引计数器变量rehashidx,并将他的值设为0,表示rehash工作正式开始。
3、在rehash进行期间,每次对字典添加、删除、查找或者更新操作时,还会顺带将ht[0]哈希表在rehashidx索引上的键值对rehash到ht[1]上,当rehash工作完成后,程序将rehashidx的属性值增1。
4、随着字典操作的不断执行,最终在某个时间点上,ht[0]上所有的键值对都会被rehash到ht[1]上,这是将rehashidx设置为-1,表示rehash的操作已完成。