Redis数据结构
Redis的每种对象其实都由对象结构(redisObject)与对应编码的数据结构组合而成,而每种对象类型对应若干编码方式,不同的编码方式所对应的底层数据结构是不同的。
从一下两个角度来研究底层:
- 对象设计机制: 对象结构(redisObject)
- 编码类型和底层数据结构: 对应编码的数据结构
对象机制
为什么Redis会设计redisObject对象
- Redis 必须让每个键都带有类型信息, 使得程序可以检查键的类型, 并为它选择合适的处理方式。
- 操作数据类型的命令除了要对键的类型进行检查之外, 还需要根据数据类型的不同编码进行多态处理.
为了解决以上问题, Redis 构建了自己的类型系统, 这个系统的主要功能包括:
- redisObject 对象.
- 基于 redisObject 对象的类型检查.
- 基于 redisObject 对象的显式多态函数.
- 对 redisObject 进行分配、共享和销毁的机制.
redisObject数据结构
1 | /* |
- type记录了对象所保存的值的类型
1
2
3
4
5 - encoding记录了对象所保存的值的编码
1
2
3
4
5
6
7
8
9
10
11
12
13
14/*
* 对象编码
*/ - ptr是一个指针,指向实际保存值的数据结构。
这个数据结构由type和encoding属性决定。
举个例子,如果一个redisObject 的type 属性为OBJ_LIST
,encoding 属性为OBJ_ENCODING_QUICKLIST
,那么这个对象就是一个Redis 列表(List),它的值保存在一个QuickList的数据结构内,而ptr 指针就指向quicklist的对象 - lru属性: 记录了对象最后一次被命令程序访问的时间
命令的类型检查和多态
那么Redis是如何处理一条命令的呢?
当执行一个处理数据类型命令的时候,redis执行以下步骤
- 根据给定的key,在数据库字典中查找和他相对应的redisObject,如果没找到,就返回NULL;
- 检查redisObject的type属性和执行命令所需的类型是否相符,如果不相符,返回类型错误;
- 根据redisObject的encoding属性所指定的编码,选择合适的操作函数来处理底层的数据结构;
- 返回数据结构的操作结果作为命令的返回值。
底层数据结构
- 简单动态字符串 - sds
- 压缩列表 - ZipList
- 快表 - QuickList
- 字典/哈希表 - Dict
- 整数集 - IntSet
- 跳表 - ZSkipList
简单动态字符串 - sds
Redis 是用 C 语言写的,但是对于Redis的字符串,却不是 C 语言中的字符串(即以空字符’\0’结尾的字符数组),它是自己构建了一种名为
简单动态字符串(simple dynamic string,SDS)
的抽象类型,并将 SDS 作为 Redis的默认字符串表示。
这是一种用于存储二进制数据的一种结构, 具有动态扩容的特点. 其实现位于src/sds.h与src/sds.c中。
SDS定义
其中sdshdr是头部, buf是真实存储用户数据的地方。
另外注意, 从命名上能看出来, 这个数据结构除了能存储二进制数据, 显然是用于设计作为字符串使用的, 所以在buf中, 用户数据后总跟着一个\0. 即图中 “数据” + “\0” 是为所谓的buf。
1 | // sds.h |
SDS有五种不同的头部. 其中sdshdr5实际并未使用到. 所以实际上有四种不同的头部, 分别如下:
- len 保存了SDS保存字符串的长度
- buf[] 数组用来保存字符串的每个元素
- alloc 分别以uint8, uint16, uint32, uint64表示整个SDS, 除过头部与末尾的\0, 剩余的字节数
- flags 始终为一字节, 以低三位标示着头部的类型, 高5位未使用
为什么使用SDS
为什么不使用C语言字符串实现,而是使用 SDS呢?这样实现有什么好处?
常数复杂度获取字符串长度
由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。
而对于 C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。
通过 strlen key 命令可以获取 key 的字符串长度。杜绝缓冲区溢出
我们知道在 C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。
而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。减少修改字符串的内存重新分配次数
C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。
而对于SDS,由于len属性和alloc属性的存在,对于修改字符串SDS实现了空间预分配
和惰性空间释放
两种策略:空间预分配
对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。当执行追加操作时,比如现在给key=‘Hello World’的字符串后追加 ‘ again!’ 则这时的len=18,free由0变成了18,此时的**buf=’Hello World again!\0………………..’(.表示空格)**,也就是buf的内存空间是18+18+1=37个字节,其中‘\0’占1个字节。redis给字符串多分配了18个字节的预分配空间,所以下次还有append追加的时候,如果预分配空间足够,就无须在进行空间分配了。
在Redis 6.0版本中,当新字符串的长度小于1M时,redis会分配他们所需大小一倍的空间,当大于1M的时候,就为他们额外多分配1M的空间。惰性空间释放
对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 alloc 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)
二进制安全
因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;
而所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。兼容部分 C 字符串函数
虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h> 中的一部分函数。
压缩列表 - ZipList
ziplist是为了提高存储效率而设计的一种特殊编码的双向链表。
它可以存储字符串或者整数,存储整数时是采用整数的二进制而不是字符串形式存储。
它能在O(1)的时间复杂度下完成list两端的push和pop操作。
但是因为每次操作都需要重新分配ziplist的内存,所以实际复杂度和ziplist的内存使用量相关。
ziplist结构
整个ziplist在内存中的存储格式如下:
- zlbytes字段的类型是uint32_t, 这个字段中存储的是整个ziplist所占用的内存的字节数。
- zltail字段的类型是uint32_t, 它指的是ziplist中最后一个entry的偏移量。
用于快速定位最后一个entry, 以快速完成pop等操作。 - zllen字段的类型是uint16_t, 它指的是整个ziplit中entry的数量。
这个值只占2bytes(16位)。
如果ziplist中entry的数目小于65535(2的16次方), 那么该字段中存储的就是实际entry的值。
若等于或超过65535, 那么该字段的值固定为65535, 但实际数量需要一个个entry的去遍历所有entry才能得到。 - zlend是一个终止字节, 其值为全F, 即0xff。
ziplist保证任何情况下, 一个entry的首字节都不会是255。
ziplist-entry结构
- 第一种情况:一般结构
<prevlen> <encoding> <entry-data>
- prevlen:前一个entry的大小;
- encoding:不同的情况下值不同,用于表示当前entry的类型和长度;
- entry-data:真是用于存储entry表示的数据;
- 第二种情况:
<prevlen> <encoding>
在entry中存储的是int类型时,encoding和entry-data会合并在encoding中表示,此时没有entry-data字段。
redis中,在存储数据时,会先尝试将string转换成int存储,节省空间。
为什么ZipList特别省内存
只有理解上面的Entry结构,我们才会真正理解ZipList为什么是特别节省内存的数据结构。
ziplist节省内存是相对于普通的list来说的,如果是普通的数组,那么它每个元素占用的内存是一样的且取决于最大的那个元素(很明显它是需要预留空间的);所以ziplist在设计时就很容易想到要尽量让每个元素按照实际的内容大小存储,所以增加encoding字段,针对不同的encoding来细化存储大小。
这时候还需要解决的一个问题是遍历元素时如何定位下一个元素呢?在普通数组中每个元素定长,所以不需要考虑这个问题;但是ziplist中每个data占据的内存不一样,所以为了解决遍历,需要增加记录上一个元素的length,所以增加了prelen字段。
快表 - QuickList
quicklist这个结构是Redis在3.2版本后新加的, 之前的版本是list(即linkedlist), 用于String数据类型中。
它是一种以ziplist为结点的双端链表结构。
宏观上, quicklist是一个链表;微观上, 链表中的每个结点都是一个ziplist。
字典/哈希表 - Dict
本质上就是哈希表
哈希表结构定义:
1 | typedef struct dictht{ |
整数集 - IntSet
整数集合(intset)是集合类型的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。
跳表 - ZSkipList
跳跃表结构在 Redis 中的运用场景只有一个,那就是作为有序列表 (Zset) 的使用。
跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这就是跳跃表的长处。
跳跃表的缺点就是需要的存储空间比较大,属于利用空间来换取时间的数据结构。
思考:为什么用跳跃表实现,而不是平衡树或者哈希表?
什么是跳跃表
跳表其实就是一种可以进行二分查找的有序链表。
跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找,将查找的时间复杂度从O(N)降到O(logN)。
Redis zskiplist的设计
redis跳跃表并没有在单独的类(比如skplist.c)中定义,而是其定义在server.h中, 如下:
1 | /* ZSETs use a specialized version of Skiplists */ |
- 头节点不持有任何数据, 且其level[]的长度为32
- 每个结点
- ele字段,持有数据,是sds类型。
- score字段,其标示着结点的得分, 结点之间凭借得分来判断先后顺序, 跳跃表中的结点按结点的得分升序排列。
- backward指针,这是原版跳跃表中所没有的. 该指针指向结点的前一个紧邻结点。
- level字段, 用以记录所有结点(除过头节点外);每个结点中最多持有32个zskiplistLevel结构,实际数量在结点创建时,按幂次定律随机生成(不超过32)。
每个zskiplistLevel中有两个字段:- forward字段指向比自己得分高的某个结点(不一定是紧邻的),并且,若当前zskiplistLevel实例在level[]中的索引为X,则其forward字段指向的结点, 其level[]字段的容量至少是X+1。这也是上图中, 为什么forward指针总是画的水平的原因。
- span字段代表forward字段指向的结点,距离当前结点的距离。紧邻的两个结点之间的距离定义为1。
Redis对象和底层结构对应关系
字符串对象 string
字符串是Redis最基本的数据类型,不仅所有key都是字符串类型,其它几种数据类型构成的元素也是字符串。
注意字符串的长度不能超过512M。
编码
字符串对象的编码可以是int,raw或者embstr
。
- int 编码:保存的是可以用 long 类型表示的整数值。
- embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。
- raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。
内存布局
字符串对象支持三种编码方式: RAW, INT, EMBSTR, 三种方式的内存布局分别如下:
raw 和 embstr 的区别
编码的转换
当 int 编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw。
对于 embstr 编码,由于 Redis 没有对其编写任何的修改程序(embstr 是只读的),在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了44个字节。
列表对象 list
list 列表,它是简单的字符串列表,按照插入顺序排序,你可以添加一个元素到列表的头部(左边)或者尾部(右边),它的底层实际上是个链表结构。
编码
列表对象的编码是quicklist
。 (之前版本中有linked和ziplist这两种编码。进一步的, 目前Redis定义的10个对象编码方式宏名中, 有两个被完全闲置了, 分别是: OBJ_ENCODING_ZIPMAP与OBJ_ENCODING_LINKEDLIST。 从Redis的演进历史上来看, 前者是后续可能会得到支持的编码值(代码还在), 后者则应该是被彻底淘汰了)
内存布局
哈希对象 hash
哈希对象的键是一个字符串类型,值是一个键值对集合。
编码
哈希对象的编码可以是 ziplist 或者 hashtable;对应的底层实现有两种,一种是ziplist,一种是dict。
编码的转换
当同时满足下面两个条件时,使用ziplist(压缩列表)编码:
- 列表保存元素个数小于512个
- 每个元素长度小于64字节
不能满足这两个条件的时候使用 hashtable 编码。
集合对象 set
集合对象 set 是 string 类型(整数也会转换成string类型进行存储)的无序集合。
注意集合和列表的区别:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。
编码
集合对象的编码可以是 intset 或者 hashtable; 底层实现有两种, 分别是intset和dict。
显然当使用intset作为底层实现的数据结构时, 集合中存储的只能是数值数据, 且必须是整数;
而当使用dict作为集合对象的底层实现时, 是将数据全部存储于dict的键中, 值字段闲置不用.
编码的转换
当集合同时满足以下两个条件时,使用 intset 编码:
- 集合对象中所有元素都是整数
- 集合对象所有元素数量不超过512
不能满足这两个条件的就使用 hashtable 编码。
第二个条件可以通过配置文件的 set-max-intset-entries
进行配置。
有序集合对象 zset
和集合对象相比,有序集合对象是有序的。
与列表使用索引下标作为排序依据不同,有序集合为每个元素设置一个分数(score)作为排序依据。
编码
有序集合的底层实现有两种:
一种是使用ziplist作为底层实现, 对应的编码值宏为ZIPLIST
;
另外一种比较特殊, 底层使用了两种数据结构: dict与skiplist, 对应的编码值宏为SKIPLIST
。
使用ziplist来实现有序集合很容易理解, 只需要在ziplist这个数据结构的基础上做好排序与去重就可以了。
使用zskiplist来实现有序集合也很容易理解, Redis中实现的这个跳跃表似乎天然就是为了实现有序集合对象而实现的, 那么为什么还要辅助一个dict实例呢?
我们先看来有序集合对象在这两种编码方式下的内存布局, 然后再做解释。
- ZIPLIST
- SKIPLIST
编码的转换
当有序集合对象同时满足以下两个条件时,对象使用 ziplist 编码:
- 保存的元素数量小于128;
- 保存的所有元素长度都小于64字节。
不能满足上面两个条件的使用 skiplist 编码。
以上两个条件也可以通过Redis配置文件zset-max-ziplist-entries
选项和 zset-max-ziplist-value
进行修改。