字典(dict) 也可称作映射(map),就像 Java 中的 Map ,Python 中的 dict 一样,是一种用于保存键值对(key - value)的抽象数据结构。但是 Redis 所使用的 C 语言并没有内置这种数据结构,所以 Redis 自己实现了字典这个数据结构。

字典可以说是 Redis 中出现最为频繁的数据结构了,整个 Redis 数据库就是使用字典来作为底层实现的,Redis 中所有的键值就构成了一个全局的字典,比如我们执行命令:set test_key test_value,就会在这个全局字典中创建一个键为 test_key,值为test_value的键值对(这里的键值实际上都是Redis中的SDS,即简单动态字符串)

hash 作为 Redis 中五大基本数据类型,大家一定不陌生吧,它底层也是基于字典来实现的(当然它在数据量很小,key-value 长度较短时是采用压缩列表实现的,后续我们再说这个结构)

不仅如此,Redis 五大基本数据类型中的集合(set),有序结构(zset)在数据量大时也用到了字典。

还有带过期时间的 key 集合也是一个字典,由此可见,字典在 Redis 中的重要性了,下面我们一起来揭秘 Redis 中的字典。

字典的实现

Redis 中的字典是使用哈希表来实现的,大家可以类比 Java 中的 HashMap,一个哈希表里面有很多个哈希槽,每个槽里面就保存了字典中的一个键值对。并且跟 HashMap 类似,每个槽中可能有很多个键值对,它们是通过链表的形式连接起来的。

下面是 Redis 字典所使用的哈希表定义

typedef struct dictht {
	dictEntry **table;
	unsigned long size;
	unsigned long sizemask;
	unsigned long used;
} dictht;

table 属性表示一个数组(即我们所说的哈希表),数组中的每一个元素都是一个保存了键值的dictEntry结构。

size 属性记录了哈希表的大小,即数组的大小。

sizemask 属性值总是等于size-1,Redis 用它来跟一个键的哈希值进行与操作,用来定于这个键在哈希槽中的位置。

used 属性表示哈希表中的键值数量。

redis zset 排序效率 redis zset默认排序_数据结构


而实际上一个字典里存在着两个哈希表,下面给出了字典结构的定义:

typedef struct dict {
	dictType *type;
	void *privdata;
	dictht ht[2];
	int rehashidx;
} dict;

type 属性表示不同类型的键值对(多态的思想),比如你总得知道这个字典的值是一个字符串(SDS),还是一个哈希(hash),还是集合(set)吧… Redis 会为这些不同类型的结构设置不同的函数。

privdata 属性保存了 type 所指向特定类型函数的可选参数。

ht 是一个包含了两个哈希表的数组,正常情况下 Redis 只会使用第一个哈希表,ht[1]只会在字典扩容进行rehash时使用。

rehashidx 记录了当前rehash的进度,如果目前没有进行rehash,那么它的值为-1。

redis zset 排序效率 redis zset默认排序_redis zset 排序效率_02

渐进式rehash

随着操作的不断执行,我们的哈希表中的键值对逐渐增多或减少,哈希表通过负载因子将哈希表的大小维持在一个合理的范围内,当哈希表中的键值太多或者太少时,会对哈希表的大小进行相应的扩容或缩容。
扩容是相对比较常见的,那我们就先来说说扩容。
扩容条件
当下列条件中任意一个条件满足时,Redis 会对哈希表进行扩容

  • 当 hash 表中元素的个数等于第一维数组的长度,并且没有执行BGSAVE命令或BGREWRITEAOF命令时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。
  • 当前正在执行BGSAVE命令或BGREWRITEAOF命令并且元素的个数已经达到了第一维数组长度的 5 倍 (dict_force_resize_ratio),说明 hash 表
    已经过于拥挤了,这个时候就会强制扩容。

思考:为什么在执行BGSAVE命令或BGREWRITEAOF命令时Redis,尽量不去扩容?

因为 Redis 的 bgsave 是采用了 Copy On Write 思想,Redis 在进行bgsave 时会产生一个子进程来执行快照持久化的工作,此时不会阻塞父进程执行命令,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复制一份分离出来,然后对这个复制的页面进行修改。这时子进程相应的页面是没有变化的,还是进程产生时那一瞬间的数据,为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容(dict_can_resize)。

缩容条件:当负载因子小于0.1,即键值对数量小于哈希表大小的0.1倍时就会进行缩容。


下面来说一说rehash的过程
当我们需要扩容和缩容时需要对ht[0]哈希表中的所有键值对进行重新计算哈希值,并将其放到ht[1]里面,但是这个动作不是一次性完成的,而是分成很多次,渐进式完成的。
大家想一想为什么要这么做?
因为这个搬迁工作是很耗时也很耗费资源的,如果一次性的集中进行的话,可能会使 Redis 在一段时间内停止服务。

Redis 通过 rehashidx 表示当前rehash的进度,在rehash 期间,每次对于字典的增删改查时还会顺带将 rehashidx 索引上的所有键值对 rehash 到ht[1]上,然后将 rehashidx 加1。这样随着字典操作的不断进行,总会将所有键值队全部 rehash。这是会将rehashidx重新置为-1.

当然有可能客户端闲下来了,没有了后续指令来触发这个搬迁,那么 Redis 就置之不理了么?

当然不会,Redis 还会在定时任务中对字典进行主动搬迁。

rehash过程中的操作

在渐进式rehash的过程中,对于字典的删除,查找,更新的操作,不仅会在ht[0]上进行,还会在ht[1]上进行,比如查找某一个键,会现在ht[0]上面找,如果没有找到,就去ht[1]上面找,对于新增操作,会一律保存在ht[1]里。这样可以保证ht[0]里面的键值对只会减少,最终变成空表。