Skip to content

Latest commit

 

History

History
1084 lines (640 loc) · 55.3 KB

Redis设计与实现.md

File metadata and controls

1084 lines (640 loc) · 55.3 KB

Redis设计与实现

1、引言

Redis数据库里面的每个键值对都是由对象组成的,其中:

数据库键总是一个字符串对象

而数据库键的值则可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象这五种对象中的其中一种。

2、数据结构与对象

简单动态字符串

动态的意思是指记录字符串的len成员它的值会随着SDS(simple dynamic string)字符串的长度而变化

当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,Redis就会使用SDS来表示字符串值,比如在Redis的数据库里面,包含字符串值的键值对在底层都是由SDS实现的。

例如,如果客户端执行命令:

SET msg "hello world"

那么Redis将在数据库中创建一个新的键值对,其中:

键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串”msg“的SDS。

键值对的值也是一个字符串对象,对象的底层实现是一个保存着字符串“hello world”的SDS。

SDS的定义

SDS与C字符串的区别

常数复杂度获取字符串长度

Redis可以用常数时间复杂度获取字符串长度,而C字符串需要O(N)。因为SDS在len属性中记录了SDS本身的长度。

避免缓冲区溢出

C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出。

减少修改字符串时带来的内存重分配次数(注意只是减少次数)

注意:内存重分配是程序员在代码里面自己执行的步骤。

每次增长或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重分配操作:

如果程序执行的是增长字符串的操作,比如拼接操作,那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小--如果忘了这一步就会产生缓冲区溢出。

如果执行的是缩短字符串的操作,比如截断操作,那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间--如果忘了这一步就会产生内存泄漏。

因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作:

在一般程序中,如果修改字符串长度的情况不经常出现,那么每次修改都执行一次内存分配是可以接受的。

但是Redis作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的大部分,如果这种修改频繁发生的话,可能还会对性能造成影响。

为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就是由SDS的free属性记录

通过未使用空间,SDS实现了空间预分配惰性空间释放两种优化策略。

惰性空间释放

当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节数量记录起来,并等待将来使用(因此被称为惰性)。

与此同时,SDS也提供了相应的API,让我们可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费

二进制安全

C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含字符串结束符,否则最先被程序读入的字符串结束符将被误认为字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据

虽然数据库一般用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见,因此,为了确保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤或者假设,数据在写入时是什么样的,它被读取时就是什么样的

这也是我们将SDS的buf属性称为字节数组的原因--Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据

例如,使用SDS来保存之前提到的特殊数据格式就没有任何问题,因为SDS使用len属性的值而不是空字符来判断字符串是否结束

所以,通过二进制安全的SDS,而不是C字符串,使得Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据。

总结

3、链表

列表键的底层实现之一压缩列表也可以实现列表键)就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现

除了列表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区。

链表和链表节点的实现

由list结构和listNode结构组成的链表:

4、字典/关联数组/映射

在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这种关联的键和值就称为键值对(也就是说键和值是有所关联的)。

Redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上的

例如:

SET msg "hello world"

在数据库中创建一个键为'msg',值为'hello world'的键值对时,这个键值对就是保存在代表数据库的字典里面的(不是说msg是由字典实现的,而是说Redis数据库是由字典实现的,而这个键值对处于这个字典结构中)。

除了表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。(区分好哈希键和键值对,哈希键可以算是对整个宏观的(所有)键值对的抽象概括

字典的实现

Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典(即Redis里面的哈希键)中的一个键值对

哈希表

哈希表是一个总的数据结构,里面有很多成员属性,例如有成员属性记录了哈希表数组的大小。而真正去存放键值对的结构是哈希表数组

Redis字典所使用的哈希表由dict.h/dictht结构定义:

table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小,也即是table数组的大小,而used属性则是记录了哈希表目前已有节点(键值对)的数量。 sizemask属性的值总是等于 size - 1 ,这个属性和哈希值(这个哈希值是对key进行哈希算法得到的值)一起决定了一个键应该被放到table数组的哪个索引上面

下图展示了一个大小为4的空哈希表(没有包含任何键值对):

哈希表节点

哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:

next属性是指向另一个哈希表节点(键值对)的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突的问题。如下所示,通过next指针,将两个索引值相同的键k1和k0连接在一起:

可以发现一个细节,k1是比k0晚添加进哈希表数组的,但是位置却在k0的前面。为什么不插入到链表的末尾呢?因为插入到链表末尾需要遍历完整个链表,消耗很多时间,所以就插在了链表头部。

字典

Redis中的字典由dict.h/dict结构表示:

哈希算法

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希数组的指定索引上面

Redis计算哈希值和索引值的方法如下:

举个例子,对于如下空字典来说:

如果我们要将一个键值对k0和v0添加到字典里面,那么程序会先使用语句:

hash = dict->type->hashFunction(k0);

计算键k0的哈希值。

假设计算得出的哈希值为8,那么程序会继续使用语句:

index = hash & dict->ht[0].sizemask // 即 8 & 3 = 0(无论哈希值是多少,它和3位与的结果一定小于等于3。这也是为什么sizemask = 哈希表大小 - 1)

计算出k0的索引值为0,这表示包含键值对k0和v0的节点应该被放置到哈希表数组的索引0位置上,如下图:

解决键冲突

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突。Redis的哈希表使用链地址法(也叫做开链法)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。

rehash

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(即表中已存元素的个数除以哈希表的长度)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩

5、跳跃表

跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的

跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。

在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树更简单,所以有不少程序都使用跳跃表来代替平衡树。

Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。有序集合的所有数据都保存在一个跳跃表里面。

和链表、字典等数据结构被广泛地应用在Redis内部不同,Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构,除此之外,跳跃表在Redis里面没有其他用途。

位于图片最左边的是zskiplist结构,该结构包含以下属性:

header:指向跳跃表的表头节点。

tail:指向跳跃表的表尾节点。

level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。

length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。

位于zskiplist结构右方的是四个zskiplistNode结构,该结构包含以下属性:

(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上图中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。

后退指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。

分值:各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列

成员对象:各个节点中的o1、o2和o3是节点所保存的成员对象。

跳跃表节点

分值和成员

节点的分支(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。

节点的成员对象是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值

在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。

6、整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

例如,我们创建一个值包含5个元素的集合键,并且集合中所有的元素都是整数值,那么这个集合键的底层实现就会是整数集合:

SADD numbers 1 3 5 7 9

整数集合的实现

每个intset.h/intset结构表示一个整数集合:

下图展示了一个整数集合的例子:

encoding属性的值为INTSET_ENC_INT16,表示整数集合的底层实现为int16_t类型的数组,而集合保存的都是int16_t类型的整数值。

contents数组按从小到大的顺序保存着集合中的5个元素。

7、压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现

例如,执行以下命令将创建一个压缩列表实现的列表键:

RPUSH lst 1 3 5 10086 "hello" "world"

查看键的类型:

redis> OBJECT ENCODING lst
"ziplist"

另外,当一个哈希键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现

例如,执行以下命令将会创建一个压缩列表实现的哈希键:

redis> HMSET profile "name" "Jack" "age" 28 "job" "Programer"

其中,name和Jack是一组键值对,age和28是一组键值对,job和Programer是一组键值对。

redis> OBJECT ENCODING profile
"ziplist"

压缩列表的构成

压缩列表是Redis为了节约内存而开发的(因为它节约了指针占用的内存,而链表等数据结构都是有指针),是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值

下图展示了一个压缩列表的示例。

为什么压缩列表要比链表更加节约内存

为了更好的理解为什么压缩列表更为高效,我们需要从链表谈起。对于一个典型的双向链表,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。另外还有一个指针指向该节点的字符串。每一个字符串又实际分为三个部分:一个代表该字符串长度的整数,一个代表剩余字节的整数以及以“\0”结尾的字符串本身。以下是一个示例:

忽略其余细节,除字符串本身和空余的字节外,三个指针和两个整数都会占用额外的空间。而压缩列表转为存储上一个结点长度、当前结点长度以及字符串本身(压缩列表是使用一片连续的内存,即数组来实现的)。如果存储指向上一个链表结点和指向下一个链表结点的指针需要8个字节,通过这样的“压缩”后在最好的情况下只需2个字节。也就是说,压缩列表大大节省了链表指针的存储。

8、对象

Redis并没有直接使用前面的数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种前面的数据结构。

通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。使用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率

除此之外,Redis的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放;另外,Redis还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。

对象的类型与编码

Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。

例如:

SET msg "hello world"

其中键值对的键是一个包含了字符串“msg”的对象,而键值对的值则是一个包含了字符串值“hello world”的对象。

Redis中的每个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是typeencodingptr

类型

对象的type属性记录了对象的类型,这个属性的值可以是下表:

对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种,因此:

当我们称呼一个数据库键为“字符串键”时,我们指的是“这个数据库键所对应的值为字符串对象”;

当我们称呼一个数据库键为“列表键”时,我们指的是“这个数据库键所对应的值为列表对象”。

TYPE命令的实现方式也与此类似,当我们对一个数据库键执行TYPE命令时,命令返回的结果为数据库对应的值对象的类型,而不是键对象的类型

# 键为字符串对象,值为字符串对象
redis> SET msg "hello world"
OK
redis> TYPE msg
string
# 键为字符串对象,值为列表对象
redis> RPUSH numbers 1 3 5
(integer) 6
redis> TPYE numbers
list
# 键为字符串对象,值为哈希对象
redis> HMSET profile name Tom age 25 career Programer
OK
redis> TYPE profile
hash
# 键为字符串对象,值为集合对象
redis> SADD fruits apple banana cherry
(integer) 3
redis> TYPE fruits
set
# 键为字符串对象,值为有序集合对象
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
redis> TYPE price
zset

编码和底层实现

对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定(注意type和encoding是有区别的,type的种类要比encoding少)。

左边是类型,右边是实现左边的类型所用的具体数据结构。通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了Redis的灵活性和效率,因为Redis可以根据不同的使用场景来为一个对象(同一种类型)设置不同的编码,从而优化对象在某一场景下的效率。例如,在列表对象包含的元素比较少时,Redis使用压缩列表作为列表对象的底层实现:

  • 因为压缩列表比双端链表更节约内存,并且在元素数量较少时,在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载入到缓存中;
  • 随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上面;

使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码:

redis> SET msg "hello world"
OK
redis> OBJECT ENCODING msg
"embstr"

可以看出和上面的type命令得到的结果是不同的。

字符串对象

字符串对象的编码可以是int、raw或者embstr。

如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void* 转换成long),并将字符串对象的编码设置为int。

例如:

redis> SET number 10086
OK
redis> OBJECT ENCODING number
"int"

如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw。例如:

redis> SET story "Long, long ago there lived a king..."
Ok
redis> STRLEN story
(integer) 37
redis> OBJECT ENCODING story
"raw"

如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。

embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间依次包含redisObject和sdshdr两个结构,如图:

举个例子:

redis> SET msg "hello"
OK
redis> OBJECT ENCODING msg
"embstr"

embstr相比于raw的优点:因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw的字符串对象能够更好地利用缓存带来的优势。(数据存储在连续的内存中可以更好地利用缓存带来的优势

因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序(只有int编码的字符串对象和raw编码的字符串对象有这些程序),所以embstr编码的字符串对象实际上只是可读的。当我们对embstr编码的字符串对象执行任何修改命令时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。因为这个原因,embstr编码的字符串对象在执行修改命令(例如APPEND这个追加命令)之后,总会变成一个raw编码的字符串对象

列表对象

列表对象的编码可以是ziplist或者linkedlist。

ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。例如,执行下面的RPUSH命令,服务器将创建一个列表对象作为numbers键的值:

redis> RPUSH numbers 1 "three" 5
(integer) 3

如果numbers键的值对象使用的是ziplist编码,这个值对象将会是下图所示:

另一方面,linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素(即列表中的那些具体数据)。

例如,如果前面所说的numbers键创建的列表对象使用的不是ziplist编码,而是linkedlist编码,那么numbers键的值将是下图所示:

linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种嵌套字符串对象的行为在哈希对象、集合对象和有序集合对象中都会出现,字符串对象是Redis五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象

注意:

图8-6中的字符串对象的完整内容如下:

当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:

  • 列表对象保存的所有字符串元素的长度小于64字节;
  • 列表对象保存的元素数量小于512个;

不能满足上面两个条件(要同时满足)的列表对象需要使用linkedlist编码。

哈希对象

哈希对象的编码可以是ziplist或者hashtable

ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:

  • 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
  • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。

例如,如果我们执行以下HSET命令,那么服务器将会创建一个列表对象作为profile键的值:

redis> HSET profile name "Tom"
(integer) 1
redis> HSET profile age 25
(integer) 1
redis> HSET profile career "Programmer"
(integer) 1

如果profile键的值对象使用的是ziplist编码,那么这个值对象将会是下图所示:

其对象所使用的压缩列表如下图所示:

另一方面,hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:

  • 字典的每个键都是一个字符串对象,对象中保存了键值对的键;
  • 字典的每个值都是一个字符串对象,对象中保存了键值对的值;

举个例子,如果前面profile键创建的不是ziplist编码的哈希对象,而是hashtable编码的哈希对象,那么这个哈希对象应该会是下图所示:

当哈希对象同时满足以下两个条件时,哈希对象使用ziplist编码:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;
  • 哈希对象保存的键值对数量小于512个

整数集合

整数对象的编码可以是inset或者hashtable。

inset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。

举个例子,以下代码将创建一个如图所示的inset编码集合对象:

redis> SADD numbers 1 3 5
(integer) 3

另一方面,hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。

redis> SAD Dfruits "apple" "banana" "cherry"
(integer) 3

当集合对象同时满足以下两个条件时,对象使用intset编码:

  • 集合对象保存的所有元素都是整数值;
  • 集合对象保存的元素数量不超过512个。

有序集合对象

有序集合的编码可以是ziplist或者skiplist

ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。

压缩列表内的集合元素分值按照分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向

例如,如果执行以下ZADD命令,那么服务器将创建一个有序集合对象作为price键的值:

redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3

如果price键的值对象使用的是ziplist编码,那么这个值对象将会是下图所示:

压缩列表中的完整内容如下:

skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表:

typedef struct zset {
    zskiplist *zsl;
    dict *dict;
} zset;

zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的。

除此之外,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度找到给定的分值,ZSCORE命令就是根据这一特性实现的。

虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。

为什么有序集合需要同时使用跳跃表和字典来实现?

如果前面price键创建的不是ziplist编码的有序集合对象,而是skiplist编码的有序集合对象,那么这个有序集合对象将会是下图所示:

zset的完整内容为:

注意:

为了展示方便,图8-17在字典和跳跃表中重复展示了各个元素的成员和分值,但在实际中,字典和跳跃表会共享元素的成员和分值,所以并不会造成任何数据重复,也不会因此而浪费任何内存。

当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:

  • 有序集合保存的元素数量小于128个;
  • 有序集合保存的所有元素成员的长度小于64字节。

内存回收

因为C语言不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数技术实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。

每个对象的引用计数信息由redisObject结构的refcount属性记录:

typedef struct redisObject {
    // ...
    // 引用计数
    int refcount;
    // ...
} robj;

对象的引用计数信息会随着对象的使用状态而不断变化:

  • 在创建一个新对象时,引用计数的值会被初始化为1;
  • 当对象被一个新程序使用时,它的引用计数值会被增加1;
  • 当对象不再被一个程序使用时,它的引用计数值会被减去1;
  • 当对象的引用计数值变为0时,对象所占用的内存会被释放。

对象共享

除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。

假设键A创建了一个包含整数值100的字符串对象作为值对象。如下图:

如果这时键B也要参加一个同样保存了整数值100的字符串对象作为值对象,那么服务器有以下两种做法:

  • 为键B新创建一个包含整数值100的字符串对象;
  • 让键A和键B共享同一个字符串对象。

在Redis中,让多个键共享同一个值对象需要执行以下两个步骤:

  • 将数据库键的值指针指向一个现有的值对象
  • 将被共享的值对象的引用计数增1

如下图所示:

目前来说,Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象

所以,当我们创建一个值为100的键A,并使用OBJECT REFCOUNT命令查看键A的值对象的引用计数,会发现值对象的引用计数为2:

redis> SET A 100
OK
redis> OBJECT REFCOUNT A
(integer) 2

引用这个值对象的两个程序分别是持有这个值对象的服务器程序,以及共享这个值对象的键A,如图:

另外,这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hashtable编码的集合对象、zset编码的有序集合对象)都可以使用这些共享对象。

注意:Redis只对包含整数值的字符串对象进行共享

9、数据库

服务器中的数据库

Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库:

struct redisServer {
    // ...
    // 一个数组,保存着服务器中的所有数据库
    redis *db;
    // ...
}

在初始化服务器时,程序会根据服务器状态的dbnum属性来决定应该创建多少个数据库:

struct redisServer {
    // ...
    // 服务器的数据库数量
    int dbnum;
    // ...
}

dbnum属性的值由服务器配置的database选项决定,默认情况下,该选项的值为16,所以Redis服务器默认会创建16个数据库,如图所示:

切换数据库

每个redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者数据库读命令的时候,目标数据库就会成为这些命令的操作对象。

默认情况下,Redis客户端的目标数据库为0号数据库,但客户端可以通过执行SELECT命令来切换目标数据库

例如:

redis> SET msg "hello world"
OK
redis> GET msg
"hello world"
redis> SELECT 2
OK
redis[2]> GET msg
(nil)
redis[2]> SET msg "another world"
redis[2]> GET msg
"another world"

在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针:

typedef struct redisClient {
    // ...
    // 记录客户端当前正在使用的数据库
    // ...
} redisClient;

redisClient.db指针指向redisServer.db数组的其中一个元素,而被指向的元素就是客户端的目标数据库。

例如:

数据库键空间

Redis是一个键值对(key-value pair)数据库服务器,服务器中的每个数据库都由一个redis.h/redisDb结构表示,其中,redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间(key space):

typedef struct redisDb {
    // ...
    //数据库键空间,保存着数据库中的所有键值对
    dict *dict;
    // ...
} redisDb;

键空间和用户所见的数据库是直接对应的

  • 键空间的键也就是数据库的键,每个键都是一个字符串对象;
  • 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象、有序集合对象中的任意一种Redis对象。

例如:

redis> SET message "hello world"
Ok
redis> RPUSH alphabet "a" "b" "c"
(integer) 3
redis> HSET book name "Redis in Action"
(integer) 1
redis> HSET book author "Josiah L. Carlson"
(integer) 1
redis> HSET book publisher "Manning"
(integer) 1

在执行这些命令之后,数据库的键空间是如图所示:

  • alpahbet 是一个列表键,键的名字是一个包含字符串“alphabet”的字符串对象,键的值则是一个包含三个元素的列表对象。
  • book是一个哈希表键,键的名字是一个包含字符串“book”的字符串对象,键的值则是一个包含三个键值对的哈希表对象。
  • message是一个字符串键,键的名字是一个包含字符串“message”的字符串对象,键的值则是一个包含字符串“hello world”的字符串对象。

因为数据库的键空间是一个字典,所以所以针对数据库的操作,比如添加一个键值对到数据库,或者从数据库中删除一个键值对,又或者在数据库中获取某个键值对等,实际上都是通过对键空间字典进行操作来实现的

设置键的生存时间或过期时间

通过EXPIRE命令或者PEXPIRE命令,客户端可以以秒或者毫秒为数据库中的某个键设置生存时间(Time To Live,TTL),在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键:

redis> SET key value
OK
redis> EXPIRE key 5
(integer) 1
redis> GET key // 5秒之内
“value”
redis> GET key // 5秒过后
(nil)

10、RDB持久化

因为Redis是内存数据库,它将自己的数据库状态存储在内存里面,所以如果不想办法将存储在内存中的数据库状态保存到磁盘里面,那么一旦服务器进程退出,服务器中的数据库状态也会消失不见。

所以,Redis提供了RDB持久化功能,这个功能可以将Redis在内存中的数据库状态保存到磁盘里面,避免数据意外丢失。

RDB持久化既可以手动执行,也可以根据服务器配置选项定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件中。如图:

RDB文件是保存在磁盘上面的

RDB文件的创建与载入

有两个命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE。

SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求

BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令。

RDB文件的载入工作是在服务器启动时自动执行的,所以Redis没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,它就会自动载入RDB文件。

另外,因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:

  • 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态
  • 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。

BGSAVE命令执行时的服务器状态

因为BGSAVE命令的保存工作是由子进程执行的,所以子进程创建RDB文件的过程中,Redis服务器仍然可以继续处理客户端的命令请求,但是在BGSAVE命令执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF三个命令的方式会和平时有所不同。

首先,在BDSAVE命令执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE命令同时执行是为了避免父进程(服务器进程)和子进程同时执行两个rdbSave调用,防止产生竞争条件

其次,在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件

RDB文件载入时的服务器状态

服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止

自动间隔性保存

因为BGSAVE命令可以在不阻塞服务器进程的情况下执行,所以Redis允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次BGSAVE命令。

Redis文件结构

RDB文件的最开头是REDIS部分,这个部分的长度为5字节,保存着“REDIS”五个字符。通过这个五个字符,程序可以在载入文件时,快速检查所载入的文件是否RDB文件。

注意:

因为RDB文件保存的是二进制数据,而不是C字符串,为了简便起见,我们用“REDIS”符号代表‘R’、‘E’、‘D’、‘I’、‘S’五个字符,而不是带‘\0’结尾符号的C字符串‘R’、‘E’、‘D’、‘I’、‘S’、‘\0’。

databases部分包含着0个或任意多个数据库,以及各个数据库中的键值的数据:

  • 如果数据库的数据库状态为空(所有数据库都是空的),那么这个部分也为空,长度为0字节。
  • 如果服务器的数据库状态为非空(有至少一个数据库非空),那么这个部分也为非空。

每个非空数据库在RDB文件中都可以保存为SELECTDB、db_number、key_value_pairs三个部分,如图:

SELECTDB常量的长度为1字节,当读入程序遇到这个值的时候,它知道接下来要读入的将是一个数据库号码。

db_number保存着一个数据库号码。当程序读入db_number部分之后,服务器会调用SELECT命令,根据读入的数据库号码进行数据库切换,使得之后读入的键值对可以载入到正确的数据库中。

key_value_pairs部分保存了数据库中的所有键值对数据

EOF常量的长度为1字节,这个常量标志着RDB文件正文内容的结束,当读入程序遇到这个值的时候,它知道所有数据库的所有键值对已经载入完毕

check_sum保存着一个校验和,检查RDB文件是否出错或者损坏

下图展示了一个完整的RDB文件,文件中包含了0号数据库和3号数据库。

11、AOF持久化

与RDB持久化通过保存数据库中的键值对来记录数据库状态不同,AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态,如图:

例如,我们对空白的数据库执行以下写命令,那么数据库中将包含三个键值对:

redis> SET msg "hello"
OK
redis> SADD fruits "apple" "banana" "cherry"
(integer) 3
redis> RPUSH numbers 128 256 512
(integer) 3

RDB持久化保存数据库状态的方法是将msg、fruits、numbers三个键的键值对保存到RDB文件中,而AOF持久化保存数据库状态的方法则是将服务器执行的SET、SADD、RPUSH三个命令保存到AOF文件中

被写入AOF文件的所有命令都是以Redis的命令请求协议格式保存的,因为Redis的命令请求协议是纯文本格式,所以我们可以直接打开一个AOF文件,观察里面的内容

例如:

AOF持久化的实现

可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。

命令追加

当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾

struct redisServer {
    // ...
    // AOF缓冲区
    sds aof_buf;
    // ...
};

AOF文件的写入与同步

Redis的服务器进程就是一个事件循环,这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复。

文件的写入和同步

AOF文件的载入与数据还原

  • 创建一个不带网络连接的伪客户端:因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令。

AOF重写

因为AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件的体检也会越来越多,如果不加以控制,体积过大的AOF文件很可能会对Redis服务器甚至整个宿主计算机造成影响,并且AOF文件的体积越大,使用AOF文件来进行数据还原所需的时间就越多。

例如:

redis> RPUSH list "A" "B" // ["A", "B"]
(integer) 2
redis> RPUSH list "C" // ["A", "B", "C"]
(integer) 3
redis> RPUSH list "D" "E" // ["A", "B", "C", "D", "E"]
(integer) 5
redis> LPOP list // [B", "C", "D", "E"]
"A"
redis> LPOP list // ["C", "D", "E"]
"B"
redis> RPUSH list "F" "G" // ["C", "D", "E", "F", "G"]
(integer) 5

那么服务器为了保存当前list键的状态,必须在AOF文件中写入6条命令。

如果服务器想用尽可能少的命令来记录list键的状态,那么最简单高效的办法不是去读取和分析现有AOF文件的内容,而是直接从数据库中读取键list的值,然后用一条

RPUSH list "C" "D" "E" "F" "G"

来代替保存在AOF文件中的六条命令,这样,就可以把6条命令缩减为1条。(因为我们不关心数据的产生过程,只关心数据最后是什么样的

AOF后台重写

上面介绍的AOF重写程序aof_rewrite函数可以很好地完成创建一个新AOF文件的任务,但是,因为这个函数会进行大量的写入操作,所以调用这个函数的线程将长时间阻塞,因为Redis服务器使用单个线程(这里并不是说Redis不能有多个线程,而是说处理命令请求的线程只有一个,所以线程之间不会发生竞争)来处理命令请求,所以如果由服务器直接调用aof_rewrite函数的话,那么在重写AOF文件期间,服务器将无法处理客户端发来的命令请求。

很明显,作为一种辅助性的维护手段,Redis不希望AOF重写造成服务器无法处理请求,所以Redis决定将AOF重写程序放到子进程里执行,这样做可以同时达到两个目的:

  • 子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求。
  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性

不过,使用子进程也有一个问题需要解决,因为子进程在进行AOF重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致。

为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区,如图:

这样一来可以保证:

  • AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行。
  • 从创建子进程开始,服务器的所有写命令都会被记录到AOF重写缓冲区里面。

当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:

  • 将AOF重写缓冲区中的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致。
  • 对新的AOF文件进行改名,原子的覆盖现有的AOF文件,完成新旧两个AOF文件的替换。

这个信号处理函数执行完毕之后,父进程就可以继续像往常一样接受命令请求了。

在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞(为什么是让父进程接受信号然后执行信号处理函数并且阻塞起来呢?因为如果让另一个子进程执行,那么此时父进程还会接受新的请求命令,这又会导致数据不一致问题),在其他时候,AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到了最低

12、事件

文件事件

即套接字。

Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(处理器实际上就是一个个函数,它们定义了某个事件发生时,服务器应该执行的动作)。

  • 文件事件处理器使用I/O多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用I/O多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好的与Redis服务器中其他同样以单线程方式运行的模块进行对接,这保持了Redis内部单线程设计的简单性。

尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字。当是一个套接字产生的事件被处理完毕之后,I/O多路复用程序才会继续向文件事件分派器传送下一个套接字。

如果一个套接字既可读也可写,那么服务器将先读套接字,后写套接字。

13、客户端

Redis服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构,对客户端执行批量操作,或者查找某个指定的客户端,都可以通过遍历clients链表来完成:

struct redisServer {
    // ...
    // 一个链表,保存了所有客户端状态
    list *clients;
    // ...
};