引入
Redis对外提供了5种类型:字符串、列表、集合、有序集合以及哈希表,但底层实现并不是固定的,以上五种数据结就好比是接口,Redis会根据不同的场景选择一种实现从而对性能进行优化,就好比Java中的List接口,既可以是ArrayList也是可以LinkedList,而这种具体的选择是不对外暴露的。
基础数据结构
Redis在底层实现了多种数据结构来实现上述5种类型,为方便区分,我把这些具体实现称为基础数据结构。
简单动态字符串
Redis没有使用C语言的字符数组表示字符串,而是自己构建了一种名为简单动态字符串(Simple Dynamic String,SDS)的类型对字符数组进行简单包装,以避免使用字符数组可能带来的一些缺陷。
数据结构
在Redis中,包含字符串值的键值对在底层都是由SDS实现的,C字符串仅仅作为字面量使用(编译时就确定的字符串常量),SDS的数据结构如下图所示:
-
len
buf中已使用字节的数量,末尾的\0
不计入长度 -
free
buf中未使用的字节数量 -
buf[]
字符数组
特性
- 空间预分配
当SDS被修改且buf
空间不够时,SDS会申请更多的内存空间,其申请内存的策略是:如果修改后的len
小于1MB,那么实际buf
的长度是len+len+1
;如果修改后的len
大于等于1MB,那么实际buf
的长度是len+1MB+1
。预留更多空间的是为了降低申请内存空间的次数,提高性能。 - 惰性空间释放
与上述特性相反的是,在SDS缩短时,不会重新分配内存空间,以备将来可能出现的变化。
链表
Redis自己实现了一个双端无环链表。
数据结构
每个链表节点是一个listNode类型的结构体。
每个节点都有指向前和指向后的指针:
第一个节点的prev
指针以及最后一个节点的next
指针都指向NULL
。
字典
C语言没有内置的字典类型,因此Redis自己实现了一个,其底层使用哈希表实现。
数据结构
我们先看哈希表的数据结构。
每个哈希表包含一个dictEntry类型的数组,dictEntry类型可以看成是一个简单的拥有
key
和
value
的对象,并拥有指向下一个dictEntry的指针,如果有两个
key
拥有相同的哈希值,处于对性能的考虑,后来的会插入到最靠前的位置(图中
k1
插入到
k0
的前面),形成链表。
-
size
dictEntry数组的大小 -
sizemark
主要用来跟哈希值一起决定一个键应该放在哪个dictEntry中,它的值总是等于size-1
-
used
节点的数量
一个字典类型主要包含两张哈希表,如果不考虑Redis的rehash操作,那么可以认为只会使用第一个哈希表,但实际上出于性能考虑,Redis会在某些条件下对哈希表的大小进行伸缩。
理想状态下每一个dictEntry都不形成链表,只有唯一的值,即不发生哈希碰撞,那么数组的大小就要足够大,哈希算法计算出的哈希值要足够平均。
但实际上是比较困难的,因此Redis会通过以下决策来决定是否rehash:
- 目前没有在执行
BGSAVE
或者BGREWRITEAOF
命令,且哈希表的负载因子大于等于1 - 目前正在执行
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)。
数据结构
整个结构分为两部分,左边的header是
zskiplist
结构,记录了跳跃表的一些元数据,其中:
-
header
指向首节点 -
tail
指向尾节点 -
level
除首节点外的最大层数 -
length
除首节点外节点的数量
右边的四个节点是zskiplistNode
结构,其数据结构如下图所示:
level[]
代表了跳跃表里的多个层,每个节点的层高都是1至32之间的随机数
backward
是后退指针,指向前一个节点,并且不能跳跃,只能一个一个节点往前遍历
score
是分数,节点按分数从小到大排序,不同节点的分数可以相同
obj
是指向一个SDS的指针,不同节点的obj
不能重复
如果两个节点分数相同,那么按
obj
的字典序从小到大排序。
整数集合
当一个集合只存储数值型元素时,Redis使用整数集合作为底层存储数据结构。
数据结构
整数集合的数据结构比较简单,如下图所示:
-
encoding
表示contents[]
中存储的存储的数值的类型,如INTSET_ENC_INT16
、INTSET_ENC_INT32
分别表示存储的数字类型是16位和32位整数 -
length
元素个数 -
contents[]
存储所有元素并从小到大排列,元素类型并不是int8_t
,而是由encoding
决定
升级
当加入一个新的元素时,如果新元素超出了当前的编码范围,Redis会对数组进行升级。
假设有一个元素都是16位整数的集合:
现在要把65536添加到集合中,由于超过了16位整数的范围,所以要把数组升级到支持存储32位整数,第一步先要对数组进行扩容,扩容后的大小是32*4:
然后把16位元素依次转为32位,再放到适当的位置上:
最后把65536放到末尾:
并不存在与升级相反的降级操作,即使把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也要进行同样的扩展,以此类推,之后所有的节点都要进行扩展,同样删除节点也可能引起连锁更新,这无疑是性能上的致命一击!
但是实际上发生以上情况的概率并不高,主要原因如下:
- 要引发连锁更新必须有多个连续的长度在250字节到253字节之间的节点
- 即使出现连锁更新,只要节点数不多,那么不会对性能造成很大的影响
对象
上文提到Redis对外提供的5种类型其实是接口,而不是具体的实现,其具体实现都是依赖于基础数据结构的,在Redis内部,使用redisObject
对这5种类型进行了封装。
数据结构
可以看到对象只是对基础数据结构的封装,主要有以下几个属性:
-
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保存,encoding
是REDIS_ENCODING_RAW
:
SET story "Long, long, long ago there lived a king ..."
如果字符串值的长度小于等于39字节,那么使用embstr编码,encoding
是REDIS_ENCODING_EMBSTR
:
SET msg "hello"
embstr和raw在结构上是完全一样的,区别在于embstr编码方式是申请一块连续的内存,而raw方式需要申请两块内存,由于embstr只要申请一次内从所以性能更好。
如果存储的是浮点类型的数值或者超过了long能表示的范围,Redis会把它转成字符串进行存储。
编码转换
int编码的字符串对象和embstr编码的字符串对象在某些情况下会转换成raw编码的字符串对象。
例如APPEND
命令只支持对字符串进行追加操作,因此会把10086转换为“10086”然后再拼接字符串。
SET number 10086
APPEND number "is a number"
如果对一个embstr编码的字符串对象进行追加操作也会转化成raw编码,因为embstr编码的字符串不支持任何修改操作。
列表对象
当以下两个条件同时被满足时,底层使用压缩列表实现列表对象,否则均使用链表实现:
- 所有字符串元素小于64个字节
- 元素数量小于512个
以上两个数值可以通过配置文件进行修改。
哈希对象
当以下两个条件同时被满足时,底层使用压缩列表实现,否则用字典实现:
- 所有价值对的键和值的字符串长度都小于64字节
- 键值对数量小于512个
以上两个数值可以通过配置文件进行修改。
用压缩列表时数据存储结构如下图所示:
按插入的先后顺序依次把键和值存储到压缩列表中,用HGET
取值时需要搜索到指定的键,然后返回对应的值。
当用字典作为底层实现时,其存储结构如下图所示:
其中StringObject
代表字符串对象,此处只为简化说明,实际并不存在这种类型。
对于用字典实现的哈希对象,键和值都是字符串对象。
集合对象
当以下两个条件同时被满足时,底层使用整数集合实现,否则用字典实现:
- 所有元素都是整数
- 元素数量不超过512个
以上两个数值可以通过配置文件进行修改。
当使用字典实现时,只使用键来存储,值都是NULL
:
有序集合对象
当以下两个条件同时被满足时,底层使用压缩列表实现,否则用zset
(其结构在后面介绍)实现:
- 元素长度都小于64字节
- 元素数量小于128个
以上两个数值可以通过配置文件进行修改。
当添加元素时,例如:
ZADD price 8.5 apple 5.0 banana 6.0 cherry
如果用压缩列表存储,其结构如下图所示:
元素按score
从小到大排列。
当用zet
存储时,存储结构如下图所示:
可以看到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设计与实现》