Redis字典
Redis 的字典底层使用哈希表作为实现,一个哈希表内有多个节点,每个节点就是字典中的键值对。
首先,先看看哈希表的定义。
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
哈希表的定义里,首先是一个二级指针,这个二级指针指向一个存放 dictEntry 指针的数组。而每一个 dictEntry 就是一个键值对。size 属性是这个哈希表数组的大小,used 属性是哈希表内已存在的节点(键值对)个数。sizemask 的值总是为 size - 1,这个属性和哈希值决定了一个键应该存放到哈希表数组的哪个索引上去。
说完哈希表的定义,再来看看哈希节点的定义。
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
哈希节点里,首先有个键值 key,他的类型是一个 void *。接着有一个联合 v,这里保存键值对中的值,这个值可以是一个指针,一个 uint64_t 的整数或 int64_t 的整数。还有最后一个属性是指向下一个哈希节点的 next 指针。
最后再来说说字典的定义。
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
字典结构里, type 属性跟 privdata 是针对不同类型的键值对,为创建多态字典而设置的。
其中 type 是指向一个 dictType 结构的指针,每个 dictType 里都是一组函数指针,这些函数能够对特定的键值对进行操作。privdata 内则保存着需要传给哪些特定函数的可选参数。
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;
ht 属性是一个包含两张哈希表的数组,一般情况下,字典只使用 ht[0] 哈希表,ht[1] 哈希表只会在对 ht[0] 进行 rehash 的时候才会使用。
rehashidx 这个属性是在 rehash 时判断是否 rehash 完成的,不进行 rehash 时这个属性的值为 -1。
Redis哈希算法
将一个新的键值对插入字典时,首先根据字典内的 type 属性找到该字典设置的哈希函数,使用该哈希函数计算出哈希值,再将哈希值跟哈希表内的 sizemask 计算出索引值,根据该索引值将新的哈希节点存储到哈希表的指定索引上面。
// 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
// 使用哈希表的 sizemask 属性和哈希值,计算出索引值
// 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
哈希冲突
Redis 哈希表在解决哈希冲突的时候采用哈希桶链式结构解决。也就是当发生冲突后,将要新增的哈希节点挂在哈希表指定索引位置的链表内,采用的头插的形式提升效率。
Rehash
随着对哈希表不断的操作,哈希表内的键值对数量也会增加或者减少,为了保证哈希表的负载因子在一个合理的范围内,当哈希表内保存的键值对数量太多或者太少时,哈希表会进行扩展或收缩,这个过程通过 Rehash 来完成,Rehash 的过程如下:
- 为字典 h[1] 分配新的空间,分配的新空间的大小取决于本次操作为扩展还是收缩,以及 h[0] 内保存的键值对的数量(即 h[0].used 的大小)。
- 如果执行扩展操作,那么 h[1] 的空间大小为第一个大于等于 h[0].used * 2 的
。
- 如果执行收缩操作,那么 h[1] 的空间大小为第一个大于等于 h[0].used 的
。
- 如果执行扩展操作,那么 h[1] 的空间大小为第一个大于等于 h[0].used * 2 的
- 将保存到 h[0] 内的键值对重新 rehash 到 h[1] 上,这里是重新计算键值对的哈希值与索引值,将键值对放到 h[1] 上的指定位置。
- 当 h[0] 内的键值对全部迁移到 h[1] 后,释放 h[0] ,此时将 h[1] 作为 h[0],并在之前 h[1] 出创建一个新的空哈希表,此时 rehash 完成。
哈希表的扩展与收缩
上面说到在哈希表扩展或者收缩的时候会执行 rehash 的操作,那么在什么情况下哈希表会进行扩展与收缩呢 ?
扩展操作:
- 当 Redis 服务器没有执行 BGSAVE 或者 BGREWRITEAOF 命令时,哈希表的负载因子达到 1;
- 当 Redis 服务器正在执行 BGSAVE 或者 BGREWRITEAOF 命令时,哈希表的负载因子达到5;
收缩操作:
- 哈希表的负载因子小于 0.1 ;
// 负载因子计算
// 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
Redis 服务器应该避免在 BGSAVE 与 BGREWRITEAOF 指令在执行时扩展哈希表,因为在这两个命令执行的时候,服务器需要创建当前进程的子进程,大多数操作系统都采用写实拷贝的方式优化子进程的使用效率,如果在这个时候进行扩展哈希表,将会产生一些不必要的内存写入操作。
渐进式 rehash
在进行 rehash 的操作的时候,这个动作不是一次性完成的。如果说哈希表内有少量的节点,一次性完成无伤大雅。但是当哈希表内保存大量的节点时,如果还要一次性完成 rehash 操作,庞大的计算量可能会导致服务器宕机。
为了避免上面所说的问题,Redis 服务器在进行 rehash 时,采用渐进式、分多次的方式进行 rehash。
渐进式 rehash 步骤:
- 先为 h[1] 开辟空间,此刻字典同时拥有两张哈希表 h[0] 与 h[1]。
- 字典中有一个叫做 rehashidx 的属性,设置为 0,表示此刻开始 rehash。
- 在 rehash 的期间,每次对字典的增删查改操作,除了完成指定操作后,还会顺带将此时 h[0] 在 rehashidx 索引上的所有键值对 rehash 到 h[1],操作完成后 rehashidx 属性值 ++。
- 直至某一刻,rehashidx 的属性值等于 h[0] 的 size 值,意味着 rehash 完成,这时将 rehashidx 设置为 -1。
在渐进式 rehash 的过程当中,字典会同时使用两张表,在执行删除、修改、查找时,会在两张表上同时进行。例如:查找某个键值时,现在 h[0] 上查找,如果没有找到则在 h[1] 上继续查找。
但是,在 rehash 的过程中,新增键值对一律保存到 h[1] 里,这保证了 h[0] 上的键值对,只减不增加,随着 rehash 的执行,最终 h[0] 成为空表。