深入理解Redis系列——底层数据结构类型详解

简单动态字符串

常见编程语言如c,Java等它们的字符串对象底层往往是一个固定长度的字符数组,如果想对其进行修改等其他操作,就得重新分配一块区域存放修改后的新的字符串对象。

而在Redis中,它的键值对,键只能是字符串对象,值可以是字符串对象,它经常需要对这些字符串对象进行操作,所以它自己构建了一种简单动态字符串Simple dynamic String(SDS),用来对字符串对象进行抽象表示。

SDS数据结构中除了有存放字符串的字符数组char[] buf,还有一个提供字符串长度的字段len和字符数组未使用的空间大小字段free。

SDS与c字符串的区别

1 SDS可以在O(1)的时间复杂度内得到字符串长度,因为有len字段,C字符串需要遍历O(n),这样的话获取字符串长度不会成为redis的性能瓶颈,加入字符串很长的话

2 避免缓冲区溢出,比如字符串的拼接操作,SDS在现有的字符串上拼接另一个字符串的时候会先检查有没有足够的空间存放新字符串,如果没有,就主动去扩展空间,并会扩展到更大的空余空间避免下次字符串的扩充操作。

3 C字符串的修改操作,比如增长和缩短都存在缓冲区溢出或者内存泄漏的问题,所以在进行这些操作前,C通过会先进行一步内存重分配,这往往涉及到系统调用,比较耗时,而Redis作为数据库,面临着数据经常被修改的场景,所以频繁的内存重分配,会对性能造成很大影响

所以Redis提供了几种空间上的优化策略去减少内存重分配这样的操作:

       1 空间预分配,当某一次修改时,发现free空间不够,就执行扩展内存,但sds不仅仅只是将数组长度扩充到够用就行,而是如果数据小于1MB,就额外申请free=len长度的空余空间,当len>1Mb,就多申请1Mb的free空间,减少下一次增加长度时要再扩充字符串长度的次数

       2 惰性空间释放,出现将字符串长度缩小的操作,会使得len减少,而缩减出来的那部分长度不会释放掉,而是加在free中,在后面可能的扩充操作上派上用场

4 Redis不仅会存放文本数据,而且会存放其他数据类型的二进制数据,所有的SDS的api对于数据的处理都是以二进制的方式处理buf中的数据,所以这也是buf成为字节数组的原因。比如c的字符串往往是以空字符\0为一个字符串的结束符,而redis不会这样认为。

5 sds的字符串对象遵循c字符串的规则,在结尾处加\0结尾,所以可以重用一部分c对于字符串的api函数<string.h>库的函数

链表

作为一种常用的数据结构,在Redis中也有广泛的使用,比如列表键的底层实现,发布与订阅,慢查询,监视器等功能都用到了链表。

链表在Redis中的应用非常广泛,比如列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。可以与后面用压缩列表实现列表键对比(包含了数量比较少的元素,又或者列表中包含的元素都是比较短的字符串时)

链表节点用listNode数据结构来表示,保存着前一个节点,后一个节点和节点值

使用list数据结构来持有链表,其中有首节点,尾节点,链表长度字段,节点复制,释放,对比函数

Redis的链表实现的特性:

1 双端节点,方便得到某个节点的前一个和后一个节点

2 无环

3 通过list结构,方便获得表头和表尾

4 通过list结构,方便获得链表长度

5 多态,节点值可存储各种数据类型值

字典

字典是一个保存键值对的抽象数据结构,在Redis中数据库的实现和哈希键的实现都使用到了字典。

字典底层使用哈希表实现,哈希表是一个数组,数组的元素为哈希表节点,哈希表节点中存放着键和值,以及下一个哈希表节点的指针。

字典的数据结构中保存着一个哈希表数组dictht ht[2],字典只是用ht[0],ht[1]是在rehash的时候使用。

往字典中加入键值对的方法和hashmap中一样,先根据key计算hash值,然后和数组长度相与,计算出索引值,然后将节点放到对应索引指向的链表中

Redis解决键冲突的方式是使用链地址法,使用每个哈希表节点的next指针去解决冲突。

Rehash的时候新哈希表的大小总是旧哈希表长度的2的倍数

Rehash使用的是渐进式的方式,不是一次性,而是分多次的移动到新哈希表中,因为如果数据量庞大,一次性可能使得服务器受不了。

跳跃表

跳跃表是一个有序数据结构,它通过在一个节点中维持多个指向其他节点的指针来达到快速查找。

为什么会出现跳跃表?https://www.jianshu.com/p/9d8296562806

比如有一个很长的有序单链表,我们要找一个相对靠后的节点,需要从头开始遍历链表,这样的时间复杂读往往太高。

所以我们考虑给单链表增加索引的方式,去减少访问节点的个数,比如创建一个新链表,从头开始每隔一个节点建立指向关系,每个节点又指向自己,因为是有序的,所以可以通过遍历索引去快速找到原链表的某个元素。

如果嫌遍历次数太多,还可以继续建立二级,三级索引,这种以空间换时间的思想。往往能实现二分查找的效果。

可能同学们会想,从上面案例来看,提升的效率并不明显,本来需要遍历8个元素,优化了半天,还需要遍历 4 个元素,其实是因为我们的数据量太少了,当数据量足够大时,效率提升会很大。如下图所示,假如有序单链表现在有1万个元素,分别是 0~9999。现在我们建了很多级索引,最高级的索引,就两个元素 0、5000,次高级索引四个元素 0、2500、5000、7500,依次类推,当我们查找 7890 这个元素时,查找路径为 0、5000、7500 ... 7890,通过最高级索引直接跳过了5000个元素,次高层索引直接跳过了2500个元素,从而使得链表能够实现二分查找。由此可以看出,当元素数量较多时,索引提高的效率比较大,近似于二分查找。

到这里大家应该已经明白了什么是跳表。跳表是可以实现二分查找的有序链表。

这种方式查找很快,但是如果往原链表中添加元素的话,假如一直往原始列表中添加数据,但是不更新索引,就可能出现两个索引节点之间数据非常多的情况,极端情况,跳表退化为单链表,从而使得查找效率从 O(logn) 退化为 O(n)。那这种问题该怎么解决呢?我们需要在插入数据的时候,索引节点也需要相应的增加、或者重建索引,来避免查找效率的退化。那我们该如何去维护这个索引呢?

假如跳表每一层的晋升概率是 1/2,最理想的索引就是在原始链表中每隔一个元素抽取一个元素做为一级索引。换种说法,我们在原始链表中随机的选 n/2 个元素做为一级索引是不是也能通过索引提高查找的效率呢? 当然可以了,因为一般随机选的元素相对来说都是比较均匀的。如下图所示,随机选择了n/2 个元素做为一级索引,虽然不是每隔一个元素抽取一个,但是对于查找效率来讲,影响不大,

我们可以认为:当原始链表中元素数量足够大,且抽取足够随机的话,我们得到的索引是均匀的。我们要清楚设计良好的数据结构都是为了应对大数据量的场景,如果原始链表只有 5 个元素,那么依次遍历 5 个元素也没有关系,因为数据量太少了。所以,我们可以维护一个这样的索引:随机选 n/2 个元素做为一级索引、随机选 n/4 个元素做为二级索引、随机选 n/8 个元素做为三级索引,依次类推,一直到最顶层索引。这里每层索引的元素个数已经确定,且每层索引元素选取的足够随机,所以可以通过索引来提升跳表的查找效率。

我们的案例中晋升概率 SKIPLIST_P 设置的 1/2,即:每两个结点抽出一个结点作为上一级索引的结点。如果我们想节省空间利用率,可以适当的降低代码中的 SKIPLIST_P,从而减少索引元素个数,Redis 的 zset 中 SKIPLIST_P 设定的 0.25。

Redis中跳跃表的实现是由两个数据结构完成

一个是跳跃表节点zskiplistNode,用来存放链表中的节点,另一个是zskipList,用来记录整个链表的一些信息。

zskipList中有指向表头和表尾的指针,一个level字段,记录了层数最多的节点的层数大小,相当于上面的例子中的索引级数,索引的高度,一个length字段,记录链表节点个数

zskiplistNode节点中有一个level数组,一个后退指针指向它的前一个节点,还有指向对象的指针和它对应的分值用来排序用的。

Level数组的每个元素中有一个前进指针和一个跨度字段。每个节点的Level数组的大小是一个随机生成的介于1到32之间的整数,类比于上面的随机生成的过程,有几层他就可以作为从一级索引到层数级索引。一个节点的层数越高,代表它的索引级别就越高,它就可以越快的访问其他节点。

前进指针指向表尾方向上的某个节点,跨度代表它两之间的距离。

头节点不包含元素,但是是满层的,找元素的时候,从头节点开始,遍历节点的level数组,遍历顺序是从l32往下遍历。

在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的。

跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序。

整数集合

整数集合是集合键的底层实现之一,它是用于保存整数值的抽象数据结构,它可以保存16位,32位,64位任意之一的整数值,并且有序,不包含重复项,底层是一个数组。

但是在某一状态下,只能存储同一类型的整数,不可能同时存在16,又存在32位的整数。

但是提供了升级的特性,比如当一个都是16位的整数集合添加进一个32位的整数,其他16位的整数都会升级为32位的。

升级就是根据新类型的长度和集合元素的数量重新对底层数组进行内存分配

升级的好处

1 因为在c当中,一个数据结构只能存储同一类型的元素,而整数集合我们可以任意存储不同位的整数,不用担心出错,使得这种数据结构更加灵活

2 节省内存,在c当中,如果一个64位的数组一直存储的是16位的整数,就会浪费内存,而整数数组只有在需要升级的时候才会扩展内存。

只支持升级操作,不支持降级操作。

压缩列表

压缩列表是列表键和哈希键的底层实现之一。当一个列表键或者哈希键包含的数据量较少,且数据都为小整数或者短的字符串,就可以用压缩列表来实现。它的目的是为了节约内存。

压缩列表底层是一块连续的内存组成的数据结构。

压缩列表的节点中有一个字段记录着当前节点的长度,另有一个字段previous_entry_length记录着当前节点的前一个节点的长度,所以我们只有拥有一个节点的指针,就可以任意得到它前面的节点位置,直到表头。

previous_entry_length字段因为它如果前一个节点的长度小于254字节,就用1个字节存储前一个节点的长度,否则用5字节。所以如果增加或者删除节点,可能会造成连锁更新,更新的就是previous_entry_length的长度,但实际场景连锁更新对性能的影响不大,因为很少有很多个连续的节点都需要更新。

为什么说压缩列表可以节约内存?

首先压缩列表底层也是一个数组,所以要和传统的数组做一个对比。

传统的数组

同之前的底层数据一样,压缩列表也是由Redis设计的一种数据存储结构。他有点类似于数组,都是通过一片连续的内存空间来存储数据。但是其和数组也有点区别,数组存储不同长度的字符时,会选择最大的字符长度作为每个节点的内存大小。如下图,一共五个元素,每个元素的长度都是不一样的,这个时候选择最大值5作为每个元素的内存大小,如果选择小于5的,那么第一个元素hello,第二个元素world就不能完整存储,数据会丢失。

存在的问题

上面已经提到了需要用最大长度的字符串大小作为整个数组所有元素的内存大小,如果只有一个元素的长度超大,但是其他的元素长度都比较小,那么我们所有元素的内存都用超大的数字就会导致内存的浪费。那么我们应该如何改进呢?

引出压缩列表

Redis引入了压缩列表的概念,即多大的元素使用多大的内存,一切从实际出发,拒绝浪费。如下图,根据每个节点的实际存储的内容决定内存的大小,即第一个节点占用5个字节,第二个节点占用5个字节,第三个节点占用1个字节,第四个节点占用4个字节,第五个节点占用3个字节。

还有一个问题,我们在遍历的时候不知道每个元素的大小,无法准确计算出下一个节点的具体位置。实际存储不会出现上图的横线,我们并不知道什么时候当前节点结束,什么时候到了下一个节点。所以在redis中添加length属性,用来记录前一个节点的长度。如下图,如果需要从头开始遍历,取某个节点后面的数字,比如取“hello”的起始地址,但是不知道其结束地址在哪里,我们取后面数字5,即可知道"hello"占用了5个字节,即可顺利找到下一节点“world”的起始位置。

至此redis的数据类型的底层数据结构就这些:简单动态字符串,链表,字典,跳跃表,整数集合,压缩列表。其它我们常说的redis数据类型,如String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)底层都是用这六种基础结构实现的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值