原文: Redis的五种数据结构的底层实现原理
redis的性能高的原因之一是它每种数据结构都是经过专门设计的,并都有一种或多种数据结构来支持,依赖这些灵活的数据结构,来提升读取和写入的性能。如果要了解redis的数据结构,可以从两个不同的层面来讨论它:
(1)第一个层面,是从使用者的角度,这一层面也是Redis暴露给外部的调用接口,比如:
(2)第二个层面,是从内部实现的角度,属于更底层的实现,比如:
本文的重点在于讨论第二个层面:
在讨论任何一个系统的内部实现的时候,我们都要先明确它的设计原则,这样我们才能更深刻地理解它为什么会进行如此设计的真正意图。在本文接下来的讨论中,我们主要关注以下几点:
redisObject数据结构详解:http://zhangtielei.com/posts/blog-redis-robj.html
1、什么是redisObject:
从Redis的使用者的角度来看,一个Redis节点包含多个database(非cluster模式下默认是16个,cluster模式下只能是1个),而一个database维护了从key space到object space的映射关系。这个映射关系的key是string类型,而value可以是多种数据类型,比如:string, list, hash、set、sorted set等。我们可以看到,key的类型固定是string,而value可能的类型是多个。
而从Redis内部实现的角度来看,database内的这个映射关系是用一个dict来维护的。dict的key固定用一种数据结构来表达就够了,这就是动态字符串sds。而value则比较复杂,为了在同一个dict内能够存储不同类型的value,这就需要一个通用的数据结构,这个通用的数据结构就是robj,全名是redisObject
举个例子:
- 如果value是一个list,那么它的内部存储结构是一个quicklist或者是一个ziplist
- 如果value是一个string,那么它的内部存储结构一般情况下是一个sds。但如果string类型的value的值是一个数字,那么Redis内部还会把它转成long型来存储,从而减小内存使用。
所以,一个robj既能表示一个sds,也能表示一个quicklist,甚至还能表示一个long型。
2、redis的数据结构定义:(基于Redis3.2版本)
(1)一个robj包含如下5个字段:
(2)encoding字段的说明:
这里特别需要仔细察看的是encoding字段。对于同一个type,还可能对应不同的encoding,这说明同样的一个数据类型,可能存在不同的内部表示方式。而不同的内部表示,在内存占用和查找性能上会有所不同。
当type = OBJ_STRING的时候,表示这个robj存储的是一个string,这时encoding可以是下面3种中的一种:
- OBJ_ENCODING_RAW: string采用原生的表示方式,即用sds来表示。
- OBJ_ENCODING_INT: string采用数字的表示方式,实际上是一个long型。
- OBJ_ENCODING_EMBSTR: string采用一种特殊的嵌入式的sds来表示。
当type = OBJ_HASH的时候,表示这个robj存储的是一个hash,这时encoding可以是下面2种中的一种:
- OBJ_ENCODING_HT: hash采用一个dict来表示
- OBJ_ENCODING_ZIPLIST: hash采用一个ziplist来表示
(3)10种encoding的十种取值:
3、robj的作用:
String数据结构是最简单的数据类型。一般用于复杂的计数功能的缓存:微博数,粉丝数等。
(1)底层实现方式:动态字符串sds 或者 long
String的内部存储结构一般是sds(Simple Dynamic String,可以动态扩展内存),但是如果一个String类型的value的值是数字,那么Redis内部会把它转成long类型来存储,从而减少内存的使用。
Hash特别适合用于存储对象,因为一个对象的各个属性,正好对应一个hash结构的各个field,可以方便地操作对象中的某个字段。
(1)底层实现方式:压缩列表ziplist 或者 字典dict
当Hash中数据项比较少的情况下,Hash底层才用压缩列表ziplist进行存储数据,随着数据的增加,底层的ziplist就可能会转成dict,具体配置如下:
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
当满足上面两个条件其中之一的时候,Redis就使用dict字典来实现hash。
Redis的hash之所以这样设计,是因为当ziplist变得很大的时候,它有如下几个缺点:
总之,ziplist本来就设计为各个数据项挨在一起组成连续的内存空间,这种结构并不擅长做修改操作。一旦数据发生改动,就会引发内存realloc,可能导致内存拷贝。
list 的实现为一个双向链表,经常被用作队列使用,支持在链表两端进行push和pop操作,时间复杂度为O(1);同时也支持在链表中的任意位置的存取操作,但是都需要对list进行遍历,支持反向查找和遍历,时间复杂度为O(n)。
list是一个能维持数据项先后顺序的列表(各个数据项的先后顺序由插入位置决定),便于在表的两端追加和删除数据,而对于中间位置的存取具有O(N)的时间复杂度。
list 的应用场景非常多,比如微博的关注列表,粉丝列表,消息列表等功能都可以用Redis的 list 结构来实现。可以利用lrange命令,做基于redis的分页功能。
(1)Redis3.2之前的底层实现方式:压缩列表ziplist 或者 双向循环链表linkedlist
当list存储的数据量比较少且同时满足下面两个条件时,list就使用ziplist存储数据:
当不能同时满足上面两个条件的时候,list就通过双向循环链表linkedlist来实现了
(2)Redis3.2及之后的底层实现方式:quicklist
quicklist是一个双向链表,而且是一个基于ziplist的双向链表,quicklist的每个节点都是一个ziplist,结合了双向链表和ziplist的优点
set是一个存放不重复值的无序集合,可以做全局去重的功能,提供了判断某个元素是否在set集合内的功能,这个也是list所不能提供的。基于set可以实现交集、并集、差集的操作,计算共同喜好,全部的喜好,自己独有的喜好等功能。
(1)底层实现方式:有序整数集合intset 或者 字典dict
当存储的数据同时满足下面这样两个条件的时候,Redis 就采用整数集合intset来实现set这种数据类型:
当不能同时满足这两个条件的时候,Redis 就使用dict来存储集合中的数据
Sorted set多了一个权重参数score,集合中的元素能够按score进行排列。可以做排行榜应用,取TOP N操作。另外,sorted set可以用来做延时任务。最后一个应用就是可以做范围查找。
(1)底层实现方式:压缩列表ziplist 或者 zset
当存储的数据同时满足下面这两个条件的时候,Redis就使用压缩列表ziplist实现sorted set
当不能同时满足这两个条件的时候,Redis 就使用zset来实现sorted set,这个zset包含一个dict + 一个skiplist。dict用来查询数据到分数(score)的对应关系,而skiplist用来根据分数查询数据(可能是范围查找)。
sds数据结构详解:http://zhangtielei.com/posts/blog-redis-sds.html
(1)什么是sds:
sds全称是Simple Dynamic String,具有如下显著的特点:
(2)sds的数据结构:
sds是Binary Safe的,它可以存储任意二进制数据,不能像C语言字符串那样以字符’\0’来标识字符串的结束,因此它必然有个长度字段。但这个长度字段在哪里呢?实际上sds还包含一个header结构:
sds一共有5种类型的header。之所以有5种,是为了能让不同长度的字符串可以使用不同大小的header。这样,短字符串就能使用较小的header,从而节省内存。
sds字符串的完整结构,由在内存地址上前后相邻的两部分组成:
除了sdshdr5之外,其它4个header的结构都包含3个字段:
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
sds字符串的header,其实隐藏在真正的字符串数据的前面(低地址方向)。这样的一个定义,有如下几个好处:
(3)String robj的编码与解码过程:
OBJ_STRING类型的字符串对象的编码和解码过程在Redis里非常重要,应用广泛。
当我们执行Redis的set命令的时候,Redis首先将接收到的value值(string类型)表示成一个type = OBJ_STRING并且encoding = OBJ_ENCODING_RAW的robj对象,然后在存入内部存储之前先执行一个编码过程,试图将它表示成另一种更节省内存的encoding方式。这一过程的核心代码,是object.c中的tryObjectEncoding函数。
当我们需要获取字符串的值,比如执行get命令的时候,我们需要执行与前面讲的编码过程相反的操作——解码。这一解码过程的核心代码,是object.c中的getDecodedObject函数。
对于编码的核心代码tryObjectEncoding函数和解码的核心代码getDecodedObject函数的详细说明,样请读者移步这篇文章:http://zhangtielei.com/posts/blog-redis-robj.html
dict数据结构详解:http://zhangtielei.com/posts/blog-redis-dict.html
dict是一个用于维护key-value映射关系的数据结构。Redis的一个database中所有key到value的映射,就是使用一个dict来维护的。不过,这只是它在Redis中的一个用途而已,它在Redis中被使用的地方还有很多。比如,Redis的hash结构,当它的field较多时,便会采用dict来存储。再比如,Redis配合使用dict和skiplist来共同维护一个sorted set。
在Redis中,dict本质上是为了解决算法中的查找问题,是一个基于哈希表的算法,在不要求数据有序存储,且能保持较低的哈希值冲突概率的前提下,查询的时间复杂度接近O(1)。它采用某个哈希函数从key计算得到在哈希表中的位置,采用拉链法解决冲突,并在装载因子(load factor)超过预定值时自动扩展内存,引发重哈希(rehashing)。Redis的dict实现最显著的一个特点,就在于它的重哈希。它采用了一种称为增量式重哈希(incremental rehashing)的方法,在需要扩展内存时避免一次性对所有key进行重哈希,而是将重哈希操作分散到对于dict的各个增删改查的操作中去。这种方法能做到每次只对一小部分key进行重哈希,而每次重哈希之间不影响dict的操作。dict之所以这样设计,是为了避免重哈希期间单个请求的响应时间剧烈增加,这与前面提到的“快速响应时间”的设计原则是相符的。
当装载因子大于 1 的时候,Redis 会触发扩容,将散列表扩大为原来大小的 2 倍左右;当数据动态减少之后,为了节省内存,当装载因子小于 0.1 的时候,Redis 就会触发缩容,缩小为字典中数据个数的大约2 倍大小。
ziplist数据结构详解:http://zhangtielei.com/posts/blog-redis-ziplist.html
ziplist是一个经过特殊编码的双向链表,可以用于存储字符串或整数,其中整数是按真正的二进制表示进行编码的,而不是编码成字符串序列。它能以O(1)的时间复杂度在表的两端提供push
和pop
操作。
一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针(或引用)连接起来,但这种方式会带来大量的内存碎片,而且地址指针也会占用额外的内存。而ziplist却是使用一整块连续的内存,将表中每一项存放在前后连续的地址空间内,类似于一个数组。
另外,ziplist为了在细节上节省内存,对于值的存储采用了变长的编码方式,大概意思是说,对于大的整数,就多用一些字节来存储,而对于小的整数,就少用一些字节来存储。
总的来说,ziplist使用一块连续的内存空间来存储数据,并采用可变长的编码方式,支持不同类型和大小的数据的存储,比较节省内存。而且数据存储在一片连续的内存空间,读取的效率也非常高。
quicklist数据结构详解:http://zhangtielei.com/posts/blog-redis-quicklist.html
(1)什么是quicklist:
quicklist是一个双向链表,而且是一个基于ziplist的双向链表,quicklist的每个节点都是一个ziplist,比如,一个包含3个节点的quicklist,如果每个节点的ziplist又包含4个数据项,那么对外表现上,这个list就总共包含12个数据项。
quicklist的结构为什么这样设计呢?总结起来,大概又是一个空间和时间的折中:
于是,结合了双向链表和ziplist的优点,quicklist就应运而生了。
(2)quicklist中每个ziplist长度的配置:
不过,这也带来了一个新问题:到底一个quicklist节点包含多长的ziplist合适呢?比如,同样是存储12个数据项,既可以是一个quicklist包含3个节点,而每个节点的ziplist又包含4个数据项,也可以是一个quicklist包含6个节点,而每个节点的ziplist又包含2个数据项。
这又是一个需要找平衡点的难题。我们只从存储效率上分析一下:
可见,一个quicklist节点上的ziplist要保持一个合理的长度。那到底多长合理呢?这可能取决于具体应用场景。实际上,Redis提供了一个配置参数list-max-ziplist-size
,就是为了让使用者可以来根据自己的情况进行调整。
list-max-ziplist-size -2
这个参数可以取正值,也可以取负值。
当取正值的时候,表示按照数据项个数来限定每个quicklist节点上的ziplist长度。比如,当这个参数配置成5的时候,表示每个quicklist节点的ziplist最多包含5个数据项。
当取负值的时候,表示按照占用字节数来限定每个quicklist节点上的ziplist长度。这时,它只能取-1到-5这五个值,每个值含义如下:
intset数据结构详解:http://zhangtielei.com/posts/blog-redis-intset.html
(1)什么是intset:
intset是一个由整数组成的有序集合,从而便于进行二分查找,用于快速地判断一个元素是否属于这个集合。它在内存分配上与ziplist有些类似,是连续的一整块内存空间,而且对于大整数和小整数(按绝对值)采取了不同的编码,尽量对内存的使用进行了优化。
(2)intset的数据结构:
各个字段含义如下:
encoding
: 数据编码,表示intset中的每个数据元素用几个字节来存储。它有三种可能的取值:INTSET_ENC_INT16表示每个元素用2个字节存储,INTSET_ENC_INT32表示每个元素用4个字节存储,INTSET_ENC_INT64表示每个元素用8个字节存储。因此,intset中存储的整数最多只能占用64bit。length
: 表示intset中的元素个数。encoding
和length
两个字段构成了intset的头部(header)。contents
: 是一个柔性数组(flexible array member),表示intset的header后面紧跟着数据元素。这个数组的总长度(即总字节数)等于encoding * length
。柔性数组在Redis的很多数据结构的定义中都出现过(例如sds, quicklist, skiplist),用于表达一个偏移量。contents
需要单独为其分配空间,这部分内存不包含在intset结构当中。其中需要注意的是,intset可能会随着数据的添加而改变它的数据编码:
(3)intset与ziplist相比:
len
),而intset只能整体使用一个统一的编码(encoding
)。skiplist数据结构详解:http://zhangtielei.com/posts/blog-redis-skiplist.html
(1)什么是跳表:
跳表是一种可以进行二分查找的有序链表,采用空间换时间的设计思路,跳表在原有的有序链表上面增加了多级索引(例如每两个节点就提取一个节点到上一级),通过索引来实现快速查找。跳表是一种动态数据结构,支持快速的插入、删除、查找操作,时间复杂度都为O(logn),空间复杂度为 O(n)。跳表非常灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。
① 跳表的删除操作:除了要删除原始链表中的节点,还要删除索引中的节点。
② 插入元素后,索引的动态更新:不停的往跳表里面插入数据,如果不更新索引,就有可能出现某两个索引节点之间的数据非常多的情况,甚至退化成单链表。针对这种情况,我们在添加元素的时候,通过一个随机函数,同时选择将这个数据插入到部分索引层。比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第K级这K级的索引中