字典在Redis中的应用很是普遍,数据库与哈希对象的底层实现就是字典。html
图解Redis之数据结构篇——简单动态字符串SDSredis
散列表(哈希表),其思想主要是基于数组支持按照下标随机访问数据时间复杂度为O(1)的特性。但是说是数组的一种扩展。假设,咱们为了方便记录某高校数学专业的全部学生的信息。要求能够按照学号(学号格式为:入学时间+年级+专业+专业内自增序号,如2011 1101 0001)可以快速找到某个学生的信息。这个时候咱们能够取学号的自增序号部分,即后四位做为数组的索引下标,把学生相应的信息存储到对应的空间内便可。c#
如上图所示,咱们把学号做为key,经过截取学号后四位的函数后计算后获得索引下标,将数据存储到数组中。当咱们按照键值(学号)查找时,只须要再次计算出索引下标,而后取出相应数据便可。以上即是散列思想。数组
上面的例子中,截取学号后四位的函数便是一个简单的散列函数。缓存
//散列函数 伪代码 int Hash(string key) { // 获取后四位字符 string hashValue =int.parse(key.Substring(key.Length-4, 4)); // 将后两位字符转换为整数 return hashValue; }
在这里散列函数的做用就是讲key值映射成数组的索引下标。关于散列函数的设计方法有不少,如:直接寻址法、数字分析法、随机数法等等。但即便是再优秀的设计方法也不能避免散列冲突。在散列表中散列函数不该设计太复杂。服务器
散列函数具备肯定性和不肯定性。数据结构
散列冲突,即key1≠key2,hash(key1)=hash(key2)的状况。散列冲突是不可避免的,若是咱们key的长度为100,而数组的索引数量只有50,那么再优秀的算法也没法避免散列冲突。关于散列冲突也有不少解决办法,这里简单复习两种:开放寻址法和链表法。运维
开放寻址法的核心思想是,若是出现了散列冲突,咱们就从新探测一一个空闲位置,将其插入。好比,咱们可使用线性探测法。当咱们往散列表中插入数据时,若是某个数据通过散列函数散列以后,存储位置已经被占用了,咱们就从当前位置开始,依次日后查找,看是否有空闲位置,若是遍历到尾部都没有找到空闲的位置,那么咱们就再从表头开始找,直到找到为止。
散列表中查找元素的时候,咱们经过散列函数求出要查找元素的键值对应的散列值,而后比较数组中下标为散列值的元素和要查找的元素。若是相等,则说明就是咱们要找的元素;不然就顺序日后依次查找。若是遍历到数组中的空闲位置尚未找到,就说明要查找的元素并无在散列表中。
对于删除操做稍微有些特别,不能单纯地把要删除的元素设置为空。由于在查找的时候,一旦咱们经过线性探测方法,找到一个空闲位置,咱们就能够认定散列表中不存在这个数据。可是,若是这个空闲位置是咱们后来删除的,就会致使原来的查找算法失效。这里咱们能够将删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并非停下来,而是继续往下探测。
线性探测法存在很大问题。当散列表中插入的数据愈来愈多时,其散列冲突的可能性就越大,极端状况下甚至要探测整个散列表,所以最坏时间复杂度为O(N)。在开放寻址法中,除了线性探测法,咱们还能够二次探测和双重散列等方式。
链表法是一种比较经常使用的散列冲突解决办法,Redis使用的就是链表法来解决散列冲突。链表法的原理是:若是遇到冲突,他就会在原地址新建一个空间,而后以链表结点的形式插入到该空间。当插入的时候,咱们只须要经过散列函数计算出对应的散列槽位,将其插入到对应链表中便可。
咱们可使用装载因子来衡量散列表的“健康情况”。
散列表的负载因子 = 填入表中的元素个数/散列表的长度
散列表负载因子越大,表明空闲位置越少,冲突也就越多,散列表的性能会降低。
对于散列表来讲,负载因子过大或太小都很差,负载因子过大,散列表的性能会降低。而负载因子太小,则会形成内存不能合理利用,从而造成内存浪费。所以咱们为了保证负载因子维持在一个合理的范围内,要对散列表的大小进行收缩或扩展,即rehash。散列表的rehash过程相似于数组的收缩与扩容。
对于开放寻址法解决冲突的散列表,因为数据都存储在数组中,所以能够有效地利用 CPU 缓存加快查询速度(数组占用一块连续的空间)。可是删除数据的时候比较麻烦,须要特殊标记已经删除掉的数据。并且,在开放寻址法中,全部的数据都存储在一个数组中,比起链表法来讲,冲突的代价更高。因此,使用开放寻址法解决冲突的散列表,负载因子的上限不能太大。这也致使这种方法比链表法更浪费内存空间。
对于链表法解决冲突的散列表,对内存的利用率比开放寻址法要高。由于链表结点能够在须要的时候再建立,并不须要像开放寻址法那样事先申请好。链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于1的状况。接近1时,就可能会有大量的散列冲突,性能会降低不少。可是对于链表法来讲,只要散列函数的值随机均匀,即使装载因子变成10,也就是链表的长度变长了而已,虽然查找效率有所降低,可是比起顺序查找仍是快不少。可是,链表由于要存储指针,因此对于比较小的对象的存储,是比较消耗内存的,并且链表中的结点是零散分布在内存中的,不是连续的,因此对CPU缓存是不友好的,这对于执行效率有必定的影响。
Redis字典使用散列表最为底层实现,一个散列表里面有多个散列表节点,每一个散列表节点就保存了字典中的一个键值对。
typedef struct dict{ //类型特定函数 void *type; //私有数据 void *privdata; //哈希表-见2.1.2 dictht ht[2]; //rehash 索引 当rehash不在进行时 值为-1 int trehashidx; }dict;
type属性和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;
typedef struct dictht { //哈希表数组,C语言中,*号是为了代表该变量为指针,有几个* 号就至关因而几级指针,这里是二级指针,理解为指向指针的指针 dictEntry **table; //哈希表大小 unsigned long size; //哈希表大小掩码,用于计算索引值 unsigned long sizemask; //该哈希已有节点的数量 unsigned long used; }dictht;
//哈希表节点定义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属性是指向另外一个哈希表节点的指针,这个指针能够将多个哈希值相同的键值对链接在一块儿,解决键冲突问题。
当有两个或以上的键被分配到散列表数组同一个索引上时,就发生了键冲突。Redis使用链表法解决散列冲突。每一个散列表节点都有一个next指针,多个散列表节点next能够用next指针构成一个单向链表,被分配到同一个索引上的多个节点可使用这个单向链表链接起来。
如图所示,当键k0和k1的通过散列函数获得索引值都为1时,就会使用next指针将两个节点链接起来。而因为节点没有指向链尾的指针,所以新的节点老是插入到链表的头部,排在已有节点的前面。
随着操做的进行,散列表中保存的键值对会也会不断地增长或减小,为了保证负载因子维持在一个合理的范围,当散列表内的键值对过多或过少时,内须要按期进行rehash,以提高性能或节省内存。Redis的rehash的步骤以下:
为字典的ht[1]散列表分配空间,这个空间的大小取决于要执行的操做以及ht[0]当前包含的键值对数量(即:ht[0].used的属性值)
将保存在ht[0]中的键值对从新计算键的散列值和索引值,而后放到ht[1]指定的位置上。
将ht[0]包含的全部键值对都迁移到了ht[1]以后,释放ht[0],将ht[1]设置为ht[0],并建立一个新的ht[1]哈希表为下一次rehash作准备。
rehash操做须要知足如下条件:
Redis这么作的目的是基于操做系统建立子进程后写时复制技术,避免没必要要的写入操做。(有关BGSAVE、BGREWRITEAOF以及写时复制会在后续持久化一文详细介绍)。
对于rehash咱们思考一个问题若是散列表当前大小为 1GB,要想扩容为原来的两倍大小,那就须要对 1GB 的数据从新计算哈希值,而且从原来的散列表搬移到新的散列表。这种状况听着就很耗时,而生产环境中甚至会更大。为了解决一次性扩容耗时过多的状况,能够将扩容操做穿插在插入操做的过程当中,分批完成。当负载因子触达阈值以后,只申请新空间,但并不将老的数据搬移到新散列表中。当有新数据要插入时,将新数据插入新散列表中,而且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,都重复上面的过程。通过屡次插入操做以后,老的散列表中的数据就一点一点所有搬移到新散列表中了。这样没有了集中的一次一次性数据搬移,插入操做就都变得很快了。
Redis为了解决这个问题采用渐进式rehash方式。如下是Redis渐进式rehash的详细步骤:
ht[1]
分配空间, 让字典同时持有 ht[0]
和 ht[1]
两个哈希表。rehashidx
, 并将它的值设置为 0
,表示 rehash 工做正式开始。ht[0]
哈希表在 rehashidx
索引上的全部键值对 rehash 到 ht[1]
, 当 rehash 工做完成以后, 程序将 rehashidx
属性的值增一。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 操做的执行而最终变成空表。
下面给出几个Redis字典常见操做的时间复杂度,能够结合上面的内容分析为何。
操做 | 时间复杂度 |
---|---|
建立一个新字典 | O(1) |
将给定的键值对添加到字典内 | O(1) |
将给定的键值对添加到字典内,若是键存在则替换之 | O(1) |
返回给定键的值 | O(1) |
从字典中随机返回一个键值对 | O(1) |
从字典中删除给定键所对应的键值对 | O(1) |
释放给定字典以及字典中包含的键值对 | O(N),N为字典包含的键值对的数量 |
本篇文章主要回顾了散列表的概念,散列函数以及如何解决散列冲突。并分析了Redis中字典的实现。下篇文章将介绍跳跃表以及跳跃表在Redis中的实现。
《Redis设计与实现》
《Redis开发与运维》
《Redis官方文档》