Redis数据结构为什么既省内存又高效?(二)
list
3.0版本及以前
在redis 3.0版本及以前,采用压缩链表(ziplist)以及双向链表(linkedlist)作为list的底层实现。当元素少时用ziplist,当元素多时用linkedlist
「linkedlist比较好理解,我们来看一下什么是ziplist?」
ziplist并不是一个用结构体定义的数据结构,而是一块连续的内存,在这块内存中按照一定的格式存储值
下图是压缩列表的示意图
zlbytes的值为0x50(十进制80),表示压缩列表的总长度为80字节 zltail的值为0x3c(十进制60),entry3元素距离列表起始位置的偏移量为60,起始位置的指针加上60就能算出表尾节点entry3的地址 zllen的值为0x3(十进制3),表示压缩列表包含3个节点
每个元素的存储形式如下图所示
「当encoding的最高2位为11时,按照整数进行读取,否则按照字节数组进行读取」。按照字节数组读取时,没有长度怎么办?
「当按照字节数组进行存储的时候,字节数组的长度放到encoding中的位中了,编码格式如下所示」
「字节数组编码」
「整数编码」
画图演示一下ziplist增加和删除的过程
「增加的过程」
「删除的过程」
「看着没啥问题,效率也比较高,奈何previous_entry_length是个变长字段」
「previous_entry_length以字节为单位,记录了压缩列表中前一个节点的长度,这样主要为了方便倒着遍历。通过zltail属性直接定位压缩列表的最后一个节点,然后通过previous_entry_length定位前一个节点」
- 如果前一个节点的长度小与254字节,那么previous_entry_length属性的长度为1字节(1个字节可以表示的最大长度为28-1=255,但是255被设置为压缩列表的结束标志了,所以为254)
- 如果前一个节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节。属性的第一字节会被设置成0xFE(十进制为254),而之后的四个字节则用于保存前一字节的长度
「由于这个变长字段导致ziplist有可能会发生连锁更新」
由于插入了一个字段,却导致了后面的元素都得再重新分配一次内存,看起来对效率影响比较大啊
「幸运的是,发生连锁更新的概率还是比较低的,因为压缩列表得有多个连续长度介于250到253的字节,不然下一个字节的previous_entry_length都不用更新」
ziplist虽然节省了内存,但他也引入了如下2个代价
- ziplist不能保存太多的元素,不然访问性能会降低
- ziplist不能保存太大的元素,不然会导致内存重新分配,甚至可能引发连锁更新
因此当list中的元素较多时,会用双向链表来存储
「但是双向链表需要的附加指针太大,比较浪费空间,而且会加重内存的碎片化」,所以在redis3.版本以后直接使用quicklist作为list的底层实现
3.0版本以后
quicklist是一个双向链表,链表中每个节点是一个ziplist,好家伙,结合了2个数据结构的优点
「假如说一个quicklist包含4个quickListNode,每个节点的ziplist包含3个元素,则这里list中存的值为12个。」
「quicklist为什么要这样设计呢?大概是基于空间和效率的一个折中」
- 双向链表方便在表两端执行push和pop操作。但是内存开销比较大,除了要保存数据,还要保存前后节点的指针。并且每个节点是单独的内存块,容易造成内存碎片
- ziplist是一块连续的内存,不用前后项指针,节省内存。但是当进行修改操作时,会发生级联更新,降低性能
「于是结合两者优点的quicklist诞生了,但这又会带来新的问题,每个ziplist存多少元素比较合适呢?」
- ziplist越短,内存碎片增多,影响存储效率。当一个ziplist只存一个元素时,quicklist又退化成双向链表了
- ziplist越长,为ziplist分配大的连续的内存空间难度也就越大,会造成很多小块的内存空间被浪费,当quicklist只有一个节点,元素都存在一个ziplist上时,quicklist又退化成ziplist了
「所以我们可以在redis.conf中通过如下参数list-max-ziplist-size来决定ziplist能存的节点元素」
hash
「元素比较少时用ziplist来存储,当元素比较多时用hash来存储」
「当用ziplist来存储时,数据结构如下」
key值在前,value值在后
「当用dict来存储时,数据结构如下」
redis中的dict和Java中的HashMap实现差不多,都是数组加链表(只不过redis中的dict用了2个数组,一般情况下也只会用一个,至于原因可以参考其他文章)
set
「当元素不多,且元素都为整数时,set的底层实现为intset,否则为dict」
「intset和ziplist都是一块完整的内存」
typedef struct intset {
// 编码方式
uint32_t encoding;
// 元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
「一看就是老套路哈,用encoding来标识contents数组的数据类型,尽量用占用字节数少的类型」
当encoding为INTSET_ENC_INT16,contents为一个int16_t类型的数组,数组中的每一项都是int16_t类型 当encoding为INTSET_ENC_INT32,contents为一个int32_t类型的数组,数组中的每一项都是int32_t类型 当encoding为INTSET_ENC_INT64,contents为一个int64_t类型的数组,数组中的每一项都是int64_t类型
「需要注意的是放入到contents中的数字是从小到大哈,这样就能通过二分查找提高查询的效率」
当放入的元素超过目前数组元素能表示的最大值,就会进行升级的过程。
假设原来的数组中只有1,3,10这3个数组,此时数据类型为int16_t就可以表示。放入一个新元素65535,int16_t类型表示不了了,所以得用int32_t来表示,数组中的其他元素也要升级为int32_t,下图演示了升级的详细过程
- 假设原来的数组中只有1,3,10这3个数组,此时数据类型为int16_t就可以表示
- 放入一个新元素65535,int16_t类型表示不了了,所以得用int32_t来表示,数组中的其他元素也要升级为int32_t
- 原先数组的大小为3(个数)*2(每个元素占用字节数)=6字节,升级后的数组为4(个数)*4(每个元素占用字节数)=16字节,在元素数组的后面申请10字节的空间
- 然后将原来数组中的元素从大到小依次移动到扩容后数组正确的位置上。例如原先10的起始位置为2(下标) * 2(大小)=4字节,结束位置为3 * 2=6字节。则现在10的位置为2 (下标)* 4(大小)=8字节,结束位置为3 * 4=12字节
- 将新添加的元素放到扩容后的数组上
「插入和删除的过程和ziplist类似,不画图了,需要注意intset目前只能升级不能降级」
「set底层实现为intset时」
元素会从小到大来放哈,这样就能用到二分查找,提高查询效率
「set底层实现是dict时」
zset
「zset当元素较少时会使用ziplist来存储,当放置的时候member在前,score在后,并且按照score值从小到大排列」。如下图所示
「zset当元素较多时使用dict+skiplist来存储」
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
「dict保存了数据到分数的映射关系」「skiplist用来根据分数查询数据」
dict就不用说了,skiplist的实现比较复杂,用一小节来概述一下
skiplist详解
skiplist(跳表)是一种为了加速查找而设计的一种数据结构。它是在「有序链表」的基础上发展起来的。注意是「有序链表」
如下图是一个有序链表(最左侧的灰色节点为一个空的头节点),当我们要插入某个元素的时候,需要从头开始遍历直到找到该元素,或者找到第一个比给定元素大的数据(没找到),时间复杂度为O(n)
假如我们每隔一个节点,增加一个新指针,指向下下个节点,如下图所示。这样新增加的指针又组成了一个新的链表,但是节点数只有原来的一半。
如下为查找23的过程,查找的过程为图中红色箭头指向的方向
- 23首先和7比较,然后和19比较,都比他们大,接着往下比。23和26比较,比26小。然后从19这节点回到原来的链表
- 和22比较,比22大,继续和下一个节点26比,比26小,说明23在跳表中不存在
利用上面的思路,我们可以在新链表的基础每隔一个节点再生成一个新的链表。
skiplist就是按照这种思想设计出来的,当然你可以每隔3,4等向上抽一层节点。但是这样做会有一个问题,如果你严格保持上下两层的节点数为1:2,那么当新增一个节点,后续的节点都要进行调整,会让时间复杂度退化到O(n),删除数据也有同样的问题。
skiplist为了避免这种问题的产生,并不要求上下两层的链表个数有着严格的对应关系,而用随机函数得到每个节点的层数。比如一个节点随机出的层数为3,那么把他插入到第一层到第三层这3层链表中。为了方便理解,下图演示了一个skiplist的生成过程
由于层数是每次随机出来的,所以新插入一个节点并不会影响其他节点的层数。插入一个节点只需要修改节点前后的指针即可,降低了插入的复杂度。
刚刚创建的skiplist包含4层链表,假设我们依然查找23,查找路径如下。插入的过程也需要经历一个类似查找的过程,确定位置后,再进行插入操作
Redis中跳表的定义如下
typedef struct zskiplistNode {
// 字符串类型的member值
sds ele;
// 分值
double score;
// 后向指针
struct zskiplistNode *backward;
struct zskiplistLevel {
// 前向指针
struct zskiplistNode *forward;
// 跨度
unsigned long span;
} level[];
} zskiplistNode;
可以看到在原始的跳表基础上做了如下2个改动
- 链表节点增加了后向指针
- 节点保存了和下一个节点的跨度
后向指针用来实现按照score倒序输出等功能
跨度则用来查询元素的排名,按照排名查询数据等(查找元素经过的跨度加起来就是排名哈)
总结
- 能用位存储变量的值绝不用基本数据类型,能用字节数少的数据类型,绝不用字节数多的数据类型(例如各种属性,保存的数据等,为了记录底层数据结构是以什么形式存的,所以大多数数据结构都有编码的概念)
- 当要保存的内容较少时甚至会将内容字段放到属性中,即属性字段的前几位表示属性,后几位表示内容(sdshdr5)
- 优先使用内存紧凑的数据结构,这样内存利用率高,内存碎片少(例如hash和zset优先用ziplist,set优先用intset)
- 在内存使用和执行效效率之间做一个比较好的均衡。当元素少时,优先使用内存占用少的数据结构,元素少对执行效率影响较小。当元素较多原有的数据结构执行效率降低时,才转为更复杂的数据结构。
- 字符串能转为整数存储的话,则以整数的形式进行存储(string用int编码存储,intset存储元素时,会先尝试转为整数存储)
在最新的github代码中redis又设计出个listpack的数据结构来取代ziplist,一代比一代高效了,如果你觉得现有的数据类型不能满足应用的需求,你也可以增加新的类型(redis支持这方面的扩展哈)
「最后对源码实现感兴趣的可以看《Redis设计与实现》,全书没有一行源码,却把Redis讲的很清楚」
文章转自公众号:Java识堂