彻底搞懂Redis--字典篇

字典

字典又称符号表,关联数组或者映射,是一种用于保存键值对的抽象数据结构。
字典中的每个键都是独一无二的,程序可以在字典中根据键值查找与之关联的值,或者通过键来更新值,删除等。

字典的实现

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结构保存着一个键值对。
sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放在什么位置

哈希表节点

哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对;

typedef struct dictEntry{
     //键
     void *key;
     //值
     union{
          void *val;
          uint64_tu64;
          int64_ts64;
     }v;

     //指向下一个哈希表节点,形成链表
     struct dictEntry *next;
}dictEntry;

从源码可以看出,保存的值(v)可以是一个指针,或者一个uint_t整数,又或者是一个int64_t整数。

next属性是指向另一个哈希表节点的指针,有同学可能懵了,不是说键值是唯一的吗,为什么还有很多呢,这里就要引出一个概念就是哈希冲突,我们储存信息的时候并不是直接存储的,而是经过了一个哈希计算,这个算法是内部提供的,所以即使是不用的键值也可能计算出相同的索引,所以就需要我们解决一下这个问题,所以就到了现在,每个节点加一个指针,指向相同索引的节点,这就是拉链法,当然还有一种方法是开放寻址法,有兴趣的同学可以研究一下。

字典结构

Redis中的字典是由dict.h/dict结构表示

typedef struct dict{
   //类型特定函数
	dictType *type;
	//私有数据
	void *privdata;
	//哈希表
	dictht ht[2];
	//rehash索引
	//当rehash不在进行时,值为-1
	int rehashidx;
}dict;

ht属性是一个包含两个项的数组,数组中的每个顶都是一个dictht哈希表,一般情况下只使用ht[0]哈希表,ht[1]哈希表只会在进行rehash时使用,我们后面详细说,包括rehashidx也是在rehash时用到的。

接下来我们再看字典的结构,就能清楚很多了吧
在这里插入图片描述

字典的结构了解清楚了,接下来就是可能涉及到的问题及其解决办法

解决键冲突

这个就是上面提到的哈希冲突,解决他的办法有两种,开放寻址法和拉链法,Redis很明显采用的是后者,后者很明显更加清晰,存取更加方便,第一种数据不多还好,如果数据量特别大的时候,插入,查找,删除,修改都是一件很困难的事情,时间复杂度太高。

rehash(重新散列)

随着操作的不断执行,哈希表保存的键值会逐渐地增多或者减少,为了让哈希表的负载因子维持在一个哈利的范围之内,当哈希表存储的键值对太多或者太少,程序要对哈希表的大小进行相应的扩展或者收缩。
这就是我们要说的rehash,这里我们解释一下负载因子
负载因子=哈希表以保存的节点数量/哈希表的大小
load_factor=ht[0].used/ht[0].size

那么什么时候才会rehash呢?

条件:
1)服务器目前没有执行的BGSAVE命令或者BGREWRUTEAOF命令,并且哈希表的负载因子大于等于1;
2)服务器目前正在执行BGSAVE命令或者BGREWRUTEAOF命令,并且哈希表的负载因子大于等于5;

这里简单解释一下BGSAVE,这个命令是redis进行RDB持久化时所用到的命令,持久化会在后面讲到,就是从内存写入磁盘的过程;BGREWRUTEAOF就是另一种AOF持久化方式的工作方式,也就是在Redis进行持久化的时候,可能会触发rehash。

rehash的实现原理

如何进行rehash呢,再将具体的步骤,方法时,我先用自己的话说一下原理,方便大家理解。

既然要进行重新散列,那么原来的表肯定是不适合了,所以要重新开辟一张表,这就是上面我们所说的ht[1],之所以维持两张表就是这个原因,如果需要扩容,那么扩容后的长度也就是ht[1].size=ht[0].used*2的2^n,缩小
的话就是ht[1].size=ht[0].used的2^n,准备条件好了就要开始移动了,直到0号哈希表中没有保存的节点,这时候释放空表的空间,将ht[1]更名为ht[0],然后将ht[1]置为空,就完成了rehash。

rehash的原理讲完了,相信大家应该理解的差不多了,那就考虑一下是不是有什么问题呢

!!!如果像这样rehash的话,如果你的字典中存储这几万条,几十万条,几百万条的数据时,如果我们一次性的,集中式的把这些数据rehash,那估计服务器就不能再进行其它服务了,高性能的Redis是绝对不允许这种事情发生的,所以接下来就是我们重点说的,渐进式rehash。

渐进式rehash

我们还是先解释再看源码,先说一下是怎么渐进式的

不知道大家是否还记得之前提到过的一个字段rehashidx,没错就是他,索引计数器,记录了rehash的进度。

以下是哈希表渐进式 rehash 的详细步骤:

1)为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
2)在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
3)在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
4)随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。

看下来rehashidx一共有三种状态,0表示要开始进行rehash,-1表示rehash结束或目前没有进行,其它就是rehash过程中的进度表示了

源码如下:


int dictRehash(dict *d, int n) {
	if (!dictIsRehashing(d)) return 0;
	while (n--) { 
		dictEntry *de, *nextde;
		if (d->ht[0].used == 0) {   // 如果 0 号哈希表为空,那么表示 rehash 执行完毕
			zfree(d->ht[0].table);
			d->ht[0] = d->ht[1];
			_dictReset(&d->ht[1]);
			d->rehashidx = -1;
			return 0;
		}
 
		// Note that rehashidx can't overflow as we are sure there are more
		// elements because ht[0].used != 0
		// 确保 rehashidx 没有越界
		assert(d->ht[0].size > (unsigned)d->rehashidx);
 
		while (d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;    // 略过数组中为空的索引,找到下一个非空索引
 
		de = d->ht[0].table[d->rehashidx];
		while (de) {
			unsigned int h;
			nextde = de->next;
			// 计算新哈希表的哈希值,以及节点插入的索引位置
			h = dictHashKey(d, de->key) & d->ht[1].sizemask;
			// 插入节点到新哈希表,而且是插入到表头
			de->next = d->ht[1].table[h];
			d->ht[1].table[h] = de;
 
			d->ht[0].used--;
			d->ht[1].used++;
			de = nextde;
		}
		// 将刚迁移完的哈希表索引的指针设为空
		d->ht[0].table[d->rehashidx] = NULL;
		d->rehashidx++;
	}
	return 1;

}

理解了就能看懂了,非常巧妙

最后一个问题了!!!!

那就是在进行rehash的过程中对他的操作是怎么样的?

因为在进行rehash的时候,两个表中都有值,所以不能确定具体在哪个表中,所以要在两个表中进行 字典的删除(delete)、查找(find)、更新(update)等操作,如果是查找的话,就会现在ht[0]中查找,没有就去ht[1]中找,但是如果是增加的话,就会一律保存到ht[1]中,不会再像ht[0]中进行任何添加操作,不会多此一举,保证ht[0]中的数据只减不增,直到他变成一个空表。

说完啦!!!!!

猜你喜欢

转载自blog.csdn.net/LYue123/article/details/88800413