Redis字典
字典中每个键对应一个值,并且字典中的键是独一无二的。C语言没有内置字典这个数据结构,Redis实现了该数据结构。字典在Redis中的使用是相当普遍的,例如对于Redis键值对数据库,底层就是使用字典实现的。
例如
redis> set k1 "redis"
其中键”k1”、值”redis”,就是保存在代表Redis数据库的字典里面的。
Redis字典底层是使用哈希表实现的,熟悉Java哈希表的同学对该数据结构应该比较清楚,Redis哈希表和Java哈希表的实现有很多相同之处。
一个哈希表包括若干哈希表结点,每个结点就是一个键值对。
哈希表
Redis的哈希表定义如下
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小(table数组的大小)
unsigned long size;
//哈希表大小掩码,用于计算索引值,其值总是等于size-1
unsigned long sizemask;
//哈希表已有结点的数量
unsigned long used;
} dictht;
- 其中table是一个数组,数组中保存的是dictEntry,每个dictEntry是一个键值对
- size记录了哈希表的大小,也就是table数组的大小
- sizemask是掩码,用于计算索引值,其值总是等于size-1。原理如下
key%size == key & (size-1)
- used记录了哈希表已有结点的数量
图1
上图展示了一个空的哈希表
哈希表结点
Redis定义了哈希表结点,如下所示
typedef struct dictEntry {
//键
void *key;
//值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
struct dictEntry *next;
} dictEntry;
其中
- key保存着键值对的键
- v保存着键值对的值
- next保存下一个哈希表节点的指针。通过next指针可以将多个哈希表节点链接起来,解决哈希冲突。
图2
如上图所示,关键字k1和k0的索引值都是1,通过next指针将这两个哈希节点链接在一起
字典
Redis中定义的字典如下
typedef struct dict {
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dict ht[2];
//rehash索引,默认值-1
int rehashidx;
} dict;
- type是一个指向dictType的指针
- dictType保存了若干函数的指针
- privdata保存了传给这些函数的参数
Redis定义的dictType如下
typedef strut 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是一个哈希表数组,该数组大小为2,其中ht[0]是在普通状态下使用的,ht[1]是在对哈希表rehash时使用的。
- rehashidx指示当前rehash的进度,当rehash=-1时表示当前不在rehash。例如当rehashidx=2时,表示正在进行ht[0]表索引2的rehash。
普通状态下的一个字典如下所示
图3
哈希算法
将一个新的键值对加入到字典时,程序将会根据键计算出哈希值,再根据这个哈希值和掩码去计算索引,最终将该键值对加入该索引对应的哈希链中。
例如,对于键值对(key,value),首先根据字典哈希算法计算出哈希值
hash = dict->type->hashFunction(key)
当字典处于普通情况下,也就是字典不在rehash时,该键值对插入字典的ht[0]哈希表中,计算索引
index = hash & dict->ht[0].sizemask
当字典处于rehash状态时,该键值对插入字典的ht[1]哈希表中,计算索引
index = hash & dict->ht[1].sizemask
解决哈希冲突
当两个不同的键通过哈希算法映射到同一个索引时,即产生了哈希冲突。如上图2,键k1和键k0冲突了,Redis将具有相同的哈希值的键值对放在一个链表中,并将最新的键值对加入到链表的头以提高效率。
Rehash
随着程序的运行,字典的大小可能扩展或者收缩,为了提高效率,Redis动态地rehash哈希表。
rehash步骤如下
- 为字典的ht[1]哈希表分配空间,这个哈希表需要分配的大小取决于执行的操作(扩展or收缩)以及ht[0].used的大小,具体地
- 如果执行的是扩展操作,那么ht[1]的大小取大于等于ht[0].usedx2的第一个2^n^ (其中n是一个正整数)。例如,当ht[0].used=5的时候,那么ht[1]的大小取大于等于5x2=10的第一个2^n^,也就是16。
- 如果执行的是收缩操作,那么ht[1]的大小取大于等于ht[0].used的第一个2^n^。例如,当ht[0].used=5的时候,那么ht[1]的大小取大于等于5的第一个2^n^,也就是8。
- 将保存在ht[0]中的所有键值对rehash到ht[1]中,也就是重新计算哈希索引,并根据新计算出的哈希值将键值对放到ht[1]的正确位置上。
- ht[0]中的所有值都转移到ht[1]后,释放h[0],将ht[0]设置为指向ht[1],新建一个空的哈希表,并将ht[1]指向该空的哈希表,为以后rehash做准备。
哈希表的扩展和收缩
首先介绍下负载因子的概念
负载因子 = 哈希表已经保存的哈希结点的数量 / 哈希表的大小
即:load_factor = ht[0].used / ht[0].size
当满足如下任一条件时,Redis将会执行扩展操作
- 服务器没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
- 服务器正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
为何扩展时会根据是否正在BGSAVE或BGREWRITEAOF来动态决定负载因子所需要的大小呢?简单解释一下,以后还有待深入研究:
Redis在执行BGSAVE或BGREWRITEAOF命令时,会fork一个子进程来执行这个操作。操作系统会通过写时复制技术(Copy On Write) 技术来提高效率。写时复制指的是,当进程fork一个子进程时,子进程其实和父进程是共用一个相同的物理空间的,只有父或子进程真正执行写操作时(系统调用),系统才会为子进程分配相应的物理内存空间。
这样如果Redis将负载因子设置为1,Redis执行BGSAVE或BGREWRITEAOF,系统就会过早的为子进程分配内存空间,内存写入操作较为频繁。
- 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。
渐进式Rehash
rehash过程会将ht[0]的所有键值对转移到ht[1]中,但是这个操作不是一次性完成的。如果ht[0]里的键值对只有几个或者几十个,那么rehash操作会很快完成,但是若ht[0]的键值对有几亿甚至几十亿个的时候,这个rehash操作可不是立马就能执行完成的,如果一次性执行完rehash操作,redis将很难再响应正常的应用请求,吞吐量下降很快。因此rehash是渐进式的、分多次执行的。
渐进式rehash步骤:
- 为字典的ht[1]哈希表分配空间,做好rehash的准备
- 将字典的rehashidx设置为0,表示开始rehash
- 在rehash期间,每次对字典执行增、删、改、查的时候,还会顺带将ht[0]在rehashidx索引上的所有键值对rehash到ht[1]中。当本次rehash完成后,rehashidx递增1
- 随着程序的进行,在某个时刻,ht[0]上的所有键值对都会被rehash到ht[1],此时将rehashidx设置为-1,表示rehash结束
Redis将Rehash过程分摊到了每一次的增、删、改、查操作中,避免了一次性执行rehash带来的巨大开销。
渐进式Rehash过程中,字典实际上是拥有两个哈希表(ht[0]和ht[1])的,字典的删除、查找、更新操作会在两个表中进行。对于查找操作,首先会去ht[0]去查找,如果没有查到会去ht[1]去查找。
渐进式Rehash过程中,对字典的添加操作只会在ht[1]中操作而不会在ht[0]中操作,这保证了在渐进式Rehash过程中ht[0]的哈希结点数只会减少,而不会增加,并最终会变为空表。