Redis总结

数据结构

Redis中有简单动态字符串SDS、双端链表、字典、跳跃表、整数集合和压缩列表6种数据结构。

简单动态字符串SDS

Redis是使用C语言的,但是C语言中的字符串只会作为字符串字面量出现在一些无需对字符串进行修改的地方。而可能出现修改时,Redis使用SDS来实现。

每个sds.h/sdshdr结构表示一个SDS值,SDS保存字符串同样以‘\0’结尾,但是这个结尾不计入长度。
在这里插入图片描述
SDS和C字符串的区别:

常数复杂度获取字符串长度:C字符串必须遍历整个字符串得到字符串长度,时间复杂度未O(N),而SDS保存了字符串的长度,时间复杂度未O(1)。

杜绝缓冲区溢出:因为C字符串不记录自身的长度,所以strcat假定用户在执行这个函数时,已经为dest分配了足够多的内存,而一旦空间不够就会发生缓冲区溢出。SDS在修改时,会先判断空间是否足够,空间足够则修改,不够则自动将SDS的空间扩展至执行修改所需的大小,然后才实际进行修改。

减少修改字符串时带来的内存重分配次数:C字符的底层是N+1长度的数组,如果要对C字符串的长度增加或者减少,减少修改字符串时带来的内存重分配次数。SDS包含未使用空间,可以很好的减少内存重新分配次数。

二进制安全:C字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。

兼容部分C字符串函数:通过遵循C字符串以空字符结尾的惯例,SDS可以在有需要时重用<string.h>函数库,从而避免了不必要的代码重复

在这里插入图片描述
SDS空间分配和惰性释放

空间预分配:
如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同。
如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。

惰性释放:惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。与此同时,SDS也提供了相应的API,让我们可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。

链表

Redis中的链表节点如下
在这里插入图片描述
虽然多个链表节点就可以组成一个链表,但是Redis用list数据类型来管理链表,包含长度/头/尾,以及复制、释放、比较等函数。
在这里插入图片描述
Redis链表特性:
双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
带表头指针和表尾指针:通过list结构的head指针和tail指针,获取链表的表头节点和表尾节点的复杂度O(1)
多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值

字典

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。Redis的数据库就是使用字典来作为底层实现的,对数据库的增删改查也是在字典的操作基础之上。

Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。Redis中的字典由dict.h/dict结构表示

在这里插入图片描述
type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的。type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数,而privdata属性则保存了需要传给那些类型特定函数的可选参数。

ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用

在这里插入图片描述
table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来。程序总是将新节点添加到链表的表头位置,方便插入。
在这里插入图片描述

rehash过程:

当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩,使负载因子(load factor)维持在一个合理的范围之内。负载因子=哈希表节点数量/哈希表数组长度

1.为字典的ht[1]哈希表分配空间,当执行扩展操作ht[1]的大小为第一个大于等于ht[0].used2的2 n(2的n次方幂),ht[1]的大小为第一个大于等于ht[0].used2的2 n(2的n次方幂)。
2.将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。这个过程不是一次性,集中式的完成,而是分多次、渐进式地完成的。
3.当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

扩展条件:
服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
收缩条件:
当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。

根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。

渐进式rehash

1.为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
2.在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
3.在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一
4.随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成

因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行。在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表

跳跃表

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单。Redis在有序集合键和集群节点中使用了跳跃表。

zskiplist结构用来保存跳跃表节点的相关信息

在这里插入图片描述
header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)
通过使用length属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度
level属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内

zskiplistNode结构表示跳跃表节点
在这里插入图片描述
层:跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,层的高度为创建节点是随机生成,大小在1~~32.
前进指针:每个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点。
跨度:层的跨度用于记录两个节点之间的距离
后退指针:节点的后退指针(backward属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点
分值和成员:节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的。

整数集合

intset结构表示一个整数集合在这里插入图片描述
contents数组是整数集合的底层实现,整数集合的每个元素都是contents数组的一个数组项,整数集合的每个元素都是contents数组的一个数组项

length记录集合的元素数量,即contents的长度。

encoding属性决定contents数组元素的类型,并不是属性声明int8_t决定。encoding的属性可以为:INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64。通过encoding可以节约内存、提升灵活性。新加入元素与原先元素类型表不同时,会引发升级,但是没有降级操作。

当有新元素加入集合,而新元素比集合中现有的类型要长时,就会升级。
1.根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
2.将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
3.将新元素添加到底层数组里面

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

压缩列表

一个压缩列表可以包含任意多个节点,每个节点可以保存一个数组或者一个整数值。
在这里插入图片描述
在这里插入图片描述
压缩列表节点由:previous_entry_length、encoding、content三部分组成
在这里插入图片描述
previous_entry_length:以字节为单位,记录了压缩列表中前一个节点的长度。如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面。如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度

encoding:节点的encoding属性记录了节点的content属性所保存数据的类型以及长度

content:节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定

连锁更新:previous_entry_length是前个节点的长度。但如果在表头设置一个新节点,导致下一个节点的长度发生变法,而下一个节点长度发生变化,又使下下一个节点长度发生变化。就像多米诺骨牌一样,引起连锁反应。连锁更新可能是连锁扩展或者连锁压缩。

对象

Redis基于数据结构创建了对象系统,包括字符串对象、列表对象、哈希对象、集合对象和有序集合对象。每一种对象都有不同的数据结构实现。Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令,可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。

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

redisObject结构来表示Redis对象:
在这里插入图片描述
type属性记录了对象的类型,即是何种对象。
encoding属性记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为对象的底层实现
对象的ptr指针指向对象的底层实现数据结构
int refount:引用计数信息,对象创建时被设置为1,会根据程序的使用加减,即对象共享。当数值为0时,内存被回收。
lru属性:记录了对象最后一次被命令程序访问的时间

字符串对象
字符串对象的编码可以是int、raw或者embstr。字符串对象是Redis五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象。

int:如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示.虽然编码是Int,但是存储的结构为long.
raw:如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值
embstr编码是专门用于保存短字符串的一种优化编码方式,raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构.

embstr的好处:内存分配和内存释放都由两次缩减为1次,同时embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,读取速度更快。
embstr编码的字符串对象不能修改,是只读的,对embst的任何改动都会使类型变为row.

列表对象

列表对象的编码可以是ziplist或者linkedlist。ziplist编码的列表对象使用压缩列表作为底层实现,linkedlist编码的列表对象使用双端链表作为底层实现。

当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:
❑列表对象保存的所有字符串元素的长度都小于64字节;
❑列表对象保存的元素数量小于512个;不能满足这两个条件的列表对象需要使用linkedlist编码

哈希对象

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

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

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

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:
❑哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;
❑哈希对象保存的键值对数量小于512个;不能满足这两个条件的哈希对象需要使用hashtable编码。

集合对象

集合对象的编码可以是intset或者hashtable。

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

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

当集合对象可以同时满足以下两个条件时,对象使用intset编码:
❑集合对象保存的所有元素都是整数值;
❑集合对象保存的元素数量不超过512个。

有序集合对象

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

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

skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。可以同时支持范围操作和分值查找。

在这里插入图片描述
zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,程序可以用O(1)复杂度查找给定成员的分值。

zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素。通过这个跳跃表,程序可以对有序集合进行范围型操作

当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:
❑有序集合保存的元素数量小于128个;
❑有序集合保存的所有元素成员的长度都小于64字节;

类型检查与多态

Redis的操作键命令分为两种。一种对所有的对象都可以使用,比如DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令。一种只有特定的对象类型才可以使用。

在执行命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令,否则,服务器将拒绝执行命令,并向客户端返回一个类型错误。

多态:
Redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令。

DEL、EXPIRE等命令是基于类型的多态——一个命令可以同时用于处理多种不同类型的键
而其他命令是基于编码的多态——一个命令可以同时用于处理多种不同编码。

在这里插入图片描述

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

单机数据库

redisServer结构表示服务器状态,其中的db数组表示数据库,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库。对于每个与服务器进行连接的客户端,服务器都为这些客户端建立了相应的redis.h/redisClient结构。

redisServer{

//一个数组 保存所有数据库
redisDb *db;
//服务器数据库数量
int dbnum;
//记录保存条件的数组
//数组中的每个元素都是一个saveparam结构 存储了秒数和修改数
struct saveparam *saveparams;
//修改计数器  从上一次保存时间 一共进行了多少次修改
long long dirty;
//上一次执行保存的时间
time_lastsave;
//AOF写入缓冲
sds aof_buf
//AOF重写缓冲区
sda aof_rewrite_buf
//一个链表 保存所有客户端状态
list *clients
//主服务器地址
char *masterhost;
//主服务器端口
int masterport;
//订阅频道字典 键为频道 值为订阅的客户端
pubsub_channels
//订阅模式 pubsub_patterns属性是一个链表,链表中的每个节点都包含着一个pubsubPattern结构(客户端+订阅模式)
pubsub_patterns;

}

在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库。每个Redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者数据库读命令的时候,目标数据库就会成为这些命令的操作对象。默认情况下,Redis客户端的目标数据库为0号数据库.
客户端状态包含的属性可以分为两类:一类是比较通用的属性,另外一类是和特定功能相关的属性,比如操作数据库时需要用到的db属性和dictid属性,执行事务时需要用到的mstate属性.

redisClient{

	//套接字描述符 伪客户端为-1 普通客户端值为大于-1的整数
	int fd;
	//名字 默认没有名字指向null  执行名称则指向一个字符串保存名称
	robj *name;
	//标志   记录了客户端的角色(role),以及客户端目前所处的状态
	int flags;
	//输入缓冲区 保存客户端发送的命令
	sds querybuf
	//命令与命令参数
	robj **argv//argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数
	int argc;//argc属性则负责记录argv数组的长度
	//命令的实现函数
	struct redisCommand *cmd;//服务器将根据项argv[0]的值,在命令表中查找命令所对应的命令实现函数
	//输出缓冲区
	char buf[];//输出缓冲区数组 有两个输出缓冲区 一个定长 一个长度可变
	int bufpos;//输出缓冲区长度
	//身份验证  0表示未通过 1为已验证
 	int auth;
 	//时间
 	创建时间
 	上一次与服务器互动时间
 	客户端空转时间
	//记录客户当前正在使用的数据库
	redisDb *db;
	//从服务器监听端口
	int slave_listen_port;
	//被WATCH监控的数据键
	dict *watck_keys;

}

服务器中的每个数据库都由一个redis.h/redisDb结构表示,其中,redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间
redisDb{
//数据库空间 保存所有键值对
dict *dict;
//过期字典,保存键的过期时间
dict *expires;
}

设置键的生存时间或者过期时间
EXPIRE命令:设置生存时间 单位S
PEXPIRE命令:设置生存时间 单位MS
EXPIREAT:命令用于将键key的过期时间设置为timestamp所指定的秒数时间戳
PEXPIREAT:命令用于将键key的过期时间设置为timestamp所指定的毫秒数时间戳
TTL命令以秒为单位返回键的剩余生存时间,而PTTL命令则以毫秒为单位返回键的剩余生存时间
在这里插入图片描述

过期键的判定:检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间,检查当前UNIX时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则的话,键未过期。

过期键的删除:Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡。所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查,并且函数周期性的在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。
在这里插入图片描述

内存淘汰策略:
当 Redis 的内存超过最大允许的内存之后,Redis 会触发内存淘汰策略,这和过期策略是完全不同的两个概念。

1)noeviction:不淘汰任何数据,当内存不足时,执行缓存新增操作会报错,它是 Redis 默认内存淘汰策略。

(2)allkeys-lru:淘汰整个键值中最久未使用的键值。

(3)allkeys-random:随机淘汰任意键值。

(4)volatile-lru:淘汰所有设置了过期时间的键值中最久未使用的键值。

(5)volatile-random:随机淘汰设置了过期时间的任意键值。

(6)volatile-ttl:优先淘汰更早过期的键值。

(7)volatile-lfu,淘汰所有设置了过期时间的键值中最少使用的键值;

(8)allkeys-lfu,淘汰整个键值中最少使用的键值。

持久化

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

在服务器启动时,会自动载入持久化文件。默认优先采用AOF持久化,只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为在这里插入图片描述

RDB持久化

RDB持久化功能所生成的RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。因为RDB文件是保存在硬盘里面的,所以即使Redis服务器进程退出,甚至运行Redis服务器的计算机停机,但只要RDB文件仍然存在,Redis服务器就可以用它来还原数据库状态。

SAVE:SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求
BGSAVE:BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求.

因为BGSAVE命令可以在不阻塞服务器进程的情况下执行,所以Redis允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次BGSAVE命令。通过redisServer的saveparams属性记录保存条件(多少时间内满足多少次修改),通过dirty计数器记录修改次数,lastsave保存上一次保存的时间。

RDB文件结构:
在这里插入图片描述
RDB文件的最开头是REDIS部分,这个部分的长度为5字节,保存着“REDIS”五个字符。程序可以在载入文件时,快速检查所载入的文件是否RDB文件。
db_version长度为4字节,这个整数记录了RDB文件的版本号。
databases部分包含着零个或任意多个数据库,以及各个数据库中的键值对数据。一个RDB文件的databases部分可以保存任意多个非空数据库,每个非空数据库在RDB文件中都可以保存为SELECTDB、db_number、key_value_pairs三个部分。
在这里插入图片描述
SELECTDB常量的长度为1字节,当读入程序遇到这个值的时候,它知道接下来要读入的将是一个数据库号码
db_number保存着一个数据库号码
key_value_pairs部分保存了数据库中的所有键值对数据,不带过期时间的键值对在RDB文件中由TYPE、key、value三部分组成,带有过期时间的键值对新增expiretime_ms,ms。
在这里插入图片描述

EOF常量的长度为1字节,这个常量标志着RDB文件正文内容的结束。
check_sum是一个8字节长的无符号整数,保存着一个校验和,这个校验和是程序通过对REDIS、db_version、databases、EOF四个部分的内容进行计算得出的。

AOF持久化

与RDB持久化通过保存数据库中的键值对来记录数据库状态不同,AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态的。被写入AOF文件的所有命令都是以Redis的命令请求协议格式保存的

AOF持久化的实现分为命令追加、文件写入、文件同步。
命令追加:当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾
文件写入和同步:
一般在进行文件写入时,操作系统会先将要写入的数据保存在内存缓冲区里面,等到内存缓冲区填满在一次性的将内存缓存中的数据刷到磁盘上。所以Redis的文件写入是指由AOF_BUF缓冲写入内存缓存,只有同步之后才是真正保存在磁盘上。
在这里插入图片描述
AOF载入过程
在这里插入图片描述
AOF重写:
AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。AOF重写在主进程执行会导致服务器无法接受命令请求,所以Redis将AOF重写放到子进程中。子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性,子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求。

Redis设置了AOF重写缓冲区,在执行AOF重写时,父进程的写命令会被同时发送AOF缓冲和AOF重写缓冲。在执行完对原数据库的AOF写之后,会将AOF重写缓冲区的命令同步到新的AOF文件,并将新的AOF替代旧的AOF,完成重写。

在执行BGREWRITEAOF命令时,Redis服务器会维护一个AOF重写缓冲区,该缓冲区会在子进程创建新AOF文件期间,记录服务器执行的所有写命令。当子进程完成创建新AOF文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新AOF文件的末尾,使得新旧两个AOF文件所保存的数据库状态一致。最后,服务器用新的AOF文件替换旧的AOF文件,以此来完成AOF文件重写操作。

事件

Redis服务器是一个事件驱动程序,服务器需处理时间事件和文件事件
文件事件:服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作
时间事件:Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。

文件事件处理器使用I/O多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。文件事件处理器的四个组成部分,它们分别是套接字、I/O多路复用程序、文件事件分派器(dispatcher),以及事件处理器。
在这里插入图片描述
I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。I/O多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件。

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

Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求。服务器最常用的要数与客户端进行通信的连接应答处理器、命令请求处理器和命令回复处理器。

时间事件分为定时事件和周期性事件,定时事件让一段程序在指定的时间之后执行一次,周期事件让一段程序每隔指定时间就执行一次。一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值,如果事件处理器返回ae.h/AE_NOMORE,那么这个事件为定时事件,该事件在达到一次之后就会被删除,之后不再到达。如果事件处理器返回一个非AE_NOMORE的整数值,那么这个事件为周期性时间:当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的when属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。

时间事件由id:服务器为时间事件创建的全局唯一ID、when:毫秒精度的UNIX时间戳,记录了时间事件的到达(arrive)时间、timeProc:时间事件处理器,一个函数三部分构成

在这里插入图片描述
一个服务器可以与多个客户端建立起网络连接,每个客户端可以向服务器发送命令请求。通过使用I/O多路复用技术实现的文件事件处理器,Redis服务器使用单线程单进程的方式处理命令请求,并与多个客户端进行网络通信。

一个命令请求从发送到完成主要包括以下步骤:1)客户端将命令请求发送给服务器;2)服务器读取命令请求,并分析出命令参数;3)命令执行器根据参数查找命令的实现函数,然后执行实现函数并得出命令回复;4)服务器将命令回复返回给客户端

serverCron函数默认每隔100毫秒执行一次,它的工作主要包括更新服务器状态信息,处理服务器接收的SIGTERM信号,管理客户端资源和数据库状态,检查并执行持久化操作等等

服务器从启动到能够处理客户端的命令请求需要执行以下步骤:1)初始化服务器状态;2)载入服务器配置;3)初始化服务器数据结构;4)还原数据库状态;5)执行事件循环

复制

在Redis中,可以通过执行SLAVROF命令或者设置slaveof选项,让一个服务器去复制另一个复制器,称为主从复制。

Redis的复制功能分为同步和命令传播:
同步:同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
命令传播:命令传播操作则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态。即同步完成后,随着主服务器中的变化,从服务器也应该跟随相对应操作,即为命令传播。

同步操作:
1.从服务器向主服务器发送SYNC命令
2.收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令
3.当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态
4.主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态
在这里插入图片描述

假设在命令传播阶段主从服务器断开,那么SYNC必须重新开始,生成RDB文件,很浪费时间。因此后来采用PSYNC,PSYNC有完整同步和部分重同步两种模式,完整同步和初次同步操作相同,而部分重同步不需要重写RDB,只需要将一些命令重新发送即可。

部分重同步功能由以下三个部分构成
主服务器的复制偏移量(replication offset)和从服务器的复制偏移量:主从服务器每次向传播N个字节的数据时,就将自己的复制偏移量的值加上N
主服务器的复制积压缓冲区:复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为1MB。当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里面,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量。主服务器接收到从服务器的复制偏移量,会判断是否在缓冲区中。在则部分重同步,不在则完全重同步。
服务器的运行ID:当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器则会将这个运行ID保存起来,当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行ID。如果相同则尝试部分重连接,否则执行同步操作。
在这里插入图片描述

复制的整体操作:
1.设置主服务器的地址和端口
2.建立套接字连接
3.发送PING命令在这里插入图片描述

4.身份验证
在这里插入图片描述

5发送端口信息.
6.同步.在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,主服务器也会成为从服务器的客户端.因为命令传播需要将缓冲区的命令传输到从服务器。在同步操作执行之后,主从服务器双方都是对方的客户端,它们可以互相向对方发送命令请求,或者互相向对方返回命令回复,
7.命令传播

在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令REPLCONF ACK <replication_offset>
replication_offset是从服务器当前的复制偏移量.主要有三个作用:
1.检测主从服务器网络状态:主服务记录最近一次从服务器心跳包到达时间,超过一秒说明有故障
2.辅助实现ming-salves选项:当延迟大于一定时间时,主服务器拒绝写命令
3.检测命令丢失:如果主服务器发送给从服务器的写命令丢失,那么通过心跳包的replication_offset会从复制积压缓冲区中找到确实的数据,重新发送

Sentinel

Sentinel(哨岗、哨兵)是Redis的高可用性(high availability)解决方案:由一个或多个Sentinel实例(instance)组成的Sentinel系统(system)可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求.

Sentinel是一个运行在特殊模式下的Redis服务器,Sentinel启动时
1.初始化服务器。初始化一个Redis服务器,普通服务器在初始化时会通过载入RDB文件或者AOF文件来还原数据库状态,但是因为Sentinel并不使用数据库,所以初始化Sentinel时就不会载入RDB文件或者AOF文件。
2.使用Sentinel专用代码。Sentinel模式下和普通模式下使用的命令不同,所有采用Sentinel专用代码命令表,因此在Sentinel模式下不能使用SET/EVAL等,PING、SENTINEL、INFO、SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE和PUNSUBSCRIBE这七个命令就是客户端可以对Sentinel执行的全部命令了。
3.初始化Sentinel状态在这里插入图片描述

4.初始化Sentinel状态的masters属性
5.创建连向主服务器的网络连接。Sentinel会创建两个连向主服务器的异步网络连接:一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复。另一个是订阅连接,这个连接专门用于订阅主服务器的__sentinel__:hello频道

Sentinel默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并通过分析INFO命令的回复来获取主服务器的当前信息。一方面是关于主服务器本身的信息,包括run_id域记录的服务器运行ID,以及role域记录的服务器角色,另一方面是关于主服务器属下所有从服务器的信息。

当Sentinel发现主服务器有新的从服务器出现时,Sentinel除了会为这个新的从服务器创建相应的实例结构之外,Sentinel还会创建连接到从服务器的命令连接和订阅连接。在创建命令连接之后,Sentinel在默认情况下,会以每十秒一次的频率通过命令连接向从服务器发送INFO命令,并获得回复得到此从服务和它的主服务器的信息。

Sentinel会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送命令,此条命令向服务器的__sentinel__:hello频道发送了一条信息,信息的内容由多个参数组成。Sentine本身的信息+主服务器的信息(如果监视的是从服务器,就是此从服务器的主服务器信息)。同时Sentinel会接受服务器__sentinel__:hello频道的信息。

对于监视同一个服务器的多个Sentinel来说,一个Sentinel发送的信息会被其他Sentinel接收到,这些信息会被用于更新其他Sentinel对发送信息Sentinel的认知,也会被用于更新其他Sentinel对被监视服务器的认知。本身通过命令连接发送的消息,还会通过订阅连接接收到。判断ID是自己则扔掉,不是自己则是其他Sentinel发送的,用户来更新。

在这里插入图片描述
Sentinel为主服务器创建的实例结构中的sentinels字典保存了除Sentinel本身之外,所有同样监视这个主服务器的其他Sentinel的资料。当Sentinel通过频道信息发现一个新的Sentinel时,它不仅会为新Sentinel在sentinels字典中创建相应的实例结构,还会创建一个连向新Sentinel的命令连接,而新Sentinel也同样会创建连向这个Sentinel的命令连接,最终监视同一主服务器的多个Sentinel将形成相互连接的网络。Sentinel之间不会创建订阅连接

主观下线:在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。通过设置判断主观下线所需要的时间,在这段时间内连续收到无效回复,则标记为主管下线。

客观下线:当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当Sentinel从其他Sentinel那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移操作

领头Sentinel:当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并由领头Sentinel对下线主服务器执行故障转移操作。

故障转移:
1.在已下线主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器。
2.让已下线主服务器属下的所有从服务器改为复制新的主服务器
3.将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器

集群

Redis是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。一个Redis集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,连接各个节点的工作可以使用CLUSTER MEET命令来完成。

一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式。节点(运行在集群模式下的Redis服务器)会继续使用所有在单机模式中使用的服务器组件。此外,采用clusterNode结构、clusterLink结构、clusterState结构保存集群模式下的数据。
在这里插入图片描述
clusterNode结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址和端口号等。每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode结构,以此来记录其他节点的状态。clusterNode中包含clusterNode,clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区。最后,每个节点都保存着一个clusterState结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元。

clusterNode{

时间
名字
配置基元
节点IP
端口号
//保存了连接节点所需的有关信息
link *clusterLink;
//槽信息
char slots[16384/8]
//处理槽的数量
int numslots;
//从节点设置主节点的地址
clusterNode *salveof;

}

clusterState{
//每个项都指向一个clutserNode结构,表示槽所指派的节点
clusterNode *slots[16384];
}
clusterState.slots数组记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode结构所代表的节点的槽指派信息,这是两个slots数组的关键区别所在
在这里插入图片描述

Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽(slot),数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)

在这里插入图片描述

集群节点保存键值对以及键值对过期时间的方式,与第9章里面介绍的单机Redis服务器保存键值对以及键值对过期时间的方式完全相同。节点和单机服务器在数据库方面的一个区别是,节点只能使用0号数据库,而单机Redis服务器则没有这一限制。

Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。

Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。
在这里插入图片描述

ASK错误:当重新分片时,源节点接收到命令,要处理的槽刚好正在被迁移。源节点先会在自己的数据查找,如果找到就执行命令,找不到则这个键可以已经被迁移,向客户端发送ASK命令,指引客户端转向正在被槽导入的目标节点,并再次发送想要执行的命令。与ASK相配合的是ASKING,它会将客户端的Redis_ASKING标识打开,标识这是再次请求的命令。

一般情况下客户端向节点发送槽i的命令,如果槽i不在这个节点,节点就会发送MOVED错误,但是如果这个节点是正在导入的槽,且客户端带有ASKING标识,那么依然会执行。ASKING是一次性的,当客户端发送一次命令之后,ASKING标识移除。在这里插入图片描述

ASK和MOVED的区别:
MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点:在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点,因为该节点就是目前负责槽i的节点。

复制与故障转移:
Redis集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求.cluster replicate 可以让接收命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制。一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点,集群中的所有节点都会在代表主节点的clusterNode结构的slaves属性和numslaves属性中记录正在复制这个主节点的从节点名单。

集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线。

集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息,例如某个节点是处于在线状态、疑似下线状态(PFAIL),还是已下线状态(FAIL)。

如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL),将主节点x标记为已下线的节点会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线

消息:
MEET消息
PING消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。如果节点A最后一次收到节点B发送的PONG消息的时间,距离当前时间已经超过了节点A的cluster-node-timeout选项设置时长的一半,那么节点A也会向节点B发送PING消息,这可以防止节点A因为长时间没有随机选中节点B作为PING消息的发送对象而导致对节点B的信息更新滞后
PONG消息:当接收者收到发送者发来的MEET消息或者PING消息时,为了向发送者确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条PONG消息
FAIL消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令

发布与订阅

Redis的发布与订阅功能由PUBLISH、SUBSCRIBE、PSUBSCRIBE等命令组成。

Redis将所有频道的订阅关系都保存在服务器状态的pubsub_channels字典里面,这个字典的键是某个被订阅的频道,而键的值则是一个链表,链表里面记录了所有订阅这个频道的客户端。
SUBSCRIBE命令用来订阅频道
UNSUBSCRIBE命令用来退订频道
PSUBSCRIBE命令订阅某些模式
UNPSUBSCRIBE命令退订某些模式

事务

Redis通过MULTI、EXEC、WATCH等命令来实现事务(transaction)功能。事务提供了一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求,它会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求。

事务可以分为三个阶段:事务开始、事务入队、事务执行

MULTI命令可以使执行该命令的客户端从非事务状态切换到事务状态。在非事务状态下,服务器接收到客户端请求会立即执行并返回,而在非事务状态下会将命令入队,并不立即执行,等到客户端发送EXEC/DISCARD/WATCH/MULTI等这四个之一时立即执行。

在这里插入图片描述

Redis事务队列保存在RedisClient的mstate属性中,包含事务队列和已经入队的命令数。执行事务时会保持先进先出,依次执行。

WATCH命令是一个乐观锁(optimistic locking),它可以在EXEC命令执行之前,监视任意数量的数据库键,并在EXEC命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复。

每个Redis数据库都保存着一个watched_keys字典,这个字典的键是某个被WATCH命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端

Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制(rollback),即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将事务队列中的所有命令都执行完毕为止。不支持事务回滚是因为这种复杂的功能和Redis追求简单高效的设计主旨不相符

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值