概述
上篇博客我简单介绍了 redis 哈希表相关知识,本篇博客我打算在哈希表的基础上,简单整理一下 redis 字典的实现原理
字典
字典又称为符号表、关联数组,映射等,它是一种保存键值对的抽象数据结构。
字典中的键是独一无二的,程序可以在字典中根据键查找与之关联的值,或通过键来更新值,删除值等。它作为一种数据结构内置在许多编程语言中,但 redis 的开发语言C语言并没有实现这种数据结构,因此 redis 构建了自己的字典实现。
redis 字典就是使用上一节我们讲过的哈希表作为底层原理。关于哈希表的知识,可以点击这里参考上一篇博客内容。
redis 中的字典由头文件 dict.h 中的 dict 表示:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引
//当rehash不在进行时,值为-1
in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
- type 属性是一个指向 dictType 类型结构的指针,每个 dictType 结构保存了一组用户操作特定类型变量的函数,redis 会为用途不同的字典设置不同的函数
- privdata 属性保存了传给 type 函数的可选参数
- ht 属性是一个包含两项的数组,数组中的每一项都是 dictht 类型的哈希表,一般情况下只需要使用 ht[0],ht[1] 是在数组 rehash 时使用的
- trehashidx 属性也是在 rehash 时使用的,使用它来记录 rehash 的进度,如果目前没有进行 rehash,那么它的值是 -1
dictht 类型已经在上一篇博客介绍过,这里我们主要看看 dictType 类型的结构:
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;
从注释可以看出,dictType 主要实现和字典操作相关的常用方法。下面我们看一张没有 rehash 的字典示意图:
哈希算法
当我们需要将一个新的键值对添加到字典时,程序首先需要根据键值对的键计算出 哈希值 和 索引值,其中索引值需要通过哈希值才能计算得出。然后再根据索引值,将包含键值对的哈希表节点,放到哈希表数组的指定索引上。
redis 计算哈希值以及索引值的方法如下:
#使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
#使用哈希表的 sizemask 属性和哈希值,计算出索引值
#根据情况不同,ht[x]可以是ht[0]或者ht[1],
#也就是说:添加新元素时,可能添加到 ht[0] 数组,也可能添加到 ht[1] 数组,关于其中的原因本篇后面介绍
index = hash & dict->ht[x].sizemask;
当存在两个或两个以上数量的键被分配到哈希表的同一个索引上面时,我们称这些键发生了 冲突。redis 使用 链地址法 来解决冲突:每个哈希表节点可以用 next 指针构成一个单向链表,被分配到同一索引上的哈希节点可以通过指针连接起来。
需要注意的一点是:dictht 结构中不存在指针指向链表尾部节点,为了提高效率,每次使用头插的方式保存新节点,这样时间复杂度可以控制在 O(1),提高添加效率。
rehash
随着操作的不断执行,哈希表保存的键值对会逐渐增多或减少,为了让哈希表的 负载因子 维持在一个合理的范围内,当哈希表的节点数过多或过少时,程序需要对哈希表的大小进行扩展或收缩。
# 负载因子= 哈希表已保存节点数量/ 哈希表大小
load_factor = ht[0].used / ht[0].size
扩展和收缩哈希表的工作可以通过执行 rehash 操作来完成,redis 对字典哈希表的 rehash 步骤如下:
- 为字典的 ht[1] 哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及 ht[0] 当前包含的键值对数量(ht[0].used 属性)
- 如果执行的是扩展操作,那么 ht[1] 的大小为第一个大于等于 2*ht[0].used 的 2的n次方幂
- 如果执行的是收缩操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2的n次方幂
- 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面
- 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后(ht[0]变为空表),释放ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空白哈希表,为下一次 rehash 做准备。
rehash:是指重新计算键的哈希值和索引值,然后将键值对放置到新哈希表的相应位置上
从这里我们可以看出,redis 哈希表数组的大小也总是2的n次幂,关于其中的原理可以参考我 hashmap 的博客内容,具体点击这里跳转。
哈希表的扩容与缩容
当以下任一条件满足时,程序会对哈希表执行扩容或缩容操作:
- 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于1
- 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于5
- 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
根据 BGSAVE 或 BGREWRITEAOF 命令是否正在执行,服务器执行扩展操作所需要的负载因子并不相同。这是因为在执行这些命令期间,redis 服务器需要创建当前服务进程的子进程,而大多数操作系统采用 写时复制 技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行拓展操作所需要的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
写时复制:如果有多个调用者同时请求相同资源,他们会共同获取相同的指针指向相同的资源。如果某个调用者试图修改资源的内容时,系统会复制一份专用副本给该调用者,而其他调用者所见到的最初的资源仍然保持不变
也就是说,如果 BGSAVE 或 BGREWRITEAOF 命令执行期间扩容,则需要考虑主进程以及子进程之间的数据同步。为了达成一致,需要额外进行同步操作,而同步操作往往需要耗费额外的资源。因此 redis 尽量避免在该命令执行期间扩容。
渐进式 rehash
前面我们提到,哈希表扩容过程中,需要将 ht[0] 上所有元素 rehash 到 ht[1] 上,但这个 rehash 的过程不是一次执行的,当哈希表上哈希节点太多是,一次 rehash 可能带来性能问题。为了解决该问题,redis 执行 rehash 操作不是集中式完成的,而是渐进式的,分多次逐步完成。
这里我们主要看一下一次 rehash 的全过程:
- 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表
- 在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为0,表示 rehash 工作正式开始
- 在 rehash 进行期间,每次对字典执行增、删、改,查操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],当rehash工作完成之后,程序将 rehashidx 属性的值增一。
- 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被 rehash 至 ht[1] ,这时程序将 rehashidx 属性的值设为-1,表示 rehash 操作已完成。
渐进式 rehash 的好处在于它采取分治的方式,将 rehash 键值对所需的计算工作均摊到对字典的每种操作上,从而避免了集中式 rehash 而带来的庞大计算量。
需要注意的一点是:在执行 rehash 期间,查询、修改,删除等操作是同时在两个数组上执行的。如果在 ht[0] 上没有找到要操作的节点,就去 ht[1] 上查找,因为此时被操作的节点可能在两个数组中的任何一个。执行添加操作只需要在 ht[1] 上操作,因为最终所有新节点都保存在 ht[1]上。
字典 API
下面我通过表格的形式列举出字典常用方法:
函数 | 作用 | 时间复杂度 |
---|---|---|
dictCreate | 创建新字典 | O(1) |
dictAdd | 将指定键值对添加到字典表 | O(1) |
dictReplace | 将指定键值对添加到字典表,如果键已存在就替换 | O(1) |
dictFetchValue | 返回给定键的值 | O(1) |
dictGetRandomKey | 从字典中返回随机键值对 | O(1) |
dictDelete | 从字典中删除给定键所对应的键值对 | O(1) |
dictRelease | 释放给定字典以及其中所有键值对 | O(1) |
用途
字典在 redis 的用途非常广泛,下面我列举出比较常见的几种:
- redis 数据库底层就是通过字典实现的,redis 数据库的增、删、改,查等操作也是建立在字典之上
- 当一个哈希键包含的键值对比较多,又或者键值对中的字符串比较长时,redis 使用字典作为哈希键的实现原理
哈希键:如果一个键所对应的值是哈希类型,就称它为哈希键
需要注意的一点是:当字典被作为数据库底层或哈希键的实现原理时,redis 使用 MurmurHash2 算法计算该键所对应的哈希值。
MurmurHash2
MurmurHash2 是一种非加密型哈希函数,通过它可以高速计算出键值对 key 所对应的哈希值。即使对于一些规律性非常强的key,它也可以良好的做到随机分布。
MurmurHash2 算法的源码如下,可以复制到项目中直接使用:
unsigned int murMurHash(const void *key, int len)
{
const unsigned int m = 0x5bd1e995;
const int r = 24;
const int seed = 97;
unsigned int h = seed ^ len;
// Mix 4 bytes at a time into the hash
const unsigned char *data = (const unsigned char *)key;
while(len >= 4)
{
unsigned int k = *(unsigned int *)data;
k *= m;
k ^= k >> r;
k *= m;
h *= m;
h ^= k;
data += 4;
len -= 4;
}
// Handle the last few bytes of the input array
switch(len)
{
case 3: h ^= data[2] << 16;
case 2: h ^= data[1] << 8;
case 1: h ^= data[0];
h *= m;
};
// Do a few final mixes of the hash to ensure the last few
// bytes are well-incorporated.
h ^= h >> 13;
h *= m;
h ^= h >> 15;
return h;
}
经过其他博主的测试,MurmurHash2 算法在键特别长时效率较高,并且方法需要以字符串长度作为参数。在C语言中,字符数组本身是不带有长度的,因此该方法本身效率不算最高。但当哈希表中需要保存海量数据时,MurmurHash2 可以做到分布均匀,这也正是 redis 使用它作为 hash 算法的主要原因。