Redis基础数据结构——字典

Redis基础数据结构——字典

  redis的字典hash相当于Java里面的HashMap,实现结构上也与Java的HashMap一样,都是“数组+链表”的二维结构。
 不同的是,redis的字典的值只能是字符串,并且它们rehash的方式也不一样,相较于Java中HashMap的一次性rehash,redis中是渐进式rehash。
  hash结构用来存储用户信息时,与字符串需要一次性全部序列化整个对象不同,hash可以对用户结构中的每个字段单独存储,这样当我们需要获取用户信息时可以进行部分获取。
1.redis字典的基本操作

hset key field value #给字典key加入field-value键值对,若field已存在,则更新field对应的value
hget key field #获取字典key中field对应的value值
hgetall key #获取字典中所有键值对
hlen key #获取字典的元素数
hmset key field value [field value …] #批量创建

在这里插入图片描述
 若是value为数值,也可以进行计数

hincrby key field increment #字典key中field对应的数值value增加increment

在这里插入图片描述

2.redis字典的内部实现

  字典是redis服务器中出现最频繁的复合型数据结构,除了hash结构的数据会用到字典外,整个redis数据库的所有key和value也组成了一个全局字典,还有待过期时间的key集合也是一个字典。zset集合中存储value和score值的映射关系也是字典结构。

struct RedisDb {
	dict* dict;			//全局字典
	dict* expires;		//过期字典
	...
}
struct zset {
	dict *dict;			//存放value和score的映射关系
	zskiplist *zsl;
}

  既然字典在redis中这么重要,那么搞懂它的内部实现就尤为重要。

  字典结构内部包含两个hashtable,通常情况下只有一个hashtable是有值的,但是在字典扩容缩容时,需要分配新的hashtable,然后进行渐进式搬迁,这时候两个hashtable存储的分别是旧的hashtable和新的hashtable。ht[0]为旧的hashtable,ht[1]为新的hashtable。等到搬迁结束之后,旧的hashtable被删除,新的hashtable取而代之。
在这里插入图片描述

struct dict {
	...
	dictht ht[2];
}

  hashtable的结构和Java的HashMap几乎是一样的,都是通过分桶的方式解决hash冲突。第一维是数组,第二维是链表,数组中存储的是第二维链表的第一个元素的指针,如图所示。
在这里插入图片描述

struct dictEntry {
	void* key;
	void* val;
	dictEntry* next;
}
struct dictht {
	dictEntry** table;
	long size;			//第一维数组的长度
	long used;			//hash表中的元素个数
	...
}

3.渐进式rehash

  redis的hash相较于Java的HashMap来说还有一点不同就是rehash方式不同,为渐进式rehash,那么为什么要采用这种方式呢?
 因为,大字典的扩容时比较耗时的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个O(n)级别的操作,作为单线程的redis很难承受这样耗时的过程,因而redis使用渐进式rehash,虽然会慢一些,但是最终肯定是可以搬迁完的。

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing){
	long index;
	dictEntry *entry;
	dictht *ht;
	
	//这里进行小步搬迁
	if(dictIsRehashing(d))
		_dictRehashStep(d);
	
	/*获取新元素的索引,如果该元素已经存在了,则索引为-1
	 *如果新元素已经存在,则返回空指针 */
	if(
	(index = _dictKeyIndex(d, key, dictHashKey(d, key), existing)) 
	== -1)
		return NUll;
	
	/*分配内存并存储新的entry
	 *将元素插入尾部
	 *系统优先访问最近添加的entry */
	//如果字典处于搬迁过程中,要将新的元素挂接到新的数组下面
	ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
	entry = zmalloc(sizeof(*entry));
	entry->next = ht->table[index];
	ht->table[index] = entry;
	ht->used++;
	
	/*设置字典输入字段 */
	dictSetKey(d, entry, key);
	return entry;
}

  字典搬迁在当前字典的后续指令中,比如来自客户端的hset、hdel等命令之后,这样稍显被动了些,除此之外,redis还会在定时任务中对字典进行主动搬迁。

//服务器定时任务
void databaseCron() {
	...
	if(server.activerehashing) {
		for(j = 0; j < dbs_per_call; j++) {
			int word_done = incrementallyRehash(rehash_db);
			if(work_done) {
				/*如果函数做了一些工作,请到此为止,
				 *将在下一个cron循环中做更多的工作*/
				 break;
			} else {
				/*如果这个db不需要rehash,
				 *将尝试rehash下一个
				 rehash_db++;
				 rehash_db %= server.dbnum;
			}
		}
	}
}

4.字典的查找过程

  要想对字典进行插入,修改,删除操作都必须先要查找到元素,才可以进行数据结构的修改。因而,搞清楚hash的查找过程是很有必要的。
  hashtable的元素是在第二维的链表上,所以首先要做的是确定目标key处在哪个链表上,然后再顺序遍历这个链表找到目标key。

func get(key) {
	//hash函数计算得出目标元素在数组中的位置,也就是处于哪个链表上
	let index = hash_func(key) % size;
	let entry = table[index];
	//遍历链表,找目标key
	while(entry != NULL){
		//查找成功,返回目标元素
		if(entry.key == target){
			return entry.value;
		}
		entry = entry.next;
	}
}

5.扩容和缩容
(1)扩容条件

//如果需要,对hash表进行扩容
static int _dictExpandIfNeeded(dict *d) {
	//若是字典正在进行rehash,则返回,此时选择不扩容
	if(dictIsRehashing(d))
		return DICT_OK;
		
	//若是hash表是空的,将其扩容至初始大小
	if(d->ht[0].size == 0)
		return dictExpand(d, DICT_HT_INITIAL_SIZE);
		
	/*如果元素数量大于等于桶的数量,
	 *且至少满足以下条件之一:
	 *1.该字典是可以进行扩容的
	 *2.元素数量/桶数量的比值大于安全阈值,默认为5
	 *此时要进行扩容,扩容为此时元素数量的两倍大小
	*/
	 if(d->ht[0].used >= d->ht[0].size &&
	 	(dict_can_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)){
	 		return dictExpand(d, d->ht[0].used*2);
	 	}
	 return DICT_OK;
}

(2)缩容条件
  当hash表因为元素逐渐被删除变得越来越稀疏时,redis会对hash表进行缩容来减少hash表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的10%,缩容不会考虑字典是否正在rehash,也就是说,字典在进行渐进式rehash时可以缩容。
 为什么缩容时不考虑是否正在rehash呢?个人认为是本来就要将原hashtable中的元素全部迁移至新的hashtable中,最后将旧的hashtable删除,内存释放。此时如果进行缩容的话,对于后面继续rehash以及最后的释放内存并不影响,甚至会更方便这些操作,相当于将一个气球中的气逐渐放了,而不是一次性给它扎破。

int htNeedsResize(dict *dict) {
	long long size, used;
	size = dictSlots(dict);
	used = dictSize(dict);
	return (size > DICT_HT_INITIAL_SIZE &&
			(used*100/size < HASHTABLE_MIN_FILL));
}

6.深入字典的遍历过程
  Redis对象树的主干是一个字典,如果对象很多的话,主干字典也会非常大。当使用keys命令搜寻指定模式的key时,它会遍历整个主干字典,在遍历的过程中,如果满足匹配条件的key被找到了,还需要判断key指向的对象是否已经过期,若是过期了就需要从主干字典中将该key删除。

迭代器

void keysCommand(client *c){
	dictIterator *di;		//迭代器
	dictEntry *de;			//迭代器当前的entry
	sds pattern = c->argv[1]->ptr;		//keys的匹配模式参数
	int plen = sdslen(pattern);
	int allkeys;		//是否要获取所有key
	unsigned long numkeys = 0;
	void *replylen = addDeferredMultiBulkLength(c);
	
	di = dictGetSafeIterator(c->db->dict);
	allkeys = (pattern[0] == '*' && pattern[1] == '\0');
	while((de = dictNext(di)) != NULL){
		sds key = dictGetKey(de);
		robj *keyobj;
		if(allkeys || stringmatchlen(pattern, plen, key, sdslen(key), 0)){
			keyobj = createStringObject(key, sdslen(key));
			//判断是否过期,过期了要删除元素
			if(expireIfNeeded(c->db, keyobj) == 0){
				addReplyBulk(c, keyobj);
				numkeys++;
			}
			decrRefCount(keyobj);
		}
	}
	dictReleaseIterator(di);
	setDeferredMultiBulkLength(c, replylen, numkeys);
}

  前面提到过,字典在扩容时要进行渐进式迁移,同时存在新旧两个hashtable。遍历需要对这两个hashtable依次进行,先遍历完旧的hashtable,再继续遍历新的hashtable。如果在遍历的过程中进行了rehashStep,将已经遍历过的旧的hashtable的元素迁移到了新的hashtable中,这时就存在一个问题,遍历是不是会出现元素的重复。

  Redis为字典的遍历提供了两种迭代器,一种是安全迭代器,一种是不安全迭代器。

typedef struct dictIterator{
	dict *d;		//目标字典对象
	long index;		//当前遍历的槽位置,初始化为-1
	int table;		//ht[0]或ht[1]
	int safe;		//表示迭代器是否安全
	dictEntry *entry;	//迭代器当前指向的对象
	dictEntry *nextEntry;	//迭代器下一个指向的对象
	long long fingerprint;	//迭代器指纹,放置迭代过程中字典被修改
}dictIterator;

//获取非安全迭代器,只读迭代器,允许rehashStep
dictIterator *dictGetIterator(dict *d){
	dictIterator *iter = zmalloc(sizeof(*iter));
	
	iter->d = d;
	iter->table = 0;
	iter->index = -1;
	iter->safe = 0;
	iter->entry = NULL;
	iter->nextEntry = NULL;
	return iter;
}

//获取安全迭代器,允许触发过期处理,禁止rehashStep
dictIterator *dictGetSafeIterator(dict *d) {
	dictIterator *i = dictGetIterator(d);
	
	i->safe = 1;
	return i;
}

  安全的迭代器可以在遍历过程中对字典进行查找和修改,由于查找和修改会触发过期判断,会删除内部元素。为了保证元素不重复,会禁止rehashStep。
  安全迭代器在刚开始遍历时,会给字典打上一个标记,有了这个标记之后,rehashStep就不会执行,遍历时就不会出现元素重复。

typedef struct dict{
	dictType *type;
	void *privdata;
	dictht ht[2];
	long rehashindex;	//标记,表示当前加在字典上的安全迭代器的数量
	unsigned long iterators;
}dict;

//如果存在安全的迭代器,就禁止rehash
static void _dictRehashStep(dict *d){
	if(d->iterators == 0)
		dictRehash(d, l);
}

  不安全的迭代器是指,在遍历过程中,字典是只读的,不可以修改,只能调用dictNext对字典进行持续遍历,不得调用任何可能触发过期判断的函数。这样不影响rehash,但是遍历的元素可能会重复。

  如果遍历过程中不允许出现重复,或者遍历过程中需要处理元素过期,需要对字典进行修改,那就使用安全迭代器。
  其他情况下,允许出现个别元素重复,一般都使用非安全迭代器。

迭代过程

dictEntry *dictNext(dictIterator *iter){
	while(1){
		if(iter->entry == NULL){
			//遍历一个新槽位下面的链表,数组的index往前移动了
			dictht *ht = &iter->d->ht[iter->table];
			if(iter->index == -1 && iter->table == 0){
				//第一次遍历,刚刚进入遍历过程
				//就是ht[0]数组的第一个元素下面的链表
				if(iter->safe){
					//给字典打安全标记,禁止字典进行rehash
					iter->d->iterators++;
				}else{
					//记录迭代器指纹
					//如果遍历过程中字典有任何变动,指纹就会改变
					iter->fingerprint = dictFingerprint(iter->d);
				}
			}
			iter->index++;
			if(iter->index >= (long)ht->size){
				//最后一个槽位都遍历完了
				if(dictIsRehashing(iter->d) && iter->table == 0){
					//如果处于rehash中,那就继续遍历第二个hashtable
					iter->table++;
					iter->index = 0;
					ht = &iter->d->ht[1];
				}else{
					//结束遍历
					break;
				}
			}
			//将当前遍历的元素记录到迭代器中
			iter->entry = ht->table[iter->index];
		}else{
			//直接将下一个元素记录为本次迭代的元素
			iter->entry = iter->nextEntry;
		}
		if(iter->entry){
			//将下一个元素也记录到迭代器中
			//防止安全迭代过程中当前元素被过期删除后,找不到下一个需要遍历的元素
			//如果后面发生了rehash,当前遍历的链表打散了
			//旧的链表将被一分为二,打散后重新挂接到新数组的两个槽位下
			//结果就是会导致当前链表上的元素会重复遍历
			//如果rehash的链表是index前面的链表,那么这部分链表也会被重复遍历
			iter->nextEntry = iter->entry->next;
			return iter->entry;
		}
	}
	return NULL;
}

//遍历完成后要释放迭代器,安全迭代器需要去掉字典的禁止rehash的标记
//非安全迭代器还需要检查指纹,如果有变动,服务器就会崩溃
void dictReleaseIterator(dictIterator *iter){
	if(!(iter->index == -1 && iter->table == 0)){
		if(iter->safe)
			iter->d->iterators--;	//去掉禁止rehash的标记
		else
			assert(iter->fingerprint == dictFingerprint(iter->d));
	}
	zfree(iter);
}

//计算字典的指纹,也就是将字典的关键字按为糅合到一起
//这样只要有任意的结构变动,指纹都会发生变化
//如果只是某个元素的value被修改了,指纹不会发生变动
long long dictFingerprint(dict *d){
	long long integers[6], hash = 0;
	int j;
	
	integers[0] = (long) d->ht[0].table;
	integers[1] = d->ht[0].size;
	integers[2] = d->ht[0].used;
	integers[3] = (long) d->ht[1].table;
	integers[4] = d->ht[1].size;
	integers[5] = d->ht[1].used;
	
	for(j = 0; j < 6; j++){
		hash += integers[j];
		hash = (~hash) + (hash << 21);
		hash = hash ^ (hash >> 24);
		hash = (hash + (hash << 3) + (hash << 8));
		hash = hash ^ (hash >> 14);
		hash = (hash + (hash << 2)) + (hash << 4);
		hash = hash ^ (hash >> 28);
		hash = hash + (hash << 31);
	}
	return hash;
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

loser与你

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值