Redis设计与实现1:数据结构

引入

Redis对外提供了5种类型:字符串、列表、集合、有序集合以及哈希表,但底层实现并不是固定的,以上五种数据结就好比是接口,Redis会根据不同的场景选择一种实现从而对性能进行优化,就好比Java中的List接口,既可以是ArrayList也是可以LinkedList,而这种具体的选择是不对外暴露的。

基础数据结构

Redis在底层实现了多种数据结构来实现上述5种类型,为方便区分,我把这些具体实现称为基础数据结构。

简单动态字符串

Redis没有使用C语言的字符数组表示字符串,而是自己构建了一种名为简单动态字符串(Simple Dynamic String,SDS)的类型对字符数组进行简单包装,以避免使用字符数组可能带来的一些缺陷。

数据结构

在Redis中,包含字符串值的键值对在底层都是由SDS实现的,C字符串仅仅作为字面量使用(编译时就确定的字符串常量),SDS的数据结构如下图所示:


Redis设计与实现1:数据结构_第1张图片
简单动态字符串
  • len buf中已使用字节的数量,末尾的\0不计入长度
  • free buf中未使用的字节数量
  • buf[] 字符数组
特性
  1. 空间预分配
    当SDS被修改且buf空间不够时,SDS会申请更多的内存空间,其申请内存的策略是:如果修改后的len小于1MB,那么实际buf的长度是len+len+1;如果修改后的len大于等于1MB,那么实际buf的长度是len+1MB+1。预留更多空间的是为了降低申请内存空间的次数,提高性能。
  2. 惰性空间释放
    与上述特性相反的是,在SDS缩短时,不会重新分配内存空间,以备将来可能出现的变化。

链表

Redis自己实现了一个双端无环链表。

数据结构

每个链表节点是一个listNode类型的结构体。

Redis设计与实现1:数据结构_第2张图片
链表
Redis设计与实现1:数据结构_第3张图片
链表节点

每个节点都有指向前和指向后的指针:


链表节点

第一个节点的prev指针以及最后一个节点的next指针都指向NULL

Redis设计与实现1:数据结构_第4张图片
链表

字典

C语言没有内置的字典类型,因此Redis自己实现了一个,其底层使用哈希表实现。

数据结构

我们先看哈希表的数据结构。


Redis设计与实现1:数据结构_第5张图片
哈希表

Redis设计与实现1:数据结构_第6张图片
key-value对

每个哈希表包含一个dictEntry类型的数组,dictEntry类型可以看成是一个简单的拥有 keyvalue的对象,并拥有指向下一个dictEntry的指针,如果有两个 key拥有相同的哈希值,处于对性能的考虑,后来的会插入到最靠前的位置(图中 k1插入到 k0的前面),形成链表。

  • size dictEntry数组的大小
  • sizemark 主要用来跟哈希值一起决定一个键应该放在哪个dictEntry中,它的值总是等于size-1
  • used 节点的数量

一个字典类型主要包含两张哈希表,如果不考虑Redis的rehash操作,那么可以认为只会使用第一个哈希表,但实际上出于性能考虑,Redis会在某些条件下对哈希表的大小进行伸缩。


Redis设计与实现1:数据结构_第7张图片
字典

理想状态下每一个dictEntry都不形成链表,只有唯一的值,即不发生哈希碰撞,那么数组的大小就要足够大,哈希算法计算出的哈希值要足够平均。
但实际上是比较困难的,因此Redis会通过以下决策来决定是否rehash:

  1. 目前没有在执行BGSAVE或者BGREWRITEAOF命令,且哈希表的负载因子大于等于1
  2. 目前正在执行BGSAVE或者BGREWRITEAOF命令,且哈希表的负载因子大于等于5
  • 负载因子 = used / size
    以上任意一个条件满足就会对哈希表进行扩展,另一方面,如果负载因子小于0.1,那么就对其进行收缩。

所以,简单来看,当Redis进行rehash时,会在ht[1]上申请一块新的空间,然后把ht[0]中所有键值对rehash到ht[1]中,再把ht[0]指向ht[1],把ht[1]指向空白哈希表,这样rehash就完成了。
但是实际上这个过程要更复杂一点,因为考虑到键值对数量可能比较多,如果一下子全部rehash,可能会影响性能,因此Redis其实是渐进式地把键值对逐步从ht[0]转移到ht[1]中。在渐进式rehash的过程中,会有对两张哈希表同时操作的情况,所有新插入的键值对都直接写到ht[1],而删除,查找,更新操作都会在两张哈希表上进行,最终ht[0]成为空表。

Redis使用MurmurHash2算法计算键的哈希值。

跳跃表

Redis使用跳跃表实现有序集合,平时工作可能不太会直接使用跳跃表,这里有一篇文章写得不错,可以对跳跃表有一个初步的了解,具体算法不在这里赘述。
跳跃表本质上是一种经过优化的链表。普通单链表查找一个元素的时间复杂度是O(n),跳跃表结合了二分法的思想进行优化,其查找一个元素的时间复杂度是O(logn)。

数据结构

Redis设计与实现1:数据结构_第8张图片
跳跃表

整个结构分为两部分,左边的header是 zskiplist结构,记录了跳跃表的一些元数据,其中:

  • header 指向首节点
  • tail 指向尾节点
  • level 除首节点外的最大层数
  • length 除首节点外节点的数量
    右边的四个节点是zskiplistNode结构,其数据结构如下图所示:
    Redis设计与实现1:数据结构_第9张图片
    跳跃表节点

level[] 代表了跳跃表里的多个层,每个节点的层高都是1至32之间的随机数
backward 是后退指针,指向前一个节点,并且不能跳跃,只能一个一个节点往前遍历
score 是分数,节点按分数从小到大排序,不同节点的分数可以相同
obj 是指向一个SDS的指针,不同节点的obj不能重复

如果两个节点分数相同,那么按obj的字典序从小到大排序。

整数集合

当一个集合只存储数值型元素时,Redis使用整数集合作为底层存储数据结构。

数据结构

整数集合的数据结构比较简单,如下图所示:


Redis设计与实现1:数据结构_第10张图片
整数
  • encoding 表示contents[]中存储的存储的数值的类型,如INTSET_ENC_INT16INTSET_ENC_INT32分别表示存储的数字类型是16位和32位整数
  • length 元素个数
  • contents[] 存储所有元素并从小到大排列,元素类型并不是int8_t,而是由encoding决定
升级

当加入一个新的元素时,如果新元素超出了当前的编码范围,Redis会对数组进行升级。
假设有一个元素都是16位整数的集合:


Redis设计与实现1:数据结构_第11张图片
16位整数集合

现在要把65536添加到集合中,由于超过了16位整数的范围,所以要把数组升级到支持存储32位整数,第一步先要对数组进行扩容,扩容后的大小是32*4:


扩容

然后把16位元素依次转为32位,再放到适当的位置上:


转换并移动

最后把65536放到末尾:

Redis设计与实现1:数据结构_第12张图片
添加新元素

并不存在与升级相反的降级操作,即使把32位数值全部移除也不会降到16位。

引起数组升级的数值,要么大于所有数值,要么小于所有数值,因此总是放置在数组开头或末尾。

压缩列表

压缩列表是Redis为节省内存而开发的,由连续内存块组成的顺序型数据结构。
Redis使用压缩列表来实现列表,另外哈希键的键和值如果都是小郑数值或短字符串,底层也会用压缩列表实现。

数据结构

一个简单的压缩列表如下图所示:


压缩列表
  • zlbytes 压缩列表占用的内存字节数
  • zltail 尾节点距离列表起始位置有多少字节数
  • zllen 该值小于65535时表示节点数,等于65535时节点的数量需要遍历得出
  • entryX 节点
  • zlend 特数值,表示列表结尾

每个节点有以下数据结构:


列表节点
  • previous_entry_length 上一个节点的长度,需要注意的是,该属性本身的长度有两种,分别是1个字节和5个字节,当上一个节点长度小于254字节,那么就取1字节,反之就取5字节
  • encoding 记录content的类型和长度
  • content 节点的值
连锁更新

这种结构非常省内存,但也存在一个问题,考虑以下这种情况:

连锁更新

有e1,e2...等n个节点的压缩列表,每个节点都是253字节,因此每个节点的 previous_entry_length 长度都是1字节,这是插入一个新的节点new,它的长度大于254,因此对于e1来说,它的 previous_entry_length属性需要扩展为5字节,这样导致e1的长度也超过了254,从而导致e2也要进行同样的扩展,以此类推,之后所有的节点都要进行扩展,同样删除节点也可能引起连锁更新,这无疑是性能上的致命一击!

但是实际上发生以上情况的概率并不高,主要原因如下:

  1. 要引发连锁更新必须有多个连续的长度在250字节到253字节之间的节点
  2. 即使出现连锁更新,只要节点数不多,那么不会对性能造成很大的影响

对象

上文提到Redis对外提供的5种类型其实是接口,而不是具体的实现,其具体实现都是依赖于基础数据结构的,在Redis内部,使用redisObject对这5种类型进行了封装。

数据结构

Redis设计与实现1:数据结构_第13张图片
对象

可以看到对象只是对基础数据结构的封装,主要有以下几个属性:

  • type 对象的类型,有以下几个值,分别对应上文所述5种类型:
名称 TYPE命令的输出
REDIS_STRING 字符串 "string"
REDIS_LIST 列表 "list"
REDIS_HASH 哈希表 "hash"
REDIS_SET 集合 "set"
REDIS_ZSET 有序集合 "zset"

可以使用TYPE key命令查看键key的类型。

  • ptr 指向底层实现的基础数据结构
  • encoding 记录了ptr指向的基础数据结构的类型,有以下几个值:
名称 OE命令输出
REDIS_ENCODING_INT long类型的整数 "int"
REDIS_ENCODING_EMBSTR embstr编码的SDS "embstr"
REDIS_ENCODING_RAW SDS "raw"
REDIS_ENCODING_HT 字典 "hashtable"
REDIS_ENCODING_LINKEDLIST 链表 "linkedlist"
REDIS_ENCODING_ZIPLIST 压缩列表 "ziplist"
REDIS_ENCODING_INTSET 整数集合 "intset"
REDIS_ENCODING_SKIPLIST 跳跃表和字典 "skiplist"

可以使用OBJECT ENCODING key命令查看键key对应的encoding属性。

每种类型可以使用多种编码:

类型 编码
REDIS_STRING REDIS_ENCODING_INT
REDIS_STRING REDIS_ENCODING_EMBSTR
REDIS_STRING REDIS_ENCODING_RAW
REDIS_LIST REDIS_ENCODING_ZIPLIST
REDIS_LIST REDIS_ENCODING_LINKEDLIST
REDIS_HASH REDIS_ENCODING_ZIPLIST
REDIS_HASH REDIS_ENCODING_HT
REDIS_SET REDIS_ENCODING_INTSET
REDIS_SET REDIS_ENCODING_HT
REDIS_ZSET REDIS_ENCODING_ZIPLIST
REDIS_ZSET REDIS_ENCODING_SKIPLIST

字符串对象

如果字符串对象保存的是整数值,且可以用long类型来表示,那么encoding就是REDIS_ENCODING_INT

SET number 100

如果保存的是一个字符串值,且长度大于39字节,那么就用SDS保存,encodingREDIS_ENCODING_RAW

SET story "Long, long, long ago there lived a king ..."

如果字符串值的长度小于等于39字节,那么使用embstr编码,encodingREDIS_ENCODING_EMBSTR

SET msg "hello"

embstr和raw在结构上是完全一样的,区别在于embstr编码方式是申请一块连续的内存,而raw方式需要申请两块内存,由于embstr只要申请一次内从所以性能更好。


Redis设计与实现1:数据结构_第14张图片
raw编码的字符串对象
embstr编码的字符串对象

如果存储的是浮点类型的数值或者超过了long能表示的范围,Redis会把它转成字符串进行存储。

编码转换

int编码的字符串对象和embstr编码的字符串对象在某些情况下会转换成raw编码的字符串对象。

例如APPEND命令只支持对字符串进行追加操作,因此会把10086转换为“10086”然后再拼接字符串。

SET number 10086
APPEND number "is a number"

如果对一个embstr编码的字符串对象进行追加操作也会转化成raw编码,因为embstr编码的字符串不支持任何修改操作。

列表对象

当以下两个条件同时被满足时,底层使用压缩列表实现列表对象,否则均使用链表实现:

  1. 所有字符串元素小于64个字节
  2. 元素数量小于512个

以上两个数值可以通过配置文件进行修改。

哈希对象

当以下两个条件同时被满足时,底层使用压缩列表实现,否则用字典实现:

  1. 所有价值对的键和值的字符串长度都小于64字节
  2. 键值对数量小于512个

以上两个数值可以通过配置文件进行修改。

用压缩列表时数据存储结构如下图所示:

用压缩列表实现的哈希对象

按插入的先后顺序依次把键和值存储到压缩列表中,用HGET取值时需要搜索到指定的键,然后返回对应的值。

当用字典作为底层实现时,其存储结构如下图所示:

Redis设计与实现1:数据结构_第15张图片
用字典实现的哈希对象

其中StringObject代表字符串对象,此处只为简化说明,实际并不存在这种类型。

对于用字典实现的哈希对象,键和值都是字符串对象。

集合对象

当以下两个条件同时被满足时,底层使用整数集合实现,否则用字典实现:

  1. 所有元素都是整数
  2. 元素数量不超过512个

以上两个数值可以通过配置文件进行修改。
当使用字典实现时,只使用键来存储,值都是NULL

Redis设计与实现1:数据结构_第16张图片
用字典实现的集合对象

有序集合对象

当以下两个条件同时被满足时,底层使用压缩列表实现,否则用zset(其结构在后面介绍)实现:

  1. 元素长度都小于64字节
  2. 元素数量小于128个

以上两个数值可以通过配置文件进行修改。

当添加元素时,例如:

ZADD price 8.5 apple 5.0 banana 6.0 cherry

如果用压缩列表存储,其结构如下图所示:

Redis设计与实现1:数据结构_第17张图片
用压缩列表实现的有序集合对象

元素按score从小到大排列。

当用zet存储时,存储结构如下图所示:

Redis设计与实现1:数据结构_第18张图片
用zset实现的有序集合对象

可以看到zset其实就是一种包含了一个字典和一个跳跃表的数据结构。元素的值和score都存储在跳跃表中,字典的作用是存储元素值和其score的映射,这样的好处是查找一个元素对应的score的时间复杂度为O(1),又同时能保证元素的有序性。

虽然表面上看同一个对象的值和score会分别存储在字典和跳跃表中,但实际上,他们存储的是同一个对象的引用,因此不会造成内存浪费。

内存回收

Redis自己实现了一套基于引用计数的内存回收机制,redisObject结构体中使用refcount属性表示引用次数,对象被创建时初始化为1,当该属性等于0时自动释放内存空间。
可以使用OBJECT REFCOUNT key命令查看键key对应的值对象的引用次数。

对象共享

目前Redis会对保存了整数值的字符串对象进行内存中的共享,比如:

SET A 100
SET B 100

此时键A和键B指向的值对象其实是同一个,此时值100所在对象的引用次数是3(初始是1,每引用一次次数加1)。

Redis默认会在启动时初始化10000个字符串对象,从0到9999,也可以在配置文件中修改。

目前Redis只会共享保存了整数值的字符串对象,因为在共享对象时需要检查共享对象和所要创建的对象是否相同,对整数的判断时间复杂度是O(1),如果是字符串则是O(n),如果是复杂对象则是O(n^2),因此出于性能考虑,只共享整数行字符串对象。

空转时长

每个redisObject对象还有一个lru属性,记录了该对象最后一次被访问的时间。使用OBJECT IDLETIME key命令可以输出键key的空转时长,单位是秒,该命令本身不会更新lru属性

另外,如果开启了maxmemory选项,Redis也会根据空转时长来进行内存回收。

参考/图片出处:
1. 机械工业出版社 -《Redis设计与实现》

你可能感兴趣的:(Redis设计与实现1:数据结构)