图解Redis之数据结构篇——字典

文章导航-readme

前言

    字典在Redis中的应用很是普遍,数据库与哈希对象的底层实现就是字典。html

系列文章

图解Redis之数据结构篇——简单动态字符串SDSredis

图解Redis之数据结构篇——链表算法

图解Redis之数据结构篇——字典数据库

1、复习散列表

1.1 散列表

    散列表(哈希表),其思想主要是基于数组支持按照下标随机访问数据时间复杂度为O(1)的特性。但是说是数组的一种扩展。假设,咱们为了方便记录某高校数学专业的全部学生的信息。要求能够按照学号(学号格式为:入学时间+年级+专业+专业内自增序号,如2011 1101 0001)可以快速找到某个学生的信息。这个时候咱们能够取学号的自增序号部分,即后四位做为数组的索引下标,把学生相应的信息存储到对应的空间内便可。c#

散列思想

    如上图所示,咱们把学号做为key,经过截取学号后四位的函数后计算后获得索引下标,将数据存储到数组中。当咱们按照键值(学号)查找时,只须要再次计算出索引下标,而后取出相应数据便可。以上即是散列思想。数组

1.2 散列函数

    上面的例子中,截取学号后四位的函数便是一个简单的散列函数。缓存

//散列函数 伪代码 
int Hash(string key) {
  // 获取后四位字符
  string hashValue =int.parse(key.Substring(key.Length-4, 4));
  // 将后两位字符转换为整数
  return hashValue;
}

在这里散列函数的做用就是讲key值映射成数组的索引下标。关于散列函数的设计方法有不少,如:直接寻址法、数字分析法、随机数法等等。但即便是再优秀的设计方法也不能避免散列冲突。在散列表中散列函数不该设计太复杂。服务器

1.3 散列冲突

    散列函数具备肯定性和不肯定性。数据结构

  • 肯定性:哈希的散列值不一样,那么哈希的原始输入也就不一样。即:key1=key2,那么hash(key1)=hash(key2)。
  • 不肯定性:同一个散列值颇有可能对应多个不一样的原始输入。即:key1≠key2,hash(key1)=hash(key2)。

散列冲突,即key1≠key2,hash(key1)=hash(key2)的状况。散列冲突是不可避免的,若是咱们key的长度为100,而数组的索引数量只有50,那么再优秀的算法也没法避免散列冲突。关于散列冲突也有不少解决办法,这里简单复习两种:开放寻址法和链表法。运维

1.3.1 开放寻址法

    开放寻址法的核心思想是,若是出现了散列冲突,咱们就从新探测一一个空闲位置,将其插入。好比,咱们可使用线性探测法。当咱们往散列表中插入数据时,若是某个数据通过散列函数散列以后,存储位置已经被占用了,咱们就从当前位置开始,依次日后查找,看是否有空闲位置,若是遍历到尾部都没有找到空闲的位置,那么咱们就再从表头开始找,直到找到为止。

开放寻址法

    散列表中查找元素的时候,咱们经过散列函数求出要查找元素的键值对应的散列值,而后比较数组中下标为散列值的元素和要查找的元素。若是相等,则说明就是咱们要找的元素;不然就顺序日后依次查找。若是遍历到数组中的空闲位置尚未找到,就说明要查找的元素并无在散列表中。

    对于删除操做稍微有些特别,不能单纯地把要删除的元素设置为空。由于在查找的时候,一旦咱们经过线性探测方法,找到一个空闲位置,咱们就能够认定散列表中不存在这个数据。可是,若是这个空闲位置是咱们后来删除的,就会致使原来的查找算法失效。这里咱们能够将删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并非停下来,而是继续往下探测。

    线性探测法存在很大问题。当散列表中插入的数据愈来愈多时,其散列冲突的可能性就越大,极端状况下甚至要探测整个散列表,所以最坏时间复杂度为O(N)。在开放寻址法中,除了线性探测法,咱们还能够二次探测和双重散列等方式。

1.3.2 链表法

    链表法是一种比较经常使用的散列冲突解决办法,Redis使用的就是链表法来解决散列冲突。链表法的原理是:若是遇到冲突,他就会在原地址新建一个空间,而后以链表结点的形式插入到该空间。当插入的时候,咱们只须要经过散列函数计算出对应的散列槽位,将其插入到对应链表中便可。

链表法

1.3.3 负载因子与rehash

    咱们可使用装载因子来衡量散列表的“健康情况”。

散列表的负载因子 = 填入表中的元素个数/散列表的长度

散列表负载因子越大,表明空闲位置越少,冲突也就越多,散列表的性能会降低。

    对于散列表来讲,负载因子过大或太小都很差,负载因子过大,散列表的性能会降低。而负载因子太小,则会形成内存不能合理利用,从而造成内存浪费。所以咱们为了保证负载因子维持在一个合理的范围内,要对散列表的大小进行收缩或扩展,即rehash。散列表的rehash过程相似于数组的收缩与扩容。

1.3.4 开放寻址法与链表法比较

    对于开放寻址法解决冲突的散列表,因为数据都存储在数组中,所以能够有效地利用 CPU 缓存加快查询速度(数组占用一块连续的空间)。可是删除数据的时候比较麻烦,须要特殊标记已经删除掉的数据。并且,在开放寻址法中,全部的数据都存储在一个数组中,比起链表法来讲,冲突的代价更高。因此,使用开放寻址法解决冲突的散列表,负载因子的上限不能太大。这也致使这种方法比链表法更浪费内存空间。

    对于链表法解决冲突的散列表,对内存的利用率比开放寻址法要高。由于链表结点能够在须要的时候再建立,并不须要像开放寻址法那样事先申请好。链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于1的状况。接近1时,就可能会有大量的散列冲突,性能会降低不少。可是对于链表法来讲,只要散列函数的值随机均匀,即使装载因子变成10,也就是链表的长度变长了而已,虽然查找效率有所降低,可是比起顺序查找仍是快不少。可是,链表由于要存储指针,因此对于比较小的对象的存储,是比较消耗内存的,并且链表中的结点是零散分布在内存中的,不是连续的,因此对CPU缓存是不友好的,这对于执行效率有必定的影响。

2、Redis字典

2.1 Redis字典的实现

    Redis字典使用散列表最为底层实现,一个散列表里面有多个散列表节点,每一个散列表节点就保存了字典中的一个键值对。

2.1.1 字典
typedef struct dict{
         //类型特定函数
         void *type;
         //私有数据
         void *privdata;
         //哈希表-见2.1.2
         dictht ht[2];
         //rehash 索引 当rehash不在进行时 值为-1
         int trehashidx; 
}dict;

type属性和privdata属性是针对不一样类型的键值对,为建立多态字典而设置的。

  • type属性是一个指向dictType结构的指针,每一个dictType用于操做特定类型键值对的函数,Redis会为用途不一样的字典设置不一样的类型特定函数。
  • privdata属性则保存了须要传给给那些类型特定函数的可选参数。
typedef struct dictType
{
         //计算哈希值的函数 
         unsigned int  (*hashFunction) (const void *key);
         //复制键的函数
         void *(*keyDup) (void *privdata,const void *key);
         //复制值的函数
         void *(*keyDup) (void *privdata,const void *obj);
          //复制值的函数
         void *(*keyCompare) (void *privdata,const void *key1, const void *key2);
         //销毁键的函数
         void (*keyDestructor) (void *privdata, void *key);
         //销毁值的函数
         void (*keyDestructor) (void *privdata, void *obj);
}dictType;
  • ht属性是一个包含两个项的数组,数组中的每一个项都是一个dictht哈希表, 通常状况下,字典只使用ht[0] 哈希表, ht[1]哈希表只会对ht[0]哈希表进行rehash时使用。
  • rehashidx记录了rehash目前的进度,若是目前没有进行rehash,值为-1。
2.1.2 散列表
typedef struct dictht
{
         //哈希表数组,C语言中,*号是为了代表该变量为指针,有几个* 号就至关因而几级指针,这里是二级指针,理解为指向指针的指针
         dictEntry **table;
         //哈希表大小
         unsigned long size;
         //哈希表大小掩码,用于计算索引值
         unsigned long sizemask;
         //该哈希已有节点的数量
         unsigned long used;
}dictht;
  • table属性是一个数组,数组中的每一个元素都是一个指向dict.h/dictEntry结构的指针,每一个dictEntry结构保存着一个键值对
  • size属性记录了哈希表的大小,也是table数组的大小
  • used属性则记录哈希表目前已有节点(键值对)的数量
  • sizemask属性的值老是等于 size-1(从0开始),这个属性和哈希值一块儿决定一个键应该被放到table数组的哪一个索引上面(索引下标值)。
2.1.3 散列表节点
//哈希表节点定义dictEntry结构表示,每一个dictEntry结构都保存着一个键值对。
typedef struct dictEntry
{
         //键
         void *key;
         //值
         union{
           void *val;
            uint64_tu64;
            int64_ts64;
            }v;
         // 指向下个哈希表节点,造成链表
         struct dictEntry *next;
}dictEntry;

key属性保存着键值中的键,而v属性则保存着键值对中的值,其中键值(v属性)能够是一个指针,或uint64_t整数,或int64_t整数。 next属性是指向另外一个哈希表节点的指针,这个指针能够将多个哈希值相同的键值对链接在一块儿,解决键冲突问题。

2.2 Redis如何解决散列冲突

2.2.1 链表法

    当有两个或以上的键被分配到散列表数组同一个索引上时,就发生了键冲突。Redis使用链表法解决散列冲突。每一个散列表节点都有一个next指针,多个散列表节点next能够用next指针构成一个单向链表,被分配到同一个索引上的多个节点可使用这个单向链表链接起来。

Redis 链表法

如图所示,当键k0和k1的通过散列函数获得索引值都为1时,就会使用next指针将两个节点链接起来。而因为节点没有指向链尾的指针,所以新的节点老是插入到链表的头部,排在已有节点的前面。

2.2.2 Redis rehash

    随着操做的进行,散列表中保存的键值对会也会不断地增长或减小,为了保证负载因子维持在一个合理的范围,当散列表内的键值对过多或过少时,内须要按期进行rehash,以提高性能或节省内存。Redis的rehash的步骤以下:

  1. 为字典的ht[1]散列表分配空间,这个空间的大小取决于要执行的操做以及ht[0]当前包含的键值对数量(即:ht[0].used的属性值)

    • 扩展操做:ht[1]的大小为 第一个大于等于ht[0].used*2的2的n次方幂。如:ht[0].used=3则ht[1]的大小为8,ht[0].used=4则ht[1]的大小为8。
    • 收缩操做: ht[1]的大小为 第一个大于等于ht[0].used的2的n次方幂。

  2. 将保存在ht[0]中的键值对从新计算键的散列值和索引值,而后放到ht[1]指定的位置上。

  3. 将ht[0]包含的全部键值对都迁移到了ht[1]以后,释放ht[0],将ht[1]设置为ht[0],并建立一个新的ht[1]哈希表为下一次rehash作准备。

rehash操做须要知足如下条件:

  1. 服务器目前没有执行BGSAVE(rdb持久化)命令或者BGREWRITEAOF(AOF文件重写)命令,而且散列表的负载因子大于等于1。
  2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,而且负载因子大于等于5。
  3. 当负载因子小于0.1时,程序自动开始执行收缩操做。

Redis这么作的目的是基于操做系统建立子进程后写时复制技术,避免没必要要的写入操做。(有关BGSAVE、BGREWRITEAOF以及写时复制会在后续持久化一文详细介绍)。

2.2.3 渐进式 rehash

    对于rehash咱们思考一个问题若是散列表当前大小为 1GB,要想扩容为原来的两倍大小,那就须要对 1GB 的数据从新计算哈希值,而且从原来的散列表搬移到新的散列表。这种状况听着就很耗时,而生产环境中甚至会更大。为了解决一次性扩容耗时过多的状况,能够将扩容操做穿插在插入操做的过程当中,分批完成。当负载因子触达阈值以后,只申请新空间,但并不将老的数据搬移到新散列表中。当有新数据要插入时,将新数据插入新散列表中,而且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,都重复上面的过程。通过屡次插入操做以后,老的散列表中的数据就一点一点所有搬移到新散列表中了。这样没有了集中的一次一次性数据搬移,插入操做就都变得很快了。

    Redis为了解决这个问题采用渐进式rehash方式。如下是Redis渐进式rehash的详细步骤:

  1. ht[1] 分配空间, 让字典同时持有 ht[0]ht[1] 两个哈希表。
  2. 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 ,表示 rehash 工做正式开始。
  3. 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操做时, 程序除了执行指定的操做之外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的全部键值对 rehash 到 ht[1] , 当 rehash 工做完成以后, 程序将 rehashidx 属性的值增一。
  4. 随着字典操做的不断执行, 最终在某个时间点上, ht[0] 的全部键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操做已完成。

说明:

1.由于在进行渐进式 rehash 的过程当中,字典会同时使用 ht[0]ht[1] 两个哈希表,因此在渐进式 rehash 进行期间,字典的删除(delete)、查找(find)、更新(update)等操做会在两个哈希表上进行。

2. 在渐进式 rehash 执行期间,新添加到字典的键值对一概会被保存到 ht[1] 里面,而 ht[0] 则再也不进行任何添加操做:这一措施保证了 ht[0] 包含的键值对数量会只减不增,并随着 rehash 操做的执行而最终变成空表。

2.3 时间复杂度

    下面给出几个Redis字典常见操做的时间复杂度,能够结合上面的内容分析为何。

操做 时间复杂度
建立一个新字典 O(1)
将给定的键值对添加到字典内 O(1)
将给定的键值对添加到字典内,若是键存在则替换之 O(1)
返回给定键的值 O(1)
从字典中随机返回一个键值对 O(1)
从字典中删除给定键所对应的键值对 O(1)
释放给定字典以及字典中包含的键值对 O(N),N为字典包含的键值对的数量

本文重点

  1. 字典在redis中普遍应用,包括数据库和hash数据结构。
  2. 每一个字典有两个哈希表,一个是正常使用,一个用于rehash期间使用。
  3. 当redis计算哈希时,采用的是MurmurHash2哈希算法。
  4. 哈希表采用链表法解决散列冲突,被分配到同一个地址的键会构成一个单向链表。
  5. 在rehash对哈希表进行扩展或者收缩过程当中,会将全部键值对进行迁移,而且这个迁移是渐进式的迁移。

小结

    本篇文章主要回顾了散列表的概念,散列函数以及如何解决散列冲突。并分析了Redis中字典的实现。下篇文章将介绍跳跃表以及跳跃表在Redis中的实现。

参考

《Redis设计与实现》

《Redis开发与运维》

《Redis官方文档》

相关文章
相关标签/搜索