Redis设计与实现——Design and implementation

简单动态字符串
Redis自己构建了一种名为简单动态字符串的抽象类型,将SDS用作Redis的默认字符串表示
当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串时,Redis使用SDS来表示字符串值,比如在Redis的数据库中,包含字符串值的键值对在底层都是由SDS实现的
举例:若客户端执行命令
redis > SET msg "hello world"
OK
Redis将在数据库中创建一个新的键值对,其中:
1、键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串的“msg”的SDS
2、键值对的值也是一个字符串对象,对象的底层实现是一个保存着字符串“hello world”的SDS
SDS的定义
每个sds.h/sdshdr结构表示一个SDS值:
示例
free属性的值为0,表示这个SDS没有分配任何未使用空间
buf属性是一个char类型的数据,数组的前五个字节分别保存了'R'、'e'、'd'、'i'、's'五个字符,而最后一个字符则保存了空字符'\0'
SDS与C字符串的区别
C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一次元素总是空字符‘\0’
例如下图展示了一个值为“Redis”的C字符串
C语言使用了这种简单的字符串表示方式,并不能满足对字符串在安全性、效率、功能方面的要求
和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所以获取SDS长度的复杂度仅为o(1),例如下图,程序只要访问SDS的len属性,就可以立即知道SDS的长度为5字节
设置和更新SDS长度都是由SDS API在执行时自动完成的,使用SDS无须进行任何手动修改长度的工作。通过使用SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降低到O(1),确保了获取字符串长度的工作不会成为Redis的性能瓶颈
杜绝缓冲区溢出
SDS的空间分配策略杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API自动将SDS的空间扩展为执行修改所需的大小,然后才执行实际的修改操作。例如,SDS的API有一个用于执行拼接操作的sdsacat函数,可以将一个C字符串拼接到给定SDS所保存的字符串的后面,但在执行拼接操作之前,sdscat会先检查给定SDS的空间是否足够,如果不够的话,sdscat会先扩展SDS的空间,然后执行操作
例如:执行
sdscat(s,"Cluster" );
SDS值s如图,sdscat将在执行拼接操作之前检查S长度是否足够,在发现S目前的空间不足以拼接“Cluster”之后,sdscat会先扩展S的空间,再执行拼接“Cluster”操作,拼接完成之后的SDS如图所示
修少修改字符串时带来的内存重分配次数
SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定是字符加一,可以包含未使用的字节,字节由SDS的free属性记录
通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展时,程序不仅会SDS分配修改所必须要的空间,还会为SDS分配13字节的未使用空间,SDS的buf数组的实际长度将变成13+13+1=27字节(额外一个字节用于保存空字符)
惰性空间释放用于优化SDS的字符串缩短操作的:当SDS的API需要缩短SDS保存的字符串时,程序不立即使用内存分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用
例如:sdstrim函数接受一个SDS和一个C字符串作为参数,移除SDS中所有在C字符串中出现过的字符,如图
二进制安全
为确保Redis可以适用于不同的使用场景,SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不仅对其中的数据做任何限制、过滤、假设,数据在写入时是什么样的,读取时就是什么样的
例如:使用SDS来保存之前提到的特殊函数格式,因为SDS使用len属性的值而不是空字符来判断字符串是否结束。如图
兼容部分C字符串函数
SDS的API都是二进制安全的,但一样遵循C字符串以空字符结尾的惯例:API会将SDS保存的数据末尾设置为空字符,并且总会为buf数组分配空间时多分配一个字节来容纳这个空字符,为了让保存文本数据SDS可以重用一部分<String.h>库定义的函数
链表
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度
链表在Redis中的应用非常广泛,比如列表键的底层实现之一就是链表
链表的链表节点的实现
每个链表节点使用一个adlist.h/listNode结构表示
多个listNode通过prev和next指针组成双端链表,如图
使用adlist.h/list来持有链表的话,操作起来方便
list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free、match成员则用于实现多态链表所需类型特定函数
dup:复制链表节点保存的值    free:释放链表节点保存的值
match函数则用于对比链表节点所保存的值和另一个输入值是否相等
如图是一个由list结构和3个listNode结构组成的链表
双端:链表节点带有prev和next指针, 获取某个节点的前直节点和后置节点的复杂度都是O(1)
无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和末尾节点的复杂度为O(1)
带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中数量的复杂度为O(1)
多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值
字典
字典又称符号表、关联数组或映射,是一种用于保存键值对的抽象数据结构。字典在Redis中的应用相当广泛,比如Redis的数据库是使用字典作为底层实现的,对数据库的增删改查操作也是构建在字典的操作之上的
Hash表
Redis字典所使用的hash表由dict.h/ditht结构定义
table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。size属性记录了Hash表的大小,use属性记录了Hash表目前已有节点的数量,sizemask属性的值总是等于size-1
下图展示了一个大小为4的空Hash表(没有包含任何键值对)
Hash表节点
使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对
key属性保存着键值对中的键,v属性则保存键值对中的值,键值对的值可以是一个指针,或是一个uint64_t整数或int64_t整数;next属性是指向另一个Hash表节点的指针,这个指针可以将多个Hash值相同的键值对连接一次,以此来解决键冲突问题
例如,下图就展示了如何通过next指针,将两个索引值相同的键k1和k0连接在一起
redis字典由dict.h/dict结构表示
type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同类型特定函数
privdata保存了需要传给那些类型特定函数的可选参数
ht属性是一个包含两个锁的数组,数组中的每个项都是一个dictht Hash索,一般情况下,字典只适用ht[0]Hash表,ht[1]Hash表只会对ht[0] Hash表进行rehash时使用 如图
Hash算法
要将一个新的键值对添加到字典里面时,程序先根据键值对的键计算出Hash值和索引值,再根据索引值,将包含新键值对的Hash表节点放到Hash表数组的特定索引上
Redis计算Hash值和索引值的方法如下
例如:图所示字段,将一个键值对k0和v0添加到字典里面,先使用语句
hash = dict -> type ->hashFunction(k0);
计算k0的Hash值,假设计算出的Hash值为8,程序会继续使用语句
index = hash &&dict ->ht[0].sizemask = 8&3 = 0
计算出k0的索引值0,表示包含键值对k0和v0的节点应该被放置到Hash表数组的索引0位置上,如图
解决键冲突
当有两个或以上数量的键被分配到了Hash表数组的同一索引上时,称这些键发生了冲突
Redis 的Hash表使用链地址法来解决键冲突,每个Hash表节点都有一个next指针,多个Hash表节点可以用next指针构成一个单向链表,被分配到同一索引上的多个节点可以用这个单向链表连接起来,解决了键冲突
例如:假设程序要将键值对k2和v2添加到如图所示的Hash表里,并计算得出k2的索引值为2,那么键k1和k2将产生冲突,办法是使用next指针将键k2和k1所在的节点连接起来
rehash(重新散列
扩散和收缩工作可以通过指向rehash操作来完成,Redis对字典的Hash表执行rehash的步骤如下
1、为字典的ht[1]Hash表分配空间,大小取决于要执行的操作,以及ht[0]当前包含的键值对数量,如果是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n;如果是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2^n
2、重新计算键的Hash值和索引值,然后将键值对放置到ht[1]Hash表的指定位置上
3、当ht[0]包含的所有键值对都迁移到了ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空包Hash表,为下一次rehash做准备
例如:假设要对下图字段的ht[0]进行扩展操作,程序将执行以下步骤
1、ht[0].used当前的值为4,4*2=8,而8恰好是第一个大于等于4的2的n次方,程序会将ht[1]Hash表的大小设置为8,
2、将ht[0]包含的四个键值对都rehash到ht[1]
3、释放ht[0],并将ht[1]设置为ht[1]分配一个空包Hash表,如图
Hash表的扩展和收缩
当以下条件的任何一个被满足时,程序会自动开始对Hash表执行扩展操作
1、服务器目前没有在执行BGSAVE命令或BGREWRITEAOF命令,并且Hash表的负载因子大于等于1
2、服务器目前正在执行BGSAVE命令或 BGREWRITEAOF命令,并且Hash表的负载因子大于等于5
Hash表的负载因子通过公式
例:对于一个大小为4.包含4个键值对的Hash表,负载因为为:load_factor = 4/4 = 1
当Hash表的负载因子小于0.1时,程序自动开始对Hash表执行收缩操作
渐进式rehash
为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里的所有键值对全部rehash到ht[1],而是分多次、渐近式地将ht[0]里的键值对慢慢rehash到ht[1]
详细步骤
1、为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个Hash表
2、在字典中维持一个索引计数器变量rehashidx,并将值设置为0,表示rehash工作正式开始
3、在rehash期间,每次对字典执行添加、删除、查找或更新操作时,程序除了执行指定的操作以外,还会将ht[0]Hash表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一
4、随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash到ht[1],将rehashidx属性的值设为-1,表示rehash操作已完成
渐近式rehash的好处是采取分开而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,避免了集中式rehash而带来的庞大计算量
跳跃表
一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的,支持平均O(logN)、最坏O(N)复杂度的查找,还可以通过顺序性操作来批量处理节点
跳跃表的实现
Redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构组成,其中z skiplistNode结构用于表示跳跃节点,而zskiplist用于保存跳跃节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等
下图是一个跳跃表示例,位于图片最左边的是zskiplist结构,包含以下属性
header:指向跳跃表的表头节点   tail:指向跳跃表的表尾节点
level:记录目前跳跃表内层数最大的那个节点的层数   length:记录跳跃表的长度,即跳跃表目前包含节点的数量
位于zskiplist结构右方是四个zskiplistNode结构,包含以下属性
层:节点中用L1、L2、L3等字样标记节点的各个层,每个层带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,跨度则记录了前进指针锁指向节点和当前节点的距离
后退指针:节点中用BW字样标记节点的后退指针,指向位于当前节点的前一个节点,后退指针在程序从表尾向表头遍历时使用
分值节点中的1.0~2.0和3.0是节点所保存的分值,在跳跃表中,节点按各自所保存的分值从小到大排列
成员对象:各个节点中的01、02、03是节点所保存的成员对象
跳跃表的节点
跳跃表节点实现由redis.h/zskiplistNode结构定义
跳跃表节点的 level数组可以包含多个元素,每个元素都包含了一个指向其他节点的指针,程序可以通过层加快访问其他节点的速度,一般层数越多 ,访问其他节点的速度就越快
前进指针
每个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点,下图用虚线表示出程序从表头向表尾方向遍历跳跃表中所有节点的路径
1、迭代程序首先访问第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点
2、在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点
3、在第三个节点时, 程序沿着第三层的前进指针移动到表中的第三四节点
4、当程序再次沿着第四个节点的前进指针移动时,碰到一个NULL,程序已知这时到达了跳跃表的表尾,于是结束这次遍历
跨度
层的跨度(level[i].span属性)用于记录两个节点之间的距离
1、两个节点之间的跨度越大,相距得越远
2、指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点
例:用虚线标记了在跳跃表中查询分值为3.0、成员对象为03的节点时,沿途经历的层:查找过程只经过一个层,并且层的跨度为3,所以目标节点在跳跃表中的排位为3
后退指针
用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点
分值和成员
节点的分值是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序
节点的成员对象是一个指针,指向一个字符串对象,保存着一个SDS值
在同一个跳跃表中,各个节点保存的成员对象是唯一的,多个节点保存的分值可以是相同的:分值相同的节点将按成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面,成员对象较大的节点会排在后面
跳跃表
通过使用一个zskiplist结构表持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或快速地获取跳跃表节点的数量等
header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个机制,程序定位表头节点和表尾节点的复杂度为O(1)
通过使用length属性来记录节点的数量,程序可以在O(1)复杂度返回跳跃表长度
level属性则用于在O(1)复杂度内获取跳跃表中层最大的节点的层数量
整数集合
整数集合的实现
Redis用于保存整数值的集合抽象数据结合,可以保存类型为int16_t、int32_t、int64_t的整数值,并且保证集合中不会出现重复元素
每个intset.h/intset结构表示一个整数集合
contest数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项,各个项在数组中按值大小从小到大有序排列,并且数组中不包含任何重复项
length属性记录了整数集合包含的元素数量,即contents数组的长度
升级
要将一个新元素添加到整数集合中,并且新元素的类型比整数集合现有元素的类型都长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合中。升级整数集合并添加到新元素共总分为3步进行
1、根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
2、将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变
3、将新元素添加到底层数组里面
升级的好处:提升灵活性、节约内存
降低
例如:如图所示的整数集合,即使将唯一一个需要使用int64_t类型来保存的元素4294967295删除了,整数集合的编码仍会维持INTSET_ENC_INT64,底层数组仍会是int64_t类型,如图所示
压缩列表
列表键和Hash键的底层实现之一。当一个列表键只包含少量的列表项,并且每个列表项是小数或比较短的字符串,那Redis会使用压缩列表来做列表键的底层实现
例:执行以下命令将创建一个压缩列表实现的列表键
包含1、3、5、10086小整数值,以及“hello world”这种短字符串
当Hash表包含少量键值对,并且每个键值对的键和值是小整数值或短的字符串,Redis使用压缩列表来做Hash键的底层实现
例如:执行以下命令将创建一个压缩列表实现的Hash键
压缩列表的构成
Reids为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值
如图展示了压缩列表的各个组成成分,表记录了各个组成部分的类型、长度以及用途
图展示了一个压缩列表示例
列表zlbytes属性的值为0X50(十进制80),表示压缩列表总长度为80字节
列表zltail属性的值为0X3c(十进制60),表示如果有一个指向压缩列表起始地址的指针P,那么只要用指针P加上偏移量60,可以计算出表尾节点entry3的地址
列表zllen属性的值为0X3(十进制3),表示压缩列表包含了3个节点
压缩列表节点的构成
每个压缩列表节点可以保存一个字节数组成一个整数值,字节数组可以是以下三种的其中一种:
1、长度小于等于63(2^6-1)、16383(2^14-1)、4294967295(2^32-1)字节的字节数组整数值可以是以下文种的其中一种:4位长,介于0至12之前的无符号整数,1、3字节长的有符号整数
int16_t、int32_t、int64_t类型整数
每个压缩列表节点都由previous_entry_length、encoding、content组成
previous_entry_length
节点的 previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度,长度可以是1字节或5字节,若前一节点的长度小于254字节, previous_entry_length属性长度为1字节,前一节点的长度保存在这一字节里面;若前一节点的长度大于等于254字节, previous_entry_length长度为5字节,其中属性的第一字节会被设置为0xFE,之后的四个字节则用于保存前一节点的长度
例:如果有一个指向当前节点起始地址的指针c,那么只要用指针c减去当前节点 previous_entry_length属性的值,可以得出一个指向前一个节点起始地址的指针p
如下图展示了一个从表尾节点项表头节点进行遍历的完整过程
1、拥有指向压缩列表表尾节点entry4起始地址的指针P1
2、通过用P1减去entry4节点 previous_entry_length属性的值,得到一个指向entry4前一节点entry3起始地址的指针P2
3、 通过用P2减去entry3节点 previous_entry_length属性的值,得到一个指向entry3前一节点entry2起始地址的指针P3
4、 通过用P3减去entry2节点 previous_entry_length属性的值,得到一个指向entry2前一节点entry1起始地址的指针P4,entry1为压缩列表的表头节点
encoding
记录了节点的content属性所保存数据类型以及长度
1、一字节、两字节或五字节长,值的最高位为00、01、10的是字节数组编码:表示字节的content属性保存着字节数组,长度由编码出去最高两位之后的其他位记录
2、一字节长,值的最高位以11开头的是整数编码:表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录
content
节点的content属性负责保存节点的值,节点值可以是一个字节数组或整数,值的类型和长度由节点的encoding属性决定
如图展示了一个保存字节数组的节点示例:
1、编码的最高位00表示节点保存的是一个字节数组
2、编码的后六位001011记录了字节数组的长度11
3、content属性保存着节点的值"hello world"
连锁更新
Redis将在特殊情况下产生的连续多次空间扩展操作称之为连锁更新
如图展示了这一过程
注:因为连锁更新在最坏情况下需要对压缩列表执行几次空间重分配操作,每次空间重分配的最坏复杂度为O(N),所以连续更新的最坏复杂度为O(N^2)
对象
Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令,也可以针对不同的场景为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率
对象的类型与编码
Redis使用对象来表示数据库中的键和值,每次Redis的数据库中新创建一个键值对时,至少会创建两个对象,一个对象用作键值对的键,另一个对象用作键值对的值
例:SET命令在数据库中创建了一个新的键值对。键是一个包含了字节串值"msg"的对象,值是一个包含字符串值"hello world"的对象
Reids中的每个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type属性、encoding属性和ptr属性
类型
对于Redis数据库,键总是一个字符串对象,值可以是字符串对象、列表对象、Hash对象、集合对象或有序集合对象的其中一个
执行TYPE命令时。希望返回的结果为数据库键对应的值对象的类型,而不是键对象的类型
 
编码的底层实现
对象的ptr指针指向对象的底层实现数据结构,数据结构由对象的encoding属性决定
每个类型的对象都至少使用了两种不同的编码,下表列出了每种类型的对象可以使用的编码
例如:在列表对象包含的元素较少时,Redis使用压缩列表作为列表对象的底层实现:
1、因为压缩列表比双端链表更节约内存,并且在元素数量较少时,在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载入到缓存中
2、随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表转向功能更强、更适合大量元素的双端链表上面
字符串对象
字符串对象的编码可以是int 、raw、embstr
如果一个字符串对象保存的是整数值,可以使用long表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面,并将字符串对象的编码设置为int
例:执行以下SET命令,服务器将创建一个下图所示int编码的字符串对象作为number键的值
embstr编码是专门用于保存短字符串的一种优化编码方式,和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,raw编码调用两次内存分配,embstr调用一次依次分配
embstr编码的字符串对象来保存短字符串值有以下好处:
1、embstr编码将创建字符串对象所需的内存分配次数从raw编码的两个降为一次
2、释放embstr编码的字符串对象只调用一次内存释放函数
3、比raw编码的字符串对象能够更好利用缓存带来的优势
例:执行以下代码将创建一个包含3.14的字符串表示"3.14"的字符串对象
编码的转换
embstr_int编码的字符串对象在条件满足的情况下,会被转换为raw编码的字符串对象
例:通过APPEND命令,向一个保存整数值的字符串对象追加了一个字符串值,因为追加操作只能对字符串值执行,所以程序会先将之前保存的整数值10086转换为字符串值"10086',然后再执行追加操作,结果是一个raw编码的、保存了字符串值的字符串对象
字符串命令的实现
 
列表对象
列表对象的编码可以是ziplist或linkedlist
ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点保存了一个列表元素,例如:执行以下RPUSH命令,那么服务器将创建一个列表对象作为numbers键的值
如果number键的值对象使用ziplist编码,这个值对象将会如图
linkedlist编码的列表对象使用双端列表作为底层实现,每个双端链表节点都保存了一个字符串对象,字符串对象都保存了一个列表元素
例:number键创建的列表对象使用的是linkedlist编码
编码转换
当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码
1、列表对象保存的所有字符串元素的长度都小于64字节
2、列表对象保存的元素数量小于512个
不能满足这两个条件的列表对象需要使用linkedlist编码
以下代码展示了列表对象因保存了长度太大的元素而进行编码转换的情况
列表命令的实现
Hash对象
Hash对象的编码可以是ziplist或hashtable
ziplist编码的Hash对象使用压缩列表作为底层实现,有新的键值对要加入到Hash对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此
1、保存了同一键值对的两个节点总是挨在一起
2、先添加到Hash对象中的键值对会被放在压缩列表的表头方向,后添加到Hash对象中的键值对会被放在压缩列表的表尾方向
例:执行以下HSHE命令,服务器将会创建一个列表对象作为profile键的值
如果profile键的值对象使用的是ziplist编码,值对象将会是如图所示,对象使用的压缩如图
hashtable编码的Hash对象使用字典作为底层实现,Hash对象的每个键值对都使用一个字典键值对来保存
1、字典的每个键都是一个字符串对象,对象中保存了键值对的键
2、字 典的每个值都是一个字符串对象,对象中保存了键值对的值
例如:profile创建的是hashtable编码的Hash对象,如图
编码转换
如果Hash对象可以同时满足以下两个条件时,Hash对象使用ziplist编码
1、Hash对象保存的所有键值对的键和值的字符串长度都小于64字节
2、Hash对象保存的键值对数量小于512个
不能满足以上两个条件的Hash对象需要使用hashtable编码
以下代码展示了Hash对象因为键值对的键长度太大而引起编码转换情况
Hash命名的实现
集合对象
集合对象的编码可以是intset或hashtable
intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合
例如:如图所示的intset编码集合对象
redis> SADD numbers 135
(integer)3
hashtable编码的集合对象使用字典作为底层实现,每个键都是一个字符串对象,字符串对象包含了一个集合元素,字典的值则全部被设置为NULL
例:以下代码创建如图所示的hashtable编码集合对象
redis> SAD Dfruits "apple" "banana" "cherry"
编码的转换
当集合对象可以同时满足以下两个条件时,对象使用intset编码:
1、集合对象保存的所有元素都是整数值
2、集合对象保存的元素数量不超过512个
不能满足这两个条件的集合对象需要使用hashtable编码
例:以下代码创建了一个只包含整数元素的集合对象,该对象的编码为intset
只要向这个只包含数集元素的集合对象添加一个字符串元素,集合对象的编码转移操作就会被执行
集合命令的实现
有序集合对象
有序集合的编码可以是ziplist或skiplist
ziplist编码的压缩对象使用压缩列表作为底层实现,使用两个挨在一起的压缩列表节点来保存集合元素,第一个节点保存元素的成员,第二个节点保存元素的分值
分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向
例:执行以下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
编码的转换
当有序集合对象可以同时满足以上两个条件时,对象使用ziplist编码
1、有序集合保存的元素数量小于128个   2、有序集合保存的所有元素成员的长度都小于64字节
不能满足以上两种条件的有序集合对象将使用skiplist编码
以下代码展示了有序集合对象因为包含了过多元素而引发编码转换的情况
有序集合命令的实现
类型检查和命令多态
Redis中用于操作键的命令基本上可以分为两种:
一种可以对任何类型的键执行,比如说DEL命令、EXPIRE命令、RENAME、TYPE、OBJECT命令
例:以下代码展示了使用DEL命令来删除3中不同类型的键
另一种命令只能对特定类型的键执行,比如
例:可以用SET命令创建一个字符串键,然后用GET和APPEND命令操作这个键,如果试图对这个字符串键执行只有列表才能执行LLEN命令,Redis将返回一个类型错误
类型检查的实现
类型特定命令所进行的类型检查是通过redisObject结构的type属性实现的
1、在执行一个类型特定命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需类型,是的话,服务器对键执行指定的指令
2、否则,服务器将拒绝执行命令,并向客户端返回一个类型错误
多态命令的实现
对一个键执行LLEN命令,那么服务器除了要确保执行命令的是列表键之外,还需要根据键的值对象所使用的编码来选择正确的LLE你命令
1、如果列表对象的编码为ziplist,说明列表对象的实现为压缩列表,程序将使用ziplisten函数返回列表的长度
2、如果列表对象的编码为linkedlist,说明列表对象的实现为双端链表,程序将使用listLength函数来返回双端链表的长度
内存回收
Redis在对象系统中构建了一个引用计数技术实现的内存回收机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收
每个对象的引用计数信息由redisObject结构的refcount属性记录
对象的引用计数信息会随着对象的使用状态而不断变化:
1、在创建一个新对象时,引用计数的值会被初始化1
2、当对象被一个新程序使用时,它的引用计数值会被增一
3、 当对象不一个新程序使用时,它的引用计数值会被减一
4、当对象的引用计数值变为0时,对象所占用的内存会被释放
对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段,以下代码展示了一个字符串对象从创建到释放的整个过程
对象共享
在Redis中,让多个键共享同一个值对象需要执行以下两个步骤:
1、将数据库键的值指针指向一个现有的值对象
2、将被共享的值对象的引用计数增一
例:下图展示了包含整数值100的字符串对象同时被键A和键B共享之后得知,除了对象的引用计数从之前1变成了2之外,其他属性没有变化
共享对象不仅只有字符串键可以使用,在数据结构中嵌套了字符串对象的对象
对象的空转时长
redisObject结构包含的最后一个属性为null属性,该属性记录了对象最后一次被命名访问的时间
OBJECT IDLETIME命名可以打印出给定键的空转时长,通过将当前时间减去键的值对象的lru时间计算得出
数据库
服务器中的数据库
Redis服务器将所有数据都保存在服务器状态redis.h/redisSever结构的db数组中,db数组的每个项都是一个redis.h/redisDB结构,每个redisDB结构代表一个数据库
在初始化服务器时,程序会根据服务器状态的dbnum属性来决定应该创建多少个数据库
dbnum属性的值由服务器配置的database选项决定,默认情况下,该选项的值为16,所以Redis服务器默认会创建16个数据库
切换数据库
默认情况下,Redis客户端的目标数据库为0号数据库,但客户端可以通过执行SELECT命名来切换目标数据库
以下代码演示了客户端在0号数据库设置并读取键msg,之后切换到2号数据库并执行类似操作的过程
如果某个客户端的目标数据库为1号数据库,那么客户端所对应的客户端状态和服务器之间的关系如图
如果这时客户端执行SELECT,将目标数据库改为2号数据库,那么客户端状态与服务器状态之间的关系将更新成下图所示
通过修改redisClient.db指针,让它指向服务器中的不同数据库,从而实现切换目标数据库的功能
数据库键空间
Redis是一个键值对数据服务器,每个数据库都由一个redis.h/redisDb结构表示。其中,redisDb结构的dict字典保存了数据库中的所有键值对,将这个字典成为键空间
键空间和用户所见的数据库都是直接对应的
1、键空间的键也是数据库的值,每个键都是一个字符串对象
2、键空间的值也是数据库的值,每个值可以是字符串对象、列表对象、Hash表对象、 集合对象和有序集合对象中的任意一种Redis对象
例:在空白的数据库中执行以下命令
键空间如图
1、alphabet是一个列表键,键的名字是一个包含字符串"alphabet"的字符串对象,键的值则是一个包含三个元素的列表对象
2、book是一个Hash表键, 键的名字是一个包含字符串"book"的字符串对象,键的值则是一个包含三个键值对的Hash表对象
3、message是一个字符串键,键 的名字是一个包含字符串"message"的字符串对象,键的值则是一字符串"hello world"的字符串对象
添加新键
添加一个新键值对到数据库,实际上就是将一个新键值对添加到键空间字典里面,其中键为字符串对象,值则为任意一种类型的Redis对象
redis> SET date"2013.12.1"
OK
键空间将添加一个新的键值对,键是包含“date”的字符串对象,值是包含"2013.12.1"的字符串对象,如图
删除键
删除数据库中的一个键,实际上是在键空间删除键所对应的键值对对象
例:执行以下命令
redis> DEL book
(integer) 1
键book以及它的值将从键空间中被删除
更新键
对一个数据库进行更新,实际上就是对键空间例键所对应的值对象进行更新,根据值对象的类型不同,更新的具体方法有所不同
例如:如果键空间当前的状态如上图所示,执行之后
redis> SET message "blah blah"
OK
键message的值对象将从之前包含"hello world"字符串更新为"blah blah"字符串
对键取值
对一个数据库键进行取值,实际上就是在键空间中取出键所对应的值对象,根据值对象的类型不同,具体取值方法也有所不同
例:如果键空间当前如图,当执行以下命令时
redis> GET message
"hello world"
GET命令将首先在键空间中查找键message,找到键之后取得改键所对应的字符串对象值,之后再返回值对象所包含的字符串
"hello world",取值过程如图所示
读写键空间时的维护操作
1、在读取一个键之后(读操作和写操作都要对键进行读取),服务器会根据键是否存在来更新服务器的键空间命中次数或键空间不命中次数,这两个值可以在INFO stats 命名的keyspace_hits属性和keyspace_misses属性中查看
2、在读取一个键之后,服务器会更新键的LRU(最后一次使用)时间,这个值可以用于计算键的闲置时间,使用OBJECT idletime<key>命令可以查看键key的闲置时间
3、如果服务器在读取一个键时发现该键已经过期,服务器会先删除这个过期键,然后执行余下操作
4、如果有客户端使用WATCH命令监视这个键,那么服务器在被监视的键进行修改之后,会将这个键标记为脏,从而让事务程序注意到这个键已经被修改过
5、如果服务器开启了数据库通知功能,在对键进行修改之后,服务器将按配置发送相应的数据库通知
设置键的生存时间或过期时间
通过EXPIRE或PEXPIRE命令,客户端可以以秒或毫秒精度为数据库中的某个键设置生存时间,在经过指定的秒数或毫秒数之后,服务器会自动删除生存时间为0的键
客户端通过EXPIREAT或PEXPIREAT命令,以秒或毫秒精度经数据库中某个键设置过期时间
过期时间是一个UNIX时间戳,当键的过期时间来临时,服务器会自动从数据库中删除这个键
TTL和PTTL命令接受一个带有生存时间或过期时间的键,返回这个键的剩余生存时间
设置过期时间
EXPIRE<key><ttl>命令用于将键key的生存时间设置为ttl秒;P EXPIRE<key><ttl>命令用于将键key的生存时间设置为ttl毫秒
EXPIREAT<key><timestamp>命令用于将键key的过期时间设置为timestamp所指定的秒数时间戳
首先EXPIRE命令可以转换成PEXPIRE命令
接着,PEXPIRE命令可以转换成PEXPIREAT命令
并且,EXPIREAT命令也可以转换成PEXPIREAT命令
最终,三个转换成PEXPIREAT命令来执行
保存过期时间
1、过期字典的键是一个指针,指向键空间中的某个键对象
2、过期字典的值是一个long long类型的整数,保存了键所指向的数据库的过期时间,如图,展示了一个带有过期字典的数据库
图中的过期字典保存了两个键值对
第一个键值对为alphabet键对象,值为1385877600000,这表示数据库键alphabet的过期时间为 1385877600000(2013年12月1日零时)
第二个的键为book键对象,值为1388556000000,表示数据库键book的过期时间为 1388556000000(2014年1月1日零时
当客户端指行PEXPIREAT命令为一个数据库键设置过期时间时,服务器会在数据库的过期字典中关联给定的数据库键和过期时间
移除过期时间
PERSIST命令可移除一个键的过期时间
PERSIST命令是PEXPIREAT命令的反操作:PERSIST命令在过期字典中查找给定的键,并解除键和值在过期字典中的关联
例:如果数据库当前图上图所示,当服务器执行以下命令之后:
redis> PERSIST book
(integer) 1
数据库将更新成下图所示的状态
计算并返回剩余生存空间
TTL命令以秒为单位返回键的剩余生存时间,而PTTL命令以毫秒为单位返回键的剩余生存时间
TTL和PTTL两个命令都是通过计算键的过期时间和当前时间之差来实现,以下是这两个命令的伪代码实现
过期键的判定
通过过期字典,程序可以用以下步骤检查一个给定键是否过期
1、检查给定键是否存在于过期字典,如果存在,则取得键的过期时间
2、检查当前UNIX时间戳是否大于键的过期时间:若是,则键已经过期,若不是,则键未过期
可以用伪代码来描述这一过程
过期键删除策略
定时删除:在设置键的过期时间的同时,创建一个定时器,让定时器在键的过程时间来临时,立即执行对键的删除操作
优点:可以保证过期键会尽可能快地被删除,并释放过期键所占用的内存
缺点:在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU时间,在内存不紧张但CPU紧张的情况下,将CPU时间用在删除和当前任务无关的过期键上,对服务器的响应时间和吞吐量造成影响
惰性删除:放任键过期不管,但每次从键空间获取键时,都检查取得的键是否过期,如果过期,删除该键;如果没有过期,返回该键
好处:可以保证删除过期键的操作只会在非做不可的情况下进行,并且删除的目标仅限于当前处理的键,不会在删除其他无关的过期键上花费任何CPU时间
缺点:对内存不友好,如果一个键已过期,而这个键仍保留在数据库中,只要这个过期键不被删除,占用的内存不会释放
定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。删除多少过期键,检查多少数据库,由算法决定
好处:通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响,也有效的减少了因过期键带来的内存浪费
缺点:如果删除操作执行得太频繁,或执行的时间太长,定期删除策列会退化成定时删除策略,如果删除操作执行得太少,或执行时间太短,定期删除退化成惰性删除
Redis的过期键删除策略
实现使用惰性删除和定期删除
惰性删除策略的实现
由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用 expireIfNeeded函数对输入键进行检查
如果输入键已经过期,那 expireIfNeeded函数对输入键进行检查
如果输入键未过期,那 expireIfNeeded函数不做动作
命令调用 expireIfNeeded函数过程如图
因为每个被访问的键都可能因为过期而被 expireIfNeeded函数删除,所以每个命令的实现函数都必须能同时处理键存在以及不存在两种中期
下图展示了GET命令的执行过程
定期删除策略的实现
由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/severCron函数执行时,activeExpireCycle函数会被调用,在规定时间内。分多次遍历服务器的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键
AOF、RDB和复制功能对过期键的处理
生成RDB文件
在执行SAVE命令或BGSAVE命令创建一个新的RDB文件时,程序对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件
例:如果数据库中包含三个信息k1、k2、k3,且k2已过期,当执行SAVE命令或BGSAVE命令时,程序会将k1和k3的数据保存到RDB,k2被忽略,因此,数据库中包含过期键不会对生成新的RDB文件造成影响
载入RDB文件
在启动Redis服务器时,如果i服务器开启了RDB功能,那么服务器就对RDB文件进行载入
1、如果服务器以主服务器模式运行,在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键会被删除,所以过期键对载入RDB文件的主服务器不会造成影响
2、如果服务器以服务器模式运行,那么在载入RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。因为主从服务器在进行数据同步时,从服务器的数据库就会被清空,一般来讲,过期键对载入RDB文件的从服务器也不会造成影响
AOF文件写入
当过期键被惰性删除或定期删除之后,程序会向AOF增加一条DEL命令,显式记录该键已被删除
例:如果客户端使用GET nessage命令,试图访问过期的message键,那么服务器将执行以下三个动作
1、从数据库中删除message键   2、追加一条DEL message命令到AOF文件   3、向执行GET命令的客户端返回空回复
AOF重写
程序对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中
例:如果数据库中包含三个键k1、k2、k3,并且k2已过期,在进行重写工作时,程序只会对k1、k3重写,k2被忽略
复制
通过由主服务器来控制从服务器统一删除过期键,可以保证主从服务器数据的一致性,当一个过期键仍存在于主服务器数据库时,过期键在从服务器里的复制品也会继续存在
例:有一对主从服务器,数据库中保存着同样的三个键message、XXX、YYY,其中message为过期键
如果此时有客户端向从服务器发送命令GET message,从服务器将发现GER message键已经过期,但服务器不会删除message键,而是继续将message键的值返回给客户端
假设在此之后,有客户端向主服务器发送命令GET message,那么主服务器将发现键message已过期,主服务器会删除message键,将客户端返回空回复,并向从服务器发送DEL message命令
从服务器在接收到主服务器发来的DEL message命令之后,也会从数据库中删除message键,之后主从服务器都不再保存过期键message
数据库通知
可以让客户端通过订阅给定的频道或模式,来获知数据库中键的变化,以及数据库中命令的执行情况
例:以下代码展示了客户端如何获取0号数据库针对message键执行的所有命令
根据发送的通知显示,共有SET、EXPIRE、DEL对键message进行了操作,以下代码展示客户端如何获取0号数据库中所有执行DEL命令的键
根据发回的通知显示,key、number、message三个键先后执行了DEL命令
发送通知
发送数据库通知的功能由notify.c/notifyKeyspaceEvent函数实现
函数type参数是当前想要发送的通知类型,程序会根据这个值来判断是否是服务器配置notify_keyspace_events选项所选定的通知类型,从而决定是否发送通知
events、keys、dbid分别是事件的名称,产生事件的键,以及产生事件的数据库号码,函数会根据type参数以及这个三个参数构建事件通知的内容,以及接受通知的频道名
例:以下是SADD命令实现函数saddCommand的其中一部分代码
当SADD命令至少成功地向集合添加了一个集合元素之后,命令就会发送通知,该通知的类型为REDIS_NOTIFY_SET,名称为sadd
发送通知的实现
以下是notifyKeyspaceEvent函数的伪代码实现
notifyKeyspaceEvent执行以下操作:
1、server.notify_keyspace_events属性是服务器配置 notify_keyspace_events选项所设置的值,如果给定的通知类型type不是服务器允许发送的通知类型,函数直接返回,不做任何操作
2、如果给定的通知是服务器允许发送的通知,下一步函数会检测服务器是否允许发送键空间通知,若允许,程序会构建并发送事件通知
3、最后,函数检测服务器是否允许发送键事件通知,若允许,程序就会构建并发送事件通知
RDB持久化
Redis提供的是RDB持久化可以将Redis在内存中的数据库状态保存到磁盘里,避免数据意外丢失。RDB持久化既可以手动执行,也可以根据服务器配置选项定期执行,可以将某个时间点上的数据库状态保存到一个RDB文件中,如图
RDB持久化功能所生成的RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时数据库状态如图
SAVE命令执行时的服务器状态
只有在服务器执行完SAVE命令、重新开始接受命令请求之后,客户端发送的命令才会被处理
BGSAVE命令执行时服务器状态
在BGSAVE命令执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF三个命令的方式和平时不同
首先,在BGSAVE命令执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE和BGSAVE同时执行是为了避免父进程和子进程同时执行两个rdbSAVE调用,防止竞争条件
其次,在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件
最后,BGREWRITEAOF和BGSAVE两个命令不能同时执行:
1、如果BGSAVE命令正在执行,那客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行
2、如果BGREWRITEAOG命令正在执行,那客户端发送的BGSAVE命令会被服务器拒绝
RDB文件载入时的服务器状态
服务器在载入RDB期间,会一直处于阻塞状态,直到载入工作完成为止
自动间隔性保存
用户可以通过save选项设置多个保存条件,但只要其中任意一个条件被满足,服务器就会执行BGSAVE命令
例:以下是Redis服务器在60秒之内,对数据库进行至少10000次修改之后,服务器执行BGSAVE命令打印出的日志
设置保存条件
当Redis服务器启动时,用户可以通过指定配置文件或传入启动参数的方式设置save选项,若用户没有主动设置save选项,那服务器会为save选项设置默认条件
接着,服务器程序会根据save选项所设置的保存条件,设置服务器状态redisSever结构的saveparams属性
saveparams属性是一个数组,数组中的每个元素都是一个saveparam结构,每个saveparam结构保存了一个save选项设置的保存条件
dirty计数器和lastsave属性
dirty计数器记录了距离上一次成功执行SAVE命令或BGSAVE命令之后,服务器对数据库状态进行了多少次修改
last属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE或BGSAVE命令的时间
当服务器成功执行一个数据库修改命令之后,程序会对dirty计数器进行更新,命令修改多少次数据库dirty计数器的值就增加多少次
检查保存条件是否满足
Redis服务器周期性操作函数severCron默认每隔100毫秒就会执行一次,该函数用于对正在进行的服务器进行维护,其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,执行BGSAVE命令
以下代码展示了severCron函数检查保存条件的过程
程序会遍历并检查saveparams数组中的所有保存条件,只要有任意一个条件被满足,服务器就会执行BGSAVE命令
RDB文件结构
下图展示了一个完成RDB文件包含的各个部分
databases部分
一个RDB文件的databases部分可以保存任意多个非空数据库
例:如果服务器的0号和3号数据库非空,服务器会创建一个如图所示的RDB文件,图中database 0代表0号数据库中所有键值对数据,database 3代表3号数据库中的所有键值对数据
每个非空数据库在RDB文件中都可以保存SELECT .db_number、key_value_pairs
key_value_pairs
保存了一个或以上数量的键值对,如果键值对带有过期时间的话,那么键值对的过期时间也会被保存在内
不带过期时间的键值对在RDB文件中由TYPE、key、value三部分组成,如图
TYPE记录了value类型,长度为1字节,值可以是以下常量的其中一个
以上列出的每个TYPe常量都代表一种对象类型或底层编码,当服务器读入RDB文件中的键值对数据时,程序会根据TYPE的值来决定如何读入和解释value的数据
带有过期时间的键值对在RDB文件中结构如图
TYPE、key、value三个部分意义与上相同,EXPIRETIME_MS常量的长度为1字节,它告知读入程序,接下来要读入的将是一个以毫秒为单位的过期时间,m是一个8字节长的带符号整数,记录一个以毫秒为单位的UNIX时间戳,即键值对的过期时间
value的编码
每个value部分都保存了一个值对象,每个值对象的类型由与之对应的TYPE记录,根据类型的不同,value部分的结构、长度也会有所不同
字符串对象
如果TYPE的值为REDIS_RDB_TYPE_STRING,那value保存的是一个字符串对象,编码可以是REDIS_ENCODING_INT或REDIS_ENCODING_RAW
若字符串对象的编码会为 REDIS_ENCODING_INT,说明对象中保存的是长度不超过32位的整数,如图结构保存
对于没有被压缩的字符串,RDB程序会以下图所示结构来保存
string部分保存了字符串值本身,len保存了字符串值的长度
对于压缩后的字符串,RDB程序会以下图所示结构保存该字符串
REDIS_RDB_ENC_LZF常量标志字符串已被LZF算法压缩过,compressed_len记录的是字符串被压缩之后的长度,origin_len记录的是字符串原来的长度,compressed_string记录的是被压缩之后的字符串
列表对象
若TYPE值为REDIS_RDB_TYPE_LIST,那value保存的是一个REDIS_ENCODING_LINKEDLIST编码的列表对象,RDB文件保存这种对象的结构如图
list_length记录了列的长度,记录列表保存了多少个项,读入程序可以通过长度直到应该读入多少个列表项。图中以item开头的部分代表列表的项,因为每个列表项都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入列表项
集合对象
如果TYPE的值为REDIS_RDB_TYPE_SET,那么value保存的是一个REDIS_ENCODING_HT编码的集合对象,RDB文件保存这种对象的结构如图
set_size是集合的大小,记录集合保存了多少个元素,读入程序可以通过这个大小知道自己应该读入多少个集合元素
图中以elem开头的部分代表集合的元素,因为每个元素都是一个字符串对象,所以程序会以处理字符串对象的方式保存和读入集合元素
Hash表对象
如果TYPE的值为REDIS_RDB_TYPE_HASH,那么value保存的是一个REDIS_ENCODING_HT编码的集合对象,RDB文件保存结构如图
hash_size记录了Hash表的大小,即Hash表保存了多少个键值对,读入程序可以通过这个大小知道应该读入多少个键值对
以key_value_pair开头的部分代表Hash表中的键值对,键和值都是字符串对象,所以程序会以处理字符串对象的方式保存和读入键值对
有序集合的对象
若TYPE的值为REDIS_RDB_TYPE_ZSET,那么value保存的是一个REDIS_ENCODING_SKIPLIST编码的有序集合对象,RDB文件保存这种对象的结构如图
sorted_set_size记录了有序集合的大小,即这个有序集合保存了多少元素,读入程序需要根据这个值来决定应该读入多少有序集合元素
以element开头的部分代表有序集合中的元素,每个元素又分为成员和分值两部分,成员是一个字符串对象,分值是一个double类型的浮点数,程序在保存RDB文件时,先将分值转换成字符串对象,然后再用保存字符串对象的方法将分值保存起来
INTSET编码的集合
若TYPE的值为REDIS_RDB_TYPE_SET_INTSET,那value保存的是一个整数集合对象,RDB文件保存对象的方法是:先将整数集合转换为字符串对象,然后将这个字符串对象保存到RDB文件里
分析RDB文件
不包含任何键值对的RDB文件
执行以下命令,创建一个数据库状态为空的RDB文件
包含字符串键的RDB文件
分析一个带有字符串键的数据库
包含带有过期时间的字符串键的RDB文件
创建一个带有过期时间的字符串键
打印RDB文件
包含一个集合键的RDB文件
打印输出如下
AOF持久化
通过保存Redis服务器执行的写命令来记录数据库状态,如图
例:如果对空白数据库执行以下写命令,那么数据库中将包含三个键值对
将服务器执行的SET、SADD、RPUSH三个命令保存到AOF文件中
AOF持久化的实现
AOF持久化功能的实现可以分为命令追加、文件写入、文件同步
命令追加
当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾
例:若客户端向服务器发送以下命令
reids> SET key VALUE
OK
那么服务器在执行这个SET命令之后,会将以下协议内容追加到aof_buf缓冲区的末尾
AOF文件的写入与同步
服务器在处理文件时间时可能会执行写命令,使得一些内容被追加到aof_buf缓冲区里,所以在服务器每次结束一个事件循环之前,都会调用flushAppendOnlyFile函数,过程可用以下伪代码表示
flushAppendOnlyFile函数的行为由服务器配置的appendfsync选项的值来决定,各个不同值产生的行为如表
例:假设服务器在处理文件时间期间,执行以下三个写入命令
那么aof_buf缓冲区将包含这三个命令的协议内容
AOF文件的载入与数据还原
步骤如下:
1、创建一个不带网络连接的伪客户端,因为Redis命令只能在客户端上下文中执行,载入AOF文件时所使用的命令直接来源于AOF文件,所以服务器使用一个设有网络连接的伪客户端来执行AOF文件保存的写命令
2、从AOF文件中分析并读出一条写命令
3、使用伪客户端执行被读出的写命令
4、一直执行步骤2、3,直到AOF文件中的所有写命令都被处理完毕为止
AOF重写
Redis服务器可以创建一个新的AOF文件来代替现有的AOF文件,新旧两个AOF文件所保存的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令,所以新AOF文件比旧AOF文件体积小得多
AOF文件重写的实现
AOF重写不需要对现有AOF文件进行任何读取,分析或写入操作,这个功能是通过读取服务器当前的数据库状态实现
若服务器对list键执行了以下命令
那么服务器为了保存当前list键的状态,必须在AOF文件中写6条命令
AOF后台重写
Redis将AOF重写程序放到紫禁城里执行,这样做可以同时达到两个目标:
1、子进程AOF重写期间,服务器进程可以继续处理命令请求
2、子进程带有服务器进程的数据副本,使用子进程而不是线程,可以避免使用锁的情况下,保证数据的安全性
下表展示了一个AOF文件重写例子
Redis服务器设置了一个AOF重写缓冲区,在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区,如图
在子进程执行AOF重写期间,服务器进程执行以下三个工作
1、执行客户端发来的命令    2、将执行后的写命令追加到AOF缓冲区   3、将执行后的写命令追加到AOF重写缓冲区
可以保证:AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如何进行;从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里
事件
文件事件:服务器对套接字操作的抽象,服务器与客户端的通信产生相应的文件事件,而服务器则通过监听并处理这些事件完成一系列网络通信操作
时间事件:Redis服务器中的一些操作,比如serverCron函数,需要在给定的时间点执行,时间事件就是服务器对这类定时操作的抽象
Redis基于Reactor模式开发了自己的网路事件处理器:
文件事件处理器使用I/O多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器
当被监听的套接字准备好执行连接应答、读取、写入、关闭等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器会调用套接字之前关联好的事件处理器来处理这些事件
文件事件处理器的构成
四个组成部分:套接字、I/O多路复用程序、文件事件分派器、事件处理器
I/O多路复用程序的实现
通过包装常见的select、epoll、evport、kqueue这些I/O多路复用函数库来实现,每个I/O多路复用函数库在Redis源码中都对应一个单独的文件,如ae_select.c  ae_epoll.c  ae_kqueue.c诸如此类
Reids在I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时启动选择系统中性能最高的I/O多路复用函数库来作为I/O多路复用的底层实现
事件的类型
I/O多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和套接字操作之间的对应关系如下
1、当套接字变得可读时,或有新的可应答套接字出现时,套接字产生AE_READABLE事件
2、当套接字变得可写时,套接字产生AE_WRITABLE事件
时间事件
Redis的时间事件分为两类
1、定时事件:让一段程序在指定的时间之后执行一次    2、周期性事件:让一段程序每隔指定时间就执行一次
一个时间事件主要由以下三个属性组成
id:服务器为时间事件创建的全局唯一ID。ID号从小到大顺序递增,新事件的ID号比旧事件的ID号要大
when:毫秒精度的UNIX时间戳,记录了时间事件的到达时间
timeProc:时间事件处理器,一个函数。当事件事件到达时,服务器就会调用相应的处理器来处理事件
一个时间事件是定时还是周期取决于返回值
1、如果事件处理器返回ae.h/AE_NOMORE,那么这个事件为定时事件,该事件在达到第一次之后就会被删除,之后不再到达
2、如果事件处理器返回一个非 AE_NOMORE的整数值,那盒这个事件为周期性事件,当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的when属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去
实现
所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器
如图,新的事件总是插入到链表的表头,按ID逆序排序,表头事件的ID为3,中间事件ID为2,表尾事件ID为1
时间事件应用实例:serverCron函数
持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调用,从而确保服务器长期、稳定地运行,这些定期操作由redis.c/serverCron函数负责执行,主要工作包括:
1、更新服务器的各类统计信息。如时间、内存占用、数据库占用情况等    2、清理数据库中的过期键值对
3、关闭和清理连接失败的客户端    4、尝试进行AOF或RDB持久化操作
5、如果服务器是主服务器,对从服务器进行过期同步    6、如果处于集群模式,对集群进行定期同步和连接测试
Redis服务器以周期性事件的方式来运行serverCron函数,在服务器运行期间,每隔一段时间,serverCron会执行一次,直到服务器关闭为止
事件的调度与执行
旧ae.c/aeProcessEvents函数负责,以下是伪代码
         
aeProcessEvents函数置于一个循环里,加上初始化和清理函数,构成了Redis服务器的主函数,以下是伪代码
运行流程图
客户端
Redis服务器是一对多夫齐程序:一个服务器可以与多个客户端建立网络连接,每隔客户端可以向服务器发送命令请求,而服务器则接收并处理客户端发送的命令请求,并向客户端返回命令回复,Redis服务器使用单线程单进程的方式处理命令请求,并与多个客户端进行网络通信。对于每个与服务器进行连接的客户端,服务器都为这些客户端建立了相应的redis.h/redisClient结构,保护了客户端当前的状态信息,以及执行相关功能需要用到的数据结构,包括:
Redis服务器状态结构的clients属性是一个链表,保存了所有与服务器连接的客户端的状态结构,对客户端执行批量操作,或查找某个指定客户端都可以通过clients完成
下图展示了一个与三个客户端进行连接的服务器
下图展示了这个服务器的clients链表的样子
客户端属性
1、通用的属性,无论客户端执行什么工作,都要用到这些属性
2、和特定功能相关的属性,如操作数据库时需要用到db属性和dictid属性,执行事务时需要用到mstate属性,执行WATCH命令时需要用到watch_keys属性等
套接字描述符
客户端状态的fd属性记录了客户端正在使用的套接字符描述
执行CLIENT list命令可以列出目前所有连接到服务器的普通客户端,命令输出中的df域显示了服务器连接客户端所使用的套接字符描述
名字
默认情况下,一个连接到服务器的客户端是没有名字的
如下展示了CLIENT list命令示例中,两个客户端的name域都是空白的
使用CLIENT setname命令可以为客户端设置一个名字,让客户端身份变得更清晰
下图展示了一个客户端状态示例。根据name属性显示,客户端名字为"message_queue"
标志
客户端的标志属性flags记录了客户端的角色,以及客户端目前所处的状态
以下是一些flags属性的例子:
输入缓冲区
客户端状态的输入缓冲区用于保存客户端发送的命令请求
例:如果客户端向服务器发送了以下命令请求
SET key value
那客户端状态的querybuf属性将是一个包含以下内容的SDS值
命令域命令参数
在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,服务器将对命令请求的内容进行分析,并将得出的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性
argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,之后的其他项则是传给命令的参数
argc属性则负责记录argv数组的长度
命令的实现函数
将根据项argc[0]的值,在命令表中查找命令所对应的命令实现函数
当程序在命令表中成功找到argv[0]所对应的redisCommand结构时,将客户端状态的cmd指针指向这个结构
之后,服务器可以用cmd属性所指向的redisCommand结构,以及argv,argc属性中保存的命令参数信息,调用命令实现函数,指向客户端指定的命令
下图演示了服务器在argv[0]为“SET”时,查找命令表并将客户端状态的cmd指针指向目标redisCommand结构的整个过程
输出缓冲区
每个客户端都有两个输出缓冲区可用,一个缓冲区的大小是固定的,存放长度比较小的回复,一个缓冲区大小是可变的,存放长度比较大的回复
客户端的固定大小缓冲区由buf和bufpos组成
buf是一个大小为REDIS_REPLY_CHUNR_BYTES字节的字节数组,bufpos属性记录了bufpos属性记录了bug数组目前已使用的字节数量
下图展示了一个使用固定大小缓冲区来保存返回值tok\r\n的例子
身份验证
客户端状态的authenticated属性用于记录客户端是否通过可身份验证
如果authenticated的值为0,那么表示客户端未通过身份验证,如果authenticated值为1,表示客户端通过可身份验证
时间
ctime属性记录了创建客户端的时间,用来计算客户端与服务器已经连接了多少秒
lastinteraction记录了客户端与服务器最后一次进行互动的时间
服务器
Redis服务器负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令所产生的数据,并通过资源管理来维持服务器自身的运转
命令请求的执行过程
例:使用客户端执行以下命令
redis> SET KEY VALUE
OK
从客户端发送SET KEY VALUE命令到获得回复OK期间,客户端和服务器共需要执行以下操作
1、客户端向服务器发送命令请求SET KEY VALUE
2、服务器接收并处理客户端发来的命令请求 SET KEY VALUE,在数据库中进行设置操作,并产生命令回复OK
3、服务器将命令回复OK发送给客户端
4、客户端接收服务器返回的命令回收OK,并将这个回复打印给用户观看
发送命令请求
当用户在客户端中键入一个命令请求时,客户端将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器
读取命令请求
当客户端与服务器之间的连接套接字因为客户端的写入变得可读时,服务器将调用命令请求处理器执行以下操作
1、读取套接字协议格式的命令请求,并将其保存到客户端状态输入缓冲区中
2、对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv和argc属性
3、调用命令执行器,执行客户端指定的命令
将得出的分析结果保存到客户端状态的argc和argv属性
命令执行器(1):查找命令实现
命令执行器做的第一件事就是根据客户端状态的argv[0]参数,在命令表中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性中
命令表是一个字典,字典则是一个个redisCommand结构,下表记录了结构的各个属性的类型和作用
下表列出了sflags属性可以使用的标识值,以及这些标识的意义
命令执行器(2):执行预备操作
1、检查客户端的cmd指针是否指向NULL。若是,说明用户输入的命令名字找不到相应的命令实现,服务器不再指向后续步骤,并向客户端返回一个错误
2、根据客户端cmd属性指向的redisCommand结构的arity属性,检查命令请求所给定的参数个数是否正确,参数个数不正确时,不再指向后续步骤,直接向客户端返回一个错误
命令执行器(3):调用命令的实现函数
当服务器要指向命令时,指行以下语句
因为指向命令所需的实际参数已保存到客户端状态的argv中,所以命令的实现函数只需要一个指向客户端状态的指针作为参数即可
命令执行器(4):执行后续工作
1、如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志
2、如果服务器开启了AOF持久化功能,那么AOF持久化模块会将刚刚执行的命令请求写入到AOF缓冲区中
3、如果有其他从服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器
将命令回复发送给客户端
当命令回复发送完毕之后,回复处理器会清空客户端状态的输入缓冲,为处理下一次请求做好准备
客户端接收并打印命令回复
当客户端接收到协议格式的命令回复之后,会将这些回复转换成可读形式,并打印给观众看,如图
serverCron函数
每隔100毫秒执行一次,负责管理服务器的资源,并保持服务器自身的良好运转
更新服务器时间缓存
为了减少系统调用的执行次数,服务器状态中的unixtime属性和mstime属性被用作当前时间的缓存
更新LRU时针
服务器状态中的lruclock属性保存了服务器的LRU时针,与之前介绍的unixtime、mstime属性一样,都是服务器时间缓存的一种
每隔Redis对象都会有一个lru属性,保存了对象最后一次被命令访问第时间
当服务器要计算一个数据库键的空转时间,程序会用服务器的lruclock属性记录的时间减去对象的lru属性记录的时间,得出的计算结果是对象的空转时间
更新服务器每秒执行命令次数
serverCron函数中的trackOperationPerSecond函数会以每100毫秒一次的频率执行,以抽样计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量,这个值可以通过INFO status命令的instanceous_ope_per_sec域查看
在最近一秒内,服务器处理了大概六个命令
trackOperationPerSecond函数和服务器状态中四个ops_sec开头的属性有关
更新服务器内存峰值记录
服务器状态中的stat_peak_memory属性记录了服务器的内存峰值大小
每次serverCron函数执行时,程序都会查看服务器当前使用的内存数量,并与stat_peak_memory保存的数值进行比较,如果当前使用的内存数量比stat_peak_memory 属性 记录的值要大,那么程序就将当前使用的内存数量记录到 stat_peak_memory属性里面
处理SIGTERM信号
在启动服务器时,Redis会为服务器进程的SIGTERM信号关联处理器
SigtermHandler函数,这个信号处理器负责在服务器接收到SIGTERM信号时,打开服务器状态的shutdown_asap标识
每次serverCron函数运行时,程序都会对服务器状态的shutdown_asp属性进行检查,并根据属性的值决定是否关闭服务器
以下代码展示了服务器在接收到SIGTERM信号之后,关闭服务器并打印相关日志的过程
管理客户资源
serverCron函数每次执行都会调用clientsCron函数,对一定数量的客户端进行以下两个检查
1、如果客户端与服务器之间的连接已经超时,程序会释放这个客户端
2、如果客户端在上一次执行命令请求之后,输入缓冲区的大小超过一定的长度,那程序会释放客户端当前的输入缓冲区,并重新构建一个默认大小的输入缓冲区,防止客户端的输入缓冲区耗费了过多的内存
管理数据库资源
serverCron函数每次执行都会调用databasesCron函数,对服务器中的一部分数据库进行检查,删除其中的过期键,在有需要时,对字典进行收缩操作
执行被延迟的BGREWRITEAOF
在服务器执行BGSAVE命令的期间,如果客户端向服务器端发来的BGREWRITEAOF,那么服务器会将BGREWRITEAOF命令的执行时间延迟到BGSAVE执行完毕之后
服务器的aof_rewrite_scheduled标识记录了服务器是否延迟了BGREWRITEAOF命令
每次执行serverCron函数时,都会检查BGSAVE或BGREWRITEAOF命令是否正在执行,若两个目录都没有执行,并且 aof_rewrite_scheduled属性值为1,那么服务器就执行之前被延迟的BGREWRITEAOF目录
检查持久化操作的运行状态
服务器状态使用rdb_child_pid和aof_child_pid属性记录执行BGSAVE命令和BGREWRITEAOF命令的子进程ID,也可以用于检查BGSAVE或BGREWRITEAOF是否正在执行
将AOF缓冲区中的内容写入AOF文件
若服务器开启了AOF持久化功能,并且AOF缓冲区里还有待写入的数据,那么serverCron函数会调用相应的程序,将AOF缓冲区的内容写入带AOF文件
关闭异步客户端
服务器关闭输出缓冲器大小超过限制的客户端
增加Cronloops计数器的值
服务器状态的Cronloops属性记录了serverCron函数执行的次数
初始化服务器状态结构
第一步是创建一个struct redisServer类型的实例变量server作为服务器的状态,并为结构中的各个属性设置默认值
初始化server变量的工作由redis.c/initserverConfig函数完成,以下是开头的代码
以下是initServerConfig完成的主要工作
  
载入配置选项
在启动服务器时,用户可以通过给定配置参数或指定配置文件来修改服务器的默认配置,例:在终端输入
$redis-server--port 10086
通过给定配置参数的方式,修改了服务器的运行端口号
初始化服务器数据结构
还原数据库状态
服务器需要载入RDB文件成AOF文件,根据文件记录的内容来还原数据的状态
如果服务器启动AOF持久化功能,那么服务器使用AOF文件还原数据库状态
如果服务器没有启动AOF持久化功能,那么服务器使用RDB文件还原数据库状态
执行事件循环
最后一步,服务器将打印出以下日志
并开始执行服务器的事件循环
复制
在Redis中,用户可以通过执行SLAVEOF或设置salveof选项,让一个服务器区复制另一个服务器,被复制的服务器为主服务器,对主服务器进行复制的服务器成为从服务器,如图
假设现有两个Redis服务器,地址分别为127.0.0.1:6379和127.0.0.1:12345,若向服务器 127.0.0.1:12345发送以下命令
127.0.0.1:12345 > SLAVEOF 123.0.0.1:6379的从服务器,而127.0.0.1:6379则会成为 127.0.0.1:12345的主服务器
复制功能的实现
分为同步和命令传播两个操作
同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态
命令传播用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致,让主从服务器的数据库重新回到一致状态
同步
从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令来完成,以下是SYNC命令的执行步骤
1、从服务器向主服务器发送SYNC命令
2、收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从开始执行的所有写命令
3、当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态
4、主服务器记录在缓冲区里的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处状态
下图展示SYNC命令执行期间,主从服务器的通信过程
命令传播
例:假设一个主服务器和一个从服务器刚完成同步操作,数据库都保存了相同的五个键k1至k5,如图
若客户端向主服务器发送命令DEL k3,那么主服务器在执行完这个DEL命令之后,主从服务器的数据库将出现不一样:主服务器的数据库已不再包含键k3,但这个键仍包含在从服务器的数据库中,如图
复制功能的缺陷
初次复制:从服务器以前没有复制过任何主服务器或从服务器当前要复制的主服务器和上一次复制的主服务器不符
断线后重复制:处于命令传播阶段的主从服务器因为网络原因中断了复制,但从服务器通过自动重连接重新连上了主服务器,并继续复制主服务器
新版复制功能的实现
Redis从2.8版本开始使用PSYNC执行复制的同步操作
PSYNC命令具有完整重同步和部分重同步:
完整重同步用于处理初次复制:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,即是通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区中的写命令来进行同步
部分重同步用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器主要接收并执行这些命令,就可以将数据库更新至主服务器当前所处的状态
部分重同步的实现
三个部分构成:
1、主服务器的复制偏移量和从服务器的复制偏移量   2、主服务器的复制积压缓冲区   3、服务器的运行ID
复制偏移量
主从服务器会分别维护一个复制偏移量:
主服务器每次向从服务器传播N个字数的数据时,将自己的复制偏移量的值加上N
从服务器每次收到主服务器传播来的N个字节的数据时,将自己的复制偏移量的值加上N
如图所示,主从服务器的复制偏移量的值为10086
若这时主服务器向三个从服务器传播长度为33字节的数据,那主服务器的复制偏移量将更新为10086+33=10119,而三个从服务器在接收到主服务器传播的数据之后,也会将复制偏移量更新为10119,如图
复制积压缓冲区
由主服务器维护的一个固定长度先进先出队列,默认大小为1MB,主服务器进行命令传播时,不仅将写命令发送给所有从服务器,还会将写命令入队列复制积压缓冲区
因此,主服务器的复制积压缓冲区中会保存一部分最近传播的写命令,并且复制积压缓冲区全为队列中的每个字节记录相应的复制偏移量
服务器运行ID
每个Redis服务器,不论主从,都有自己的运行ID;运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成
当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器会将这个运行ID保存起来;当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行ID
PSYNC的实现
调用方法有两种:
1、如果从服务器以前没有复制过任何主服务器,或执行之前过SLAVEOF on one命令,那么从服务器在开始一次新的复制时将向主服务器发送 PSYNC ? -1命令,主动请求主服务器进行完整重同步
2、如果从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC <runid> <offset>命令,其中runid是上一次复制的主服务器的运行ID,offset则是从服务器当前的复制偏移量,接收到这个命令的主服务器通过这两个参数来判断应该对从服务器执行哪种同步操作
流程图总结了PSYNC命令执行完整重同步和部分重同步可能遇上的情况
复制的实现
步骤1:设置主服务的地址和端口
当客户端向从服务器发送以下命令时:
127.0.0.1:12345> SLAVEOF 127.0.0.1 6379
OK 
从服务器首先将客户端给定的主服务器IP地址127.0.0.1以及端口6379保存带服务器状态的nasterhost属性和masterport属性中
下图展示了SLAVEOF命令执行之后,从服务器的服务器状态
步骤2:建立套接字连接
在SLAVEOF命令执行之后,从服务器将根据命令所设置的IP地址和端口,创建连向主服务器的套接字连接
若从服务器创建的套接字能成功连接到主服务器,那么从服务器将为这个套接字关联一个专门用于处理复制工作的文件事件处理器,负责执行后续的复制工作,如接收RDB文件,以及接收主服务器传播来的写命令等
主服务器在接收从服务器的套接字连接之后,将为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端来支持,这时从服务器具有服务器和客户端两个身份:从服务器可以向主服务器发送命令请求,主服务器会向从服务器返回目录回复
步骤3:发送PING命令
从服务器成为主服务器客户端之后,做的第一件事就是向主服务器发送一个PING命令,如图
PING命令有两个作用
1、可以检查套接字的读写状态是否正常    2、可以检查主服务器能否正常处理命令请求
流程图总结了从服务器在发送PING命令时可能遇到的情况
步骤4:身份验证
如果从服务器设置了masterauth选项,那么进行身份验证
例:如果从服务器masterauth选项的值为10086,那么从服务器将向主服务器发送命令 AUTH 10086,如图
流程图总结了从服务器在身份验证阶段可能遇到的情况,以及各个情况的处理方式
步骤5:发送端口信息
在身份验证步骤之后,从服务器执行命令REPLCONF listening-port <port- number>,向主服务器发送从服务器的监听端口号,从服务器的监听端口号为12345,那么从服务器将向主服务器发送命令REPLCONG listening-port 12345,如图
步骤6:同步
从服务器将向主服务器发送PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态
步骤7:命令传播
主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从服务器保持一致
Sentinel
Sentinel是Redis的高可用性解决方案:由一个或多个Sentinel实例组成的Sentinel系统可以监视任意各个服务器,并在被监视的主服务器进入下线状态时,自动将下线的主服务器属下的某个从服务器升级为新的主服务器,由新的主服务器继续处理命令请求
下图展示了一个Sentinel系统监视服务器的例子
用双环图案表示的是当前的主服务器server1,用单环图案表示的是主服务器的三个从服务器server2、3、4
server2、3、4三个从服务器正在复制主服务器server1,而sentinel系统则在监视所有四个服务器
主服务器server1进入下线状态,从服务器server2、3、4对主服务器的复制操作将被中止,并且Sentinel系统会察觉到server1已下线,如图(下线的服务器用虚线表示)
启动并初始化Sentinel
启动一个Sentinel可以使用命令:$ redis-sentinel/path/to/your/sentinel.conf
当一个sentinel启动时,需要执行以下步骤:
1、初始化服务器    2、将普通Redis服务器使用的代码替换成Sentinel专用代码    3、初始化sentinel状态
4、根据给定的配置文件,初始化sentinel的监视主服务器列表    5、创建连向主服务器的网络连接
初始化服务器
因为Sentinel并不使用数据库,初始化时不会载入RDB文件或AOF文件
下表展示了Redis服务器在Sentinel模式下运行时,各个主要功能的使用情况
使用Sentinel专用代码
将一部分普通Redis服务器使用的代码替换成Sentinel专业代码
Sentinel使用sentinel.c/REDIS_SENTINEL_PORT常量的值作为服务器端口
#define REDIS_SENTINEL_PORT 26379
Sentinel使用sentinel.c/sentinelcmds作为服务器的命令表,并且其中的INFO命令会使用Sentinel模式下专用实现sentinel.c/sentinelInfoCommand函数
初始化Sentinel函数
服务器会初始化一个Sentinel.c/sentinelState结构,这个结构保存了服务器中所有和Sentinel功能有关的状态
初始化Sentinel状态的master属性
Sentinel状态中的masters字典记录了所有被Sentinel监视的主服务器的相关信息,其中:
1、字典的键是被监视主服务器的名字   2、字典的值则是被监视主服务器对应的sentinel.c/sentinelRedisInstance结构
每个  sentinelRedisInstance结构代表一个被Sentinel监视的Redis服务器实例,这个实例可以是主服务器、从服务器,或另外一个Sentinel
以下代码展示了实例结构在表示主服务器时使用的其中一部分属性
sentinelRedisInstance.addr属性是一个指向sentinel.c/sentinelAddr结构的指针,这个结构保存着实例的IP地址和端口号
对Sentinel状态的初始化将引发对masters字典的初始化,而masters字典的初始化是根据载入的Sentinel配置文件进行的
创建连向主服务器的网络连接
sentinel将成为主服务器的客户端,可以向主服务器发送命令。并从命令回复中获取相关的信息
对于每个被Sentinel监视的主服务器,Sentinel会创建两个连向主服务器的异步网络连接
1、命令连接,专门用于向主服务器发送命令,并接收命令回复
2、订阅连接,专门用于订阅主服务器_sentinel_:hello频道
下图展示了一个sentinel向被监视的两个主服务器master 1和master2创建命令连接和订阅连接
获取主服务器信息
Sentinel默认会以十秒为一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并提供分析INFO命令的回复来获取主服务器的当前信息
例:如图,主服务器master有三个服务器slave0、1、2,并且一个Sentinel正在连接主服务器,那么Sentinel将持续地向服务器发送INFO命令,并获得类型于以下内容的回复
通过分析主服务器返回的INFO命令回复,Sentinel可以获取以下两个方面的信息
1、关于主服务器本身的信息,包括run_id域记录的服务器运行ID,以及role域记录的服务器角色
2、关于主服务器属下所有从服务器信息,每个从服务器都由一个“slave”字符串开头的行记录,每个的ip=域记录了从服务器的IP地址,而port=域则记录了从服务器的端口号。根据这些IP地址和端口号,Sentinel无须用户提供从服务器的地址信息,就可以自动发现从服务器
根据run_id域和role域记录的信息,Sentinel将对主服务器的实例结构进行更新,例,主服务器重启之后,运行ID就会和实例结构之前保存的运行ID不同,Sentinel检测到这一情况后,就会对实例结构的运行ID进行更新
获取从服务器信息
当Sentinel发现主服务器有新的从服务器出现时,除了为这个新的从服务器创建相应的实例结构之外,还会创建连接到从服务器的命令连接和订阅连接
例:Sentinel将对slave0、1、2三个从服务器分别创建命令连接和订阅连接,
在创建命令连接之后,Sentinel在默认情况下,会以每10秒一次的频率通过命令连接向从服务器发送INFO命令,并获得以下内容的回复:
根据INFO命令的回复,Sentinel会提取以下信息
根据这些信息,Sentinel会对从服务器的实例结构进行更新,下图展示了Sentinel根据上面INFO命令回复对从服务器的实例结构进行更新之后,实例结构的样子
向主服务器和从服务器发送信息
在默认情况下,Sentinel会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令
这条命令向服务器的_sentinel_:hello频道发送了一条信息,信息的内容由多个参数组成
其中s_开头的参数记录的是Sentinel本身的信息参数意义表
以m_开头的参数记录的是主服务器的信息,参数的意义如表所示
接收来自主服务器和从服务器的频道信息
当Sentinel与一个主服务器或从服务器建立起订阅连接之后,Sentinel会通过订阅连接,向服务器发送以下命令:SUBSCRIBE_sentinel_:hello
Sentinel对 _sentinel_:hello频道的订阅会一直持续到Sentinel与服务器的连接断开为止
更新sentinel字典
Sentinel为主服务器创建的实例结构中的sentinels字典保存了除Sentinel本身之外,所有同样监视这个主服务器的其他Sentinel资料
1、sentinels字典的键是其中一个Sentinel的名字,格式为ip:port
2、 sentinels字典的值是键所对应Sentinel实例结构
当一个Sentinel接收到其他Sentinel发来的信息时,目标Sentinel会从信息中分析并提取出以下两方面参数
1、与Sentinel有关的参数:源Sentinel的IP地址、端口号、运行ID和配置纪元
2、与主服务器有关的参数:源Sentinel正在监视的主服务器的名字、IP地址、端口号和配置纪元
创建连向其他Sentinel的命令连接
当Sentinel通过频道发现一个新的Sentinel时,不仅会为新Sentinel在sentinels字典中创建相应的实例结构,还会创建一个连向新Sentinel的命令连接,新Sentinel同样会创建连向这个Sentinel的命令连接,最终监视同一主服务器的多个Sentinel将形成互相连接的网络
下图展示了三个监视同一主服务器的sentinel之间是如何连接的
检测主管下线状态
在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线
下图展示的例子中,带箭头的连线显示了Sentinel1和Sentinel2是如何向实例发送PING命令的
检查客观下线状态
发送SENTINEL is-master-down-by-addr命令
Sentinel使用:SENTINEL  is-master-down-by-addr <ip> <port> <current_epoch> <run id>
命令询问其他Sentinel是否同意主服务已下线,个参数意义如表
接收 SENTINEL  is-master-down-by-addr
当一个Sentinel接收到另一个Sentinel发来的 SENTINEL  is-master-down-by命令时,目标Sentinel会分析并取出命令请求中包含的各个参数,并根据其中的主服务器ID和端口号,检查主服务器是否已下线,然后向源Sentinel返回一个包含三个参数的Multi Bulk回复作为 SENTINEL  is-master-down-by命令的回复
1、<down_state>    2、<leader_runid>     3、<leader_epoch>
下表分别记录了这三个参数的意义
接收 SENTINEL  is-master-down-by-addr命令的回复
根据其他Sentinel发回的 SENTINEL  is-master-down-by-addr命令回复,Sentinel统计其他Sentinel同意主服务器已下线的数量,当这一数量达到配置指定的判断客观格式所需的数量时,Sentinel会将主服务器实例结构flag属性的SRI_0_DOWN标识打开,表示主服务器已经进入客观下线状态,如图
选举领头Sentinel
当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并且零头Sentinel对下线主服务器执行故障转移操作
故障转移
在选举产生出Sentinel之后,领头Sentinel将对已下线的主服务器执行故障转移操作,包含以下三个步骤
1、在已下线主服务器属下的所有从服务器中,挑选出一个从服务器,比盖将其转换为主服务器
2、让已下线主服务器属下的所有从服务器改为复制新的主服务器
3、将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,就会成为新的主服务器的从服务器
选出新的主服务器
在挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送 SLAVEOF no one命令,将这个从服务器转换为主服务器
下图展示了在一次故障转移操作中,领头Sentinel向被选中的从服务器Serber2发送SLAVEOF no one命令的情形
修改从服务器的复制目标
下图展示了在故障转移操作中,领头Sentinel向已下线主服务器两个server1的两个从服务器server3、4发送SLAVEOF命令,让其复制新的主服务器server2,server3、4成为server2的从服务器之后,各个服务器以及领头Sentinel的样子
将旧的主服务器变为从服务器
下图展示了被领头Sentinel设置为从服务器之后,服务器server1的样子
因为旧的主服务器已下线,这种设置保存的是serber1对应的实例结构,当server1重新上线时,Sentinel就会向它发送SLAVEOF命令,让它成为server2的从服务器
集群
节点
一个Redis集群通常由多个节点组成,开始时,每个节点都是相互独立的,都处于一个只包含自己的集群当中,组建一个真正可工作的集群,必须将各个独立的节点连接起来,构成一个包含多个节点的集群
连接各个节点的工作可以使用CLUSTER MEET命令来完成,命令的格式如下
CLUSTER MEET <ip> <port>
向一个节点node发送CLUSTER MEET命令,可以让node节点与ip和port所指定的节点进行握手,当握手成功时,node节点会将ip和port所指定的节点添加到node节点当前所在的集群中
启动节点
Redis服务器在启动时会根据cluster_enabled配置选项是否为yes决定是否开启服务器的集群模式
集群数据结构
clusterNode结构保存了一个节点的当前状态,如节点的创建事件、节点的名字、节点当前的配置纪元、节点的ip地址和端口号等
每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点都创建一个相应的clusterNode结构,以此来记录其他节点的状态
CLUSTER MEET命令的实现
通过向节点A发送CLUSTER MEET命令,客户端可以让接收命令的节点A将另一个节点B添加到节点A当前所在的集群中:
CLUSTER MEET <ip> <port>
槽指派
Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽
当数据库中的16384个槽都有节点处理时,集群处于上线状态(ok),若有任何一个槽没有得到处理,集群处于下线状态
在集群中执行命令
当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己
1、若键所在的槽正好指派给了当前节点,那么节点直接执行这个命令
2、若键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向至正确的节点,并再次发送之前想要执行的命令
计算键属于哪个槽
节点使用以下算法来计算给定键key属于哪个槽
def slot_number(key)
     return CRC16(key)  & 16383
判断槽是否由当前节点负责处理
当节点计算出键所属的槽之后,节点就会检查自己在clusterState.slots数组中的项i,判断键所在的槽是否由自己负责
1、如果clusterState.slots[i]等于clusterState.myself,那么说明槽i由当前节点负责,节点可以执行客户端发送的命令
2、如果 clusterState.slots[i]不等于clusterState.myself,那么说明槽i并非由当前节点负责,节点会根据 clusterState.slot[i]指向的clusterNode结构所记录的节点IP和端口号,项客户端返回MOVED错误,指引客户端转向正在处理槽i的节点
MOVED错误
当节点发现键所在的槽并非由自己负责处理时,节点向客户端返回一个MOVED错误,指引客户端转向至正在负责槽的节点
MOVED错误的格式为:MOVED <slot> <ip>:<port>
节点数据库的实现
集群节点保存键值对以及键值对过期时间的方式,与之后介绍的单机Redis服务器保存键值对以及键值对过期时间的方式完全相同,唯一的区别是节点只能使用0号数据库,单机Redis服务器没有这一限制
重新分片
可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,并且相关槽所属的键值对也会从源节点被移动到目标节点
实现原理:由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有指令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作
ASK错误
在进行重新分片时,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点中,而另一部分键值对保存在目标节点中
当客户端向源节点发送一个与数据键有关的命令,并且命令要处理的数据库键恰好属于正在被迁移的槽时,源节点会先在自己的数据库中查找指定的键,若找到的话,直接执行客户端发送的命令;相反,如果源节点没能在自己的数据库找到指定的键,这个键有可能已被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令
CLUSTER SETSLOT IMPORTING命令的实现
clusterState结构的inporting_slots_from数组记录了当前节点正在从其他节点导入的槽
CLUSTER SETSLOT MIGRATING命令的实现
clusterState结构的migrating_slots_to数组记录了当前节点正在迁移至其他节点的槽
ASKING命令
唯一要做的是打开发送该命令的客户端的REDIS_ASKING标识,以下是命令的伪代码实现
复制与故障转移
Redis集群中的节点分为主节点和从节点,其中主节点用于处理槽,从节点用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求
设置从节点
向一个节点发送命令:CLUSTER REPLICATE <node_id>
可以让接收命令的节点成为node_id所指定节点的 从节点,并开始对主节点进行复制:
接收到该命令的节点首先在自己的clusterState.nodes字典中找到node_id所对应节点的clusterNode结构,并将自己的clusterState.myself.slaveof指针指向这个结构,以此来记录这个节点正在复制的主节点
然后节点会修改自己在 clusterState.myself。flags中的属性,关闭原本的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标志,表示这个节点已经由原来的主节点变成了从节点
最后,节点会调用复制代码,并根据 clusterState.myself.slaveof指向的clusterNode结构所保存的IP地址和端口号,对主节点进行复制
下图展示了节点7004在复制节点7000时的clusterState结构
故障检测
集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定时间内发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线
故障转移
执行步骤:
1、复制下线主节点的所有从节点中,会有一个从节点被选中
2、被选中的从节点会执行SLAVEOF no one命令,成为新的主节点
3、新的主节点会撤销所有已下线主节点的槽指派,并将这些槽全部指派给自己
4、新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即直到这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽
5、新的主节点开始接收和字节负责处理的槽有关的命令请求,故障转移完成
发布与订阅
由通过执行SUBSCRIBE命名,客户端可以订阅一个或多个频道,从而成为这些频道的订阅者,每当有其他客户端向被订阅的频道发送消息时,频道的所有订阅者都会收到这条消息
频道的订阅与退订
Redis将所有频道的订阅都保存在服务器状态的pubsub_channels字典中,这个字典的键是某个被订阅的频道,键的值则是一个链表,链表里记录了所有订阅这个频道的客户端
下图展示了一个pubsub_channel字典示例,记录了以下信息:
1、client-1、2、3三个客户端正在订阅“news.it”频道
2、客户端client-4正在订阅“news.sport”频道
3、client-5和client-6两个客户端正在订阅“news.business”频道
订阅频道
每当客户端执行SUBSCRIBE命令订阅某个或某些频道时,服务器都会将客户端与被订阅的频道在pubsub_channels字典中进行关联
SUBSCRIBE命令的实现可以用以下伪代码描述
退订频道
当一个客户端退订某个或某些频道时,服务器将从pubsub_channels中解除客户端与被退订频道之间的关联
1、程序会根据被退订频道的名字,在 pubsub_channels字典中找到频道对应的订阅者链表,然后从订阅链表中删除退订客户端的信息
2、如果删除退订客户端之后,频道的订阅链表变成了空链表,说明这个频道已没有任何订阅者,程序将从 pubsub_channels字典中删除频道对应的键
UNSUBSCRIBE命令的实现可以用以下伪代码描述
模式的订阅与退订
订阅模式
每当客户端执行PSUBSCRIBE命令订阅某个或某些模式时,服务器会对每个被订阅的模式执行以下两个操作
1、新建一个pubsubPattern结构,将结构的pattern属性设置为被订阅的模式,client属性设置为订阅模式的客户端
2、将pubsubPattern结构添加到pubsub-pattern链表的表尾
PSUBSCRIBE命令的实现原理可以用以下伪代码来描述:
退订模式
当一个客户端退订某个或某些时,服务器将在 pubsub_patterns链表中查找并删除那些pattern属性为被退订模式,并且client属性为执行退订命令的客户端的pubsubPattern结构
例:假设服务器 pubsub_patterns链表的当前状态如图
当客户端client-9执行命令PUNSUBSCRIBE "news .*"
之后,client属性为client-9,pattern属性为"news .*"的pubsubPattern结构将被删除,pubsub-patterns链表将更新至下图所示
PUNSUNSCRIBE命令的实现原理可以用以下伪代码描述
发送消息
当一个Redis客户端执行PUBLISH <channel> <message>命令将消息message发送给频道channel时,服务器需要执行以下两个动作
1、将消息message发送给channel频道的所有订阅者
2、如果有一个或多个模式pattern与频道channel相匹配,然后将消息发送给名单上的所有客户端
PUBLISH命令将消息发送给频道订阅者的方法用以下代码描述
将消息发送给模式订阅者
PUBLISH遍历整个pubsub_patterns链表,查找那些与channel频道相匹配的模式,并将消息发送给订阅了这些模式的客户端
PUBLISH命令将消息发送给模式订阅的方法用以下伪代码描述
最后,PUBLISH命令的实现可以用以下伪代码来描述
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值