字典
字典, 又称为符号表(symbol table), 关联数组(associative array)或映射(map), 是一种用于保存键值对的抽象数据结构
字典中, 一个键(key)可以和一个值(value)进行关联, 并且每个键都是独一无二的, 程序可以在字典中根据键查找与之关联的值, 或者通过键来更新值, 或者根据键来删除整个键值对等
字典在Redis中的应用广泛,比如Redis的数据库就是使用字典作为底层实现的, 对数据库的增删改查操作就是构建在对字典的操作之上的
1.字典的实现
Redis的字典用哈希表作为底层实现, 一个哈希表中可以有多个哈希表节点, 而一个哈希表节点就保存了字典中的一个键值对
1>哈希表
Redis字典所用的哈希表由dict.h/dictht结构定义:
typedef struct dicht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值,总是等于size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
} dictht;
table属性是一个数组, 数组中的每个元素都是一个指向dict.h/dictEntry结构的指针, 每个dictEntry结构保存着一个键值对
2>哈希表节点
哈希表节点用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属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决键冲突(collision)的问题
3>字典
typedef struct dict {
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash索引,当rehash不进行时,值为-1
int rehashidx
} 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);
}
ht属性是一个包含两个项的数组, 数组中每一个项都是一个dictht哈希表,一般情况下,ht[0]作为哈希表,ht[1]只会在对ht[0]进行rehash时使用
rehashidx属性表示rehash的进度, 如果没有在进行rehash,则它的值为-1;
2.哈希算法
当要将一个新的键值对添加到字典里面时, 程序需要先根据键值对的键算出哈希值和索引值,然后根据索引值, 将包含新键值对的哈新表节点放到哈希表数组的指定索引上面
Redis计算哈希值和索引值的方法如下:
#使用字典设置的哈希函数,计算key的哈希值
hash = dict->type->hashFunction(key);
#使用哈希表的sizemask属性和哈希值,计算出索引值
#根据情况不同,ht[x]可以是ht[0]或者ht[1]
index = hash & dict->ht[x].sizemask;
当字典被用于数据库的底层实现, 或者哈希键的底层实现时, Redis使用MurmurHash2算法来计算键的哈希值
3.解决键冲突
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键产生了冲突.
Redis的哈希键使用链地址法来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的节点可以用这个单向链表连接起来, 这就解决了键冲突的问题
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑, 程序总是将新节点添加到链表的表头位置,排在其他已有节点的前面
4.rehash
随着操作的不断进行,哈希表保存的键值对会逐渐的增多或减少,为了让哈希表的**负载因子(load factor)**维持在一个合理的范围内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行响应的扩展或者收缩
哈希表执行rehash的步骤如下:
- 1.为字典的ht[1]哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及ht[0]当前包含的键值对数量
- 如果是扩展,ht[1]的大小为第一个大于等于ht[0].used*2的2^n
- 如果是收缩,ht[1]的大小为第一个大于等于ht[0].used的2^n
- 2.将保存在ht[0]中的所有键值对rehash到ht[1]上面: rehash就是重新计算键的哈希值和索引值,然后放到ht[1]的指定位置上
- 3.当ht[0]包含的键值对都迁移到了ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表, 为下一次rehash做准备
扩展与收缩条件
-
服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令, 并且哈希表的负载因子大于1
-
服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令, 并且哈希表的负载因子大于5
load_factor = ht[0].used / ht[0].size
-
当哈希表负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
渐进式hash
rehash动作并不是一次性,集中式完成的, 而是分多次, 渐进式地完成的
为了避免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],然后rehashidx属性的值增一
- 直到ht[0]上所有的键值对都rehash到ht[1]后, 将rehashidx属性设置为-1,表示rehash完成
这样处理的好处是将rehash的过程均摊到每次增删改查操作上
因为在进行渐进式rehash的过程中, 字典会同时使用ht[0]和ht[1]两个哈希表,所以rehash期间, 字典的删除,查找,更新会在两个哈希表上进行, 但是插入新的键值对操作只对ht[1]作用而不对ht[0]操作,这样保证ht[0]包含的键值对数量只减不增,并随着rehash过程最终变为空表