赞
踩
Redis本质上是一个数据结构服务器,使用C语言编写,是基于内存的一种数据结构存储系统,它可以用作数据库、缓存或者消息中间件。
我们经常使用的redis的数据结构有5种,分别是:string(字符串)、list(列表)、hash(哈希)、set(集合)、sorted set(有序集合),下面基于redis 5.0 版本查看这5种数据结构的底层实现原理。
首先redis是支持key-value键值对存储的内存型数据库,它的所有key都是字符串的类型,value的类型是上面5种类型中的一种,但是在底层,不管是哪一种类型,都是通过redisObject对象来实现的。比如我们在redis中执行了如下命令,创建一个键值对时:
那我们在redis中其实是创建了两个redisObject对象,一个是"msg"的字符串对象(键对象),另一个是"hello"的字符串对象(值对象)。
首先看下redisObject的源码:
对应下侧的图示,有5个属性
其中,标红的type、encoding、ptr是和数据保存相关的:
下面两个图,分别是type属性和encoding属性的取值范围:
我们还是执行 => set msg hello,这里我们关注值对象"hello",它基于redisObject的实现结构是这样的(上图),然后我们执行命令查看一下(下图)
值对象"hello"的redisObject结构,type属性对应的是string,encoding属性对应的是embstr,prt指向的是真实的底层数据结构,通过对应的命令可以查看。
redis为什么要使用redisObject这种结构去实现上面提到的5种类型呢?
下面,我们先从右侧这几个底层结构入手,了解一下他们的实现原理是什么。
redis使用了一种名为简单动态字符串(simple dynamic string)的类型作为字符串类型的默认实现,而不是直接复用C语言的字符串。传统的C字符串是使用’\0’表示字符数组的结尾(NULL结束符),但是sds在C字符串的基础上进行了封装,是典型的以空间换时间的效率提升方案,sds的源码及两种header的字符串实现方案如下图:
sds一共有5种以上类型的header,除了sdshdr5之外,其余的4种header都有以下4个属性结构,分别是:
shshdr5是不包含len和alloc属性的,它的flags低3位表示header,高5位用来表示它的最大长度len,所以sdshdr5是不能够动态扩展长度的,一般用于存储静态的短字符串,如key 键的字符串对象。
在初始化的时候,如果字符串长度小于32,key和value的header的类型是不一样的,具体可参考: https://cloud.tencent.com/developer/article/1837860
有如下的字符串hello,header类型是sdshdr8
现在比如要执行 append msg ‘gentleman’,要追加9个字符,但是上面空余的只有5个字节。
上图在进行扩容后( append msg ‘gentleman’ )就会变为下面的结构,其中header的类型不变,但是len=14,alloc=28,数组总长度变为 = 28 + 1(结束符) 个字节
有扩容就有缩容,如果sds的操作使得字符串的长度缩短,redis并不会立即回收缩短后空余出来的字节,而是基于sds的惰性空间释放策略,等待这部分空间将来被使用,尽量减少内存重分配的次数。当然redis也提供了相应的api,在有需要的时候,可以调用某些函数完成空余空间的释放,只保留被使用的部分,使得alloc = len。
综上,redis使用sds的优势在于,首先sds在执行字符串拼接等操作的时候,比如sdscat,空余的字节空间可以减少内存重分配的次数,内存重分配涉及到复杂的算法,涉及到系统调用,比较耗时。其次,sds相对于传统C字符串,获取字符串长度的时间复杂度,从O(N)降到O(1),可以直接读取len的值。
字典在redis中应用是非常广泛的,redis的数据库就是使用字典来作为底层实现的,比如我们执行了 set msg hello,数据库会创建一个键是msg,值是hello的键值对,保存在代表数据库的字典里面。
key:键; value:值; next:指向另一个哈希表节点的指针
随着对字典的不断操作,哈希表的中键值对会不断增多或减少,这时候,redis就会对哈希表进行相应的扩展或者收缩,可以让负载因子 (ht[0].used / ht[0].size)维持在一个合理的范围内。
触发条件:
前面提到在rehash期间,ht[0]中所有的键值对要迁移到ht[1]上面,但是如果ht[0]上面有上亿个键值对,那么一次全部rehash是需要消耗很长时间的,会造成redis的卡顿或者短时间的不可用,所以这个rehash的动作并不是一次性集中完成的,而是采用了一种渐进式的rehash过程。具体步骤如下:
压缩列表是redis为了节约内存而开发的,由一些列特殊编码组成的 连续内存块 的顺序型数据结构。可以将他理解成一个特殊的双向链表,可以用于存储字符串和整数,能够以0(1)的时间复杂度在压缩列表的两端进行 push 和 pop 操作。
我们来看下每项都代表什么含义:
借用网上摘抄的案例,上面博客链接可参考
跳跃表是一种有序的数据结构,支持平均O(log n),最坏O(n)时间复杂度的节点查找,同各类平衡树、哈希表一样也是一种查找算法的实现。redis中使用跳跃表作为有序集合底层实现的一种方式。
上面一层的节点数量是下面一层节点数量的一半,这样在查找的时候类似于二分查找,时间复杂度可以降低到O(log n),但是在插入或者删除数据的时候,为了维持这种节点数量1:2的关系,就必须把插入节点之后的所有节点都重新调整层数,这样时间复杂度重新蜕化成O(n)。
下图展示了一个查找数字23的路径图:
redis使用的zskiplist与传统跳跃表不同,它并不要求上下两层节点的数量有严格的对应关系,每个节点的层数是通过计算一个随机数来得到的。如下图所示:
redis的跳跃表是通过zskiplist和zskiplistNode两个结构定义的,其中zskiplist用于保存跳跃表的相关信息,而zskiplistNode用于表示每个跳跃表的节点。两者的具体结构如下:
zskiplist:
zskiplistNode:
程序在查找某个节点的时候,并不是挨个遍历的,所以span这个属性在用于记录所要查找的节点的“排位”的时候就显得十分重要,也是某些rank命令(zrank/zrevrank)能实现的主要依据。
整数集合是redis中用于保存整数值的抽象数据结构,它可以保存2个字节、4个字节、8个字节的整数值,并且整个集合中不会出现重复元素,与ziplist相似,是一块连续的内存空间。我们先来看下redis中intset的代码结构:
encoding:编码类型,表示整数集合中每个元素用几个字节存储,有3种的取值情况,INTSET_ENC_INT16表示每个元素用2个字节存储,INTSET_ENC_INT32表示每个元素用4个字节存储,INTSET_ENC_INT64表示每个元素用8个字节存储。因此,intset中存储的整数最多只能占用64bit,也就是(-263 ~ 263-1)。
length:记录了整数集合中元素的数量,也就是contents数组的长度。
contents:是整数集合的底层实现,整数集合的每个元素都是content数组的数据项,各个项在数组中从小到大有序排列,并且不会有任何重复项。数组占用总字节数 = encoding * length。
需要注意的是,随着元素的增加,encoding的值是可能会发生变化的,比如最开始encoding类型是 INTSET_ENC_INT16 (-32768~32767),但是如果我们add 了 32768,那么encoding的类型就会升级成 INTSET_ENC_INT32。
通过升级这种的方式,intset可以最大程度的节约内存的使用。升级的过程会包含以下步骤:
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保存在升级后的状态。也就是encoding从INTSET_ENC_INT32变成 INTSET_ENC_INT64后,即使删除某些较大的元素,剩余元素可以使用 INTSET_ENC_INT32表示了,encoding的编码类型也不会变回去了。
快速列表是redis列表list的唯一实现方式,我们可以把quicklist简单的理解成一个双向链表。它有这样的一些特性,支持在列表的头尾插入和删除元素,时间复杂度都是O(1),但是在列表中间存取元素,需要对列表进行遍历,时间复杂度是O(n)。我们看一下官方对quicklist的描述:A doubly linked list of ziplists,是一个节点是ziplist的双向链表,前面我们已经提到了ziplist,结合压缩列表的特性,我们分析一下quicklist的特点。
ziplist是一块内存连续的空间,而且能维持插入元素的先后顺序,而双向链表同样也可以通过指针维护插入元素的先后顺序。但是这两个结构都有各自的优缺点,qucklist最终结合两者的特性,实现了redis对外暴露的list的数据结构:
于是,结合了双向链表和ziplist,quicklist就应用而生了。但是这里还有一个问题,比如现在总共有20个元素,如果每个ziplist里面有4个节点,有5个ziplist,这样的组合是可以的;如果每个ziplist里面有10个节点,有2个ziplist,这样的组合也是可以的。该如何去确定这个平衡,也要在内存使用效率上去综合分析:
redis在配置文件redis.conf中,设定了1个参数,其中 list-max-ziplist-size -2 是来决定这个平衡的。
这里默认值是-2,其他情况下,这个值可正可负。正数表示,每个ziplist中entry个数不能超过这个值,而负数只能取值 -1到-5,表示每个ziplist大小不能超过一定的字节数,如下:
redis还提供了一个配置项,用于决定是否对quicklist的元素进行压缩,以及压缩多少个,list-compress-depth 0,默认是0,表示不压缩,其他的正整数表示从quicklist的头部和尾部开始,分别跨过多少元素,对剩下的元素进行压缩。这里压缩的是一整个ziplist,而不是ziplist中的某个节点。通过压缩这些节点,可以进一步节省内存。
快速列表的实现是由3个部分组成的,分别是quicklist表示整个快速列表的元数据,quicklistNode表示每个ziplist节点,而quicklistLZF表示的是经过压缩后的ziplist节点。
这是当前快速列表的元数据信息。
这是每个quicklist的节点。
这是每个quicklist节点压缩后的状态。
我们来看一个案例,下面这个快速列表表示了每个ziplist中节点最大个数是3,两端的2个节点之外节点都被压缩了。
综上,6种底层的数据结构已经比较清晰了,我们再看下,基于这些数据结构,我们常用的5种类型是怎么实现的,点击看下一篇 面试题之Redis数据结构与对象-RedisObject(下篇)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。