Redis从入门到入土(二):Redis数据结构与内部编码

【日常扯皮】

                上一期我给大家介绍了Redis的安装与部署,安装好了之后我们先不急着用,先了解一下Redis的数据结构,只有当你真正了解它的时候,用起来才会得心应手,不同的数据结构适用于不同的应用场景,即便是同一个数据结构,应用场景不同,配置也不尽相同。希望大家能通过这篇文章能针对不同的场景选择最适合的应用方式。如有不当之处,也希望大家能在留言处批评指正。

【进入正题】

        1.Redis数据结构

                众所周知,Redis共有5种数据结构,分别是:String、Hash、List、Set、Zset。每种数据结构都有多种内部编码,根据存储的数据不同,Redis会切换不同的内部编码来存储,如下图是Redis3.2版本以前的各数据结构的内部编码,值得注意的是,Redis3.2版本开始,对list的内部编码进行了优化,不再使用linkedlist(链表)和ziplist(压缩列表)作为list的内部编码,取而代之的是quicklist(快速列表),至于原因,下文会有详细讲解。

        

         1.1 String

                String是Redis基础的数据结构,比如我们平时操作的get/set命令就是直接操作的String类型的键,其他的几种数据结构也都是在字符串的基础上构建的,所以字符串类型能为其他四种数据结构的学习奠定基础,字符串类型的值可以是字符串(简单的字符串、复杂的字符串(如:json,xml))、数字(整数、浮点数)、甚至是二进制(图片、音频、视频),但是值最大不能超过512MB。

                String的内部编码共有三种:raw、int、embstr。我们可以通过object encoding 命令来查看当前键的内部编码。如下图,当值为小于8个字节的整数类型时,内部编码为int;当值为小于等于39字节的字符串时,内部编码为embstr(短字符串);当值大于39字节时,内部编码为raw(长字符串)。

         1.2 Hash

                Hash类型是指键值本身又是一对键值对的结果,形如:user={(filed1:value1),(filed2"value2)....(filedN:valueN)}。类似java中的map。很多编程语言中都有类似的结构,相信不需要我过多解释。我们来看一下hash类型的数据是怎么存取的。

                 可以看到,redis存储hash类型的数据时也是根据key(图中的user1)来存储的,只是这个key里面不是一个字符串,而是一个filed-value的键值对。它的内部编码也不是一个,而是两个:hashtable(哈希表)和ziplsit(压缩列表),当hash类型的元素个数小于hash-max-ziplist-entries配置(默认512个)、同时每个filed的值都小于hash-max-ziplist-value配置(默认64字节)时,Redis就会选用ziplist作为hash的内部实现,ziplist是一段连续的内存块,所以在节省内存方面ziplist更加优秀。但是当hash里面的数据不满足上述条件时,比如某个filed的值很长,大于64字节了,那这个时候再使用ziplist作为内部实现效率就会有所下降,尤其是当filed的数量有很多,值又很大的情况下进行增删操作当前hash时可能会造成Redis阻塞。

                所以当ziplist无法满足当前hash数据时,redis就会自动切换为hashtable作为其内部实现,hashtable的结构不用多说(我觉得是,相信你们都有了解),hashtable的时间复杂度为O(1),所以,当值类型比较多比较大时,hashtable的效率要优于ziplist。

                有不了解ziplist的同学可能会好奇,为什么ziplist更节省内存,且数据量比较小时效率更快呢?它在内存中是一个什么样的结构呢?上文提到,ziplist由一段连续的内存块组成。这一段内存块分为3个部分:表头、数据存储节点、表尾。具体如下图

                 zlbytes:占4个字节,记录整个压缩列表占用的内存字节数。

                zltail_offset:占4个字节,记录压缩列表尾节点entryN距离压缩列表的起始地址的字节数。

                zllength:占2个字节,记录了压缩列表的节点数量。

                entry[1-N]:长度不定,保存数据

                zlend:占1个字节,保存一个常数255(0xFF),标记压缩列表的末端。

                也就是说,一个ziplist最少由zlbytes、zltail_offset、zllength、zlend四块内存块组成,中间连续存储数据。这也解释了为什么ziplist更节省内存且数据量少的时候更快的原因,就是一段连续内存块,短小快捷。但它也有一个致命的缺点,就是每次增删操作时,ziplist都需要扩展或者减小内存,然后进行数据”搬移”,甚至可能引发连锁更新,造成严重效率的损失

        1.3 List

                list用来存储多个有序的字符串,可以重复存储,最多能存储2^{32}-1个元素,可以对列表两端插入(push)和弹出(pop),还可以获取指定范围内的元素列表或者指定索引下的元素,甚至可以充当栈和队列的角色,在实际开发中应用比较广泛,是一种比较灵活的一种数据结构。在Redis3.2版本以前,它的内部编码有两种,一种是我们前文提到的ziplist(压缩列表),另外一种是linkedlist(链表)。当列表的元素个数小于list-max_ziplist-entries(默认512个),同时列表中每个元素的值都小于list-max-ziplist-value(默认64字节)时,列表的内部编码为ziplist;反之,则为linkedlist,原因和hash一样。

           

                 如上图,当我们往一个空list中插入a、b、c三个元素时,mylist的内部编码为ziplist,当我们在插入一个大于64字节的字符串时,内部编码变成了linkedlist;但这一切在redis3.2版本之后变得完全不一样了,redis采用了quicklist代替ziplist和linkedlist。为什么呢?之前我们说过,ziplist在数据量比较多比较大或者进行增删操作时,效率有些许不尽人意。那切换为linkedlist就能解决这个问题了吗?我们都知道linkedlist是链表结构,特点就是增删快,查询慢。这显然不能满足redis快的特性,于是quicklist就应运而生了。quicklist是什么?它又是怎么解决这个问题呢?你可以把它当做是linkedlist和ziplist的结合体,它本质上是一个包含ziplist的双向链表,链表的每个节点都包含一个ziplist,如下图。

                

                 可以看到,qulicklist的结构由一个头部和若干个节点构成,每个节点都指向一个ziplist(或者指向一个quicklistLZF结构(设置压缩时))。下面解释一下图中的quicklist结构。

        表头:        

  •  *head:指向头部(最左边)quicklist节点的指针
  • *tail: 指向尾部(最右边)quicklist节点的指针
  • count:ziplist中的entry节点计数器
  •  len:quicklist的quicklistNode节点计数器  

        节点:

  • *prev:指向前一个节点的指针(没有时指向null)
  • *next:指向后一个节点的指针(没有时指向null)
  • sz:压缩列表的总长度
  • *zl:未压缩时指向一个ziplsit,压缩时指向一个quicklistLZF结构(LZF是quicklist的一种压缩算法,下文会解释)

                有些比较优秀的同学看到这里可能会有些疑惑,这不是换汤不换药吗?虽然是双向链表,但节点多了的话查询不还是会变慢吗?虽然每个节点都有一个ziplist,但如果每个节点的ziplist存的多了不也一样增删慢吗?这就要说到quicklist最重要的两个配置了,分别是list-max-ziplist-sizelist-compress-depth。前者是配置quicklist每个节点的ziplist的最大存储量,后者是配置是否压缩quicklist节点,压缩多少个。他们的配置如下:

        list-max-ziplist-size  -2

  •  -1,每个quicklistNode节点的ziplist字节大小不能超过4kb。(合适)
  •  -2,每个quicklistNode节点的ziplist字节大小不能超过8kb。(默认配置)
  •  -3,每个quicklistNode节点的ziplist字节大小不能超过16kb。(一般不建议)
  •  -4,每个quicklistNode节点的ziplist字节大小不能超过32kb。(有点大了,看情况)
  •  -5,每个quicklistNode节点的ziplist字节大小不能超过64kb。(选这个的业务数据量得有多大)
  • 当数字为正数,表示:ziplist结构所最多包含的entry个数。最大值为 2^{15}

        list-compress-depth 0

        后面的数字有如下含义:

  • 0 表示不压缩。(默认)
  • 1 表示quicklist列表的两端各有1个节点不压缩,中间的节点压缩。
  • 2 表示quicklist列表的两端各有2个节点不压缩,中间的节点压缩。
  • 3 表示quicklist列表的两端各有3个节点不压缩,中间的节点压缩。
  • 以此类推,最大为 2^{16}

                需要注意的是,这里的压缩并不是单单压缩ziplist而是整体压缩,比如我们的quicklist中有一百个元素,我们配置每个quicklist节点的ziplist保存5个数据entry,也就是说我们当前的quicklist有20个节点,此时我们再配置压缩深度为2,也就是左右两边各有两个节点不被压缩,中间的16个节点全部压缩。那么此时,我们两边未压缩的节点的zl指针就是指向一个ziplist结构,而中间被压缩的16个节点的zl指针都指向对应被压缩过的ziplist结构,也就是quicklistLZF。至此,我们可以看出,根据实际应用场景配置合适的list-max-ziplist-size和 list-compress-depth就可以解决我们列表结构数据的效率问题。那是不是list-max-ziplist-size配置的越短越好呢?并不是,比如说我们配置每个节点的ziplist的最大存储为1个entry,那么此时这个quicklist就是一个普通的双向链表,而且由于我们配置的list-max-ziplist-size过小,所以对应的quicklist节点就会增多,就有存在多个ziplist,就会产生很大内存碎片,内存没有得到最大化的应用,反而浪费了很多,过犹不及。

        1.4 Set

                   set和list一样是用来保存多个的字符串集合,但与list不同的是,set是无序的,且不允许重复,和java中的set一样。一个集合最多可以存储2^{32}-1个元素,set不但可以进行增删改查操作,还能操作交集,并集,差集,在实际开发中能解决很多问题。它的内部编码也同样是两个,intset(整数集合)和hashtable(哈希表)。当集合中的元素都是整数,且集合中的元素个数小于set-max-intset-entries(默认512个)时,Redis就会选用intset来作为集合的内部实现。从而减少内存的使用;反之,则会使用hashtable作为内部实现。

                如图,我们往一个空集合(myset)中添加了5个整数元素,此时的集合内部编码为intset当我们再往里插入a、b、c之后,集合的内部编码变为了hashtable,而且查询出来的元素顺序和我们的插入顺序并不一致,也说明了 集合的无序性,为什么元素数量少且为整数型时intset效率会要快一些呢?这是因为intset和ziplist一样,也是一块连续的内存块,比较节省内存空间,自然要快一些。

        1.5 Zset

                Zset和Set比较类似,最大的不同之处在于Set是无序的,Zset是有序的,因此Zset被称为有序集合,集合内排序的依据和list又不一样,list是根据插入顺序和索引保证数据顺序的,Zset是根据我们插入时给元素设置的分数排序的。有序集合的分数可以重复,但元素不能重复,就像一个班级的学生学号不能重复,但分数可以重复一样。Zset的内部编码也有两种,分别是ziplist(压缩列表)和skiplist(跳跃表)。对于Redis不了解的同学可能对skiplist比较陌生,它其实是一个跳跃表,本质上是一个有序链表,它查找元素的时候不是挨个查的,是跳跃查询的。当有序集合的元素个数小于zset-max-ziplist-entries配置(默认128个)。同时每个元素的值都小于zset-max-ziplist-value配置(默认64字节)时,内部编码为ziplist,反之,则为skiplist。

                如图,我们在往我们的有序集合(myzset)中添加元素的时候 ,一开始由于元素值都比较小,所以内部编码为ziplist,但当我们插入一个大于64字节的值的时候,集合的内部编码就变成了skiplist。那么skiplist又是怎么跳跃查询的呢?它为什么会查询的快一些呢?我可以为大家简单讲解一下。

                 如图,最开始我们的有序链表查询一个元素是按顺序查的(图中第一个),比如我要查询元素9,我就要查到第三个才能查到;但是如果我们给它升级一下,给相邻的元素多创建一个指针(如图中第二个),这样我再查询元素9的时候就可以根据图中红色指针来查找,我就能一步到位,查找的时间复杂度就为O(log n),效率提高了一倍,和二分查找法有些许类似;但是如果我有非常多的元素,提高一倍的效率显然还是无法满足我们的需求,有没有更快的方法呢?有的同学可能会想到给相邻的元素再多加一层指针,使得第三层的指针比第二层的指针快上一倍,也形成一个2:1的结构,就和现在升级版的有序链表一样,这样就又可以提高一倍了,但是这样做有一个弊端,就是每个元素的指针与位置必须是固定的新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整。这样做局限性太大,而且增加和删除元素的效率并不会提高,因为要重新调整节点。

                skiplist为了避免上述出现的问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。就像图中第三个那样,每一个节点的层数都是随机的,而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。这就降低了插入操作的复杂度。但每个节点随机的层数并不是完全随机,每个节点的层数至少为1,最大为32(在redis中),并且如果一个节点有第   i(i >= 1)层,那么这个节点有第(i+1)层的概率为1/4(在redis中)。

                skiplist查找的方法是从节点最上层指针开始查找,确定范围之后再往下一层指针查找,比如图中的skiplist,如果要查找元素为13的节点,那skiplist会首先在第一层(深蓝色)指针查找,结果发现没找到(画的有点极端,一般第一层就能确定元素所在范围),于是在第二层(亮蓝色)开始查找,查找到第三次的时候确定了元素13所在范围,然后继续往下一层查找,直到查找到最后一层,确定元素所在位置。元素插入的时候也会有一个这样的查找过程,因为这个链表是有序的嘛。虽然图画的有点极端,但我相信大家的聪明才智已经可以忽略这些细节了(手动狗头),谁让我真不擅长画图呢。。。。

                今天这一期就到这里了,j讲的不好,还希望各位大佬海涵,如果有什么疑问或者不当之处,欢迎大家在留言区留言指出。下一期要给大家分享redis的常用命令了,也是我们学习redis最基础最常用的了,欢迎大家收藏关注,下期见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值