哈希类型
- 字典类型 (Hash) 又被成为散列类型或者是哈希表类型,它是将⼀个键值 (key) 和⼀个特殊的“哈希表”关联起来。相当于java中的Map<Object, Map<Object, Object>>
- 哈希类型的底层数据结构可以是压缩列表(ZipList)或者字典(Dict)
- 当哈希对象的所有键值对的键和值的字符串长度都小于64字节,并且保存的键值对数量小于512个时,使用压缩列表, 压缩列表在上一篇文章中讲到
- 如果不满足上述条件中的任意一个,都会使用字典
字典介绍
- 在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值), 这些关联的键和值就称为键值对
- 字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对,等等
字典的底层实现
-Redis的字典底层采用了哈希表来进行实现。一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对
哈希表(struct dictht)
- 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结构保存着一个键值对
-
size属性:记录了哈希表的大小,也即是table数组的大小
-
used属性:则记录了哈希表目前已有节点(键值对)的数量
-
sizemask属性:的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面
-
下图展示了一个大小为4的空哈希表(没有包含任何键值对)
哈希表节点
- 哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:
typedef struct dictEntry {
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
} v;
//指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
成员如下:
-
key属性保存着键值对中的键
-
v属性则保存着键值对中的值,值可以是 一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数
-
next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连 接在一次,以此来解决键冲突(collision)的问题
-
下图就展示了如何通过next指针,将两个索引值相同的键k1和k0连接在一 起
-
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是 将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面。
字典(struct dict)
typedef struct dict {
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash 索引。当rehash 不在进行时,值为-1
in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
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;
-
为保证字典具有多态及泛型,dictType中提供了如哈希函数以及K-V的各种操作函数,使得字典适用于多重情景
-
下图展示了一个普通状态下(没有进行rehash)的字典
-
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上 面
#使用字典设置的哈希函数,计算键key 的哈希值, Redis使用MurmurHash2算 法来计算键的哈希值
hash = dict->type->hashFunction(key);
#使用哈希表的sizemask 属性和哈希值,计算出索引值
#根据情况不同,ht[x] 可以是ht[0] 或者ht[1]
index = hash & dict->ht[x].sizemask;
- Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有 一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上 的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题
rehash(重新排列)
- Redis作为一个插入频繁且对效率要求高的数据库,当插入的数据过多时,就会因为哈希表中的负载因子过高而导致查询或者插入的效率降低,此时就需要通过rehash来进行重新扩容并重新映射。
- 但是如果只是用一个哈希表,映射时就会导致数据库暂时不可用,作为一个使用频繁的数据库,短期的停机几乎是不可容许的问题,所以Redis设计时采用了双哈希的结构,并采用了渐进式rehash的方法来解决这个问题。
rehash双哈希结构的实现
- rehash双哈希结构实现步骤如下
- 为ht[1]的哈希表分配空间
- 将ht[0]中的键值对重新映射到ht[1]上
- 当ht[0]的数据迁移完成,此时ht[0]为一个空表,此时释放ht[0],并让ht[1]成为新的ht[0],再为ht[1]创建一个新的空白哈希表,为下一次的rehash做准备
渐进式rehash的实现
- 由于数据库中可能存在大量的数据,而rehash的时候又过长,为了避免因为rehash造成的服务器停机,rehash的过程并不是一次完成的,而是一个多次的,渐进式的过程。
哈希表渐进式 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 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。