Redis设计与实现——数据结构与对象
数据结构
由于C语言内置的数据结构匮乏,Redis实现了一些自己的数据结构。
我们需要分清数据结构和Redis数据类型的区别:
- 数据结构就只是按照某种结构组织起来的数据,Redis会在很多地方复用它
- Redis数据类型指的是面向Redis用户提供的类型,即:
string
、hash
、zset
、list
、set
- Redis使用不同的数据结构来实现Redis的五种数据类型,甚至对于某些数据类型,它的底层并不一定只有一种数据结构来支撑它
- Redis服务器中的很多功能也使用到了它定义好的数据结构进行工作,也就是说这些定义好的数据结构并非只用于构建Redis的五种数据类型
你可能看的一头雾水,没关系,在数据结构一节之后你就会明白上面这些话的意思。你现在只需要知道,这一节要说的数据结构并不是我们所熟知的Redis五种数据类型,而是实现它们的基础。
简单动态字符串(Simple Dynamic String, SDS)
由于C语言中的字符串比较底层,很多字符串函数效率不高,而且C原始的字符串对象不方便复用。
SDS是Redis对C语言字符串的包装,提供了一些更强大的特性。
- 快速获取长度,SDS通过一个成员变量
len
来记录字符串的长度,这让函数sdslen
可以在O(1)
的时间复杂度下实现。 - SDS不会出现缓冲区溢出,
sdscat
函数在连接两个字符串时,如果检测到目标字符串长度不够,会创建一个足够大小的新字符串来实现连接 - SDS提供空间预分配技术,在对SDS进行空间扩展时,如果要扩展成的空间大小小于1M,SDS实际会分配出两倍于它的空间。这样能够避免每次对SDS进行修改(拼接)都要内存重分配。(若大于1M则只额外分配1M的空间)
- SDS提供惰性空间释放,它提供
free
成员变量,记录当前SDS对象中没有被用到的空间,当你修剪一个字符串时,它只调节free
变量而已。这让修剪操作不需要内存重分配,并且让后续的连接操作提供直接复用这些空闲空间的可能性。 - 支持存储二进制:SDS可以直接把底层的char数组当成字节数组来用,SDSAPI在读取时没有任何限制,如果你存的是二进制值,你取出来也是二进制值。
- 兼容部分C函数:由于底层仍然使用char数组,所以能够直接兼容部分C的字符串操作函数
API
函数 | 作用 | 时间复杂度 |
---|---|---|
sdsnew | 创建一个包含指定字符串的SDS | O(N) |
sdsempty | 创建一个空SDS | O(1) |
sdsfree | 释放给定的SDS | O(N) |
sdslen | 获取SDS长度 | O(1) |
sdsavail | 返回SDS空闲空间长度 | O(1) |
sdsdup | 创建SDS副本 | O(N) |
sdsclear | 清空SDS中的内容 | O(1) |
sdscat | 拼接字符串到SDS | O(N) |
sdscatsds | 拼接两个SDS | O(N) |
sdscpy | 将给定的C字符串复制到SDS中 | O(N) |
sdsgrowzero | 用空字符串将SDS扩展到指定长度 | O(N) |
sdsrange | 保留SDS给定区间内的数据 | O(N) |
sdstrim | 在SDS中移除所有在给定C字符串中出现过的字符 | O(N^2) |
sdscmp | 比较两个SDS是否相同 | O(N) |
链表
链表作为Redis数据结构中list
的实现之一,在Redis数据结构里非常重要。
Redis的链表特性:
- 双端,有head和tail节点的引用
- 无环
- 带和SDS一样的长度计数器
- 多态,可以存任意类型数据,链表有三个成员属性来支持多态性
dup
属性是一个函数,用于复制链表节点所保存的值free
属性是一个函数,用于释放链表节点所保存的值match
属性是一个函数,用于对比链表节点所保存的值和另一个输入值是否相等
API
函数 | 作用 | 时间复杂度 |
---|---|---|
listSetDupMethod | 设置dup函数 | O(1) |
listGetDupMethod | 获取dup函数 | O(1) |
listSetFreeMethod | 设置free函数 | O(1) |
listGetFreeMethod | 获取free函数 | O(1) |
listSetMatchMethod | 设置match函数 | O(1) |
listGetMatchMethod | 获取match函数 | O(1) |
listLength | 链表长度获取 | O(1) |
listFirst | 返回链表头节点 | O(1) |
listLast | 返回链表尾节点 | O(1) |
listPrevNode | 返回给定节点的前置节点 | O(1) |
listNextNode | 返回给定节点的后置节点 | O(1) |
listNodeValue | 返回给定节点的值 | O(1) |
listCreate | 创建新链表 | O(1) |
listAddNodeHead | 添加新节点到链表头 | O(1) |
listAddNodeTail | 添加新节点到链表尾 | O(1) |
listInsertNode | 将一个给定节点插入到另一个给定节点的之前或之后 | O(1) |
listSearchKey | 返回链表中包含指定值的节点 | O(N) |
listIndex | 返回链表在给定索引上的节点 | O(N) |
listDelNode | 从链表中删除给定节点 | O(N) |
listRotate | 把链表尾弹出,插入到链表头 | O(1) |
listDup | 复制给定链表的副本 | O(N) |
listRelease | 释放链表占用空间 | O(N) |
字典
字典就是存储一个东西到另一个东西的映射,在Redis数据结构中,字典就是一个HashMap。
- Redis的HashMap使用链表解决冲突
- 如果负载因子过大,Redis会使用重哈希(rehash)将当前哈希表重映射到另一个更大的哈希表
- 由于Redis是单线程模型,为了避免重映射大哈希表带来的服务停顿,Redis的重映射机制是惰性的,目的是将rehash的成本均摊到每一次对该哈希表的请求上。每个哈希表在rehash阶段维护一个
rehashindex
,它代表当前要移动底层数组中那一项的项目链表中的所有节点到新的哈希表中,rehashindex
初始是0,hash表每被请求一次,rehashindex
+1,同时底层数组中有一个链表中的所有节点被rehash到新哈希表。rehash期间,redis先在原哈希表中查询,如果查询不到,再尝试去新哈希表中查询,当rehash结束,rehashindex=-1
,源哈希表被销毁。 - 负载因子=哈希表中元素数量/底层数组大小
- 如果服务器没有执行BGSAVE、BGREWRITEAOF,那么负载因子大于等于1时rehash,否则大于等于5时rehash。这是因为rehash操作会产生大量的写内存操作,BGSAVE、BGREWRITEAOF这两个操作都会创建子进程来读取原哈希表,在使用写时复制的系统中,这会产生大量的复制,所以在这两个操作时Redis为了避免复制调高了负载因子。
API
函数 | 作用 | 时间复杂度 |
---|---|---|
dictCreate | 创建字典 | O(1) |
dictAdd | 向哈希表中添加键值对 | O(1) |
dictReplace | 根据指定的键修改值 | O(1) |
dictFetchValue | 返回指定键的值 | O(1) |
dictGetRandomKey | 返回随机键值对 | O(1) |
dictDelete | 删除一个键值对 | O(1) |
dictRelease | 释放字典 | O(N) |
跳跃表
跳表就是将链表之上使用链表再建立一层索引,达到几乎和平衡二叉树一样的搜索效率。这有点像数据库系统中的多级非聚集索引。
跳表这种数据结构不经常在教材上出现,但在很多设计中都常用,这里是一篇关于跳表的文章:数据结构与算法——跳表。
Redis中的跳跃表就用来实现有序集合,所以,跳跃表节点中提供一个score字段,用于对跳跃表节点进行排序。
API
函数 | 作用 | 时间复杂度 |
---|---|---|
zslCreate | 创建一个跳跃表 | O(1) |
zslFree | 释放跳跃表 | O(N) |
zslInsert | 向跳跃表中插入 | 平均O(logN),最坏O(N) |
zslDelete | 删除跳跃表中的一个节点 | 平均O(logN),最坏O(N) |
zslGetRank | 返回包含给定成员和分值的节点在跳跃表中的排位 | 平均O(logN),最坏O(N) |
zslGetElementByRank | 返回跳跃表在给定排位上的节点 | 平均O(logN),最坏O(N) |
zslIsInRange | 返回跳跃表中是否至少有一个元素在给定分值范围内 | O(1) 检查跳表头节点和尾节点即可 |
zslFirstInRange | 返回跳表中第一个在给定范围内的元素 | 平均O(logN),最坏O(N) |
zslLastInRange | 返回跳表中最后一个在给定范围内的元素 | 平均O(logN),最坏O(N) |
zslDeleteRangeByScore | 删除分值范围内所有节点 | O(N) N是被删除元素个数 |
zslDeleteRangeByRank | 删除指定排位范围的所有节点 | O(N) N是被删除元素个数 |
整数集合
intset是一种简单的,基于数组的集合实现,它只能保存整数,这个整数的位数并不固定。下面是intset的定义
encoding
指定整数集合中,整数占用的位数,比如INTSET_ENC_INT16
代表content
中保存的实际是int16_t
类型的值。length
是元素数
intset将维护底层数组中元素的有序性以及唯一性。
升级
如果在一个encoding==INTSET_ENC_INT16
的intset
上插入一个32位整数,那么当前intset
需要被升级:
- 将底层数组大小扩展到以32位整数存储时足够的大小
- 将每个元素转换成32位,并放到正确的位置
- 将新元素添加到底层数组中
intset不支持降级
整数集合API
函数 | 作用 | 时间复杂度 |
---|---|---|
intsetNew | 创建一个新的整数集合 | O(1) |
intsetAdd | 添加一个元素到整数集合中 | O(N) |
intsetRemove | 从整数集合中移出一个元素 | O(N) |
intsetFind | 检查给定值是否存在于集合 | O(logN) 二分查找 |
intsetRandom | 从整数集合中随机返回一个元素 | O(1) |
intsetGet | 取出在给定索引上的元素 | O(1) |
intsetLen | 获取集合中元素数量 | O(1) |
intsetBlobLen | 获取集合占用字节数 | O(1) |
压缩列表
压缩列表是比刚才所说的链表更加紧凑的列表结构,它是经过编码的内存区块,你也可以理解为字节数组。下图是它的编码方式:
zlbytes
:4字节,记录压缩列表占用的内存字节数zltail
:4字节,记录压缩列表的尾节点距离压缩列表的起始地址的偏移量,这使得压缩列表可以在O(1)的时间复杂度下找到尾节点地址zllen
:2字节,压缩列表节点数量entryX
:具体的列表项,长度不定zlend
:1字节,用于标记压缩列表的尾部。和字符串尾部的\0
异曲同工。
节点构成
由于压缩列表的节点可以存储多种多样的数据(整数和字节数组),所以不能像整数集合一样简单,下面是节点的内部结构:
-
previous_entry_length
:前一个节点的长度,用于获得前一个节点的起始地址。当前一个节点的长度小于254字节,该字段可以用1个字节实现,否则就用5个字节实现 -
encoding
:占用1~5字节,记录content
的类型和长度。下面是该值于content
类型长度的对照表:
对于字节数组类型,
encoding
开头两位代表类型,后面代表字节数组长度。 -
content
:实际保存节点值
连锁更新
考虑极端情况下,压缩列表中所有节点长度都为254,它们的previous_entry_length
字段只需要1字节就可以。现在,想要往压缩列表头部插入一个长度大于254字节的节点,那么压缩列表原先的第一个节点的previous_entry_length
就要由原先的1字节变成5字节,节点长度就需要变成258,那么后面的节点也要变,随后的所有节点都要变。
删除也可能引发相同的连锁更新。
连锁更新中的每一步都需要空间重分配,所以最坏情况下需要O(N^2)的时间复杂度完成更新。但这种最坏情况太难发生了。
压缩列表API
函数 | 作用 | 时间复杂度 |
---|---|---|
ziplistNew | 创建一个新的压缩列表 | O(1) |
ziplistPush | 创建一个包含给定值的新节点,加到表头或表尾 | O(N),最坏O(N^2) |
ziplistInsert | 将包含新值的节点插入到给定节点后 | O(N),最坏O(N^2) |
ziplistIndex | 返回给定索引上的节点 | O(N) |
ziplistFind | 查找并返回包含了给定值的节点 | 由于节点值可能是字节数组,所以最坏情况下O(N^2) |
ziplistNext | 返回给定节点的下一个节点 | O(1) |
ziplistPrev | 返回给定节点的上一个节点 | O(1) |
ziplistGet | 获取给定节点所保存的值 | O(1) |
ziplistDelete | 从压缩列表中删除指定节点 | O(N),最坏O(N^2) |
ziplistDeleteRange | 从压缩列表中删除多个连续指定节点 | O(N),最坏O(N^2) |
ziplistBlobLen | 返回压缩列表占用字节数 | O(1) |
ziplistLen | 返回压缩列表目前包含的节点数量 | 节点数量小于65535时可以直接使用zllen ,所以是O(1),大于等于时就是O(N),需要手动遍历 |
对象
就如开篇所说的,数据类型并不直接映射到Redis面向用户提供的对象上,而这一节要介绍的对象,直接映射到五种对象上。为啥要搞这么一套呢?因为Redis并不希望这五种对象与具体的数据结构耦合,Redis可以在某些情况下动态替换对象底层的数据结构。想想为什么redis的string甚至可以被当作数字用。
Redis的对象系统是使用前面介绍的数据类型构建的,具有五种对象:字符串对象、列表对象、集合对象、有序集合对象、哈希对象。
对象的类型和编码
对象在Redis中统一使用redisObject
类型来表示,如下是它的定义。
type
,指定了该对象的类型,可以是以下几种类型之一:REDIS_STRING
REDIS_LIST
REDIS_HASH
REDIS_SET
REDIS_ZSET
encoding
,指定该对象的编码,即用什么数据结构来实现该对象REDIS_ENCODING_INT
:long类型整数REDIS_ENCODING_EMBSTR
:一种物理上与redisObject
对象的绑定在一块的SDSREDIS_ENCODING_RAW
:SDSREDIS_ENCODING_HT
:字典、HashTableREDIS_ENCODING_LINKEDLIST
:链表REDIS_ENCODING_ZIPLIST
:压缩列表REDIS_ENCODING_INTSET
:整数集合REDIS_ENCODING_SKIPLIST
:跳跃表
ptr
,底层数据结构的指针
所以我们可以看到,type
向外界提供当前对象的类型,encoding
向内部提供所使用的具体数据结构,Redis就是这样实现了对象和数据类型的解耦。
使用
TYPE key
可以获取一个键的对象类型,使用OBJECT ENCODING key
可以获取它的实际编码,也就是底层数据类型。
字符串对象
- 当字符串保存的是可以用long类型来表示的整数值时,使用
int
编码 - 当字符串保存的是长度小于等于32的字符串时,使用
embstr
编码 - 否则,使用
raw
编码
127.0.0.1:6379> set num 29
OK
127.0.0.1:6379> object encoding num
"int"
127.0.0.1:6379> set num 29a
OK
127.0.0.1:6379> object encoding num
"embstr"
127.0.0.1:6379> set num 29ab32gasdvj124123ikj1...
OK
127.0.0.1:6379> object encoding num
"raw"
embstr和raw的区别
embstr对象中,buf
所指定的实际数据结构在物理上与持有它的redisObject
相邻,这样只需要一次内存分配,回收时也只需要一次内存释放。
embstr
让经常在业务中出现的小字符串的创建和回收更快速,并且更容易加载到计算机存储系统的缓存中。
浮点数的保存
刚学Redis时,发现浮点数并不能直接用incr
指令进行递增,而是使用浮点数特定的指令incrbyfloat
,当时觉得很迷惑。
实际上这是Redis字符串的存储结构决定的,Redis所指定的编码中,并没有能表示浮点数的,所以Redis采用embstr
或raw
来保存浮点数,在运算时再把它手动转换成浮点数参与运算,结果再转成字符串。
127.0.0.1:6379> set num 1.2
OK
127.0.0.1:6379> object encoding num
"embstr"
127.0.0.1:6379> incrbyfloat num 4
"5.2"
127.0.0.1:6379> object encoding num
"embstr"
参与运算的int和embstr
int和embstr参与字符串运算后会变成raw。
127.0.0.1:6379> set tmp a
OK
127.0.0.1:6379> object encoding tmp
"embstr"
127.0.0.1:6379> append tmp b
(integer) 2
127.0.0.1:6379> object encoding tmp
"raw"
列表对象
就像string对象具有多态实现,列表对象也有。
- 列表对象保存的所有字符串的元素长度都小于
list-max-ziplist-value
的值(默认64),并且列表的元素数量小于list-max-ziplist-entries
的值(默认512)时,使用ziplist - 否则,使用linkedlist
quicklist
在redis3.0之后,引入了一种新的数据结构,quicklist
,列表对象改为使用quicklist
实现。
不用害怕这之前的还没学懂就又来了一个,quicklist
实际上只是链表和压缩列表的合作。回想一下列表和压缩列表的优缺点缺点
优点:
- 压缩列表占用空间小
- 链表两端插入快
缺点:
- 压缩列表插入慢,需要内存拷贝
- 链表地址不连续,容易产生内存碎片,维持数据结构正常工作所需要维护的状态变量多
quicklist
就是把若干个ziplist
连接成链表,链表的节点就是一个ziplist
,当ziplist
过大时,创建链表节点,这样对小的ziplist进行修改时需要的成本也低,而一个链表中包含的节点从一个变成一堆,内存碎片啥的就不会很多了,浪费在用来维护数据结构上的存储空间也低了。
哈希对象
- 哈希对象保存的所有字符串(包括键和值)的元素长度都小于
hash-max-ziplist-value
的值(默认64),并且hash的元素数量小于hash-max-ziplist-entries
的值(默认512)时,使用ziplist - 否则,使用hashtable
使用ziplist
时,键值对在ziplist紧凑存储
listpack
很遗憾,没个消停,哈希对象的存储方式在redis5中已经变成了listpack
,这本书可能确实太老了。
listpack
和ziplist
大体上并无区别,或者说思路一致,只不过它其中并不保存上一个对象的长度,而是保存自己的长度,这样免去了ziplist
的一个大问题——连锁更新。
ziplist
的前一个entry长度大小是可变的,listpack
中没有这种可变长元素,所以它的内存利用率不如ziplist
,但这也是现在大环境下的一个设计抉择——在占用内存可以忍受的前提下,更快一点。
读者应该注意,
listpack
只是用来替换ziplist
的,在适当情况下,Redis还是会将其转换成hash
集合对象
- 集合对象保存的所有元素都是整数值,并且元素个数不超过
set-max-intset-entries
个时(默认512),使用intset - 否则使用hashtable
127.0.0.1:6379> sadd myset 1
(integer) 1
127.0.0.1:6379> object encoding myset
"intset"
127.0.0.1:6379> sadd myset bi
(integer) 1
127.0.0.1:6379> object encoding myset
"hashtable"
集合对象貌似并没有使用什么新的数据结构
有序集合对象
- 有序集合保存的元素数量小于
zset-max-ziplist-entries
个时(默认128)并且所有元素的长度都小于zset-max-ziplist-value
大小时(默认64字节),使用ziplist
作为底层存储,元素按照分值大小在ziplist
中排布
- 否则,使用
skiplist
listpack
注意,有序集合底层也使用listpack
替换了ziplist
。
127.0.0.1:6379> zadd like 10 yudoge
(integer) 1
127.0.0.1:6379> object encoding like
"listpack"
127.0.0.1:6379> zadd like 10 yudogesdijogiogfsjdiofgjsio...
(integer) 1
127.0.0.1:6379> object encoding like
"skiplist"
内存回收
在redisObject
中维护refCount
字段,代表该对象的引用数量,如果引用数量为0,代表redis可以在适当的时候回收该对象了。
对象共享
在下面这种情况下,Redis可以共享100这个对象,被a和b分别引用
set a 100
set b 100
Redis在初始化时,建立0~9999的数字对象,可以直接引用。
由于比较字符串对象是否相等会耗费大量的CPU时钟周期,所月Redis只对使用整数的字符串对象进行共享。
使用
object refcount key
可以查询key
被引用的次数
蜜汁refCount
在新版本的redis中,我的是7.0.2,redis会将初始建立的0~9999的对象的refCount
设置为max_int
,也就是2147483647,然后不管有新引用还是有解除引用,这个值都不会变,意思就是该对象永远不会被回收。
而矛盾的是,10000以上的对象redis就不共享了,所以refCount
永远为1。
127.0.0.1:6379> set a 100
OK
127.0.0.1:6379> set b 100
OK
127.0.0.1:6379> object refcount a
(integer) 2147483647
127.0.0.1:6379> set a 10000
OK
127.0.0.1:6379> object refcount a
(integer) 1
那我可不可以理解为,现在,redis中只有0~9999是共享的了,refCount
只用来回收对象,不用来对共享对象标记了?
我也不知道该说法是否正确,欢迎在评论区指教。
对象空转时常
redisObject
对象包含一个lru
属性,用于支持lru算法,它记录最后一次访问该对象的时间。
通过object idletime key
可以获得对象已经有多久没被访问,也就是通过当前时间减去它所绑定对象的lru属性。