Redis源码实现

数据结构与对象

简单动态字符串

Redis没有直接使用C语言传统的字符串(以空字符结尾的字符数组),而是自己构建了简单动态字符串类型(SDS)表示字符串

在Redis里面,C语言传统字符串只会用在字符串常量,不需要对字符串内容进行修改的地方,例如打印日志

redisLog(REDIS_ERROR, "error log")

举个例子:
客户端执行:set msg “hello world”
Redis会在数据库中创建一个键值对。其中键是一个字符串对象,底层实现是一个保存着字符串"msg"的SDS;值也是一个字符串对象,底层实现是一个保存着字符串"hello world"的SDS

除了用来保存字符串值外,SDS还被用作缓冲区:AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区

SDS的定义

每个sdshdr结构表示一个SDS值

type sdshdr struct {
	int len		//已使用字节数,表示字符串长度
	int free		//空闲字节数
	[]char buf  //字节数组,保存字符串
}

SDS遵循C字符串以空字符结尾的习惯,SDS函数会自动为空字符分配额外的一个字节空间,并将空字符添加到字节数组的末尾,这个空字符不会计算在len属性中
SDS遵循空字符结尾的好处是,可以重用C语言中一些操作字符串的函数
例如:想打印s这个SDS变量保存的字符串值,可以使用printf函数,执行prinf("%s", s->buf)

SDS与C字符串的区别
常数复杂度获取字符串长度

因为C语言字符串不记录本身的长度,所以要获取字符串长度需要遍历整个字符串,直到遇到空字符为止,统计遍历的字符个数,时间复杂度是O(n);而SDS结构使用len属性记录了保存的字符串长度,时间复杂度是O(1)

设置和更新SDS长度是SDS API在执行时自动完成的,不需要手动设置SDS的长度

好处:因为Redis采用SDS存储字符串键,当使用STRLEN命令获取字符串键的长度时,即使字符串键很长也不会影响效率

杜绝缓冲区溢出

C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出,例如strcat函数可以将src字符串拼接到desc字符串的末尾
strcat([]char src, []char desc)
因为C字符串不记录自身长度,所以strcat函数在执行时假设用户已经为desc分配了足够多的空间,来存储src字符串,而一旦这个假设不成立,就会造成缓冲区溢出
例如:在内存中存储着两个相邻的字符串s1、s2,s1的内容是"Redis",s2的内容是"hello",如图:

R e d i s h e l l o
s1        s2

如果直接执行strcat(“world”,s1),那么s1的内容会变成"Redisworld",由于s1没有提前分配额外的空间,所以s2的内容会变成"world"

R e d i s w o r l d
s1        s2

SDS的空间分配策略完全杜绝了这种情况,当SDS的API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改的需要,如果不满足那么将空间扩容至执行修改所需的大小,然后才执行修改,因此不会出现缓冲区溢出的情况

减少修改字符串时带来的内存重分配次数

每次增长或者减少一个C字符串,都要对这个C字符串的数组进行内存重分配操作:

  1. 如果执行的是字符串增长操作,例如strcat操作,那么在执行操作之前要通过内存重分配对底层数组进行扩展——如果忘了扩展会带来内存溢出的问题
  2. 如果执行的是字符串缩短操作,例如截断(trim)操作,那么在执行操作后要通过内存重分配对底层数组不用的空间进行回收——如果忘了回收会带来内存泄露的问题

例如:拥有一个C字符串"Redis",为了将字符串s的值改为"Redis Cluster",要执行strcat操作
strcat(" Cluster", “Redis”)
执行之前要进行内存重分配对字符串s底层数组进行扩展
之后,又打算将s的值改为”Redis Cluster Cluster“,执行之前又要进行内存重分配对字符串s进行扩展

内存重分配是一个非常耗时的操作:

1. 在一般系统中,对字符串值的修改并不频繁,每次修改都执行一次内存重分配没啥问题
2. 但是Redis作为一个数据库,是一个速度要求严苛、数据频繁修改的场景,如果每次修改都涉及一次内存重分配的话,那么光是内存重分配的时间都要占据修改时间的一半了

SDS通过空闲长度free解决这个问题,在SDS的buf字节数组中包含未使用的字节部分
通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略:

1. 空间预分配
	空间预分配用来优化字符串的拼接操作,当SDS的API对SDS进行操作,发现SDS空间不够要进行内存重分配做扩展时,不仅会分配修改所需要的空间,还会额外分配一些未使用的空间,未使用空间可以减少连续拼接带来的内容重分配操作
	额外分配的字节数由以下公式决定:
		1. 如果修改后的空间即len属性小于1M,那么额外分配和len属性相同的空闲空间,即len等于free
		2. 如果修改后的空间即len属性大于等于1M,那么额外分配1M的空闲空间,即free等于1M
2. 惰性空间释放
	惰性空间释放用来优化字符串的缩短操作,当SDS的API对SDS的字符串进行缩短时,不会使用内存重分配释放缩短的空间,而是会用free属性记录缩短的字节数
	当进行字符串拼接时,就可以使用free属性记录的缩短字节数,减少内存重分配
	SDS也提供了相应的API让我们可以在必要的时候对空闲空间进行回收
二进制安全

C语言字符串中的字符必须符合某种编码(例如ASCII),并且除了字符串的末尾外,字符串里面不能包含空字符,这些限制使得C语言字符串只能存储文本数据,不能存储二进制数据
所有SDS的API都以处理二进制的方式来处理存储在buf数组中的数据,程序不会对其中的数据做任何限制、过滤,数据在写入时是什么样,被读取出来时就是什么样
SDS的buf数组可以用来保存二进制数据

兼容部分C字符串函数

SDS遵循了C字符串以空字符结尾的习惯,SDS的API会自动为空字符分配一字节的空间并且会将空字符添加到buf数组的末尾,目的就是为了SDS可以使用一部分C语言字符串函数

总结
C字符串SDS
获取字符串长度时间复杂度为O(n)获取字符串长度时间复杂度为O(1)
API是不安全的,可能造成内存溢出API是安全的,不会造成内存溢出
修改N次字符串要内存重分配N次修改N次字符串最多内存重分配N次
只能保存文本数据可以保存文本或者二进制数据
可以使用所有C字符串函数只能使用部门C字符串函数
重点回顾
  • Redis只会使用C字符串存储字符串常量(即值不会变的字符串)例如日志,Redis使用SDS存储字符串值
  • 相比C字符串,SDS的好处
    1. 获取字符串长度的时间复杂度为O(1)
    2. API是安全的,不会出现内存溢出
    3. 拼接、缩短字符串时,内存重分配次数少
    4. 二进制安全

链表

列表键的底层实现之一就是链表,当一个列表键包含数量比较多的元素或者列表中包含的元素都是比较长的字符串时,Redis会选择链表作为列表的底层实现

发布订阅、慢查询、监视器等功能也都用到了链表,Redis服务器还是用链表保存多个客户端的状态信息,以及使用链表构建客户端输出缓冲区

链表和链表节点的实现
  1. 每个链表节点使用一个listNode结构表示:
type listNode struct {
	*listNode pre			//前置节点
	*listNode next		//后置节点
	void *value			//节点的值
}

多个listNode可以通过pre和next指针组成双端链表

  1. 使用list持有链表,操作起来更方便
type list struct {
	*listNode head		//表头节点
	*listNode tail			//表尾节点
	long len				//节点数量
	dup(*ptr)			//节点值复制函数
	free(*ptr)			//节点值释放函数
	match(*ptr)		//节点值对比函数
}

list为链表提供了表头指针head、表尾指针tail、节点数量len
并且提供了3个操作节点值的函数:

 dup:复制节点保存的值
 free:释放节点保存的值
 match:判断节点保存的值和另一个输入值是否相等
  1. 链表特性
    • 双端
      每个节点带有pre、next指针,获取前后节点的时间复杂度都是O(1)
    • 无环
      表头节点的pre指针和表尾节点的next指针都为NULL
    • 带有表头指针和表尾指针
      带有表头指针head、表尾指针tail,获取表头节点、表尾节点的时间复杂度都是O(1)
    • 带链表长度
      len属性记录了链表中节点个数
重点回顾
  • 链表被广泛用于各种功能,比如列表键、发布订阅、慢日志、监视器
  • 每个链表节点由一个listNode结构表示,该结构拥有指向前置节点的pre指针和指向后置节点的next指针,节点组成的链表是一个双端链表
  • 链表由list结构表示,该结构拥有表头指针head、表尾指针tail、链表节点数len等信息
  • 链表头节点的pre指针和尾结点的next指针都为NULL,所以链表是无环的
  • 链表节点保存的值可以是任何类型

字典

字典又称为映射,是一种保存键值对的数据结构
字典中的每个键都是独一无二的,可以在字典中根据键查找对应的值,也可以根据键修改对应的值或者根据键删除键值对
Redis数据库底层实现就是使用字典实现的,对数据库的增删改查都是基于字典的操作

字典还是哈希键的实现之一,当哈希键包含的键值对比较多或者键值对都是比较长的字符串时,会使用字典作为实现

字典的实现

字典采用哈希表作为底层实现,一个哈希表可以有多个哈希节点,而每个哈希节点就保存了一个键值对

哈希表

哈希表由dictht结构定义:

type dichth struct {
	[]*dictEntry table 		        //哈希表数组
	long 		 size	           	//哈希表大小
	long         sizemask			//哈希表大小掩码,用来计算索引值,总是等于size-1
	long         used				//哈希表已有节点数
}

属性介绍:

  1. table是一个哈希表节点的数组,每一个元素都是指向哈希表节点dicEntry的指针,每个哈希表节点保存了一个键值对
  2. size记录了哈希表的大小,即table数组的大小
  3. sizemask是哈希表大小的掩码,等于size-1,用来和键的哈希值进行运算得到键所在的索引值
  4. used代表哈希表已有节点数
哈希表节点

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

type dictEntry struct {
	void *key				//键
	union {
		void *val
		uint64 u64
		int64   s64
	} v					//值
	*dictEntry	next 		//指向下一个哈希表节点
}

属性介绍:

  1. key是键值对中的键
  2. v是键值对中的值,其中值可以是一个指针、一个uint64的整数、一个int64的整数
  3. next指定下一个哈希表节点,可以用来将多个哈希值相同的键值对串起来,解决哈希冲突问题
字典

字典由dict结构表示:

type dict struct {
	*dictType 	type 		//类型特定函数
	*void privdata			//私有数据
	dictht	ht[2]				//哈希表
	int   rehashindex   //rehash索引,当不在rehash时值为-1
}

属性介绍:

  1. type属性指向一个包含一系列操作键值对函数的结构dictType,Redis会为不同的字典设置不同的函数
type dictType struct {
	hashFunction(key)			//计算键的哈希值
	keyDup(key)					//复制键的函数
	valDup(val)						//复制值的函数
	keyCompare(key1,key2)	//对比键的函数
	keyDestructor(key)			//销毁键的函数
	valDestructor(key)			//销毁值的函数
}
  1. ht属性是一个包含两个哈希表的数组,一般情况下只使用ht[0]哈希表,ht[1]哈希表只在对ht[0]哈希表进行rehash时使用
  2. rehashindex记录了当前rehash的进度,如果当前没有rehash那么值为-1
哈希算法

当要将一个键值对添加到字典中时,需要先根据键的hash值和哈希表sizemash的值计算得到索引值index,然后将包含键值对的哈希表节点放到哈希表数组index索引位置上

计算key的hash值和索引值的公式如下:

hash = dict->dictType->hashFunction(key)
index = hash & dict->ht[x]->sizemash
解决键冲突

当有两个及以上的键被分配到了哈希表数组的同一个位置,称这些键发生了哈希冲突

Redis的哈希表使用链表法解决哈希冲突,每个哈希表节点都有一个next指针指向下一个哈希表节点,同一个索引位置的多个哈希表节点可以通过next指针串成一个链表

由于哈希表节点组成的链表没有指向尾节点的指针,所以在添加新节点时是将节点添加到链表的头部,时间复杂度是O(1)

Rehash

随着操作的不断执行,哈希表保存的键值对会逐渐变多或者变少,为了让哈希表的负载因子维持在一个合理的范围内,当哈希表保存的键值对太多或者太少时,需要对哈希表的大小进行缩小或扩大

缩小和扩大哈希表的操作由rehash完成,rehash步骤如下:

1. 为ht[1]哈希表分配空间,这个哈希表的空间大小取决于要进行的操作以及ht[0]哈希表中键值对的个数
	* 如果执行的是扩展操作,那么ht[1]的大小是第一个大于等于ht[0].used*2的2^n
	* 如果执行的是缩小操作,那么ht[1]的大小是第一个大于等于ht[0].used的2^n
2. 将保存在ht[0]的键值对重新计算哈希值和索引值迁移到ht[1]上
3. 当ht[0]中的键值对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],并为ht[1]创建一个空白哈希表,为下一次rehash做准备

哈希表的扩展和收缩

  1. 当以下任意一个条件满足时,对哈希表执行扩展
    1)服务器目前没有在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
    2)服务器目前正在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
    负载因子计算公式: load_factor = ht[0].used/ht[0].size
    3)为什么负载因子大小和BGSAVE、BGREWRITEAOF有关
    因为执行BGSAVE和BGREWRITEAOF时会创建子进程,大多数操作系统都会采用写时复制(会占用大量内存)优化子进程的使用效率,所以在子进程存在期间要尽可能避免rehash操作,因此rehash操作会占用大量内存
  2. 当哈希表的负载因子小于0.1时,会执行哈希表缩小操作
渐进式rehash

扩展和收缩哈希表时,会将ht[0]的键值对rehash到ht[1],但这个rehash操作并不是一次性的而是分多次、渐进式的
原因在于:如果ht[0]包含的键值对过多,那么一次性迁移所有键值对会消耗很多时间,对服务器的性能产生影响;而分多次、渐进式的迁移键值对则不会有影响

渐进式rehash的步骤:

  1. 为ht[1]分配空间
  2. 在字典中维护rehashindex变量记录rehash进度,初始值为0,代表rehash开始
  3. 在rehash期间,每次对字典执行增删改查操作时,除了执行本身的操作外,还会将ht[0]中rehashindex位置的所有键值对迁移到ht[1],迁移完后将rehashindex的值加1
  4. 随着字典的不断操作,最终ht[0]上的所有键值对都会迁移到ht[1]上,这时将rehashindex的值置为-1,代表rehash结束

渐进式rehash期间的字典操作:
5. rehash期间,查询、修改、删除操作会在ht[0]、ht[1]两个哈希表上进行,先在ht[0]上查找键,查不到再去ht[1]上查
6. 添加操作会在ht[1]哈希表上操作

重点回顾
  1. 字典被用于Redis数据库和哈希键的实现
  2. 字典使用哈希表作为底层实现,每个字典有两个哈希表,一个平时使用,一个热哈啥时使用
  3. 哈希表使用链表法解决哈希冲突
  4. 在对哈希表进行扩展或收缩时,需要将ht[0]哈希表中的键值对分多次、渐进式地rehash到ht[1]哈希表中

跳表

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

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

Redis只在有序集合键和集群节点的内部数据结构中用到了跳表

跳表的实现

Redis的跳表由zskiplistNode和zskiplist两个结构定义,其中zskiplistNode表示跳跃表节点,而zskiplist保存跳表相关信息,比如节点数量、指向表头节点和表尾节点的指针
TODO 补充跳表图
最左边是zskiplist结构,该结构包含以下属性:

  1. header: 指向跳跃表的表头节点
  2. tail: 指向跳跃表的表尾节点
  3. level: 层数最大的跳跃表节点的层数(不包括头节点)
  4. length: 跳跃表包含的节点个数(不包含头结点)

右边是四个zskiplistNode结构,zskiplistNode结构包含如下属性:

  1. 层(level):节点中用L1、L2等字样标记各个层。每个层都带有两个属性:前进指针和跨度。前进指针用于访问表尾方向的其他节点,跨度记录了前进指针指向的节点和当前节点的距离
  2. 后退指针(backward):节点中用BW标记后退指针,它指向当前节点的前一个节点
  3. 分值(score):各个节点的1.0、2.0是节点保存的分值。在跳跃表中,各个节点按照分值从小到大排列
  4. 成员对象(obj):各个节点中的o1、o2保存的是成员对象
跳跃表节点

跳跃表节点由zskiplistNode结构表示:

type zskiplistNode struct {
	*zskiplistNode backward			//后退指针
	double score							//分数
	*redisObject obj					//成员对象
	[]zskiplistLevel		level			//层
}

type zskiplistLevel struct {
	*zskiplistNode   forward		//前进指针
	int span						//跨度
}

属性介绍:


  1. 跳跃表level数组中的每个元素都是一个层结构,该结构包含一个指向表尾方向节点的前进指针,可以通过这些层的前进指针加快访问其他节点的速度,一般来说,层数越多,访问其他节点的速度越快
    每次创建一个新的跳跃表节点时,都会根据幂次定律(越大的数出现的概率越低)随机生成一个介于1~32的整数作为节点的层数即level数组的大小
  2. 前进指针
    每一层都包含一个指针表尾方向节点的前进指针,用于从表头向表尾方向访问节点
  3. 跨度
    层的跨度用于记录前进指针指向的节点和当前节点的距离
    跨度越大,两个节点相距的越远
    前进节点指向NULL的层跨度为0
    跨度是用来计算节点在跳表中的排位的,在查找某个节点的过程中,将沿途访问的所有层的跨度加起来就是这个节点在跳表中的排位
  4. 后退指针
    节点的后退指针用于从表尾向表头方向访问节点
    后退指针每次只能后退一个节点,前进指针可以前进多个节点
  5. 分值和成员
    节点的分值是一个double类型的浮点数,跳表中的所有节点按照分值从小到大排列
    节点的成员对象是一个指针,指向一个字符串对象,字符串对象底层采用SDS实现
    在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,分值可以是相同的。相同分值的节点按照成员对象的字典顺序从小到大排列。
跳跃表

通过使用zskiplist结构可以更方便地对跳表进行处理
zskiplist定义如下:

type zskiplist struct {
	*zskiplistNode		header		//头节点指针
	*zskiplistNode		tail			//尾节点指针
	long length							//节点数量
	int level								//层数最大的节点的层数
}

属性说明:

  1. header指向头节点,定位头节点的时间复杂度是O(1)
  2. tail指向尾节点,定位尾节点的时间复杂度是O(1)
  3. length存储节点数量,不包括头节点
  4. level存储层数最大节点的层数,用来获取跳表最大层数
重点回顾
  1. 跳表是有序集合的实现之一
  2. Redis的跳表由zskiplistNode和zskiplist组成,其中zskiplistNode保存的是跳表节点信息,zskiplist保存跳表相关信息
  3. 每个跳表节点的高度是1~32之间的随机数
  4. 同一个跳表中,每个跳表节点的成员对象必须唯一,分值可以相同
  5. 跳表节点按照分值从小到大排列,分值相同的成员对象按照字典序从小到大排列

整数集合

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

整数集合的实现

整数集合是用来保存整数值的集合,可以保存类型为int16、int32、int64类型的整数值,并且保证集合中不会出现重复的元素

每个intset的结构:

type intset struct {
	uint32 encoding		//编码方式
	uint32  length			//包含的元素数量
	contents					//元素数组
}

属性说明:

  1. contents:整数集合的所有元素都存储在contents数组中,该数组中的数组项都按从小到大的顺序排列,并且数组中不包含重复的数组项
  2. length:整数集合包含的元素数量即contents数组的长度
  3. encoding:contents数组元素的类型取决于encoding属性的值
    1. 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16类型的数组
    2. 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32类型的数组
    3. 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64类型的数组

根据整数集合的升级规则,当像一个int16类型的contents数组中添加一个int64的整数,会将contents数组中的所有元素提升为int64类型

升级

每当添加一个新元素到整数集合时,并且新元素的类型比整数集合已有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面

升级整数集合并添加新元素的步骤为:
1. 根据新元素类型,扩展整数集合底层contents数组的空间大小,并为新元素分配空间
2. 将底层数组现有的所有元素都转换成和新元素相同的类型,并将类型转换后的元素放在正确的位置上,维持底层数组有序性不变
3. 将新元素添加到底层数组里

因为每次向整数集合添加新元素都可能会引起升级,而每次升级都要对已经元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(n)

升级的好处

升级策略有两个好处:1.增加灵活性 2.节约内存

  1. 增加灵活性
    C语言是静态语言,数组中只会存储一种类型的元素,但是由于整数集合可以通过升级策略适应添加的新类型的元素,所以整数集合中可以存储int16、int32、int64类型的整数
  2. 节约内存
    如果要让一个数组保存int16、int32、int64类型的整数,那么就需要将数组的元素类型设置为int64,即使数组中只存了int16、int32类型的整数也要使用int64类型,这样比较浪费内存
    但是通过升级策略,当只存储int16、int32类型的整数时,整数集合只需要使用int32类型作为元素类型,不需要使用int64类型,这样节约内存
降级

整数集合不支持降级,一旦升级,编码就会一直保持升级后的状态

重点回顾
  1. 整数集合是集合键的实现之一
  2. 整数集合可以存储int16、int32、int64类型的整数
  3. 整数集合的底层存储是数组,这个数组中的数组项从小到大排列,并且数组中不包含重复的数组项
  4. 当新添加元素的元素类型比整数集合当前存储的元素的元素类型都要长时,那么要对整数集合进行升级,扩展底层存储的数组
  5. 升级操作带来了存储上的灵活性以及节约内存
  6. 整数集合只支持升级操作,不支持降级操作

压缩列表

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

压缩列表的构成

一个压缩列表可以包含多个节点,每个节点可以保存一个字符串或者一个整数值

压缩列表组成项:

  1. zlbytes:4字节,记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或者计算zlend位置时使用
  2. zltail:记录压缩列表表尾节点距离起始地址有多少字节,通过该属性可以直接定位到表尾节点不需要遍历
  3. zllen:记录压缩列表包含的节点数。当节点数小于65535时,该值就是节点的真实数量;当节点数等于65535时,节点的真实数量需要遍历整个压缩列表
  4. entryX:压缩列表节点,节点的长度由节点保存的内容决定
  5. zlend:标记压缩列表的末尾
压缩列表节点构成

每个压缩列表节点可以保存一个字节数组或者一个整数值
字节数组可以是以下三种长度之一:
1. 长度小于等于2^6-1字节的字节数组
2. 长度小于等于2^14-1字节的字节数组
3. 长度小于等于2^32-1字节的字节数组
整数值可以是以下六种长度之一:
1. 4位长
2. 1字节长的有符号整数
3. 3字节长的有符号整数
4. int16类型整数
5. int32类型整数
6. int64类型整数

每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成

previous_entry_length

previous_entry_length属性记录了前一个节点的长度,该属性的长度可以是1字节或5字节

  1. 如果前一个节点的长度小于254字节,那么该属性的长度是1字节,前一个节点的长度保存在这一个字节中
  2. 如果前一个节点的长度大于等于254字节,该属性的长度是5字节,前一个节点的长度保存在后4个字节,第一个字节被设置为OxFE

如果有一个指向当前节点起始地址的指针c,那么只要用指针c减去当前节点previous_entry_length属性的值,就可以得出指向前一个节点起始地址的指针p。压缩列表从表尾向表头遍历就是使用这一原理实现的。

encoding

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

  1. 一字节、两字节或者五字节长,值的最高位为00、01或者10是字节数组编码表明content属性保存的是字节数组,数组的长度是编码除去最高两位之后的其他位
  2. 一字节长,值的最高位为11是整数编码,整数类型取决于除去编码最高两位的剩余位
content

content负责保存节点值,节点值可以是字节数组也可以是整数,值的具体类型和长度由encoding属性决定

连锁更新

在一个压缩列表中,有多个连续的、长度介于250字节到253字节的节点e1至eN
因为e1至eN的所有节点长度都小于254字节,所以每个节点都只需要1字节长的previous_entry_length
这时,如果将一个长度大于等于254字节的新节点添加到压缩列表的头结点即成为e1的前一个节点,那么e1节点的previous_entry_length属性要扩展为5字节,e1节点的长度就会大于等于254字节,导致e2字节扩展,e2又导致e3扩展,最终会导致所有的节点都进行扩展,程序需要进行很多次的空间重分配

除了添加节点外,删除节点也会导致连锁更新的发生

尽管连锁更新会给性能带来很大影响,但是发生的几率是很低的:

1. 首先压缩列表中需要恰好有多个连续的、长度介于250~253字节的节点,连锁更新才有可能发生
2. 即使出现连锁更新,只要被更新的节点数量不多,也不会对性能造成任何影响
重点回顾
  1. 压缩列表是一种为了节约内存而开发的顺序性顺序结构
  2. 压缩列表可以作为列表键和哈希键的实现之一
  3. 压缩列表可以包含多个节点,每个节点可以保存字符串或者整数值
  4. 添加节点到压缩列表或者删除压缩列表节点都可能引发连锁更新,但是出现的概率并不高

对象

Redis中包含五种不同类型的对象,包括字符串对象、列表对象、集合对象、哈希对象、有序集合对象

对象的类型与编码

每当在Redis数据库中创建一个键值对时,都会创建一个键对象和一个值对象
每个对象都由一个redisObject表示

type redisObject struct {
	type 		//类型
	encoding	//编码
	*ptr		//指向底层数据结构的指针
}
类型

type属性记录了对象的类型,类型常量如下:

常量名称对象名称
REDIS_STRING字符串对象
REDIS_LIST列表对象
REDIS_HASH哈希对象
REDIS_SET集合对象
REDIS_ZSET有序集合对象

当我们执行TYPE命令时,就是将数据库键对应的值对象的type属性返回
TYPE msg

编码和底层实现

对象的编码encoding决定了对象底层指向的数据结构
编码常量如下:

编码常量编码对应的数据结构
REDIS_ENCODING_INTlong类型的整数
REDIS_ENCODING_EMBSTRembstr编码的简单动态字符串
REDIS_ENCODING_RAW简单动态字符串
REDIS_ENCODING_HT字典
REDIS_ENCODING_LINKEDLIST双端链表
REDIS_ENCODING_ZIPLIST压缩列表
REDIS_ENCODING_INTSET整数集合
REDIS_ENCODING_SKIPLIST跳表和字典

每种不同的对象都至少可以使用两种数据结构,对应关系如下表:

对象类型编码数据结构
REDIS_STRINGREDIS_ENCODING_INTlong类型整数
REDIS_STRINGREDIS_ENCODING_EMBSTRembstr编码的简单动态字符串
REDIS_STRINGREDIS_ENCODING_RAW简单动态字符串
REDIS_LISTREDIS_ENCODING_ZIPLIST压缩列表
REDIS_LISTREDIS_ENCODING_LINKEDLIST双端链表
REDIS_HASHREDIS_ENCODING_HT字典
REDIS_HASHREDIS_ENCODING_ZIPLIST压缩列表
REDIS_SETREDIS_ENCODING_HT字典
REDIS_SETREDIS_ENCODING_INTSET整数集合
REDIS_ZSETREDIS_ENCODING_ZIPLIST压缩列表
REDIS_ZSETREDIS_ENCODING_SKIPLIST跳表和字典

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

Redis可以在不同的场景为对象设置不同的编码,采用不同的数据结构,优化效率
例如,当列表中元素较少,Redis会采用压缩列表作为底层数据结构

1. 因为压缩列表比双端链表更节省内存,并且在内存中以连续块保存的压缩列表比双端链表可以更快被载入缓存
2. 随着列表包含的元素越来越多,压缩列表的优势逐渐消失时,Redis会采用功能性更强、更适合存储大量元素的双端链表
字符串对象

字符串对象的编码可以是int、embstr、raw
如果一个字符串对象保存的是整数,那么会将long类型的整数保存在对象的ptr属性中并将对象的编码设置为int

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

如果字符串对象保存的是一个字符串值,并且字符串的长度小于等于39字节,那么字符串对象将使用embstr编码的简单动态字符串保存这个字符串值,并将对象的编码设置为embstr

raw编码和embstr编码都使用redisObject、SDS结构表示字符串对象,raw编码在创建字符串对象时会进行两次内存空间分配,分别为redisObject、SDS结构分配内存,embstr编码只会进行一次内存分配,为redisObject、SDS分配一块连续的内存

embstr编码相比raw编码的好处:
1. 创建embstr编码的字符串对象只需要进行一次内存空间分配,比raw编码少一次
2. embstr编码的字符串对象,它的redisObject和SDS在一块连续的内存空间,更容易加载进缓存
3. 释放embstr编码的字符串对象只需要释放一次,raw编码的字符串对象需要释放两次

double类型的浮点数在Redis中也是作为字符串值保存的

编码的转换

对于int编码的字符串对象来说,如果执行某些操作将整数值改变成字符串,那么编码将从int转换成raw
因为embstr编码的字符串对象是只读的,当对embstr编码的字符串对象执行任何修改命令时,字符串对象的编码会从embstr转换成raw再进行修改

列表对象

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

ziplist编码的列表对象底层采用压缩列表存储,每个压缩列表节点保存一个列表元素
linkedlist编码的列表对象底层采用双端链表存储,每个双端链表节点保存了一个字符串对象,每个字符串对象都保存了一个列表元素

注意,字符串对象是五种对象中唯一可以被其他对象嵌套的对象

编码转换

当列表对象同时满足以下两个条件时,采用ziplist编码:
1. 所有字符串元素的长度都小于64字节
2. 元素数量小于512个

以上两个条件可以通过配置修改,list-max-ziplist-value和list-max-ziplist-entries

当两个条件中的任意一个不能满足时,都会进行编码转换,将原来保存在ziplist里的元素转移到linkedlist双端链表中,然后将对象编码从ziplist变成linkedlist

哈希对象

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

ziplist编码的哈希对象,当添加新的键值对时,为键创建一个压缩列表节点添加到压缩列表末尾,然后再为值创建一个压缩列表节点添加到压缩列表末尾
保存了同一键值对的两个压缩列表节点总是挨在一起,保存键的节点在前,保存值的节点在后
先添加到键值对保存在压缩列表的前面,后添加的键值对保存在压缩列表的后面

字典编码的哈希对象,哈希对象的每个键值对都使用字典的一个键值对保存
字典的每个键都是一个字符串对象,保存键
字典的每个值都是一个字符串对象,保存值

编码转换

当哈希对象同时满足以下两个条件时,采用ziplist编码
1. 所有键值对的字符串长度都小于64字节
2. 键值对个数小于512

注意,这两个条件可以通过配置修改,hash-max-ziplist-value和hash-max-ziplist-entries

当两个条件中任意一个不满足时,都会执行编码转换操作,将保存在ziplist中的键值对迁移到字典中,然后将对象的编码从ziplist变成hashtable

集合对象

集合对象的编码可以是intset和hashtable

intset编码的集合对象保存的都是整数

hashtable编码的集合对象,字典的每个键都是一个字符串对象保存集合元素,每个值都是NULL

编码转换

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

  1. 集合中所有的元素都是整数
  2. 集合中元素个数小于512

注意,可以通过配置修改条件,set-max-intset-entries

当不满足任意一个条件时,会进行编码转换工作,将保存在整数集合中的元素转移到字典中,然后将对象的编码改成hashtable

有序集合

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

ziplist编码的有序集合,集合中的每个元素由紧挨着的两个压缩列表节点组成,第一个压缩列表节点保存的是成员member,第二个节点保存的是分值score
压缩列表中的集合元素按照分值从小到大排列,分值小的元素排在前面,分值大的元素排在后面

skiplist编码的有序集合底层使用zset结构存储,zset包含一个字典和一个跳表

type zset strcut {
	*zskiplist zsl		//跳表
	*dict dict			//字典
}

zset中的zsl跳表,按照分值从小到大存储元素,每个元素使用一个zskiplistNode跳表节点存储,元素的成员存储节点的obj属性中,元素的分值存储在节点的score属性中。通过跳表可以对有序集合进行范围操作,例如ZRANGE就是通过跳表实现的
zset中的dict字典,存储了成员到分值的键值对,通过字典可以根据成员快速获取对应的分值,ZSCORE命令就是通过字典实现的

虽然zsl跳表和dict字典同时被用来保存有序集合,但是这两种结构会通过指针共享元素的成员和分数

编码的转换

当有序集合满足以下条件时,使用ziplist编码:

  1. 元素的成员长度小于64字节
  2. 元素个数少于512

注意,这两个条件可以通过配置修改,zset-max-ziplist-value和zset-max-ziplist-entries

当两个条件中任意一个不满足时,都会执行编码转换操作,将保存在ziplist中的键值对迁移到zset中,然后将对象的编码从ziplist变成skiplist

类型检查与命令多态

操作键的命令可以分为两种
一种是可以对任何类型键执行,例如DEL、EXPIRE、RENAME等
一种是只能对特定类型的键执行,比如SET、GET只能对字符串键执行

类型检查的实现

在执行一个特定类型的命令之前,Redis会先检查键的类型是否正确,然后在执行命令

类型检查是通过redisObject结构的type属性判断的:
Redis执行特定类型命令时,先检查键对应的值对象的类型即type属性是不是命令所需要的类型,如果是则执行命令,否则返回类型错误

多态命令的实现

Redis除了会根据值对象的类型判断是否是命令所需的类型外,还会根据值对象的编码选择命令对应的实现方法

例如,LLEN命令可以计算列表键的长度,无论列表键的底层实现是ziplist或者linkedlist,即编码的多态
DEL、EXPIRE命令可以对所有类型的键进行操作,即类型的多态

内存回收

Redis在自己的对象系统中构建了一个引用计数实现的内存回收机制,每个对象的引用计数信息由redisObjct的refcount属性记录

type redisObject struct {
	int refcount			//引用计数
}

对象的引用计数信息会随着对象的使用而改变:

  1. 对象刚创建的时候,引用计数被初始化为1
  2. 对象被一个新程序使用时,引用计数+1
  3. 对象不再被一个程序使用时,引用计数-1
  4. 当对象的引用计数为0时,释放对象所占用内存空间

对象的整个生命周期可以分为创建对象、操作对象、释放对象三个阶段

对象共享

除了实现引用计数的垃圾回收机制外,refcount属性还被用于对象共享

让多个键共享同一个值对象需要执行两个步骤:
1. 让键的值指针指向共享的值对象
2. 将共享值对象的refcount属性加1

共享对象机制对节约内存非常有帮助,内存中相同的值对象越多,那么共享对象机制就越能节约内存

目前来说,Redis在初始化的时候会创建一万个共享字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要使用0~9999的字符串对象时,会直接使用这些共享对象
创建共享字符串对象的数量可以通过REDIS_SHARED_INTEGERS修改

共享字符串对象不止只有字符串键可以使用,内嵌字符串对象的其他四种类型的对象也可以使用

为什么Redis只共享包含整数值的字符串对象?
当考虑将一个共享对象作为键的值对象时,需要检查共享对象是否和键需要的目标对象完全相同,只有完全相同时才能使用共享对象作为键的值对象,那么在检查共享对象和目标对象是否相同时,如果共享对象越复杂那么检查的复杂度就越高

  1. 如果共享对象是包含整数值的字符串对象,那么检查的时间复杂度为O(1)
  2. 如果共享对象是包含字符串的字符串对象,那么检查的时间复杂度为O(n)
  3. 如果共享对象是包含多个值的对象(例如列表对象、哈希对象),那么检查的时间复杂度为O(n^2)
    为了不占用过多的CPU资源,所以只共享包含整数值的字符串对象
对象的空转时长

redisObject结构包含一个lru属性,该属性记录了对象最后一次被命令访问的时间

type redisObject struct {
	int lru			//最后一次被访问的时间
}

OBJECT IDLETIME可以获取键的空转时长,空转时长就是当前时间-lru计算得出的
OBJECT IDLETIME命令比较特殊,当这个命令访问键时,不会修改键的lru属性

如果服务器打开了max-memory选项,并且服务器回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存超过maxmemory时,就会回收空转时间较长的键的内存

重点回顾
  1. Redis中每个键值对的键和值都是一个redisObject
  2. Redis共有字符串、列表、哈希、集合、有序集合五种类型的对象,每种类型的对象都至少有两种编码方式,不同的编码在不同的场景有各自的性能提升
  3. 执行特性类型的命令前,会先检查键的值对象的类型是否和命令所需的类型相同
  4. Redis的对象系统带有引用计数实现的内存回收机制,当一个对象不再被使用时,回收该对象占用的内存空间
  5. Redis在初始化时,会创建0~9999的共享字符串对象
  6. Redis会记录每个键的最后一次访问时间,用来计算键的空转时长

单机数据库的实现

数据库

服务器中的数据库

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

type redisServer struct {
	[]redisDb		db
}

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

type redisServer struct {
	int dbnum
}

dbnum属性的值由配置的database选项决定,默认情况下该选项的值为16即Redis会创建16个数据库

切换数据库

每个Redis客户端都有自己的目标数据库,默认的目标数据库是0号数据库,客户端可以通过Select命令切换目标数据库

在服务器内部,客户端状态redisClient的db属性指向了目标数据库,db属性是一个指向redisDb的指针

type redisClient struct {
	*redisDb 			db			//目标数据库
}

redisClient的db属性实际上指向的就是redisServer.db数组中的数组项,当客户端使用Select命令切换目标数据库时,就是让redisClient的db属性指向redisServer.db数组中的其他数组项

数据库键空间

每个数据库都由redisDb结构表示,该结构中有一个dict字典保存数据库中的键值对,这个dict字段称为键空间

type redisDb struct {
	*dict dict		//键空间,保存数据库中所有键值对
}

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

所有针对数据库的操作,本质上都是对键空间进行操作

添加新键

添加一个新的键值对到数据库,实际上就是将一个键值对添加到键空间字典,其中键是一个字符串对象,值是五种对象中的任意一种

删除键

删除键实际上就是将键值对从键空间字典中删除

更新键

对一个数据库键进行更新,实际上就是更新键空间字典中该键对应的值对象

对键取值

对一个数据库键取值,实际上就是获取键空间字典中该键对应的值对象

其他键空间操作

FLUSHDB:用于清空数据库,本质上就是将键空间字典中的所有键值对删除
RANDOMKEY:随机返回一个数据库键,本质上就是随机获取键空间字典中的一个键
DBSIZE:获取数据库键数量,本质上就是获取键空间字典中所有的键值对数量

读写键空间时的维护操作

当执行读写命令时,除了对键空间字典执行读写操作外,还会执行一些维护操作,包括:
1. 读取一个键之后,会根据键是否存在更新键空间命中次数和键空间不命中次数,可以通过INFO stats命令的keyspace_hits属性和keyspace_misses属性查看
2. 读写一个键后,会更新该键的lru属性,该属性用于计算键的空转时长,可以通过OBJECT INDLETIME查看lru属性值
3. 如果服务器读取一个键时发现这个键已经过期了,那么会删除这个键
4. 如果有客户端正在watch这个键,那么服务器对被监视的健进行修改后,会将这个键标记为脏,从而让事物程序注意到这个键已经不安全
5. 每次修改一个键后,都会对脏键计数器加1,这个计数器会触发持久化以及复制操作
6. 如果开启了数据库通知,那么在对键进行修改后,按照配置发送相应的数据库通知

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

通过EXPIRE或者PEXPIRE命令,可以以秒为单位或者毫秒为单位为数据库中的某个键设置生存时间

注意,SETNX命令可以在设置一个键的同时设置键的生存时间,该命令是一个特定类型命令,只能对字符串键操作

通过EXPIREAT或者PEXPIREAT命令可以为键设置秒为精度或者毫秒为精度的过期时刻

TTL命令和PTTL命令可以以秒为单位或者毫秒为单位获取一个键的剩余生存时间

设置过期时间

Redis有两个命令可以用来设置键的生存时间,两个命令可以用来设置键的过期时刻
设置键的生存时间:

  1. EXPIRE key ttl,以秒为单位设置键的生存时间
  2. PEXPIRE key pttl,以毫秒为单位设置键的生存时间
    设置键的过期时刻:
  3. EXPIREAT key timestamp,以秒为单位设置键的过期时刻
  4. PEXPIREAT key timestamp,以毫秒为单位设置键的过期时刻
    但实际上EXPIRE、PEXPIRE、EXPIREAT命令底层都是通过PEXPIREAT命令实现的

EXPIRE伪代码:

func EXPIRE(key, ttl) {
	ttl_in_ms = second_to_million(ttl)
	PEXPIRE(key, ttl_in_ms)
}

PEXPIRE伪代码:

func PEXPIRE(key, pttl) {
	now_ms = get_now_time_uinix_timestamp_ms()
	PEXPIREAT(key, now_ms + pttl)
}

EXPIREAT伪代码:

func EXPIREAT(key, timestamp) {
	timestamp_ms = sec_to_ms(timestamp)
	PEXPIREAT(key, timestamp_ms)
}
保存过期时间

redisDb的expires字段保存了数据库中的所有键的过期时间,该字段是一个字典,其中键是指向键对象的指针,值是以毫秒为单位的过期时间

type redisDb struct {
	dict expires
}

PEXPIREAT伪代码:

func PEXPIREAT(key, timestamp) {
		if key not in redisDb.dict:
			return 0
		redisDb.expires[key] = timestamp
		return 1
}
移除过期时间

PERSIST命令可以移除一个键的过期时间
PERSIST命令在过期字典中查找给定的键,如果键存在则删除对应的键值对
伪代码实现:

func PERSIST(key) {
	if key not in redisDb.expires:
		return 0
	redisDb.expires.remove(key)
	return 1
}
计算并返回剩余生存时间

TTL以秒为单位返回键的剩余生存时间,PTTL以毫秒为单位返回键的剩余生存时间
TTL底层调用PTTL实现,PTTL计算当前时间和过期时间的差值作为剩余生存时间

func PTTL(key) {
	if key not in redisDb.dict:
		return -2
	if key not in redisDb.expires:
		return -1
	expire_timestamp = redisDb.expires[key]
	time_now = get_cur_unix_timestamp_ms
	return expire_timestamp-time_now
}

func TTL(key):
	ttl_in_ms = PTTL(key)
	if ttl_in_ms < 0:
		return ttl_in_ms
	return ms_to_sec(ttl_in_ms)
过期键的判断

判断一个键是否过期步骤:

  1. 检查一个键是否在过期字典中,如果不在返回false,否则继续判断
  2. 获取键的过期时间,如果当前时间大于过期时间那么返回true,否则返回false
过期键删除策略

键过期后,有三种不同的删除策略:

  1. 定时删除
    设置键的过期时间的同时创建一个定时器,让定时器在键的过期时间来临时删除过期键
  2. 惰性删除
    放任过期键不管,但是对键进行读写操作时,检查键是否过期,如果过期删除该键,否则继续执行操作
  3. 定期删除
    每隔一段时间,对数据库进行一次检查,删除数据库中过期的键。至于要检查多少数据库,由具体算法决定
定时删除

定时删除是对内存最友好的,通过使用定时器可以在键过期时尽快将过期键删除,释放过期键占用的内存空间
定时删除对CPU时间是不友好,如果多个键的过期时间相同,同一时间需要删除多个键,服务器需要执行与当前任务无关的删除过期键操作,会对整个服务器的吞吐造成影响

惰性删除

惰性删除策略对CPU时间是最友好的,服务器只在操作键时检查键是否过期,如果过期则删除键,不会在与当前任务无关的键上浪费时间
惰性删除策略对内存是不友好的,如果键过期之后没有对该键执行读写操作,那么该键永远不会被删除会一直占用内存空间

定期删除

定时删除和惰性删除都有明显缺陷:
定时删除占用太多CPU时间
惰性删除浪费内存空间,造成内存泄露

定期删除策略是这两种策略的折中:

  1. 定期删除每隔一段时间执行一次删除过期键操作,并通过限制删除的频率和时长来减少删除操作对服务器性能的影响
  2. 通过定期删除过期键,也减少对内存的影响

难点是确定定期删除的频率和时长:

  1. 如果过于频繁或者每次执行时长过长,就会退化成定时删除,对服务器CPU时间不友好
  2. 如果频率太大或者执行时长过小,就会退化成惰性删除,对服务器内存不友好,浪费内存

所以要根据实际情况,调整定期删除的频率和时长

Redis的过期键删除策略

Redis实际使用惰性删除和定期删除两种策略,通过这两种策略服务器可以在合理使用CPU时间和避免浪费内存之间取得平衡

惰性删除策略的实现

过期键的惰性删除策略由expireIfNeeded函数实现,所有对数据库进行读写的命令执行之前都会调用expireIfNeeded函数,该函数会判断键是否过期如果过期则将键删除,否则不做任何动作

expireIfNeeded函数执行后继续执行实际命令流程

定期删除策略的实现

过期键的定期删除策略由activeExpireCycle函数实现,每当服务器周期性执行serverCron函数时,serverCron函数都会调用activeExpireCycle函数
activeExpireCycle函数会分批遍历所有数据库,每次遍历一个数据库时从数据库的expire字典中随机获取指定数量的过期键,检查过期键是否过期如果过期则删除,每检查一个过期键就会判断操作是否已达时间上限,如果是那么停止操作

activeExpireCycle函数工作模式:

  1. 函数每次运行都从一部分数据库中随机选取一部分过期键进行检查,如果键已过期那么就删除
  2. 全局变量current_db记录了检查数据库的进度,当下一次执行activeExpireCycle函数时,会接着之前检查的数据库继续检查
  3. 随着activeExpireCycle函数的不断执行,会对所有的数据库都检查一遍,下次再调用activeExpireCycle时会重头开始检查数据库
AOF、RDB持久化和复制功能对过期键的处理
生成RDB文件

在执行SAVE、BGSAVE命令时,服务器会对键进行检查,已经过期的键不会保存到新创建的RDB文件中

载入RDB文件

启动Redis服务器时,如果服务器开启了RDB持久化,那么会载入RDB文件:

  1. 如果服务器是主服务器,那么在载入RDB文件时会检查键是否过期,未过期的键会被载入,过期的键会被忽略,所以过期键对RDB文件的载入没有影响
  2. 如果服务器是从服务器,那么在载入RDB文件时不会检查键是否过期,会全部载入,但是主从进行数据同步时,会清空从服务器的数据,所以,过期键对从服务器的RDB载入也没有影响
AOF文件写入

当过期键被惰性删除或者定期删除时,程序会向AOF文件追加一条过期键的DEL命令

AOF重写

在对AOF文件进行重写时,会检查键是否过期,如果过期则不会将键写入新的AOF文件

复制

当服务器运行在复制模式下时,从服务器删除过期键的动作由主服务器控制:

  1. 主服务器删除一个过期键时,会给所有从服务器发送一个DEL命令,通知从服务器删除这个过期键
  2. 从服务器执行客户端发送的读命令时,即使检查到键过期了,也不做任何处理
  3. 从服务器只有接收到了主服务器的DEL命令,才会删除过期键
数据库通知

可以让客户端订阅给定频道或者模式,来获知数据库键的变化,以及数据库中命令的执行情况

例如:
SUBSCRIBE keyspace@0:message
就是关注message这个键执行了什么命令,这一类关注某个键执行了什么命令的通知称为键空间通知
还有一类称为键事件通知,它关注的是某个命令被什么键执行了,例如:SUBSCRIBE keyevent@0:del,就是关注del命令被什么键执行了

服务器配置的notify-keyspace-events选项决定了服务器发送通知的类型

发送通知

发送数据库通知由notifyKeyspaceEvent函数实现:

func notifyKeyspaceEvent(type int, event char, key robj,dbid int)

type代表通知类型,程序会根据这个值来判断是否是notify-keyspace-events选项配置的类型,如果是则发送通知,否则不发
event是事件名称,key是产生事件的键,dbid是数据库编号,函数会根据这些参数构建发送通知的频道名称和发送的通知内容

notifyKeyspaceEvent函数步骤:

  1. 判断通知类型是不是服务器配置的类型,如果不是则返回,如果是继续执行
  2. 如果通知类型是键空间通知,那么构建键空间通知的频道名称,向该频道发送事件名称即event的值
  3. 如果通知类型是键事件通知,那么构建键事件通知的频道名称,向该频道发送key即数据库键

RDB持久化

将服务器中非空数据库以及它们的键值对称为数据库状态
Redis提供了RDB持久化功能,将Redis的数据库状态保存到磁盘中,避免数据意外丢失
RDB持久化既可以手动执行,也可以根据服务器的配置条件触发执行,RDB持久化就是将某一个时间点的数据库状态保存到RDB文件中

RDB文件的创建与载入

有两个命令可以用来生成RDB文件,分别是SAVE和BGSAVE
SAVE命令会阻塞服务器进程,生成RDB文件期间服务器不能执行任何命令
BGSAVE命令会派生一个子进程,由子进程负责创建RDB文件,父进程负责执行命令请求

创建RDB文件的实际工作由rdbSave函数完成,SAVE命令和BGSAVE命令会以不同的方式调用该函数

func SAVE() {
	rdbSave()
}

func BGSAVE() {
	pid = fork()
	if pid == 0:
		rdbSave()		//子进程创建rdb文件
		signal_parent()		//创建完成后向父进程发送信号
	else if pid > 0:
		hanlder_request_and_wait_signal()  //父进程执行请求命令并且轮询等待子进程通知	
}

服务器启动时自动执行RDB文件载入,当服务器启动时检测到RDB文件存在,就会载入RDB文件
由于AOF持久化的写入频率比RDB持久化高,所以如果服务器开启了AOF持久化,那么服务器启动时会载入AOF文件不会载入RDB文件

SAVE命令执行时的数据库状态

当SAVE命令执行时,服务器进程会阻塞不会执行任何命令请求,数据库状态不会变

BGSAVE命令执行时的数据库状态

BGSAVE命令执行期间,子进程负责创建RDB文件保存数据库状态,父进程继续处理客户端的命令请求,但是父进程在处理客户端的SAVE、BGSAVE、BGREWRITEAOF命令时跟平常有所不同

当服务器正在执行BGSAVE命令时,会拒绝执行SAVE命令,防止父子进程同时调用rdbSave方法产生竞争条件
当服务器正在执行BGSAVE命令时,会拒绝执行BGSAVE命令,防止同时调用rdbSave方法产生竞争条件

当服务器正在执行BGSAVE命令时,如果客户端发送BGREWRITEAOF命令,那么服务器会延迟BGREWRITEAOF命令直到BGSAVE命令执行完后再执行
当服务器正在执行BGREWRITEAOF命令时,如果客户端发送BGSAVE命令,那么服务器会拒绝执行BGSAVE命令
因为多个子进程同时进行大量写操作,性能不好

RDB文件载入时的数据库状态

服务器载入RDB文件期间,处于阻塞状态,不会处理任何客户端的命令请求

自动间隔性保存

因为BGSAVE命令可以在不阻塞服务器进程的情况下执行,所以可以配置服务器的save选项,让服务器定期执行BGSAVE命令保存服务器状态

可以设置多个save选项,任意一个save选项满足后就会执行BGSAVE命令

例如如下save选项配置:
save 900 1
save 300 10
save 60 10000
三个选项的意思分别是:
900秒内至少进行1次修改
300秒内至少进行10次修改
60秒内至少进行10000次修改

设置保存条件

可以通过配置文件或者设置启动参数的方式设置save选项,如果用户没有主动设置save选项会使用默认的save选项
默认的save选项:
save 900 1
save 300 10
save 60 10000

save选项信息会保存在redisServer的saveparams属性中

type redisServer struct {
	saveparam[] saveparams
}

type saveparam struct {
	int second  //秒数
	int changes  //修改次数
}
dirty计数器和lastsave属性

dirty计数器记录了上一次成功执行BGSAVE或者SAVE命令后到现在服务器执行修改的次数(增删改)
lastsave记录了上一次成功执行BSAVE或者SAVE命令的UNIX时间戳

type redisServer struct {
	dirty  long
	lastsave long
}

注意,dirty计数器增长的值和修改的元素个数有关
例如:
set msg “hello” //dirty计数器增长1
sadd set1 “a” “b” //dirty计数器增长2

检查保存条件是否满足

redis服务器的周期性执行函数serverCron默认每隔100毫秒执行一次,该函数其中的一项工作就是检查设置的save选项是否有满足的,如果有那么执行BGSAVE命令

func serverCron() {
	for saveparam in redisServer.saveparams:
		time_interval = unix_now - redisServer.lastsave
		if time_interval <= saveparam.time && redisServer.dirty >= saveparam.changes:
			BGSAVE()
}

BGSAVE命令执行完后,dirty计数器会置为0,lastsave会设置为执行完的时间戳

RDB文件结构

RDB文件包含以下各个部分:
REDIS | db_version | database | EOF | check_sum
全大写表示常量,全小写表示变量或数据

REDIS部分长度为5字节,保存着”REDIS“这五个字符,服务器载入文件时,根据该部分检查是否是RDB文件

db_version长度为4字节,值是一个字符串表示的整数,记录了RDB文件的版本号,例如”0006“就是第六版

database包含零个或者多个数据库,以及各个数据库中的键值对:

  1. 如果数据库状态为空即所有数据库都没有键值对,那么该部分也是空,长度为0字节
  2. 如果数据库状态不为空即至少一个数据库非空,那么该部分也非空,根据保存的键值对的数量、类型、内容不同,该部分的长度也不同

EOF常量的长度为1字节,标志RDB文件正文内容结束,当载入程序读到这个值时,就知道所有的键值对都加载完了

check_sum是一个无符号整数,保存着一个校验和,这个校验和是程序对REDIS、db_version、database、EOF四个部分进行计算后得到的。服务器在载入RDB文件时,会计算载入数据的校验和,然后和check_sum对比,以此来检查RDB文件是否有出错

databases部分

RDB文件的databases部分可以保存任意多个非空数据库
如果0号数据库和3号数据库不为空,那么RDB文件如图:
REDIS | db_version | database0 | database3 | EOF | check_sum
database0代表0号数据库的键值对,database3代表3号数据库的键值对

每个非空数据库可以保存为SELECTDB、db_number、key_value_pairs三个部分
SELECTDB | db_number | key_value_pairs
SELECTDB常量,当程序读到这个常量时,就知道接下来要读入的是数据库号码
db_number保存着一个数据库号码。当程序读入一个db_number之后,会调用SELECT命令根据读入的数据库号码进行数据库切换,使得之后读入的键值对可以进入正确的数据库中

key_value_pairs保存了数据库中所有的键值对,如果键值对有过期时间,那么过期时间也会和键值对保存在一起

key_value_pairs部分

不带过期时间的键值对由TYPE、key、value三部分组成
TYPE代表value的类型,可以是以下任意一个:
STRING
LIST
SET
ZSET
HASH
LIST_ZIPLIST
SET_INTSET
ZSET_ZIPLIST
HASH_ZIPLIST
每个TYPE都代表一种底层编码,当服务器读入键值对时,会根据TYPE的值决定如何读入和解释value的数据,key和value分别保存了键值对的键对象和值对象

带有过期时间的键值对的结构如图:
EXPIRETIME_MS | ms | TYPE | key | value
EXPIRETIME_MS常量告知程序接下来要读取的是键的过期时间
ms是一个以毫秒为单位的时间戳,代表过期时间

value的编码
  1. 字符串对象
    如果TYPE的值是REDIS_RDB_TYPE_STRING,那么值是一个字符串对象。字符串对象的编码可以是ENCODING_INT或者ENCODING_RAW。
    INT编码的字符串对象保存的是长度小于等于32位的整数,结构如图:
    encoding | integer
    encoding的值可以是INT8、INT16、INT32三种之一

    如果字符串对象的编码是RAW,那么字符串对象保存的是一个字符串值,根据字符串长度不同,有压缩和不压缩两种存储方式:
    * 字符串长度小于20等于字节,字符串按原样保存
    * 字符串长度大于20字节,将字符串压缩后保存
    注意,如果关闭了RDB文件压缩功能,那么不会对字符串进行压缩后存储
    没有压缩的字符串对象结构如图:
    len | string
    压缩后的字符串对象结构如图:
    REDIS_RDB_ENV_LZF | compressed_len | origin_len | compressed_string
    REDIS_RDB_ENV_LZF常量告诉程序字符串已经被LZF压缩算法压缩过,读入程序读到该变量后会使用LZF对后面三部分数据进行解压缩得到原始字符串。compressed_len代表压缩后的字符串长度,origin_len代表原始的字符串长度,compressed_string是压缩后的字符串。

  2. 列表对象
    如果TYPE的值是REDIS_RDB_TYPE_LIST,那么value保存的就是双端链表编码的列表对象,这种对象的结构如图:
    list_length | item1 | item2 | … | itemN
    list_length代表列表长度,程序读入这个变量就知道接下来要读取多少个元素
    每个元素都是一个字符串对象,所以程序会以字符串对象的方式来保存和读入集合元素

  3. 集合对象
    如果TYPE的值为REDIS_RDB_TYPE_SET,那么value保存的是一个字典编码的集合对象,结构如图:
    set_size | elem1 | elem2 | … | elemN
    set_size是集合的大小,记录保存了多少个元素,程序可以读入该变量知道要读取多少个元素
    每个元素都是一个字符串对象,所以程序会以字符串对象的方式来保存和读取集合元素
    如下,是包含四个元素的集合:
    2 | 5 “apple” | 4 “food”

  4. 哈希表对象
    如果TYPE的值为REDIS_RDB_TYPE_HASH,那么value保存的就是一个字典编码的哈希表对象,结构如图:
    hash_size | key1 | value1 | key2 | value2 | … | keyN | valueN
    hash_size代表哈希表中键值对的个数,key_value_pair代表键值对,其中键、值都是字符串对象
    包含两个键值对的哈希对象结构如图:
    2 | 5 | “apple” | 1 | “a” | 2 | “ee” | 3 | “bbb”

  5. 有序集合对象
    如果TYPE的值为REDIS_EDB_TYPE_ZSET,那么value就是SKIPLIST编码的有序集合对象,对象的结构如图:
    sorted_set_size | element1 | element2 | … | elementN
    sorted_set_size记录了有序集合的大小
    element代表有序集合元素,每个元素又分为成员和分数两部分,成员是一个字符串对象,分数是一个double类型的浮点数会以字符串对象的形式保存
    详细结构如图:
    sorted_set_size | member1 | score1 | member2 | score2 | … | memberN | scoreN
    包含2个元素的有序集合结构如图:
    2 | 2 | “aa” | 1 | “1” | 3 | “eee” | “4” | “3.14”

  6. INTSET编码的集合
    如果TYPE是REDIS_RDB_TYPE_SET_INTSET,那么value保存的就是一个整数集合编码的集合对象,RDB保存这种对象的方法是将整数转换成字符串对象,然后以字符串对象的形式保存
    程序读取RDB文件时,也是将读取到的字符串对象转换成整数存入整数集合

  7. ZIPLIST编码的列表、哈希表、有序集合对象
    如果TYPE的值为REDIS_RDB_TYPE_LIST(HASH、ZSET)_ZIPLIST,那么value保存的就是一个ZIPLIST编码的列表、哈希表、有序集合对象
    保存ZIPLIST编码的对象时,先将压缩列表对象转换成字符串对象,然后将字符串对象保存到文件中

重点回顾
  1. RDB文件用来保存和还原数据库状态(服务器中包含的所有键值对)
  2. SAVE命令由服务器进程直接执行,会阻塞服务器进程
  3. BGSAVE命令由子进程负责创建RDB文件,不会阻塞服务器进程
  4. save选项保存在redisServer中,任意一个save选项满足时,服务器会执行BGSAVE命令
  5. RDB文件是一个经过压缩的二进制文件,由多个部分组成
  6. 对于不同类型的键值对,RDB文件会使用不同的方式保存它们

AOF持久化

AOF持久化是通过保存执行的写命令来记录数据库状态
被写入AOF文件的所有写命令都是以命令请求协议格式保存的,Redis的命令请求协议是纯文本格式,所以可以直接打开一个AOF文件查看文件内容
服务器启动时,可以直接载入和执行AOF文件中保存的写命令,让数据库状态恢复到关闭之前的状态

AOF持久化的实现

AOF持久化的实现可以分为命令追加、文件写入、文件同步三个步骤

命令追加

当AOF持久化功能打开时,服务器每执行一条写命令,会以命令请求协议格式将写命令追加到服务器状态中的AOF_BUF缓冲区的末尾

type redisServer struct {
	sds aof_buf  //aof缓冲区
}
AOF文件的写入和同步

redis的服务器进程就是一个事件循环,这个循环中的文件事件负责接收客户端的命令请求以及向客户端发送命令回复
在处理文件事件时可能会执行写命令,使得一些内容被追加到服务器状态中的aof缓冲区,所以每次事件循环结束前都要执行flushAppendOnlyFile函数考虑是否将aof缓冲区中的内容写入和同步到AOF文件中

func eventLoop() {
	while True:
		processFIleEvents()    //处理文件事件,接受命令请求和发送命令回复。接受命令请求时可能执行写命令,追加内容到aof缓冲区
		processTimeEvents()		//处理时间事件
		flushAppendOnlyFile()	//考虑是否将aof缓冲区中的内容写入和同步到AOF文件中
}

flushAppendOnlyFile函数的行为由服务器配置appendfsync选项决定

appendfsync选项的值flushAppendOnlyFile函数行为
always将aof缓冲区中的内容写入并同步到aof文件中
everysec将aof缓冲区中的内容写入到AOF文件,如果和上一次同步AOF文件的时间超过1s,那么对AOF文件进行同步
no将aof缓冲区中的内容写入到AOF文件,但不进行同步,何时同步由操作系统决定

appendfsync选项的默认值为everysec

文件的写入和同步
为了提高文件的写入效率,现代操作系统在调用write函数写入文件时,是先将内容写到pageCache中,当pageCache满了或者超过指定时限后再将pageCache中的内容刷到磁盘中
这种做法虽然提高了效率,但是带了数据安全问题,如果数据还未刷到磁盘,服务器宕机,那么数据就会丢失
为此系统提供了fsync和fdatasync两个同步函数,它们可以强制系统将pageCache中的数据刷到磁盘中,从而确保数据的安全性

AOF持久化的效率和安全性
appendfsync选项的设置决定了AOF持久化的效率和安全

  1. 当appendfsync选项的值为always,那么每次事件循环结束前都会将aof缓冲区中的内容写入并同步到aof文件,这种方式效率最慢,但是最安全
  2. 当appendfsync选项的值为everysec,那么每次事件循环结束前都会将aof缓冲区中的内容写入aof文件,每隔1s对aof文件进行一次同步。这种方式效率也够快,就算宕机也只会丢失1s的数据
  3. 当appendfsync选项的值为no,那么每次事件循环结束前都会将aof缓冲区中的内容写入aof文件,由操作系统决定何时同步aof文件。这种方式效率最高但是最不安全,当服务器宕机时会丢失上次同步到现在的数据
AOF文件的载入和数据还原

Redis读取AOF文件并还原数据库状态的步骤:

  1. 创建不需要网络连接的伪客户端,因为Redis的命令需要在客户端上下文中执行,而AOF还原数据库状态中命令来自AOF文件并非网络连接,所以需要创建不需要网络连接的伪客户端来执行AOF文件中的命令
  2. 读取并解析AOF文件中的一条命令
  3. 在伪客户端执行读取的命令
  4. 重复2、3步骤,直到AOF文件中的命令被读取完
AOF重写

为了解决AOF文件体积膨胀的问题,Redis提供了AOF重写功能。AOF重写会生成一个新的AOF文件,新AOF文件和原来AOF文件保存着相同的数据库状态,但是新AOF文件中不会包含冗余的命令,所以新AOF文件的体积比原来AOF文件小很多

AOF文件重写的实现

实际上,AOF重写并不会对旧的AOF文件进行读取、分析,而是根据当前的数据库状态创建新的AOF文件

例如对list键执行以下命令:
rpush list “a” “b”
rpush list “c”
为了保存list键的状态,必须记录两条命令
如果要用尽量少的命令记录list键的状态,最简单的不是读取、解析、合并旧的AOF文件中的命令,而是读取list键对应的值,用一条rpush list “a” “b” "c"命令代替之前的命令

AOF重写的原理就是读取键对应的值,然后用一条命令来插入键值对,代替该键之前的所有命令

AOF后台重写

Redis不希望AOF重写造成服务器进程阻塞,所以Redis将AOF重写交给子进程执行

  1. 子进程进行AOF重写期间,父进程可以继续执行命令请求
  2. 使用子进程而不使用线程是因为子进程带有父进程的数据副本,不共享数据,避免使用锁的情况下保证数据安全

使用子进程有一个问题需要解决,子进程在进行AOF操作期间,父进程一直在处理客户端命令请求,新的命令可能会对数据库状态有影响,从而使得服务器当前的数据库状态和重写后保存的数据库状态不一致

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

  1. 写入AOF缓冲区的命令会被写入和同步到AOF文件
  2. 子进程进行AOF重写时,新的写入命令会被记录到AOF重写缓冲区中

当子进程完成AOF重写时,会给父进程发送信号,父进程接收到信号后,将AOF重写缓冲区中的命令都写入新的AOF文件,并且对新的AOF文件改名原子地覆盖原来的AOF文件
执行完后,父进程继续处理客户端的命令请求

重点回顾
  1. AOF通过保存所有数据库的写命令来记录服务器的数据库状态
  2. AOF文件中的命令都是以命令请求协议格式(纯文本)保存的
  3. 命令请求会先写入AOF缓冲区,后面再写入和同步到AOF文件
  4. appendfsync选项的值对AOF持久化的性能和安全性有很大的影响
  5. 服务器只需要读取、解析AOF文件中的命令并执行就可以还原数据库状态
  6. AOF重写会产生一个小体积的新AOF文件,新AOF文件和老AOF文件保存的数据库状态相同
  7. AOF重写是通过读取键对应的值,再生成一条插入该键值对的命令完成的
  8. 执行BGREWRITEAOF期间,Redis会维护一个AOF重写缓冲区。创建完子进程后,就会创建AOF重写缓冲区,子进程在进行AOF重写期间,主进程处理的写命令会写到AOF重写缓冲区,当子进程完成AOF重写后会给主进程发送信号,主进程接收到信号后会将AOF重写缓冲区中的命令写入新的AOF文件,并且给新的AOF文件重名令原子地替换旧AOF文件

事件

Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:

  1. 文件事件
    客户端和服务器通信时会产生该类事件,服务器监听并处理该类事件,完成网络通信操作
  2. 时间事件
    Redis中的一些操作(例如serverCron函数)需要定期或者在指定时间点执行,时间事件就是对这类操作的抽象
文件事件

文件事件处理器使用IO多路复用程序来同时监听多个套接字,并且根据套接字目前执行的任务来为套接字关联不同的事件处理器
当被监听的套接字准备好执行连接应答、读取、写入、关闭等操作时,与操作相关的文件事件就会产生,这时文件事件处理器会调用套接字之前关联好的事件处理器来处理该事件

文件事件处理器的构成

文件时间处理器由四部分构成,套接字、IO多路复用程序、事件分派器、事件处理器
每当套接字准备执行连接应答、写入、读取、关闭操作时,都会产生一个文件事件
IO多路复用程序负责监听多个套接字,并向事件分派器传送产生了事件的套接字

尽管多个文件事件会并发产生,但是IO多路复用程序会将所有产生事件的套接字放到一个队列中,然后通过这个队列有序、同步、每一个套接字的方式传送给事件分派器。这样,只有上一个套接字被处理完后,下一个套接字才会被处理。

事件分派器收到IO多路复用程序传送过来的套接字后,根据套接字产生事件的类型调用对应的事件处理器

API

aeCreateFileEvent函数接受一个套接字描述符、一个事件类型、以及一个事件处理器作为参数,将给定套接字的给定事件加入到IO多路复用程序的监听范围内,并对事件和事件处理器作关联

aeDeleteFileEvent函数接受一个套接字描述符、一个事件类型作为参数,让IO多路复用程序取消给定套接字的给定事件的监听,并取消事件和事件处理器的关联

arGetFileEvents函数接受一个套接字描述符,返回该套接字正在被监听的事件类型

文件事件处理器

为了对连接服务器的各个客户端进行连接应答,服务器要为监听的套接字关联连接应答事件
为了接收客户端的命令请求,服务器为监听的套接字关联命令请求处理器
为了回复客户端命令的执行结果,服务器为监听的套接字关联命令回复处理器
当主服务器和从服务器进行复制操作时,主从服务器都需要关联复制处理器

  1. 连接应答处理器
    当服务器初始化时,会将服务器监听的套接字的AE_READABLE事件和连接应答处理器关联起来,当客户端连接服务器时,服务器监听的套接字就会产生AR_READABLE事件,引发连接应答处理器的执行
  2. 命令请求处理器
    当一个客户端通过连接应答处理器和服务器成功建立连接后,服务器会将客户端套接字的AE_READABLE事件和命令请求处理器关联起来,当客户端向服务器发送命令请求时,就会产生AE_READABLE事件,引发命令请求处理器的执行
  3. 命令回复处理器
    当服务器有命令回复需要传送给客户端时,服务器会将这个客户端套接字的AE_WRITEABLE事件和命令回复处理器关联起来,当客户端准备好接收命令回复时就会产生AE_WRITEABLE事件,引发命令回复处理器的执行。
    当命令回复完毕后,服务器会解除命令回复处理器与客户端套接字AE_WRITEABLE事件的关联
  4. 一次完整的客户端、服务器连接事件示例
    假设一个服务器正在运行,那么这个服务器监听的套接字的AE_READABLE事件处于监听下,该事件对应的处理器是连接应答处理器
    如果这时有一个客户端向服务器发起连接,那么监听套接字的AE_READABLE事件就会产生,触发连接应答处理器的执行,连接应答处理器对客户端发起的连接进行应答,然后创建客户端套接字、以及客户端状态,并将客户端套接字的AE_READABLE事件和命令请求处理器关联起来
    之后,客户端向服务器发送命令请求,客户端套接字产生AE_READABLE事件,引发命令请求处理器的执行,处理器读取客户端的命令内容,执行命令
    执行命令会产生命令回复,为了将回复发送给客户端,服务器将客户端套接字的AE_WRITEABLE事件和命令回复处理器关联起来。当客户端套接字准备读取命令回复时,触发客户端套接字的AE_WRITEABLE事件,引发命令回复处理器的执行,当命令回复处理器将回复写入到客户端套接字后,就会解除客户端套接字AE_WRITEABLE事件和命令回复处理器的关联
时间事件

Redis的时间事件可以分为两类:

  1. 定时事件
    让一段程序在指定时间之后执行一次
  2. 周期性事件
    让一段程序每隔指定时间就执行一次

一个时间事件由三个属性组成:
3. id
全局唯一ID,新事件的ID比旧事件的ID大
2. when
毫秒精度的时间戳,记录事件的到达时间
4. timeProc
时间事件处理器,一个函数。当时间事件到达执行时间时,服务器就会调用时间事件处理器处理事件

一个时间事件是定时事件还是周期性事件,取决于时间事件处理器的返回值

  1. 如果处理器的返回值是AE_NOMORE,那么这个时间事件为定时事件,执行一次后就被删除
  2. 如果事件处理器的返回值是非AE_NOMORE的整数值,那么这个时间事件是周期性事件,执行一次后重置when属性的值,让这个事件过一段时间后再次到达,并通过这种方式周期性执行事件处理器
实现

服务器将所有时间事件都放在一个无序链表中,每当执行时间事件时,遍历整个链表,查找到达执行时间的时间事件,并调用事件对应的事件处理器

新的时间事件总是插入到表头

时间事件应用实例:serverCron函数

serverCron函数主要工作包括:

  1. 更新服务器各类统计信息,比如时间、内存占用、数据库占用等
  2. 清理数据库的过期键值对
  3. 尝试进行AOF和RDB持久化操作
  4. 如果服务器是主服务器,那么对从服务器进行定期同步

默认规定serverCron函数平均每隔100毫秒执行一次

事件的调度与执行

事件的调度与执行由aeProcessEvents函数负责:

func aeProcessEvents() {
		time_event = aeSearchNearestTimer()		//获取到达时间离当前时间最近的时间事件
		remaind_ms = time_event.when - ms_now()     //计算现在到最近到达的时间事件还有多少ms
		if remaind_ms < 0:			//如果时间事件已经到达,remaind_ms可能为0
			remaind_ms = 0
		time_val = create_timeval_with_ms(remaind_ms)	 //计算timeval结构
		aeApiPoll(time_val)    //阻塞等待文件事件产生,如果remaind_ms为0那么不阻塞直接返回
		processFileEvents()	//处理所有产生的文件事件
		processTimeEvents()   //处理所有到达的时间事件
}

将aeProcessEvents函数置于循环中并加上一些初始化、清理函数,就构成了Redis服务器的主函数

func main():
	init_server()
	while server_is_not_shutdown:
		aeProecessEvents()
	clean()
重点回顾
  1. Redis服务器是一个事件驱动程序,服务器的事件分为文件事件和时间事件两种
  2. 文件事件是对套接字操作的抽象,每次套接字变为可应答、可写、可读、关闭时就会产生对应的文件事件
  3. 文件事件分为AE_READABLE事件和AE_WRITEABLE事件
  4. 时间事件分为定时事件和周期性事件:定时事件只在某个时间点执行一次、周期性事件会每隔一段时间执行一次
  5. 一般时间事件只有一个,那就是serverCron函数
  6. 服务器会轮流执行文件事件和时间事件,执行过程中不会发生抢占
  7. 时间事件的执行时间通常会比设定的时间晚一点

客户端

对于每个和服务器连接的客户端,服务器都为这些客户端创建了相应的客户端状态redisClient,这个结构保存了客户端当前的状态和执行相关功能时需要的数据结构

Redis服务器状态的clients属性是一个链表,该链表就保存了所有与服务器连接的客户端的客户端状态

type redisServer struct {
	list *clients    //客户端状态链表
}
客户端属性

客户端属性可以分为两类:

  • 一类是比较通用的,无论客户端执行什么工作,都要用到这些属性
  • 一类是与特定功能相关的属性
套接字描述符

fd属性记录客户端正在使用的套接字描述符

type redisServer struct {
 int fd   //客户端套接字描述符
}

根据客户端类型的不同,客户端套接字描述符的值可以是-1也可以是大于-1的整数

  1. 伪客户端的fd属性值为-1:伪客户端处理的命令来自AOF文件或者lua脚本,而不是网络,所以这么客户端不需要套接字连接,也就不需要套接字描述符。目前Redis有两个场景会用到伪客户端,一个是载入AOF文件恢复数据库状态的时候,一个是执行lua脚本中的redis命令时
  2. 普通客户端的fd属性大于-1:普通客户端使用套接字与服务器通信,所以普通客户端需要套接字描述符

执行Client list命令可以列出目前与服务器连接的普通客户端

名字

默认情况下,客户端是没有名字的
使用CLIENT setname命令可以为客户端设置一个名字,让客户端的身份变得更清晰
客户端的名字记录在客户端状态中:

type redisClient struct {
	string name
}
标志

标志属性flags记录了客户端的角色以及客户端目前所属的状态

type redisClient struct {
	string flags
}

flags属性的值可以是单个标志,也可以是多个标志的二进制或
flags = flag 或者 flags = flag1 | flag2

每个标志用一个常量表示,一部分标志记录了客户端的角色:

  1. 在主从服务器进行复制操作时,主服务器会成为从服务器的客户端,从服务器会成为主服务器的客户端。REDIS_MASTER标志表示客户端是一个主服务器,REDIS_SLABE标志表示客户端是一个从服务器
  2. REDIS_PRE_PSYNC标志表示客户端代表的是一个版本小于2.6的从服务器,主服务器不能使用PSYNC命令与这个从服务器进行同步。这个标志只要能在REDIS_SLAVE标志打开时使用
  3. REDIS_LUA_CLIENT标志表示客户端是用于执行lua脚本中redis命令的伪客户端

一部分标志记录了客户端的状态:

  1. REDIS_MONITOR标志表示客户端正在执行MONITOR命令
  2. REDIS_UNIX_SOCKET标志表示服务器使用UNIX套接字连接客户端
  3. REDIS_BLOCKED标志表示客户端正在被BLPOP、BRPOP等命令阻塞
  4. REDIS_UNBLOCKED标志表示客户端已经从阻塞中脱离,不再阻塞

PUBSUB命令和SCRIPT LOAD命令的特殊性
通常,Redis只会将对数据库进行修改的命令写入到AOF文件,并复制到各个从服务器。
但PUBSUB命令和SCRIPT LOAD命令是例外,PUBSUB命令虽然没有修改数据库,但是会向订阅了指定频道的订阅者发送消息这一行为带有副作用,所以服务器需要使用REDIS_AOF_FORCE标志强制将这个命令写入AOF文件
SCRIPT LOAD命令虽然没有修改数据库,但是加载了LUA脚本到数据库状态中,执行LUA脚本时候可能会有Redis命令修改数据库,所以需要使用REDIS_AOF_FORCE和REDIS_FORCE_REPL将命令写入AOF文件并且同步到从服务器

输入缓冲区

客户端状态的输入缓冲区用来保存客户端发送过来的命令

type redisClient struct {
	sds querybuf
}

输入缓冲区的大小会根据输入内容动态缩小或者扩大,但它的最大大小不能超过1GB,否则服务器将关闭这个客户端

命令与命令参数

服务器将客户端发送的命令保存到输入缓冲区后,服务器会对命令进行解析,并将得出的命令参数和命令参数的个数保存到客户端状态的argv、argc属性中

type redisClient struct {
	robj argv    //保存命令参数
	int argc      //保存命令参数个数
}

argv属性是一个数组,数组中的每个元素都是一个字符串对象,第一个元素是执行的命令,后面的元素是命令参数
argc属性保存的是argv数组的大小

命令的实现函数

当服务器解析命令请求获取到argv和argc属性后,服务器会根据argv[0]的值在命令表中查找命令对应的命令实现函数
命令表是一个字典,其中字典的键是一个字符串对象保存的是命令的名称,字典的值是一个redisCommand结构,该结构保存的命令实现函数、命令的总执行次数和总执行时间
当程序找到命令对应的redisCommand结构后,会将redisClient的cmd属性指向redisCommand结构

type redisClient struct {
      *redisCommand cmd
}

之后,服务器就使用cmd属性指向的redisCommand结构以及argv、argc属性保存的命令参数,调用命令实现函数,执行命令

针对命令表的查找不区分字母的大小写,无论存储的是"SET"、“set”、“Set”都可以找到set命令对应的redisCommand结构

输出缓冲区

执行命令所得到的命令回复会保存在客户端的输出缓冲区,每个客户端都有两个输出缓冲区可用,一个缓冲区的大小是固定的,一个缓冲区的大小是可变的

  1. 固定大小的缓冲区用于保存长度比较小的回复,比如OK、简短的字符串值、整数值、错误回复等
  2. 可变大小的缓冲区用于保存长度比较大的回复,比如一个非常长的字符串值,一个很多项组成的列表,一个包含很多元素的集合

固定大小的缓冲区由buf和bufpos两个属性组成:

type redisClient struct {
	char[REDIS_REPLY_CHUNK_SIZE] buf
	int  bufpos
}

buf是一个字节数组,bufpos记录了buf数组已使用的字节数
REDIS_REPLY_CHUNK_SIZE默认大小是16*1024,即16K大小

可变大小缓冲区是一个reply链表,通过使用链表连接多个字符串对象,这样就可以保存较长的命令回复,而不必受到固定缓冲区的16K大小限制

身份验证

客户端状态的authenticated记录了客户端是否通过了身份验证

type redisClient struct {
	int authenticated
}

如果authenticated的值为0,代表客户端未通过身份认证;如果authenticated的值为1,代表客户端通过了身份认证

当客户端的authenticated的值为0时,除了AUTH命令外服务器会拒绝客户端发送的其他命令

当客户端通过AUTH命令通过了身份验证后,客户端状态的authenticated的值会变为1

时间
type redisClient struct {
	time_t ctime
	time_t lastinteraction
	time_t obuf_soft_limit_reached_time
}

ctime记录了客户端的创建时间,这个可以用来计算客户端和服务器已经连接了多少秒

lastinteraction属性记录了客户端与服务器最后一次进行互动的时间,这里的互动可以是客户端向服务器发送命令请求也可以是服务器向客户端发送命令回复
lastinteraction属性可以用来计算客户端的空转时间,即距离客户端和服务器最后一次互动后过了多长时间

obuf_soft_limit_reached_time属性记录了输出缓冲区第一次到达软性限制的时间

客户端的创建与关闭
创建普通客户端

如果客户端是通过网络连接和服务器通信的普通客户端,那么客户端在使用connect函数连接服务器时,会触发服务器连接应答处理器的执行,该处理器会为客户端创建客户端状态,并将这个新的客户端状态存储到redisServer的clients属性中

关闭普通客户端

如果要发送的命令回复超过了输出缓冲区的大小,那么这个客户端会被关闭
虽然可变大小的输出缓冲区理论上是无限的,但是为了避免服务器的命令回复过大,占用过多的服务器资源,服务器会时刻检查客户端状态的输出缓冲区大小,并在缓冲区大小超过范围时,执行相应的限制操作

服务器使用两种模式来限制输出缓冲区的大小:

  1. 硬性限制
    如果输出缓冲区的大小超过了硬性限制,那么服务器立即关闭客户端
  2. 软性限制
    如果输出缓冲区的大小超过了软性限制但没有超过硬性限制的大小,那么服务器会在客户端状态的obuf_soft_limit_reached_time中记录达到软性限制的时间,之后服务器会继续监视客户端,如果输出缓冲区一直超过软性限制大小,并且持续时间超过服务器设定的时长,那么服务器将关闭客户端;相反,如果输出缓冲去在设定时长内,不再超过软性限制那么客户端就不会关闭,并且obuf_soft_limit_reached_time属性也会被清零

使用client-output-buffer-limit选项可以为普通客户端、从服务器客户端设置不同的软性限制和硬性限制
client-output-buffer-limit 类型 硬限制 软限制 软限制超时时间
例如:
client-output-buffer-limit normal 0 0 0
给普通客户端的限制都设置为0代表不限制
client-output-buffer-limit slave 256mb 64mb 60
给从服务器客户端设置硬限制256mb,软限制64mb,软限制超时时间60s

Lua脚本的伪客户端

服务器在初始化的时候会创建用来执行lua脚本中redis命令的伪客户端,并将这个伪客户端保存在redisServer的lua_client属性

type redisServer struct {
	*redisClient lua_client
}

lua伪客户端会在服务器运行期间一直存在,直到服务器被关闭时,这个客户端才会关闭

AOF文件的伪客户端

服务器在载入AOF文件时,会创建用于执行AOF文件中redis命令的伪客户端,AOF文件载入完成后,关闭这个伪客户端

服务器

命令请求执行过程

从客户端发送SET KEY VALUE命令到收到OK回复期间,客户端和服务器共需要执行以下操作:

  1. 客户端向服务器发送命令请求SET KEY VALUE
  2. 服务器接收并处理客户端发来的命令请求,操作数据库,并且产生命令回复OK
  3. 服务器将命令回复OK发送给客户端
  4. 客户端接收服务器返回的命令回复OK,并打印给用户观看
发送命令请求

当用户在客户端键入一个命令请求时,客户端会将这个请求转换成命令请求协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器

读取命令请求

当服务器的客户端套接字由于客户端的写入而产生AE_READABLE事件时,会触发服务器命令请求处理器的执行,命令请求处理器处理步骤如下:

  1. 读取客户端套接字中的命令请求协议格式的请求内容,并将请求内容添加到客户端状态的输入缓冲区中
  2. 对输入缓冲区中的请求内容进行解析,得到命令参数和命令参数个数,将命令参数和命令参数个数设置到客户端状态的argv、argc属性
  3. 调用命令执行器,执行命令
命令执行器:查看命令实现

命令执行器要做的第一件事就是根据argv[0]中的参数值在命令表中查找对应的命令结构redisCommand,然后将redisCommand赋值给客户端状态cmd属性

redisCommand的结构:

type  redisCommand struct {
	string name		//命令名称
	func proc 		//命令执行函数
	int arity			//命令参数个数(-3表示至少3个,2表示只有2个)
	sflags				//读写标识(wm表示写命令,r表示读命令)
}
命令执行器:执行预备操作

到目前为止,服务器已经将执行命令所需要的命令执行函数(客户端状态的cmd属性),命令参数(argv属性),命令参数个数(argc属性)都收集齐了,但是在执行命令函数之前还需要执行一些预备操作

这些操作包括:

  1. 检查客户端状态的cmd属性是否为null,如果为null说明根据命令没有找到对应的命令执行函数,向客户端返回一个错误
  2. 根据客户端状态cmd属性指向的redisCommand结构的arity属性,检查命令参数个数是否正确,如果错误那么向客户端返回一个错误
  3. 检查客户端是否通过了身份验证,未通过身份验证的客户端执行执行AUTH命令,如果未通过身份验证的客户端试图执行其他命令,那么服务器向客户端返回一个错误
  4. 如果服务器打开了maxmemory选项,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存回收,从而使得接下来的命令可以顺利执行,如果内存回收失败那么服务器向客户端返回一个错误
  5. 如果服务器上一次执行BGSAVE命令出错,并且服务器打开了stop-writes-on-bgsave-error功能,而且客户端发送的是一个写命令,那么服务器会拒绝执行,并且向客户端返回一个错误
  6. 如果客户端正在用SUBSCRIBE命令订阅频道,或者正在用PSUBSCRIBE订阅模式,那么服务器只会执行客户端发送的SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、PUNSUBSCRIBE四个命令,其他命令都会被拒绝
  7. 如果服务器正在进行数据载入,那么客户端发送的命令必须带有l标识(比如INFO、SHUTDOWN)才会被服务器执行,其他命令都会被拒绝
  8. 如果服务器因为执行lua脚本超时而进入阻塞状态,那么服务器只会执行客户端发送的shutdowe nosave命令和SCRIPT KILL命令,其他命令都会被拒绝
  9. 如果客户端正在执行事务,那么服务器只会执行客户端的EXEC、DISCARD、MULTI、WATCH四个命令,其他命令都会进入事务队列
    10.如果服务器开启了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器
命令执行器:调用命令的实现函数

当服务器决定要执行命令时,只需要执行以下语句:
client -> cmd -> proc(argv, argc)
调用命令实现函数会产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区中,命令实现函数还会为客户端套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端

命令执行器:执行后续工作

执行完命令实现函数后,还需要执行一些后续工作:

  1. 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令记录一条慢查询日志
  2. 根据刚刚执行完命令的时长,更新redisCommand的milliseconds属性,以及将calls属性+1
  3. 如果服务器开启了AOF持久化功能并且执行的命令是写命令,那么将命令写入AOF缓冲区
  4. 如果有其他从服务器正在复制该服务器并且执行的命令是写命令,那么该服务器会将命令传播给其他从服务器
将命令回复发送给客户端

命令实现函数会将命令回复存储到客户端状态的输出缓冲区中,并将客户端套接字的AE_WRITEABLE事件关联到命令回复处理器,当客户端准备读取命令回复时,会产生AE_WRITEABLE事件,触发命令回复处理器的执行,该处理器会将输出缓冲区中的内容发送给客户端
当命令回复发送完毕后,处理器会清空客户端的输出缓冲区

客户端接收并打印命令回复

当客户端接收到协议格式的命令回复后,它会将这些回复转换成人类可读的格式,并打印给观众看

serverCron函数

Redis服务器的serverCron函数平均每100毫秒执行一次,这个函数负责管理服务器资源

更新服务器时间缓存

Redis服务器中不少地方都需要获取系统当前时间,而每次获取系统当前时间都需要执行一次系统调用,为了减少系统调用次数,服务器状态的unixtime属性和mstime属性被用作当前时间的缓存

type redisServer struct {
	time_t unixtime    //秒级别的时间戳
	time_t mstime      //毫秒级别的时间戳
}

因为serverCron函数默认每100毫秒执行一次,所以缓存的当前时间并不准确

  1. 服务器只会在打印日志、更新服务器LRU时钟、决定是否执行RDB持久化、计算服务器上线时间时使用这类时间
  2. 对于为键设置过期时间、添加慢查询日志这类需要准确时间的功能来说,还是会进行系统调用去获取当前时间
更新LRU时钟

服务器状态的lruclock属性保存了服务器的LRU时钟

type redisServer struct {
	int lruclock   //默认每10秒更新一次,用来计算键的空转时长
}

每个redis对象都会有一个lru属性,该属性记录最后一条命令访问该对象的时间

type redisObject struct {
		int lru		//最后一次被命令访问的时间
}

当服务器要计算一个键的空转时间,程序会用服务器的lruclock属性减去对象的lru属性得到的结果就是键的空转时间
serverCron函数默认每100s更新一次服务器的lruclock属性

处理SIGTERM信号

在启动服务器时,Redis会为服务器进程的SIGTERM信号关联处理器sigtermHandler函数,这个处理器负责在服务器进程接收到SIGTERM信号时,打开服务器的shutdown_asap标识(即将标识置为1)
serverCron函数每次执行时会检查shutdown_asap标识,如果该标识的值为1,那么会进行RDB持久化,持久化完后关闭服务器

这里之所以要拦截SIGTERM信号进行处理,就是为了在关闭服务器前进行RDB持久化

管理客户端资源

serverCron函数每次执行都会调用clientsCron函数,clientsCron函数会对一定数量的客户端进行以下检查:

  1. 如果客户端与服务器之间的连接已经超时,那么程序释放这个客户端
  2. 如果客户端输入缓冲区的大小超过了一定长度,那么服务器会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,避免客户端的输入缓冲区占用过多的内存
管理数据库资源

serverCron函数每次执行都会调用databaseCron函数,databaseCron函数会检查一部分数据库,删除其中的过期键,并在有需要时对字典进行收缩操作

执行被延迟的BGREWRITEAOF

在服务器执行BGSAVE命令期间,如果客户端向服务器发送了BGREWRITEAOF命令,那么服务器会将BGREWRITEAOF命令延迟到BGSAVE命令执行完后执行
服务器状态的aof_rewrite_scheduled标识记录了服务器是否延迟执行了BGREWRITEAOF命令

type redisSerer struct {
	int aof_rewrite_scheduled //是否延迟执行aof重写标识,为1代表是
}
检查持久化操作的状态

服务器状态使用rdb_child_pid和aof_child_pid属性记录执行BGSAVE命令和BGREWRITEAOF命令的子进程id

type redisServer struct {
	int rdb_child_pid     //执行BGSAVE命令的子进程的id,如果没有执行BGSAVE命令那么为-1
	int aof_child_pid    //执行BGREWRITEAOF命令的子进程的id,如果没有执行BGSAVE命令那么为-1
}

每次serverCron函数执行时,程序都会检查rdb_child_pid和aof_child_pid两个属性的值,只要其中一个属性不为-1,那么程序会执行一次wait3函数,检查子进程是否有信号发来服务器进程

  1. 如果有信号到达,那么表示新的RDB文件或者AOF已经生成完毕,服务器可以进行后续操作,比如用新的RDB文件替换现有的RDB文件或者用新的AOF文件替换现有的AOF文件
  2. 如果没有信号到达,那么表示持久化操作未完成,程序不做动作

如果rdb_child_pid和aof_child_pid的值都为-1,那么表示服务器没有进行rdb持久化和aof重写操作,程序会执行以下三个检查:

  1. 查看是否有延迟执行的BGREWRITEAOF操作,如果有,那么执行一次BGREWRITEAOF操作
  2. 检查服务器的RDB条件是否满足,如果条件满足并且没有其他持久化操作(主要是防止第一步的BGREWRITEAOF操作),那么就执行BGSAVE命令
  3. 检查服务器设置的AOF重写条件是否满足,如果满足并且服务器没有执行其他的持久化操作,那么服务器会执行一次BGREWRITEAOF命令
将AOF缓冲区中的内容写入AOF文件

如果服务器开启了AOF持久化,并且AOF缓冲区中还有待写入的数据,那么serverCron函数会将AOF缓冲区中的内容写入AOF文件中

关闭异步客户端

关闭客户端状态中输出缓冲区超过限制的客户端

初始化服务器
初始化服务器状态结构

第一步就是创建一个redisServer结构保存服务器状态,并给结构中的每个属性设置默认值

载入配置选项

载入用户给定的配置参数和配置文件,并且根据用户设定的配置对redisServer的属性进行赋值

初始化服务器数据结构

服务器状态除了包含命令表结构外,还包含一些其他的结构,比如:

  1. server.clients链表,该链表记录了所有与服务器连接的客户端的状态
  2. server.db数组,包含的服务器的所有数据库
  3. 保存频道订阅信息的server.pub_sub_channels字典,以及用于保存模式订阅信息的server.pub_sub_patterns字典
  4. 用于执行lua脚本的Lua环境server.lua
  5. 用于保存慢查询日志的server.slowlog属性

除了初始化数据结构外,还进行了一些非常重要的设置操作:

  1. 为服务器进程设置信号处理器
  2. 创建共享对象,包含整数1~10000的整数对象,服务器通过重用这些共享对象来避免创建相同的对象
  3. 打开服务器监控端口,并为监听套接字关联连接应答处理器,等待客户端连接服务器套接字
  4. 为serverCron函数创建时间事件,等待服务器正式运行时执行serverCron函数
  5. 如果AOF持久化功能开启,并且存在AOF文件那么载入AOF文件,否则创建新的AOF文件为AOF写入做好准备
  6. 初始化服务器后台的io模块,为将来的io操作做好准备
还原数据库状态

完成了server变量的初始化之后,需要载入RDB文件或者AOF文件还原数据库状态

如果服务器开启了AOF持久化,那么服务器使用AOF文件还原数据库状态
如果服务器开启了RDB持久化,那么服务器使用RDB文件还原数据库状态

执行事件循环

最后开始循环执行事件(文件事件、时间事件)

多机数据库的实现

复制

在Redis中,可以通过SLAVEOF命令或者设置slaveof选项,让一个服务器去复制另外一个服务器,被复制的服务器称为主服务器,复制的服务器称为从服务器

旧版复制功能的实现

Redis的复制功能分为同步和命令传播

  1. 同步用于将从服务器的数据库状态更新至主服务器的数据库状态
  2. 命令传播用于当主服务器执行写命令改变数据库状态,导致主从服务器数据库状态不一致时,让主从服务器的数据库状态重新回到一致
同步

当客户端向从服务器发送SLAVEOF命令时,要求从服务器复制主服务器时,从服务器首先会执行同步操作

同步操作步骤:

  1. 从服务器向主服务器发送SYNC命令
  2. 主服务器接收到SYNC命令后,开始执行BGSAVE命令生成RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令
  3. 主服务器RDB文件生成后,将RDB文件传送给从服务器,从服务器载入RDB文件将数据库状态更新至RDB文件记录的数据库状态
  4. 主服务器将缓冲区中的写命令传送给从服务器,从服务器执行这些写命令,将数据库状态更新至主服务器当前的数据库状态
命令传播

同步操作执行完后,每当主服务器执行客户端发送的写命令时,可能会修改主服务器的数据库状态,导致主从服务器的数据库状态不一致,命令传播就是用来让主从服务器的数据库状态重新回到一致

主服务器会将自己执行的写命令传播给从服务器,从服务器执行完主服务器传过来的写命令后,主从服务器的数据库状态重新回到一致

旧版复制功能的缺陷

在redis2.8以前,从服务器对主服务器的复制可以分为以下情况:

  1. 初次复制
    从服务器之前没有复制过主服务器或者从服务器之前复制的主服务器不是当前要复制的主服务器
  2. 断线后复制
    处于命令传播阶段的主从服务器因为网络原因中断复制,从服务器自动重新连上主服务器后继续开始复制

旧版复制功能可以很好的完成初次复制,但是处理断线后重复制效率极低,断线后重复制采用的是同步的方式

从服务器重新连接上主服务器后,是向主服务器发送SYNC命令,采用同步的方式继续复制数据的,但其实从服务器中已经包含了大部分数据不需要采用同步操作复制主服务器所有的数据,所以效率极低

主从服务器只有一小部分数据不一致,仅仅为了弥补一小部分数据而重新执行一次同步操作,是很不合理的

执行一次同步操作是非常消耗资源的:

  1. 主服务器要执行BGSAVE命令生成RDB文件,会耗费主服务器的CPU、内存、IO资源
  2. 主服务器要将RDB文件发送给从服务器,会消耗主服务器大量网络资源并且阻塞主服务器执行其他命令
  3. 从服务器接收RDB文件并且载入RDB文件,会阻塞从服务器执行其他命令
新版复制功能的实现

Redis从2.8版本开始,使用PSYNC命令替换SYNC命令来完成同步操作,提高断线后重复制的效率

PSYNC命令具有完整重同步和部分重同步两种模式:

  1. 完整重同步
    完整重同步用来解决初次复制的情况,完整重同步和SYNC命令的执行步骤一样
  2. 部分重同步
    部分重同步用来解决断线后重复制的情况,当从服务器断线后重新连接上主服务器时,如果条件允许,主服务器可以将从服务器断连期间执行的写命令发送给从服务器,从服务器只需要执行这些写命令,就可以让主从服务器的状态恢复一致
部分重同步的实现

部分重同步由以下三个部分组成:

  1. 主服务器的复制偏移量和从服务器的复制偏移量
  2. 主服务器的复制积压缓冲区
  3. 服务器的运行ID
复制偏移量

主服务器、从服务器会各自分别维护一个复制偏移量

  1. 主服务器每次向从服务器传播N个字节的数据,就将自己的复制偏移量加上N
  2. 从服务器每次收到主服务器传播的N个字节的数据,就将自己的复制偏移量加上N

通过对比主从服务器的复制偏移量,就可以知道主从服务器是否处于一致:

  1. 如果主从服务器处于一致,那么主从服务器的复制偏移量相同
  2. 如果主从服务器不一致,那么主从服务器的复制偏移量不相同
复制积压缓冲区

复制积压缓冲区是主服务器维护的一个固定大小的、先进先出的队列,默认大小为1MB

固定大小的先进先出的队列:
当入队元素个数大于队列长度时,最先入队的元素会被踢出去,新元素会被放到队尾

当主服务器进行命令传播时,不仅会将命令传播给从服务器,还会将命令写入复制积压缓冲区
因此,复制积压缓冲区中会存有一部分最近传播的写命令,复制积压缓冲区为队列中的每一个字节记录相应的复制偏移量

当从服务器重新连接上主服务器后,会向主服务器发送PSYNC命令带上自己的复制偏移量offset,如果offset+1在主服务器的复制积压缓冲区中,那么主服务器会执行部分重同步操作,否则主服务会执行完整重同步操作

复制积压缓冲区的大小可以设置为2 * second * per_write_second,其中second为从服务器断线重连上主服务器的平均时间,per_write_second为主服务器平均每秒产生的写命令数量,这样就可以保证大部分断线重连情况下都可以走部分重同步

服务器运行ID

每个Redis服务器,不论主服务器还是从服务器,都有自己的运行ID
运行ID在服务器启动时生成

当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,从服务器会将这个ID保存起来

当从服务器断线重连上主服务器后,会将自己保存的主服务器的运行ID发送给重连上的主服务器:

  1. 如果保存的主服务器ID和重连上的主服务器ID相同,那么主服务器尝试执行部分重同步
  2. 如果保存的主服务器ID和重连上的主服务器ID不同,那么主服务器执行完整重同步
PSYNC命令的实现

PSYNC的调用方法有两种:

  1. 如果从服务器没有复制过主服务器,那么从服务器会向主服务器发送PSYNC ? -1命令,主动请求主服务器进行完整重同步
  2. 如果从服务器复制过主服务器,那么从服务器会向主服务器发送PSYNC runid offset命令,其中runid是从服务器保存的上次同步的主服务器的运行id,offset是从服务器的复制偏移量,主服务器会根据这两个参数判断执行完整重同步还是部分重同步

主服务器会向从服务器返回三种回复:

  1. 如果主服务器返回+FULLRESYNC runid offset回复,那么表示主服务器会与从服务器进行完整重同步,其中runid是主服务器的运行id从服务器会将这个id保存起来,再下一次使用PSYNC命令时带上这个id;offset是主服务器当前的复制偏移量,从服务器会把这个偏移量作为自己的初始复制偏移量
  2. 如果主服务器返回+CONTINUE回复,那么表示主服务器与从服务器执行部分重同步,从服务器只需要等着主服务器把缺失的数据发过来即可
  3. 如果主服务器返回-ERR回复,那么表示主服务器的版本低于Redis2.8,它识别不了PSYNC命令,从服务器将向主服务器发送SYNC命令和主服务器进行完整重同步
复制的实现

通过向从服务器发送SLAVEOF命令,可以让从服务器去复制一个主服务器
SLAVEOF masterip masterport

步骤1:设置主服务器的地址和端口

从服务器首先做的就是将客户端传过来的主服务器的ip和port保存到服务器状态的masterhost属性和masterport属性

type redisServer struct {
	string masterhost
	string masterport
}

SLAVEOF命令是一个异步命令,从服务器设置好masterhost和masterport属性后就会向客户端返回OK,实际的复制工作将在OK返回之后真正执行

步骤2:建立套接字连接

从服务器设置好主服务器的ip和port后,会创建连向主服务器的套接字连接,并为这个套接字关联一个处理复制的文件事件处理器,这个处理器将负责后续的接收RDB文件、接收主服务器发送的命令

主服务器在接受从服务器的连接后,会为该套接字创建相应的客户端状态

步骤3:发送PING命令

从服务器成为主服务器的客户端后,做的第一件事就是向主服务器发送一个PING命令

这个PING命令有两个作用:

  1. 检查套接字的读写状态是否正常
  2. 检查主服务器是否可以正常处理命令请求

从服务器发送PING后会遇到三种情况的回复:
3. 如果主服务器向从服务器返回了一个命令回复,但是从服务器在超时时间内没有收到命令回复,那么说明主从服务器之间存在网络问题,不能继续后续的复制操作,从服务器会断开连接并创建新的连接
4. 如果主服务器向从服务器返回一个错误,那么表示主服务器暂时无法处理从服务器的命令请求,不能继续后续的复制操作,从服务器会断开连接并创建新的连接
5. 如果主服务器返回“pong”命令回复,那么表示主从服务器之间的网络连接正常,并且主服务器可以正常处理从服务器的命令请求,在这种情况下,可以继续复制

步骤4:身份验证

如果从服务器设置了masterauth选项,那么进行身份验证
如果从服务器没有设置masterauth选项,那么不进行身份验证
在需要进行身份验证的情况下,从服务器将向主服务器发送一条AUTH命令,命令参数就是masterauth选项的值

从服务器在身份验证阶段可能遇到如下几种情况:

  1. 如果主服务器没有设置reqeuirepass选项,并且从服务器也没有设置masterauth选项,那么继续执行复制工作
  2. 如果主服务器设置的requirepass选项和从服务器auth命令发送的密码相同,那么复制继续;如果不同,那么主服务器返回一个invalid错误
  3. 如果主服务器设置了requirepass选项,但是从服务器没有设置masterauth选项,那么主服务器会返回一个NO AUTH错误;如果主服务器没有设置了requirepass选项,但是从服务器设置masterauth选项,那么主服务器会返回一个NO PASSWORD IS SET错误
步骤5:发送端口信息

身份验证后,从服务器向主服务器发送自己监听的端口号,主服务器会将这个端口号记录在从服务器客户端状态的slave_listening_post属性中

步骤6:同步

从服务器向主服务器发送PSYNC命令执行同步操作,将自己的数据更新至主服务器当前的数据库状态

如果PSYNC命令执行的是完整重同步,那么主服务器需要成为从服务器的客户端,这样主服务器才能向从服务器发送保存在缓冲区中的写命令
如果PSYNC命令执行的是部分重同步,那么主服务器需要成为从服务器的客户端,才能将保存在复制积压缓冲区中的缺少的写命令发送给从服务器

步骤7:命令传播

同步操作完成后就会进入命令传播阶段,这时主服务器将自己执行的写命令传播给从服务器

心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:
REPLCONF ACK replication_offset
其中replication_offset是从服务器当前的复制偏移量
该命令有三个作用:

  1. 检测主从服务器的网络连接状态
  2. 辅助实现min-slaves选项
  3. 检测命令丢失
检测主从服务器的网络连接状态

如果主超过一秒钟没有收到从服务器发送的REPLCONF ACK命令,那么主就知道主从之间的网络连接出问题了

辅助实现min-slaves选项

min-slaves-to-write和min-slaves-max-lag可以防止主服务器在不安全的情况下执行写命令
例如:
min-slaves-to-write 3
min-slaves-max-lag 10
如果slave服务器少于3个或者3个从服务器的延迟lag大于等于10,那么主服务器就拒绝执行写命令

检测命令丢失

如果因为网络故障,主传播给从的写命令丢失了,那么当从向主发送REPLCONF ACK命令时,主会发现从的复制偏移量比自己的小,然后主就会根据从的复制偏移量,在复制积压缓冲区找出缺少命令发送给从

重点回顾
  1. Redis2.8以前的复制功能不能高效地解决,但Redis2.8新添加的部分重同步可以解决这个问题
  2. 部分重同步通过复制偏移量、复制积压缓冲区、服务器运行ID三个部分来实现
  3. 在复制操作刚开始的时候,从服务器会成为主服务器的客户端,并且向主发送命令请求来执行复制,在复制的后期,主从会相互成为客户端
  4. 主向从传播命令保持主从状态一致,从向主发送心跳进行命令丢失检测

Sentinel

Sentinel(哨兵)是Redis高可用性解决方案:由一个或多个Sentinel实例组成的Sentinel系统可以监视任意多个主服务器以及这些主服务器属下的从服务器,并在被监视的主服务器下线时自动将下线主服务器属下的某个从服务器提升为新的主服务器,然后由新的主服务器继续执行命令

当主的下线时长超过用户设置的下线时长上限时,Sentinel系统会对这个主进行故障转移操作:

  1. 首先,Sentinel系统从主属下的从服务器中挑选一台作为新的主
  2. 之后,Sentinel系统会向原主属下的从服务器发送复制命令,让原主属下的从服务器复制新的主,当所有的从服务器都开始复制新的主时,故障转移操作完成
  3. Sentinel系统还会监视下线的原主,当原主重新上线时,让原主作为新主的从服务器加入集群
启动并初始化Sentinel

启动一个Sentinel可以使用命令:
redis-sentinel /path/to/your/sentinel.conf

当一个sentinel启动时,需要执行以下步骤:

  1. 初始化服务器
  2. 将普通redis服务器使用的代码替换成sentinel使用的代码
  3. 初始化Sentinel状态
  4. 根据给定的配置文件,初始化sentinel监视的主服务器列表
  5. 创建连接主服务器的网络连接
初始化服务器

因为sentinel本质上是一个特殊模式下的redis服务器,所以第一步就是初始化一个普通的redis服务器
因为sentinel和redis服务器的工作不同,所以初始化过程并不完全相同,例如redis服务器初始化需要加载RDB或者AOF文件还原数据库状态,但是sentinel不需要使用数据库所以不需要恢复数据库状态

使用sentinel专用代码

第二步就是将普通redis服务器使用的代码替换成sentinel使用的代码
比如:

  1. 普通redis服务器使用6379作为服务器端口,sentinel则使用26379作为服务器端口
  2. 普通redis服务器使用redisCommandTable作为命令表,sentinel服务器则使用sentinelcmds作为命令表

在Sentinel模式下,redis服务器不能执行诸如set、dbsize、eval等命令,因为命令表中根本没有存这些命令,sentinel只能执行ping、info、sentinel、subscribe、unsubscribe、psubscribe、punsubscribe七个命令

初始化sentinel状态

应用了sentinel的代码后,会初始化一个sentinelState结构,这个结构保存了所有和sentinel有关的状态

初始化sentinel状态的masters属性

masters字典记录了所有被哨兵监视的主服务器(这里的主指的是主服务器,主服务器对应的结构中有一个slaves属性保存的是从服务器)
字典的键是主服务器的名字
值是主服务器对应的sentinelRedisInstance结构
每个sentinelRedisInstance结构可以代表一个被sentinel监视的主服务器、从服务器或者另一个sentinel

sentinelRedisInstance结构的addr属性保存着实例的ip和端口

对sentinel状态的初始化会引起对masters属性的初始化,而masters属性是根据sentinel配置文件载入的

创建连向主服务器的网络连接

最后一步是创建连向被监视主服务器的网络连接,Sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息

Sentinel会创建两个连向主服务器的异步连接:
一个是命令连接,用来向主服务器发送命令,并且从命令回复中获取相关信息
一个是订阅连接,用来订阅主服务器的__sentinel__:hello频道

获取主服务器信息

Sentinel默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并通过分析INFO命令的回复来获取主服务器的当前信息

通过分析主服务器返回的INFO命令回复,Sentinel可以获取以下两方面的信息:

  1. 一方面是关于主服务器本身的信息,包括运行id以及服务器角色
  2. 另一方面是主服务器属下的所有从服务器的信息,其中包括从服务器的ip和port,做到自动发现从服务器

根据run_id和服务器角色,Sentinel将对主服务器的sentinelRedisInstance结构进行更新

返回的从服务器信息,用于更新主服务器实例结构的slaves字典,这个字典记录了从服务器名单:

  1. 字典的键是从服务器的名字,格式为ip:port
  2. 值是从服务器对应的sentinelRedisInstance
获取从服务器信息

Sentinel除了会为新的从服务器创建实例结构外,还会创建连接到从服务器的命令连接和订阅连接

Sentinel默认每十秒一次通过命令连接向被监视的从服务器发送INFO命令,获取从服务器当前的信息

向主服务器和从服务器发送信息

默认情况下,Sentinel会以每两秒一次的频率通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:
PUBLISH sentinel:hello “s_ip, s_port, s_runid, s_epoch, m_name, m_ip, m_port, m_epoch”
其中s开头的是sentinel本身的信息
m开头的是主服务器的信息,如果被监视的是主服务器那么就是主服务器的信息,如果被监视的是从服务器那么就是从服务器正在复制的主服务器的信息

接收来自主服务器和从服务器的频道信息

当Sentinel与被监视的服务器建立起订阅连接后,Sentinel就会通过订阅连接向服务器发送以下命令:
SUBSCRIBE sentinel:hello
订阅__sentinel__:hello频道

这样一个sentinel向某个被监视的服务器发送的信息就可以被其他的sentinel接收到,这些信息会用于更新其他sentinel对发送信息sentinel的认知以及对被监视服务器的认知

更新sentinels字典

Sentinel为主服务器创建的sentinelRedisInstance结构中的sentinels字典除了保存当前的sentinel外,还会保存监视该主服务器的其他sentinel

sentinels字典的键是sentinel的名字格式为ip:port,值是sentinel的实例结构

当一个sentinel接收到其他sentinel发来的信息时,目标sentinel会在sentinelState的masters属性中查找主服务器对应的sentinelRedisInstance实例,检查主服务器实例的sentinels属性中是否包含源sentinel:

  1. 如果源sentinel已经存在,那么对源sentinel结构(sentinelRedisInstance)进行更新
  2. 如果源sentinel不存在,那么会为源sentinel创建一个sentinelRedisInstance结构,并将这个结构存到slaves属性中
创建连向其他sentinel的命令连接

当sentinel通过频道信息发现一个新的sentinel时,除了会为新的sentinel创建sentinelRedisInstance结构外,还会和新的sentinel创建命令连接

检测主观下线状态

在默认情况下,Sentinel每隔一秒就会向所有创建了命令连接的服务器发送PING命令(包括主、从、其他sentinel),并通过其他服务器返回的PING命令回复判断服务器是否在线

服务器对PING命令的回复可以分为两类:

  1. 有效回复:+PONG、-LOADING、-MASTERDOWN三种回复中的一种
  2. 无效回复:除+PONG、-LOADING、-MASTERDOWN三种回复外的其他回复,或者在指定时间内没有回复

Sentinel配置文件中的down-after-miliiseconds选项指定了时间,如果在指定时间内服务器一直返回无效回复或者没有回复,那么sentinel会打开这个服务器的SRI_S_DOWN标识,认为这个服务器进入下线状态

检测客观下线状态

当sentinel认为一个主服务器主观下线后,为了确定主服务器是否真的下线,会向监视该主服务器的其他sentinel进行询问,看它们是否也认为主服务已下线(可以是主观或者客观)。当sentinel从其他sentinel那里接收到足够的已下线判断后,sentinel就会判断主服务器为客观下线

发送SENTINEL is-master-down-by-addr命令

Sentinel使用:
SENTINEL is-master-down-by-addr ip port current_epoch runid
询问其他sentinel是否认为主服务器下线
各个参数意义:

  1. ip
    主服务器ip
  2. port
    主服务器port
  3. current_epoch
    sentinel当前的配置纪元,用来选举领头sentinel
  4. runid
    检测主服务器下线时是*,选举领头sentinel时是运行id
接收 SENTINEL is-master-down-by-addr命令

当一个sentinel接收到另一个sentinel发送的命令时,目标sentinel会根据主服务器的ip port检查主服务器是否下线,然后向源服务器返回一个包含三个参数的Multi回复
三个参数分别为:

  1. down_state
    检查结果,1为下线,0为没有下线
  2. leader_runid
    局部领头sentinel的运行id,如果是判断主观下线那么为*
  3. leader_epoch
    局部领头sentinel的配置纪元,如果是判断主观下线那么为0
接收 SENTINEL is-master-down-by-addr命令回复

根据其他sentinel返回的SENTINEL is-master-down-by-addr命令回复,sentinel会统计其他sentinel认为主服务器主观下线的数量,当这一数量达到配置的指定数量时,sentinel会将主服务器的SRI_O_DOWN标识打开,表示认为主服务器进入客观下线

选举领头Sentinel

当一个主服务器被判断为客观下线,监视这个主服务器的sentinel会协商选举出领头sentinel,领头sentinel会对主服务器进行故障转移

故障转移

在选举出领头sentinel后,领头sentinel将对已下线的主服务器执行故障转移操作,包含以下三个步骤:

  1. 从已下线主服务器属下的从服务器中挑选出一个从服务器,并将其转换为主服务器
  2. 让已下线主的所有从服务器复制新的主服务器
  3. 将已下线的主服务器设置为新的主服务器的从服务器
选举出新的主服务器

新的主服务器是如何选举出来的:

  1. 删除slaves列表中所有处于下线的从服务器
  2. 删除slaves列表中近5秒内没有回复过sentinel INFO命令的从服务器
  3. 删除slaves列表中与已下线主服务器超过down-after-millisecond * 10毫秒的从服务器,因为判断主服务器主观下线需要down-after-millisecond 毫秒,删除超过down-after-millisecond * 10毫秒的从服务器可以保证剩下的从服务器没有过早与主服务器断开连接即从服务器中保存的数据是较新的

之后,领头Sentinel根据从服务器的优先级(选最高)对从服务器进行排序,对于相同优先级的从服务器再按照复制偏移量(选最大)排序,对于复制偏移量也相同的从服务器再按照运行id(选最小)排序

选出从服务器后,领头sentinel会向该从服务器发送SLAVEOF no one命令
发送命令后,领头sentinel会每隔一秒向从服务器发送INFO命令,观察返回的角色信息,当从服务器的角色从slave变成master时,领头sentinel就知道从服务器被转换成了主服务器

修改其他从服务器的复制目标

当新主出现后,领头sentinel向其他从服务器发送SLAVEOF 命令让其他从服务器复制新的主服务器

将旧主变为新主的从服务器

当旧主重新上线时,sentinel会向它发送SLAVEOF命令,让它成为新主的从服务器

集群

节点

一个Redis集群通常由多个节点node组成,连接各个节点的工作可以使用CLUSTER MEET命令完成,命令格式如下:
CLUSTER MEET ip port
向一个节点node发送CLUSTER MEET命令,可以让node节点和ip port指定的节点进行握手,当握手成功时,node节点就会将ip port指定的节点加入到node节点所在集群

启动节点

一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enabled配置选项是否为yes决定是否开启集群模式

节点会继续使用redisServer保存服务器状态,redisClient保存客户端状态,至于集群模式下才会用到的数据,会将它们保存到clusterNode、clusterLink、clusterState结构中

集群数据结构

clusterNode结构保存了一个节点的当前状态,比如创建时间、ip、port、配置纪元、节点名字

clusterNode的link属性是一个clusterLink结构,clusterLink保存了连接的节点的相关信息,比如套接字描述符、输入缓冲区、输出缓冲区

redisClient和clusterLink的区别:
redisClient保存的是连接的客户端的信息,clusterLink保存的是连接的节点的信息

每个节点都保存着一个clusterState结构,该结构记录了在当前节点的视角下,集群目前所处的状态:

type struct clusterState {
	*clusterNode   myself   //指向当前节点
	int size  //集群中至少处理一个槽的节点数
	dict nodes //集群节点字典,其中键是节点名称,value是clusterNode
}
CLUSTER MEET命令实现

通过向节点A发送Cluster Meet命令,可以让节点A将另一个节点B添加到节点A所在的集群里

接收到命令的节点A会与B进行握手
握手过程如下:

  1. 节点A会为节点B创建一个clusterNode结构,并将这个clusterNode结构添加到自己的clusterState结构的nodes属性中
  2. 之后,节点A将根据Cluster Meet命令给定的ip port,向节点B发送一条MEET信息
  3. 节点B会收到节点A发送的Meet信息,节点B会为节点A创建一个ClusterNode结构,并将这个结构添加到自己的ClusterState的nodes属性中
  4. 之后,节点B向节点A返回一条PONG消息
  5. 节点A会接收到节点B返回的PONG消息,从而知道节点B接收到了自己发送的MEET信息
  6. 之后,节点A会向节点B返回一条PING消息
  7. 节点B接收到节点A发送的PING消息后,知道节点A成功收到了自己的PONG消息,握手成功

之后,节点A会将节点B的信息通过Gossip协议传播给集群的其他节点,让其他节点和节点B进行握手。最终经过一段时间后,节点B会被集群中所有节点认识。

槽指派

集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽

当16384个槽都有节点在处理时,集群处于上线状态;如果有一个槽没被处理的话,那么集群处于下线状态

通过向节点发送CLUSTER ADDSLOTS命令可以将一个或者多个槽指派给节点
CLUSTER ADDSLOTS [slot…]

记录节点的槽指派信息

clusterNode结构的slots属性和numslot属性记录了节点在处理哪些槽

type clusterNode struct {
	char[16384/8] slots
	int numslot   
}

slots属性是一个二进制位数组,长度是16384/8个字节,每个字节是8位,共包含16384位
如果slots数组在索引i上的二进制位为1,那么表示节点在处理槽i
如果slots数组在索引i上的二进制位为0,那么表示节点没有处理槽i

因为slots是一个二进制位数组,所以设置节点处理槽i或者获取节点是否处理槽i的时间复杂度都为O(1)

numslots记录节点处理的槽数

传播节点的槽指派信息

节点会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽

当节点A通过消息从节点B那里接收到节点B的slots数组时,节点A会在自己的clusterState的nodes字典中找到B对应的clusterNode,并根据接收到的slots数组对clusterNode结构的slots数组进行更新或保存

因此,集群中的每个节点都知道16384个槽分别分配给了哪些节点

记录集群所有槽的指派信息

clusterState结构的slots数组记录了集群中16384个槽的指派信息

slots数组包含16384个元素,每个元素都是一个指向clusterNode的指针

  1. 如果slots[i]指向null,说明槽i没有被分配给任何节点
  2. 如果slots[i]不指向null,说明槽i已经分配给了clusterNode代表的节点

如果只将槽指派信息存储在各个节点clusterNode的slots属性中会出现一些无法高效解决的问题,而clusterState.slots数组的存在专门解决这个问题:

  1. 如果节点想知道槽i指派给了哪个节点,那么需要遍历所有的clusterNode结构的slots属性,直到找到负责槽i的节点,时间复杂度为O(n);而使用clusterState的slots属性只需要O(1)时间复杂度
  2. 如果不在clusterNode的slots属性中记录槽指派信息,那么需要将当前节点分配的槽信息发送给其他节点时,就需要遍历clusterState的slots属性找到自己负责的槽,再将信息发送出去,这比直接发送clusterNode的slots属性麻烦的多
CLUSTER ADDSLOTS命令实现

CLUSTER ADDSLOTS命令接受一个或多个槽作为参数,并且将指定的槽指派给接收命令的节点

CLUSTER ADDSLOTS命令伪代码:

fun addslots(all_slots_arr):
	for slot in all_slots_arr:
		if clusterState.slots[slot] != nil      //检查槽是否有负责的节点
			return err
	for slot in all_slots_arr:
		clusterState.slots[slot] = myself   //槽由myself节点负责
		setSlotBit(clusterState.myself.slots, slot)		//记录槽已经有节点负责

在CLUSTER ADDSLOTS命令执行完之后,节点会通过发送消息告知集群中的其他节点,自己目前正在负责哪些槽

在集群中执行命令

对数据库中的16384个槽都进行指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了

当客户端向集群发送与键有关的命令时,接收命令的节点会计算键所属的槽,并检查这个槽是否指派给了自己

  1. 如果键所在的槽指派给了自己,那么当前节点直接执行命令
  2. 如果键所在的槽没有指派给自己,那么向客户端返回MOVED错误,指引客户端转向正确的节点,并再次发送之前发送的命令
计算键属于哪个槽

计算给定键属于哪个槽:

func slot_number(key):
	return CRC16(key) & 16384

CRC16(key)是计算出键的CRC16校验和,&16384用于计算出一个介于0~16383之间的整数作为槽号
使用CLUSTER KEYSLOT< key >可以查看一个给定键对应的槽号

判断槽是否由当前节点处理

当根据键计算出键所属的槽号i后,判断槽i是否由自己负责:

  1. 如果clusterState.slots[i]等于clusterState.myself,那么说明槽i由当前节点负责
  2. 如果clusterState.slots[i]不等于clusterState.myself,那么说明槽i不由当前节点负责,当前节点会根据clusterState.slots[i]指向的clusterNode记录的ip和port,向客户端返回MOVED错误,指引客户端向正确的节点发送刚才发送的命令
MOVED错误

MOVED错误会引导客户端向键所在的节点发送命令
MOVED错误的格式为:
MOVED < slot > < ip >:< port >

其中,slot是键所属的槽号,ip是槽所在节点的ip,port是槽所在节点的port

被隐藏的MOVED错误

  1. 集群模式下的redis-cli,收到服务器返回的MOVED错误后,不会打印MOVED错误信息,会自动进行节点转向并打印出转向信息
  2. 单机模式下的redis-cli,收到服务器返回的MOVED错误后,会将MOVED错误信息打印出来,不会进行转向

因为单机模式下的redis-cli不清楚MOVED错误的作用,所以只会打印错误信息而不会进行转向

节点数据库的实现

节点和单机服务器的一个区别是,节点只能使用0号数据库,单机服务器可以使用任意数据库

节点会用clusterState的slots_to_keys跳跃表来保存槽和键之间的关系,跳表每个节点的分值都是一个槽号,每个节点的成员都是数据库键

通过在slots_to_keys跳跃表中记录各个数据库键所属的槽,可以很方便地对属于某个槽或者某些槽的所有数据库键进行批量操作

重新分片

重新分片可以将任意数量已经指派给某个节点的槽指派给另外一个节点,并且槽所属的键值对也会从源节点移到目标节点

重新分片操作可以在线进行,在重新分片过程中,源节点和目标节点都可以继续处理命令请求

重新分片实现原理

重新分片操作是由redis集群管理工具redis-trib负责执行的,redis-trib通过向源节点和目标节点发送命令来执行重新分片操作

redis-trib对单个槽执行重新分片的步骤为:

  1. 向目标节点发送CLUSTER SETSLOT < slot > IMPORTING < source_id >,让目标节点准备好从源节点导入槽slot的键值对
  2. 向源节点发送CLUSTER SETSLOT < slot > MIGRATING < target_id >,让源节点准备好向目标节点迁移槽slot的键值对
  3. 向源节点发送CLUSTER GETKEYSIN SLOT < slot > < count >,获取最多count个槽slot的键值对的键名
  4. 对于步骤3获取到的键名,向源节点发送一个MIGRATE < target_ip> < target_port > < key_name > 0 < timeout >命令,让源节点将指定键值对迁移到目标节点
  5. 重复步骤3和步骤4,直到将槽slot所有的键值对从源节点迁移至目标节点
  6. 向集群中所有节点发送CLUSTER SETSLOT < slot > NODE < target_id >,告诉所有节点槽slot已经指派给了目标节点target_id
ASK错误

在进行重新分片期间,可能会出现这样一种情况:被迁移槽的一部分键值对保存在源节点,一部分键值对保存在目标节点

当客户端向源节点发送一个与数据库键有关的命令,并且数据库键恰好就在被迁移的目标节点时:

  1. 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就执行客户端发送的命令
  2. 如果没找到指定的键,那么这个键很可能已经迁移到了目标节点,源节点会向客户端返回一个ASK错误,指引客户端转向目标节点,向目标节点发送刚才的命令
CLUSTER SETSLOT IMPORTING命令实现

clusterState结构的importing_slots_from数组记录了当前节点正在从其他节点导入的槽:

type clusterState struct {
	clusterNode[16384] importing_slots_from
}

如果importing_slots_from[i]不为null,说明当前节点正在从其他节点导入槽i

重新分片时,redis-trib向目标节点发送CLUSTER SETSLOT < slot > IMPORTING < source_id >
可以将目标节点的importing_slots_from[i]设置为source_id对应的clusterNode

CLUSTER SETSLOT MIGRATING 命令实现

clusterState结构的migrating_slot_to数组记录了当前节点正在向其他节点迁移槽:

type clusterState struct {
	clusterNode[16384] migrating_slot_to
}

如果migrating_slot_to[i]不为空,说明当前节点正在向其他节点迁移槽i

重新分片时,redis-trib向源节点发送CLUSTER SETSLOT < slot > MIGRATING < target_id >,当前节点会将migrating_slot_to[i]设置为target_id对应的clusterNode

ASK错误

如果节点收到一个关于键的命令,并且键所属的槽i正好由当前节点负责,那么这个节点会在自己的数据库中查找键,如果找到了就直接执行客户端发送的命令

如果节点没有在自己的数据库里找到键,那么节点会检查clusterState.migrating_slot_to[i]是否为空,如果不为空说明槽i正在迁移,那么会向客户端返回一个ASK错误,引导客户端向目标节点发送命令

接到ASK错误的客户端会根据错误提供的ip、port,转向正在迁移的目标节点,然后首先向目标节点发送ASKING命令,之后再重新发送刚才发送的命令

ASKING命令

ASKING命令唯一要做的就是打开客户端状态的REDIS_ASKING标识

一般情况下,如果客户端向节点发送一个关于槽i的命令,槽i不属于当前节点,那么当前节点会向客户端返回一个MOVED错误,但是,如果节点向客户端返回ASK错误引导客户端向目标节点发送槽i的命令,客户端会先发送ASKING命令打开客户端状态的REDIS_ASKING标识,这样目标节点就会破例执行槽i的命令

注意,客户端状态的REDIS_ASKING标识是一个一次性标识,当节点执行完客户端发送的命令后,客户端状态的REDIS_ASKING标识就会被移除

ASK错误和MOVED错误的区别

MOVED错误代表槽的负责权从一个节点转移到了另外一个节点
ASK错误代表槽迁移过程中的临时措施

复制与故障转移

Redis集群中的节点分为主节点和从节点,主节点用于处理槽,从节点用于复制主节点的数据,并且在主节点下线时代替下线的主节点继续处理命令请求

主节点下线,集群中正常运行的几个主节点将在下线主节点的从节点中选出一个节点作为新的主节点,新的主节点将接管下线主节点负责的槽,并继续处理客户端请求,下线主节点的从节点也会复制新的主节点

设置从节点

向一个节点发送命令:
CLUSTER REPLICATE < node_id >
可以让接收该命令的节点成为node_id节点的从节点,并开始对主节点进行复制

  1. 接收到该命令的节点首先会在自己的clustesState.nodes字典中查找node_id对应的clusterNode,然后将这个clusterNode设置到clusterState.myself.slaveof中,以此来记录当前节点复制的主节点
  2. 然后节点会修改自己在clusterState.myself.flags中的属性,关闭原本的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识,表示这个节点已经由原来的主节点变成了从节点
  3. 最后,节点会调用复制代码,并根据clusterState.myself.slaveof指向的clusterNode结构所保存的IP和端口号,对主节点进行复制

一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点

集群中的所有节点都会在代表主节点的clusterNode的slaves属性和numslaves属性中记录正在复制这个主节点的从节点和从节点数量

故障检测

集群中的每个节点都会定期向集群中其他节点发送PING消息,以此来检测对方是否在线,如果接受PING消息的节点没有在规定时间内返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线(打开接收PING消息节点对应的clusterNode的flags属性的REDIS_NODE_PFAIL标识)

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

当一个主节点A通过消息得知主节点B认为主节点C进入了疑似下线,那么主节点A会在自己的clusterState.nodes中找到C对应的clusterNode,然后将主节点B的下线报告添加到clusterNode的fail_reports链表里

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

故障转移

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,故障转移的步骤:

  1. 下线主节点的所有从节点,会有一个从节点被选中
  2. 被选中的从节点会执行SLAVEOF no one命令,成为新的主节点
  3. 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽指派给自己
  4. 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责的槽
  5. 新的主节点开始处理槽有关的请求,故障转移完成
选举新的主节点

选举新的主节点的方法:

  1. 集群的配置纪元是一个自增计数器,初始值为0
  2. 当集群里的某个节点开始一次故障转移操作时候,集群的配置纪元会增加1
  3. 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票
  4. 当从节点发现自己复制的主节点下线时,从节点会向集群广播一条消息,要求所有收到这条消息的主节点向自己投票
  5. 如果一个主节点具有投票权(正在负责槽),并且尚未投票给其他节点,那么主节点将向要投票的从节点返回一条ACK消息,表示将票投给这个从节点
  6. 每个参与选举的从节点会根据接收的ACK消息数量统计自己收到了多少票
  7. 如果集群中有N个具有投票权的主节点,那么当从节点收到大于等于N/2+1张票时,这个从节点会成为新的主节点
  8. 如果在一个配置纪元里,没有从节点收到足够的投票,那么会进入一个新的配置纪元,继续选举,直到选出新的主节点为止
消息

集群中的各个节点通过发送和接收消息来进行通信

节点发送的消息主要有以下五种:

  1. MEET消息
    当发送者接到客户端发送的CLUSTER MEET命令时,发送者会向接收者发送MEET消息,请求接收者加入发送者当前所在的集群
  2. PING消息
    集群里的每个节点默认每隔一秒就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。除此之外,如果节点A最后一次收到节点B发送来的PONG消息的时间,距离当前时间已经超过了节点A的cluster-node-timeout的一半,那么节点A也会向节点B发送PING消息,这可以防止节点A长时间未向节点B发送PING消息导致对节点B消息的更新滞后
  3. PONG消息
    当接收者收到发送者发来的MEET消息或者PING消息时,接收者会向发送者返回一条PONG消息,确认这条接收的消息已到达。另外,一个节点也可以通过向集群广播自己的PONG消息来让集群中的其他节点立即刷新关于这个节点的认识,例如当一次故障转移操作完成后,新的主节点会向集群广播一条PONG消息,以此来让集群中的其他节点立即知道这个节点已经变成了主节点,并且已经接管了下线主节点的槽
  4. FAIL消息
    当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息,所有收到这条消息的节点都会立即将节点B标记为下线状态,从节点会进行故障转移
  5. PUBLISH消息
    当节点接收到一个PUBLISH消息时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令
消息头

消息头除了包含消息正文之外,还记录了消息发送者自身的一些信息

MEET、PING、PONG消息的实现

Redis集群中各个节点通过Gossip协议来交换各自关于不同节点的状态信息,其中Gossip协议由MEET、PING、PONG三种消息实现,这三种消息的正文都由两个clusterMsgDataGossip结构组成

clusterMsgDataGossip结构包括节点的名字、ip、port、flags

每次发送MEET、PING、PONG消息时,发送者都会从自己已知节点列表中随机选出两个节点(可以是主,可以是从),并将这两个节点的信息保存到两个clusterMsgDataGossip结构中

当接收者收到MEET、PING、PONG消息时,会访问消息中的两个clusterMsgDataGossip结构

  1. 如果被选中的节点不存在于接收者已知节点列表中,接收者会根据节点的ip、port和被选中节点进行握手
  2. 如果被选中节点已经存在于接收者已知列表中,那么接收者根据被选中节点的信息更新被选中节点对应的clusterNode
FAIL消息的实现

当集群里的主节点A将主节点B标记为已下线时,主节点A将向集群广播一条关于B的FAIL消息,所有接收到这条FAIL消息的节点都会将主节点B标记为下线

在集群节点数量比较大时,靠Gossip协议传播节点已下线的信息会带来一定延迟,因为Gossip协议消息通常需要一段时间才能传播到整个集群,而发送FAIL消息需要立即让所有节点知道某个主节点已下线

FAIL消息的消息正文中记录了下线节点名称

PUBLISH消息的实现

当客户端向集群中的某个节点发送命令:
PUBLISH < channel > < message >
接收到PUBLISH命令的节点不仅会向channel频道发送message消息,还会向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会向channel发送message消息

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值