Redis极致设计-五大数据结构的底层结构原理
一、字符串对象(string)
上一篇文章最后能看到底层的哈希表元素会指向一个redisObject对象,通过object encoding key 命令可以看到具体的存储结构,string 类型查看如下:
上图可以看到不同的字符串其内部的结构不一样,一个是embstr、另外一个是raw;
Redis为了将内存的使用率做到极致,针对字符串对象,提供了三种数据结构
接下来我们看一下具体区别
1. int
我们根据上一节知道每个hashtable中的值作为一个指针会指向redisObject对象,下图是该对象的数据结构
该对象有一个属性是ptr(指针类型),一个指针比如在64位系统中就是用8个字节来存储,这个存储大小正好是一个长整型的存储,所以为了节省内存空间,如果一个字符串内容可转为 long,那么该字符串会被转化为 long 类型,对象 ptr直接存储该值,并将 encoding 设置为 int,这样就不需要重新开辟空间,算是长整形的一个优化。
2. raw
如果字符串对象保存的是一个字符串值,并且这个字符串的长度大于 32 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为 raw。
3. embstr
如果字符串对象保存的是一个字符串值,并且这个字符串的长度小于等于 44 字节,那么字符串对象将使用 embstr 编码的方式来保存这个字符串。 3.2版本之后是44个字节,之前是39个字节,这也是因为sds结构的版本变化所导致的。
embstr类型是如何存放字符串的【重点】
我们知道一般cpu从内存中读取数据会先读取到 cache line(缓存行), 一个缓存行基本占64个字节,其中redisObject最少占16个字节(根据属性的类型计算得出),所以如果要读取一个 redisObject,会发现只读取了16个字节,剩下的48个字节的空间相当于浪费,所以为了提高性能(主要减少了内存读取的次数),所以在RedisObject空间后又开辟48个字节的连续空间,将ptr指向的值存入其中,注意此处存入的是字符串类型,48个字节对应的是sdshdr8存储结构。而 sdshdr8 在不存入数据的情况下,最少要 4 个字节(其中一个字节是字符串尾部的'\0'),那么还剩余 44 个字节,所以如果在 44 个字节以内字符串就可以放在缓存行里面,从而减少了内存I/O次数
embstr 编码方式的优点:embstr 编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次。raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构,而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject 和 sdshdr 两个结构释放 embstr 编码的字符串对象只需要调用一次内存释放函数,而释放 raw 编码的字符串对象需要调用两次内存释放函数。因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起 raw ,编码的字符串对象能够更好地利用缓存带来的优势。
二、列表对象(list)
1. Redis3.2 版本前采用ziplist和linkedlist结构
list 是一个有序(按加入的时序排序)的数据结构,一般有序我们会采用数组或者是双向链表,其中双向链表由于有前后指针实际上会很浪费内存。
3.2版本之前采用两种数据结构作为底层实现:压缩列表ziplist双向链表linkedlist
压缩列表相对于双向链表更节省内存,所以在创建列表时,会先考虑压缩列表,并在一定条件下才转化为双向链表,在说明转化条件之前,我们先了解一下什么是压缩列表。
(1)压缩列表(ziplist)(为节省内存而设计)
下图是ziplist的数据结构
其中黄色区域用来表示列表的特征,绿色区域就是列表中具体的元素了,ziplist是使用连续的内存块存储的zlbytes:表示整个ziplist占用的字节数,一般用于内存重分配或者计算列表尾端zltail:到达列表最后一个节点的偏移量,方便直接找到尾部节点zllen:列表节点的数量 注意zllen用16个比特位存储,也就是说起长度最大表示65535,所以如果长度超过这个值,只能够通过节点遍历来确定列表元素数量entryX:列表中的各节点zlend:作用就是用来标记列表尾端,占用一个字节
接下来重点看一下列表中每个节点是如何存储的
完整的zlentry由以下3各部分组成:prevrawlen: 记录前一个节点所占内存的字节数,方便查找上一个元素地址 根据图中可以看到prevrawlen是变长编码的,如果上一个节点长度小于254个字节,则只是用一个字节存储prevrawlen,如果大于等于254,那么第一个字节用254标记,后面四个字节表示上一个节点长度lensize, len:记录当前节点所占内存字节数,以及内容的存储类型,方便解析,其具体对应关系可参考上图文本框中内容content:保存了当前节点的值。
知道了ziplist原理后,我们来看一下在压缩列表转化成双向链表的条件:如果添加的字符串元素长度超过默认值64zip包含的节点数超过默认值512 这两个条件是可以修改的,在redis.conf中
(2)压缩列表(ziplist) 的缺点
ziplist 最大的确定就是连锁更新问题
因为在 ziplist 中,每个 zlentry 都存储着前一个节点所占的字节数,而这个数值又是变长编码的。假设存在一个压缩列表,其包含 e1、e2、e3、e4.....,e1 节点的大小为 253 字节,那么 e2.prevrawlen 的大小为 1 字节,如果此时在 e2 与 e1 之间插入了一个新节点 e,e 编码后的整体长度(包含 e1 的长度)为 254 字节,此时 e2.prevrawlen 就需要扩充为 5 字节;如果 e2 的整体长度变化又引起了 e3.prevrawlen 的存储长度变化,那么 e3 也需要扩.......如此递归直到表尾节点或者某一个节点的 prevrawlen 本身长度可以容纳前一个节点的变化。其中每一次扩充都需要进行空间再分配操作。删除节点亦是如此,只要引起了操作节点之后的节点的 prevrawlen 的变化,都可能引起连锁更新,引发内存 realloc。
连锁更新在最坏情况下需要进行 N 次空间再分配,而每次空间再分配的最坏时间复杂度为 O(N),因此连锁更新的总体时间复杂度是 O(N^2)。
基于上述来看ziplist的缺点大于优点,所以*3.2版本之后采用了另外一个数据结构为quicklist
2. Redis3.2版本之后升级为 quicklist(双向链表)
quicklist是3.2版本之后引入的。
根据上文谈到,ziplist会引入频繁的内存申请和释放,而linkedlist由于指针也会造成内存的浪费,而且每个节点是单独存在的,会造成很多内存碎片,所以结合两个结构的特点,设计了quickList。
quickList 是一个 ziplist 组成的双向链表。每个节点使用 ziplist 来保存数据。本质上来说,quicklist 里面保存着一个一个小的 ziplist。结构如下:
根据图中可以看到每一个ziplist都是一个很小的集合,ziplist太短,内存碎片越多,如果太长,则分配大块连续内存空间的难度就越大,这个范围也是可以配置的-5: 每个quicklist节点上的ziplist大小不能超过64 Kb。-4: 每个quicklist节点上的ziplist大小不能超过32 Kb。-3: 每个quicklist节点上的ziplist大小不能超过16 Kb。-2: 每个quicklist节点上的ziplist大小不能超过8 Kb。(默认值)-1: 每个quicklist节点上的ziplist大小不能超过4 Kb。
这个设置的值是可以通过配置文件看到,默认8kb最好(-2对应的就是8kb,可以参考下图中的注释)
我们知道list比较适合于用在热点数据中,一般最容易被访问的是列表两端的数据,中间的访问频率很低,如果符合这个场景,list还有一个配置,可以对中间节点进行压缩0: 是个特殊值,表示都不压缩。这是Redis的默认值。1: 表示quicklist两端各有1个节点不压缩,中间的节点压缩。2: 表示quicklist两端各有2个节点不压缩,中间的节点压缩。以此类推
三、哈希对象(hash)
hash的底层存储有两种数据结构,一种是ziplist,另外一种是hashtable,这两种数据结构我们之前都有讲解,ziplist就是上文提到的结构,hashtable之前讲解的redis结构,hash对象只有同时满足以下条件,才会采用ziplist编码:hash对象保存的键和值字符串长度都小于64字节hash对象保存的键值对数量小于512
ziplist存储的结构如下
上图中可以看到,当数据量比较小的时候,我们会将所有的key及value都当成一个元素,顺序的存入到ziplist中,构成有序。
hashtable存储的结构如下:字符串的set key value 和 hash 的区别是什么1. 过期时间,hash没有过期时间2. set不断的加值有一个问题,dict中有一个属性是dictht ht[2],主要是> 扩容用的,如果不断的加key,则整体redis内存就需要扩容,扩容就需要基于原有内存增加一倍,内存消耗很大
四、集合对象(set)
set是一个无序的、自动去重的集合数据类型,Set底层用两种数据结构存储,一个是hashtable,一个是inset。
其中hashtable的key为set中元素的值,而value为null
inset为可以理解为数组,使用inset数据结构需要满足下述两个条件:元素个数不少于默认值512
元素可以用整型表示
intset的底层结构
查询方式一般采用二分查找法,实际查询复杂度也就在log(n)
五、有序集合对象(zset)
zset为有序(有限score排序,score相同则元素字典序),自动去重的集合数据类型,其底层实现为 字典(dict) + 跳表(skiplist),当数据比较少的时候用ziplist编码结构存储。
同时满足以下两个条件采用ziplist存储:有序集合保存的元素数量小于默认值128个有序集合保存的所有元素的长度小于默认值64字节
1. ziplist存储方式
当ziplist作为zset的底层存储结构时候,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个元素保存元素的分值
2. 字典(dict) + 跳表(skiplist)的存储方式
zset底层的存储结构包括ziplist或skiplist,在同时满足上说的两个条件时使用ziplist,其他时候使用skiplist
(1)跳表的数据结构
首先我们理解一下什么是跳表
上图可以看到通过分等级,从最高等级向低等级查询,效率提高,其时间复杂度为logn(类似于二分查找)
dict+skiplist的最终的存储结构如下:
基于上图我们看一下skiplist几个关键对象的数据结构,方便大家理解
(2)skiplist中的关键结构——zset
可以看到一个是dict结构,主要key是其集合元素,而value就是对应分值,而zkiplist作为跳跃表,按照分值排序,方便定位成员
(3)skiplist中的关键结构——zskiplist
(4)skiplist中的关键结构——zskiplistNode
zskiplistNode中的robj指针指向具体元素,注意这个指针和dict中key指针指向同一个元素,其中backward后腿指针便于回溯
六、总结
本节内容主要讲解了Redis中string、list、hash、set、zse对象底层结构,string通过int、raw、embstr三种结构来表示,而list在3.2版本之后采用quicklist的数据结构,hash底层采用两种,分别是ziplist和hashtable,set底层也分别采用两种hashtable和inset,其中inset也可以理解为数组,zset底层分别是ziplist和dict+skiplist。
我们可以看到在节省内存、提高查询效率方面都体现了优秀的设计,这些都可以作为我们日后设计及开发中的宝贵经验。