Redis没有直接使用C语言传统的字符串(以空字符结尾的字符数组),而是自己构建了简单动态字符串类型(SDS)表示字符串
在Redis里面,C语言传统字符串只会用在字符串常量,不需要对字符串内容进行修改的地方,例如打印日志
redisLog(REDIS_ERROR, "error log")
举个例子:
客户端执行:set msg “hello world”
Redis会在数据库中创建一个键值对。其中键是一个字符串对象,底层实现是一个保存着字符串"msg"的SDS;值也是一个字符串对象,底层实现是一个保存着字符串"hello world"的SDS
除了用来保存字符串值外,SDS还被用作缓冲区:AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区
每个sdshdr结构表示一个SDS值
type sdshdr struct {
int len //已使用字节数,表示字符串长度
int free //空闲字节数
[]char buf //字节数组,保存字符串
}
SDS遵循C字符串以空字符结尾的习惯,SDS函数会自动为空字符分配额外的一个字节空间,并将空字符添加到字节数组的末尾,这个空字符不会计算在len属性中
SDS遵循空字符结尾的好处是,可以重用C语言中一些操作字符串的函数
例如:想打印s这个SDS变量保存的字符串值,可以使用printf函数,执行prinf("%s", s->buf)
因为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字符串的数组进行内存重分配操作:
例如:拥有一个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数组可以用来保存二进制数据
SDS遵循了C字符串以空字符结尾的习惯,SDS的API会自动为空字符分配一字节的空间并且会将空字符添加到buf数组的末尾,目的就是为了SDS可以使用一部分C语言字符串函数
C字符串 | SDS |
---|---|
获取字符串长度时间复杂度为O(n) | 获取字符串长度时间复杂度为O(1) |
API是不安全的,可能造成内存溢出 | API是安全的,不会造成内存溢出 |
修改N次字符串要内存重分配N次 | 修改N次字符串最多内存重分配N次 |
只能保存文本数据 | 可以保存文本或者二进制数据 |
可以使用所有C字符串函数 | 只能使用部门C字符串函数 |
列表键的底层实现之一就是链表,当一个列表键包含数量比较多的元素或者列表中包含的元素都是比较长的字符串时,Redis会选择链表作为列表的底层实现
发布订阅、慢查询、监视器等功能也都用到了链表,Redis服务器还是用链表保存多个客户端的状态信息,以及使用链表构建客户端输出缓冲区
type listNode struct {
*listNode pre //前置节点
*listNode next //后置节点
void *value //节点的值
}
多个listNode可以通过pre和next指针组成双端链表
type list struct {
*listNode head //表头节点
*listNode tail //表尾节点
long len //节点数量
dup(*ptr) //节点值复制函数
free(*ptr) //节点值释放函数
match(*ptr) //节点值对比函数
}
list为链表提供了表头指针head、表尾指针tail、节点数量len
并且提供了3个操作节点值的函数:
dup:复制节点保存的值
free:释放节点保存的值
match:判断节点保存的值和另一个输入值是否相等
字典又称为映射,是一种保存键值对的数据结构
字典中的每个键都是独一无二的,可以在字典中根据键查找对应的值,也可以根据键修改对应的值或者根据键删除键值对
Redis数据库底层实现就是使用字典实现的,对数据库的增删改查都是基于字典的操作
字典还是哈希键的实现之一,当哈希键包含的键值对比较多或者键值对都是比较长的字符串时,会使用字典作为实现
字典采用哈希表作为底层实现,一个哈希表可以有多个哈希节点,而每个哈希节点就保存了一个键值对
哈希表由dictht结构定义:
type dichth struct {
[]*dictEntry table //哈希表数组
long size //哈希表大小
long sizemask //哈希表大小掩码,用来计算索引值,总是等于size-1
long used //哈希表已有节点数
}
属性介绍:
哈希表节点由dictEntry结构表示,每个dictEntry都保存着一个键值对:
type dictEntry struct {
void *key //键
union {
void *val
uint64 u64
int64 s64
} v //值
*dictEntry next //指向下一个哈希表节点
}
属性介绍:
字典由dict结构表示:
type dict struct {
*dictType type //类型特定函数
*void privdata //私有数据
dictht ht[2] //哈希表
int rehashindex //rehash索引,当不在rehash时值为-1
}
属性介绍:
type dictType struct {
hashFunction(key) //计算键的哈希值
keyDup(key) //复制键的函数
valDup(val) //复制值的函数
keyCompare(key1,key2) //对比键的函数
keyDestructor(key) //销毁键的函数
valDestructor(key) //销毁值的函数
}
当要将一个键值对添加到字典中时,需要先根据键的hash值和哈希表sizemash的值计算得到索引值index,然后将包含键值对的哈希表节点放到哈希表数组index索引位置上
计算key的hash值和索引值的公式如下:
hash = dict->dictType->hashFunction(key)
index = hash & dict->ht[x]->sizemash
当有两个及以上的键被分配到了哈希表数组的同一个位置,称这些键发生了哈希冲突
Redis的哈希表使用链表法解决哈希冲突,每个哈希表节点都有一个next指针指向下一个哈希表节点,同一个索引位置的多个哈希表节点可以通过next指针串成一个链表
由于哈希表节点组成的链表没有指向尾节点的指针,所以在添加新节点时是将节点添加到链表的头部,时间复杂度是O(1)
随着操作的不断执行,哈希表保存的键值对会逐渐变多或者变少,为了让哈希表的负载因子维持在一个合理的范围内,当哈希表保存的键值对太多或者太少时,需要对哈希表的大小进行缩小或扩大
缩小和扩大哈希表的操作由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做准备
哈希表的扩展和收缩
扩展和收缩哈希表时,会将ht[0]的键值对rehash到ht[1],但这个rehash操作并不是一次性的而是分多次、渐进式的
原因在于:如果ht[0]包含的键值对过多,那么一次性迁移所有键值对会消耗很多时间,对服务器的性能产生影响;而分多次、渐进式的迁移键值对则不会有影响
渐进式rehash的步骤:
渐进式rehash期间的字典操作:
5. rehash期间,查询、修改、删除操作会在ht[0]、ht[1]两个哈希表上进行,先在ht[0]上查找键,查不到再去ht[1]上查
6. 添加操作会在ht[1]哈希表上操作
跳表是一种有序数据结构,通过在每个节点维护多个指向其他节点的指针,从而达到快速访问节点的目的
Redis使用跳表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,或者有序集合中元素的成员是比较长的字符串时,就会使用跳跃表作为底层实现
Redis只在有序集合键和集群节点的内部数据结构中用到了跳表
Redis的跳表由zskiplistNode和zskiplist两个结构定义,其中zskiplistNode表示跳跃表节点,而zskiplist保存跳表相关信息,比如节点数量、指向表头节点和表尾节点的指针
TODO 补充跳表图
最左边是zskiplist结构,该结构包含以下属性:
右边是四个zskiplistNode结构,zskiplistNode结构包含如下属性:
跳跃表节点由zskiplistNode结构表示:
type zskiplistNode struct {
*zskiplistNode backward //后退指针
double score //分数
*redisObject obj //成员对象
[]zskiplistLevel level //层
}
type zskiplistLevel struct {
*zskiplistNode forward //前进指针
int span //跨度
}
属性介绍:
通过使用zskiplist结构可以更方便地对跳表进行处理
zskiplist定义如下:
type zskiplist struct {
*zskiplistNode header //头节点指针
*zskiplistNode tail //尾节点指针
long length //节点数量
int level //层数最大的节点的层数
}
属性说明:
整数集合是集合键的底层实现之一,当一个集合只包含整数元素,并且这个集合元素数量不多的时候,Redis就会使用整数集合作为实现
整数集合是用来保存整数值的集合,可以保存类型为int16、int32、int64类型的整数值,并且保证集合中不会出现重复的元素
每个intset的结构:
type intset struct {
uint32 encoding //编码方式
uint32 length //包含的元素数量
contents //元素数组
}
属性说明:
根据整数集合的升级规则,当像一个int16类型的contents数组中添加一个int64的整数,会将contents数组中的所有元素提升为int64类型
每当添加一个新元素到整数集合时,并且新元素的类型比整数集合已有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面
升级整数集合并添加新元素的步骤为:
1. 根据新元素类型,扩展整数集合底层contents数组的空间大小,并为新元素分配空间
2. 将底层数组现有的所有元素都转换成和新元素相同的类型,并将类型转换后的元素放在正确的位置上,维持底层数组有序性不变
3. 将新元素添加到底层数组里
因为每次向整数集合添加新元素都可能会引起升级,而每次升级都要对已经元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(n)
升级策略有两个好处:1.增加灵活性 2.节约内存
整数集合不支持降级,一旦升级,编码就会一直保持升级后的状态
压缩列表是列表键或者哈希键的底层实现之一
当一个列表键只包含少量列表项,并且每个列表项要么是小的整数值,要么是长度比较短的字符串,那么Redis就会使用压缩列表作为列表键的底层实现
当一个哈希键只包含少量键值对,并且每个键值对的键和值都是比较小的整数或者长度比较小的字符串,那么Redis就会使用压缩列表作为哈希键的底层实现
一个压缩列表可以包含多个节点,每个节点可以保存一个字符串或者一个整数值
压缩列表组成项:
每个压缩列表节点可以保存一个字节数组或者一个整数值
字节数组可以是以下三种长度之一:
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属性记录了前一个节点的长度,该属性的长度可以是1字节或5字节
如果有一个指向当前节点起始地址的指针c,那么只要用指针c减去当前节点previous_entry_length属性的值,就可以得出指向前一个节点起始地址的指针p。压缩列表从表尾向表头遍历就是使用这一原理实现的。
节点的encoding属性记录了节点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. 即使出现连锁更新,只要被更新的节点数量不多,也不会对性能造成任何影响
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_INT | long类型的整数 |
REDIS_ENCODING_EMBSTR | embstr编码的简单动态字符串 |
REDIS_ENCODING_RAW | 简单动态字符串 |
REDIS_ENCODING_HT | 字典 |
REDIS_ENCODING_LINKEDLIST | 双端链表 |
REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_ENCODING_INTSET | 整数集合 |
REDIS_ENCODING_SKIPLIST | 跳表和字典 |
每种不同的对象都至少可以使用两种数据结构,对应关系如下表:
对象类型 | 编码 | 数据结构 |
---|---|---|
REDIS_STRING | REDIS_ENCODING_INT | long类型整数 |
REDIS_STRING | REDIS_ENCODING_EMBSTR | embstr编码的简单动态字符串 |
REDIS_STRING | REDIS_ENCODING_RAW | 简单动态字符串 |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST | 双端链表 |
REDIS_HASH | REDIS_ENCODING_HT | 字典 |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_SET | REDIS_ENCODING_HT | 字典 |
REDIS_SET | REDIS_ENCODING_INTSET | 整数集合 |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_ZSET | REDIS_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编码:
注意,可以通过配置修改条件,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编码:
注意,这两个条件可以通过配置修改,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 //引用计数
}
对象的引用计数信息会随着对象的使用而改变:
对象的整个生命周期可以分为创建对象、操作对象、释放对象三个阶段
除了实现引用计数的垃圾回收机制外,refcount属性还被用于对象共享
让多个键共享同一个值对象需要执行两个步骤:
1. 让键的值指针指向共享的值对象
2. 将共享值对象的refcount属性加1
共享对象机制对节约内存非常有帮助,内存中相同的值对象越多,那么共享对象机制就越能节约内存
目前来说,Redis在初始化的时候会创建一万个共享字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要使用0~9999的字符串对象时,会直接使用这些共享对象
创建共享字符串对象的数量可以通过REDIS_SHARED_INTEGERS修改
共享字符串对象不止只有字符串键可以使用,内嵌字符串对象的其他四种类型的对象也可以使用
为什么Redis只共享包含整数值的字符串对象?
当考虑将一个共享对象作为键的值对象时,需要检查共享对象是否和键需要的目标对象完全相同,只有完全相同时才能使用共享对象作为键的值对象,那么在检查共享对象和目标对象是否相同时,如果共享对象越复杂那么检查的复杂度就越高
redisObject结构包含一个lru属性,该属性记录了对象最后一次被命令访问的时间
type redisObject struct {
int lru //最后一次被访问的时间
}
OBJECT IDLETIME可以获取键的空转时长,空转时长就是当前时间-lru计算得出的
OBJECT IDLETIME命令比较特殊,当这个命令访问键时,不会修改键的lru属性
如果服务器打开了max-memory选项,并且服务器回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存超过maxmemory时,就会回收空转时间较长的键的内存
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有两个命令可以用来设置键的生存时间,两个命令可以用来设置键的过期时刻
设置键的生存时间:
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)
判断一个键是否过期步骤:
键过期后,有三种不同的删除策略:
定时删除是对内存最友好的,通过使用定时器可以在键过期时尽快将过期键删除,释放过期键占用的内存空间
定时删除对CPU时间是不友好,如果多个键的过期时间相同,同一时间需要删除多个键,服务器需要执行与当前任务无关的删除过期键操作,会对整个服务器的吞吐造成影响
惰性删除策略对CPU时间是最友好的,服务器只在操作键时检查键是否过期,如果过期则删除键,不会在与当前任务无关的键上浪费时间
惰性删除策略对内存是不友好的,如果键过期之后没有对该键执行读写操作,那么该键永远不会被删除会一直占用内存空间
定时删除和惰性删除都有明显缺陷:
定时删除占用太多CPU时间
惰性删除浪费内存空间,造成内存泄露
定期删除策略是这两种策略的折中:
难点是确定定期删除的频率和时长:
所以要根据实际情况,调整定期删除的频率和时长
Redis实际使用惰性删除和定期删除两种策略,通过这两种策略服务器可以在合理使用CPU时间和避免浪费内存之间取得平衡
过期键的惰性删除策略由expireIfNeeded函数实现,所有对数据库进行读写的命令执行之前都会调用expireIfNeeded函数,该函数会判断键是否过期如果过期则将键删除,否则不做任何动作
expireIfNeeded函数执行后继续执行实际命令流程
过期键的定期删除策略由activeExpireCycle函数实现,每当服务器周期性执行serverCron函数时,serverCron函数都会调用activeExpireCycle函数
activeExpireCycle函数会分批遍历所有数据库,每次遍历一个数据库时从数据库的expire字典中随机获取指定数量的过期键,检查过期键是否过期如果过期则删除,每检查一个过期键就会判断操作是否已达时间上限,如果是那么停止操作
activeExpireCycle函数工作模式:
在执行SAVE、BGSAVE命令时,服务器会对键进行检查,已经过期的键不会保存到新创建的RDB文件中
启动Redis服务器时,如果服务器开启了RDB持久化,那么会载入RDB文件:
当过期键被惰性删除或者定期删除时,程序会向AOF文件追加一条过期键的DEL命令
在对AOF文件进行重写时,会检查键是否过期,如果过期则不会将键写入新的AOF文件
当服务器运行在复制模式下时,从服务器删除过期键的动作由主服务器控制:
可以让客户端订阅给定频道或者模式,来获知数据库键的变化,以及数据库中命令的执行情况
例如:
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函数步骤:
将服务器中非空数据库以及它们的键值对称为数据库状态
Redis提供了RDB持久化功能,将Redis的数据库状态保存到磁盘中,避免数据意外丢失
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命令执行时,服务器进程会阻塞不会执行任何命令请求,数据库状态不会变
BGSAVE命令执行期间,子进程负责创建RDB文件保存数据库状态,父进程继续处理客户端的命令请求,但是父进程在处理客户端的SAVE、BGSAVE、BGREWRITEAOF命令时跟平常有所不同
当服务器正在执行BGSAVE命令时,会拒绝执行SAVE命令,防止父子进程同时调用rdbSave方法产生竞争条件
当服务器正在执行BGSAVE命令时,会拒绝执行BGSAVE命令,防止同时调用rdbSave方法产生竞争条件
当服务器正在执行BGSAVE命令时,如果客户端发送BGREWRITEAOF命令,那么服务器会延迟BGREWRITEAOF命令直到BGSAVE命令执行完后再执行
当服务器正在执行BGREWRITEAOF命令时,如果客户端发送BGSAVE命令,那么服务器会拒绝执行BGSAVE命令
因为多个子进程同时进行大量写操作,性能不好
服务器载入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计数器记录了上一次成功执行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文件包含以下各个部分:
REDIS | db_version | database | EOF | check_sum
全大写表示常量,全小写表示变量或数据
REDIS部分长度为5字节,保存着”REDIS“这五个字符,服务器载入文件时,根据该部分检查是否是RDB文件
db_version长度为4字节,值是一个字符串表示的整数,记录了RDB文件的版本号,例如”0006“就是第六版
database包含零个或者多个数据库,以及各个数据库中的键值对:
EOF常量的长度为1字节,标志RDB文件正文内容结束,当载入程序读到这个值时,就知道所有的键值对都加载完了
check_sum是一个无符号整数,保存着一个校验和,这个校验和是程序对REDIS、db_version、database、EOF四个部分进行计算后得到的。服务器在载入RDB文件时,会计算载入数据的校验和,然后和check_sum对比,以此来检查RDB文件是否有出错
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保存了数据库中所有的键值对,如果键值对有过期时间,那么过期时间也会和键值对保存在一起
不带过期时间的键值对由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是一个以毫秒为单位的时间戳,代表过期时间
字符串对象
如果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是压缩后的字符串。
列表对象
如果TYPE的值是REDIS_RDB_TYPE_LIST,那么value保存的就是双端链表编码的列表对象,这种对象的结构如图:
list_length | item1 | item2 | … | itemN
list_length代表列表长度,程序读入这个变量就知道接下来要读取多少个元素
每个元素都是一个字符串对象,所以程序会以字符串对象的方式来保存和读入集合元素
集合对象
如果TYPE的值为REDIS_RDB_TYPE_SET,那么value保存的是一个字典编码的集合对象,结构如图:
set_size | elem1 | elem2 | … | elemN
set_size是集合的大小,记录保存了多少个元素,程序可以读入该变量知道要读取多少个元素
每个元素都是一个字符串对象,所以程序会以字符串对象的方式来保存和读取集合元素
如下,是包含四个元素的集合:
2 | 5 “apple” | 4 “food”
哈希表对象
如果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”
有序集合对象
如果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”
INTSET编码的集合
如果TYPE是REDIS_RDB_TYPE_SET_INTSET,那么value保存的就是一个整数集合编码的集合对象,RDB保存这种对象的方法是将整数转换成字符串对象,然后以字符串对象的形式保存
程序读取RDB文件时,也是将读取到的字符串对象转换成整数存入整数集合
ZIPLIST编码的列表、哈希表、有序集合对象
如果TYPE的值为REDIS_RDB_TYPE_LIST(HASH、ZSET)_ZIPLIST,那么value保存的就是一个ZIPLIST编码的列表、哈希表、有序集合对象
保存ZIPLIST编码的对象时,先将压缩列表对象转换成字符串对象,然后将字符串对象保存到文件中
AOF持久化是通过保存执行的写命令来记录数据库状态
被写入AOF文件的所有写命令都是以命令请求协议格式保存的,Redis的命令请求协议是纯文本格式,所以可以直接打开一个AOF文件查看文件内容
服务器启动时,可以直接载入和执行AOF文件中保存的写命令,让数据库状态恢复到关闭之前的状态
AOF持久化的实现可以分为命令追加、文件写入、文件同步三个步骤
当AOF持久化功能打开时,服务器每执行一条写命令,会以命令请求协议格式将写命令追加到服务器状态中的AOF_BUF缓冲区的末尾
type redisServer struct {
sds aof_buf //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持久化的效率和安全
Redis读取AOF文件并还原数据库状态的步骤:
为了解决AOF文件体积膨胀的问题,Redis提供了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重写的原理就是读取键对应的值,然后用一条命令来插入键值对,代替该键之前的所有命令
Redis不希望AOF重写造成服务器进程阻塞,所以Redis将AOF重写交给子进程执行
使用子进程有一个问题需要解决,子进程在进行AOF操作期间,父进程一直在处理客户端命令请求,新的命令可能会对数据库状态有影响,从而使得服务器当前的数据库状态和重写后保存的数据库状态不一致
为了解决数据不一致问题,Redis服务器设置了一个aof重写缓冲区,这个缓冲区在服务器创建子进程后开始使用,当Redis服务器执行完一个写命令后,他会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区
这样一来:
当子进程完成AOF重写时,会给父进程发送信号,父进程接收到信号后,将AOF重写缓冲区中的命令都写入新的AOF文件,并且对新的AOF文件改名原子地覆盖原来的AOF文件
执行完后,父进程继续处理客户端的命令请求
Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:
文件事件处理器使用IO多路复用程序来同时监听多个套接字,并且根据套接字目前执行的任务来为套接字关联不同的事件处理器
当被监听的套接字准备好执行连接应答、读取、写入、关闭等操作时,与操作相关的文件事件就会产生,这时文件事件处理器会调用套接字之前关联好的事件处理器来处理该事件
文件时间处理器由四部分构成,套接字、IO多路复用程序、事件分派器、事件处理器
每当套接字准备执行连接应答、写入、读取、关闭操作时,都会产生一个文件事件
IO多路复用程序负责监听多个套接字,并向事件分派器传送产生了事件的套接字
尽管多个文件事件会并发产生,但是IO多路复用程序会将所有产生事件的套接字放到一个队列中,然后通过这个队列有序、同步、每一个套接字的方式传送给事件分派器。这样,只有上一个套接字被处理完后,下一个套接字才会被处理。
事件分派器收到IO多路复用程序传送过来的套接字后,根据套接字产生事件的类型调用对应的事件处理器
aeCreateFileEvent函数接受一个套接字描述符、一个事件类型、以及一个事件处理器作为参数,将给定套接字的给定事件加入到IO多路复用程序的监听范围内,并对事件和事件处理器作关联
aeDeleteFileEvent函数接受一个套接字描述符、一个事件类型作为参数,让IO多路复用程序取消给定套接字的给定事件的监听,并取消事件和事件处理器的关联
arGetFileEvents函数接受一个套接字描述符,返回该套接字正在被监听的事件类型
为了对连接服务器的各个客户端进行连接应答,服务器要为监听的套接字关联连接应答事件
为了接收客户端的命令请求,服务器为监听的套接字关联命令请求处理器
为了回复客户端命令的执行结果,服务器为监听的套接字关联命令回复处理器
当主服务器和从服务器进行复制操作时,主从服务器都需要关联复制处理器
Redis的时间事件可以分为两类:
一个时间事件由三个属性组成:
3. id
全局唯一ID,新事件的ID比旧事件的ID大
2. when
毫秒精度的时间戳,记录事件的到达时间
4. timeProc
时间事件处理器,一个函数。当时间事件到达执行时间时,服务器就会调用时间事件处理器处理事件
一个时间事件是定时事件还是周期性事件,取决于时间事件处理器的返回值
服务器将所有时间事件都放在一个无序链表中,每当执行时间事件时,遍历整个链表,查找到达执行时间的时间事件,并调用事件对应的事件处理器
新的时间事件总是插入到表头
serverCron函数主要工作包括:
默认规定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()
对于每个和服务器连接的客户端,服务器都为这些客户端创建了相应的客户端状态redisClient,这个结构保存了客户端当前的状态和执行相关功能时需要的数据结构
Redis服务器状态的clients属性是一个链表,该链表就保存了所有与服务器连接的客户端的客户端状态
type redisServer struct {
list *clients //客户端状态链表
}
客户端属性可以分为两类:
fd属性记录客户端正在使用的套接字描述符
type redisServer struct {
int fd //客户端套接字描述符
}
根据客户端类型的不同,客户端套接字描述符的值可以是-1也可以是大于-1的整数
执行Client list命令可以列出目前与服务器连接的普通客户端
默认情况下,客户端是没有名字的
使用CLIENT setname命令可以为客户端设置一个名字,让客户端的身份变得更清晰
客户端的名字记录在客户端状态中:
type redisClient struct {
string name
}
标志属性flags记录了客户端的角色以及客户端目前所属的状态
type redisClient struct {
string flags
}
flags属性的值可以是单个标志,也可以是多个标志的二进制或
flags = flag 或者 flags = flag1 | flag2
每个标志用一个常量表示,一部分标志记录了客户端的角色:
一部分标志记录了客户端的状态:
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结构
执行命令所得到的命令回复会保存在客户端的输出缓冲区,每个客户端都有两个输出缓冲区可用,一个缓冲区的大小是固定的,一个缓冲区的大小是可变的
固定大小的缓冲区由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属性中
如果要发送的命令回复超过了输出缓冲区的大小,那么这个客户端会被关闭
虽然可变大小的输出缓冲区理论上是无限的,但是为了避免服务器的命令回复过大,占用过多的服务器资源,服务器会时刻检查客户端状态的输出缓冲区大小,并在缓冲区大小超过范围时,执行相应的限制操作
服务器使用两种模式来限制输出缓冲区的大小:
使用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脚本中redis命令的伪客户端,并将这个伪客户端保存在redisServer的lua_client属性
type redisServer struct {
*redisClient lua_client
}
lua伪客户端会在服务器运行期间一直存在,直到服务器被关闭时,这个客户端才会关闭
服务器在载入AOF文件时,会创建用于执行AOF文件中redis命令的伪客户端,AOF文件载入完成后,关闭这个伪客户端
从客户端发送SET KEY VALUE命令到收到OK回复期间,客户端和服务器共需要执行以下操作:
当用户在客户端键入一个命令请求时,客户端会将这个请求转换成命令请求协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器
当服务器的客户端套接字由于客户端的写入而产生AE_READABLE事件时,会触发服务器命令请求处理器的执行,命令请求处理器处理步骤如下:
命令执行器要做的第一件事就是根据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属性)都收集齐了,但是在执行命令函数之前还需要执行一些预备操作
这些操作包括:
当服务器决定要执行命令时,只需要执行以下语句:
client -> cmd -> proc(argv, argc)
调用命令实现函数会产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区中,命令实现函数还会为客户端套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端
执行完命令实现函数后,还需要执行一些后续工作:
命令实现函数会将命令回复存储到客户端状态的输出缓冲区中,并将客户端套接字的AE_WRITEABLE事件关联到命令回复处理器,当客户端准备读取命令回复时,会产生AE_WRITEABLE事件,触发命令回复处理器的执行,该处理器会将输出缓冲区中的内容发送给客户端
当命令回复发送完毕后,处理器会清空客户端的输出缓冲区
当客户端接收到协议格式的命令回复后,它会将这些回复转换成人类可读的格式,并打印给观众看
Redis服务器的serverCron函数平均每100毫秒执行一次,这个函数负责管理服务器资源
Redis服务器中不少地方都需要获取系统当前时间,而每次获取系统当前时间都需要执行一次系统调用,为了减少系统调用次数,服务器状态的unixtime属性和mstime属性被用作当前时间的缓存
type redisServer struct {
time_t unixtime //秒级别的时间戳
time_t mstime //毫秒级别的时间戳
}
因为serverCron函数默认每100毫秒执行一次,所以缓存的当前时间并不准确
服务器状态的lruclock属性保存了服务器的LRU时钟
type redisServer struct {
int lruclock //默认每10秒更新一次,用来计算键的空转时长
}
每个redis对象都会有一个lru属性,该属性记录最后一条命令访问该对象的时间
type redisObject struct {
int lru //最后一次被命令访问的时间
}
当服务器要计算一个键的空转时间,程序会用服务器的lruclock属性减去对象的lru属性得到的结果就是键的空转时间
serverCron函数默认每100s更新一次服务器的lruclock属性
在启动服务器时,Redis会为服务器进程的SIGTERM信号关联处理器sigtermHandler函数,这个处理器负责在服务器进程接收到SIGTERM信号时,打开服务器的shutdown_asap标识(即将标识置为1)
serverCron函数每次执行时会检查shutdown_asap标识,如果该标识的值为1,那么会进行RDB持久化,持久化完后关闭服务器
这里之所以要拦截SIGTERM信号进行处理,就是为了在关闭服务器前进行RDB持久化
serverCron函数每次执行都会调用clientsCron函数,clientsCron函数会对一定数量的客户端进行以下检查:
serverCron函数每次执行都会调用databaseCron函数,databaseCron函数会检查一部分数据库,删除其中的过期键,并在有需要时对字典进行收缩操作
在服务器执行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函数,检查子进程是否有信号发来服务器进程
如果rdb_child_pid和aof_child_pid的值都为-1,那么表示服务器没有进行rdb持久化和aof重写操作,程序会执行以下三个检查:
如果服务器开启了AOF持久化,并且AOF缓冲区中还有待写入的数据,那么serverCron函数会将AOF缓冲区中的内容写入AOF文件中
关闭客户端状态中输出缓冲区超过限制的客户端
第一步就是创建一个redisServer结构保存服务器状态,并给结构中的每个属性设置默认值
载入用户给定的配置参数和配置文件,并且根据用户设定的配置对redisServer的属性进行赋值
服务器状态除了包含命令表结构外,还包含一些其他的结构,比如:
除了初始化数据结构外,还进行了一些非常重要的设置操作:
完成了server变量的初始化之后,需要载入RDB文件或者AOF文件还原数据库状态
如果服务器开启了AOF持久化,那么服务器使用AOF文件还原数据库状态
如果服务器开启了RDB持久化,那么服务器使用RDB文件还原数据库状态
最后开始循环执行事件(文件事件、时间事件)
在Redis中,可以通过SLAVEOF命令或者设置slaveof选项,让一个服务器去复制另外一个服务器,被复制的服务器称为主服务器,复制的服务器称为从服务器
Redis的复制功能分为同步和命令传播
当客户端向从服务器发送SLAVEOF命令时,要求从服务器复制主服务器时,从服务器首先会执行同步操作
同步操作步骤:
同步操作执行完后,每当主服务器执行客户端发送的写命令时,可能会修改主服务器的数据库状态,导致主从服务器的数据库状态不一致,命令传播就是用来让主从服务器的数据库状态重新回到一致
主服务器会将自己执行的写命令传播给从服务器,从服务器执行完主服务器传过来的写命令后,主从服务器的数据库状态重新回到一致
在redis2.8以前,从服务器对主服务器的复制可以分为以下情况:
旧版复制功能可以很好的完成初次复制,但是处理断线后重复制效率极低,断线后重复制采用的是同步的方式
从服务器重新连接上主服务器后,是向主服务器发送SYNC命令,采用同步的方式继续复制数据的,但其实从服务器中已经包含了大部分数据不需要采用同步操作复制主服务器所有的数据,所以效率极低
主从服务器只有一小部分数据不一致,仅仅为了弥补一小部分数据而重新执行一次同步操作,是很不合理的
执行一次同步操作是非常消耗资源的:
Redis从2.8版本开始,使用PSYNC命令替换SYNC命令来完成同步操作,提高断线后重复制的效率
PSYNC命令具有完整重同步和部分重同步两种模式:
部分重同步由以下三个部分组成:
主服务器、从服务器会各自分别维护一个复制偏移量
通过对比主从服务器的复制偏移量,就可以知道主从服务器是否处于一致:
复制积压缓冲区是主服务器维护的一个固定大小的、先进先出的队列,默认大小为1MB
固定大小的先进先出的队列:
当入队元素个数大于队列长度时,最先入队的元素会被踢出去,新元素会被放到队尾
当主服务器进行命令传播时,不仅会将命令传播给从服务器,还会将命令写入复制积压缓冲区
因此,复制积压缓冲区中会存有一部分最近传播的写命令,复制积压缓冲区为队列中的每一个字节记录相应的复制偏移量
当从服务器重新连接上主服务器后,会向主服务器发送PSYNC命令带上自己的复制偏移量offset,如果offset+1在主服务器的复制积压缓冲区中,那么主服务器会执行部分重同步操作,否则主服务会执行完整重同步操作
复制积压缓冲区的大小可以设置为2 * second * per_write_second,其中second为从服务器断线重连上主服务器的平均时间,per_write_second为主服务器平均每秒产生的写命令数量,这样就可以保证大部分断线重连情况下都可以走部分重同步
每个Redis服务器,不论主服务器还是从服务器,都有自己的运行ID
运行ID在服务器启动时生成
当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,从服务器会将这个ID保存起来
当从服务器断线重连上主服务器后,会将自己保存的主服务器的运行ID发送给重连上的主服务器:
PSYNC的调用方法有两种:
主服务器会向从服务器返回三种回复:
通过向从服务器发送SLAVEOF命令,可以让从服务器去复制一个主服务器
SLAVEOF masterip masterport
从服务器首先做的就是将客户端传过来的主服务器的ip和port保存到服务器状态的masterhost属性和masterport属性
type redisServer struct {
string masterhost
string masterport
}
SLAVEOF命令是一个异步命令,从服务器设置好masterhost和masterport属性后就会向客户端返回OK,实际的复制工作将在OK返回之后真正执行
从服务器设置好主服务器的ip和port后,会创建连向主服务器的套接字连接,并为这个套接字关联一个处理复制的文件事件处理器,这个处理器将负责后续的接收RDB文件、接收主服务器发送的命令
主服务器在接受从服务器的连接后,会为该套接字创建相应的客户端状态
从服务器成为主服务器的客户端后,做的第一件事就是向主服务器发送一个PING命令
这个PING命令有两个作用:
从服务器发送PING后会遇到三种情况的回复:
3. 如果主服务器向从服务器返回了一个命令回复,但是从服务器在超时时间内没有收到命令回复,那么说明主从服务器之间存在网络问题,不能继续后续的复制操作,从服务器会断开连接并创建新的连接
4. 如果主服务器向从服务器返回一个错误,那么表示主服务器暂时无法处理从服务器的命令请求,不能继续后续的复制操作,从服务器会断开连接并创建新的连接
5. 如果主服务器返回“pong”命令回复,那么表示主从服务器之间的网络连接正常,并且主服务器可以正常处理从服务器的命令请求,在这种情况下,可以继续复制
如果从服务器设置了masterauth选项,那么进行身份验证
如果从服务器没有设置masterauth选项,那么不进行身份验证
在需要进行身份验证的情况下,从服务器将向主服务器发送一条AUTH命令,命令参数就是masterauth选项的值
从服务器在身份验证阶段可能遇到如下几种情况:
身份验证后,从服务器向主服务器发送自己监听的端口号,主服务器会将这个端口号记录在从服务器客户端状态的slave_listening_post属性中
从服务器向主服务器发送PSYNC命令执行同步操作,将自己的数据更新至主服务器当前的数据库状态
如果PSYNC命令执行的是完整重同步,那么主服务器需要成为从服务器的客户端,这样主服务器才能向从服务器发送保存在缓冲区中的写命令
如果PSYNC命令执行的是部分重同步,那么主服务器需要成为从服务器的客户端,才能将保存在复制积压缓冲区中的缺少的写命令发送给从服务器
同步操作完成后就会进入命令传播阶段,这时主服务器将自己执行的写命令传播给从服务器
在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:
REPLCONF ACK replication_offset
其中replication_offset是从服务器当前的复制偏移量
该命令有三个作用:
如果主超过一秒钟没有收到从服务器发送的REPLCONF ACK命令,那么主就知道主从之间的网络连接出问题了
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命令时,主会发现从的复制偏移量比自己的小,然后主就会根据从的复制偏移量,在复制积压缓冲区找出缺少命令发送给从
Sentinel(哨兵)是Redis高可用性解决方案:由一个或多个Sentinel实例组成的Sentinel系统可以监视任意多个主服务器以及这些主服务器属下的从服务器,并在被监视的主服务器下线时自动将下线主服务器属下的某个从服务器提升为新的主服务器,然后由新的主服务器继续执行命令
当主的下线时长超过用户设置的下线时长上限时,Sentinel系统会对这个主进行故障转移操作:
启动一个Sentinel可以使用命令:
redis-sentinel /path/to/your/sentinel.conf
当一个sentinel启动时,需要执行以下步骤:
因为sentinel本质上是一个特殊模式下的redis服务器,所以第一步就是初始化一个普通的redis服务器
因为sentinel和redis服务器的工作不同,所以初始化过程并不完全相同,例如redis服务器初始化需要加载RDB或者AOF文件还原数据库状态,但是sentinel不需要使用数据库所以不需要恢复数据库状态
第二步就是将普通redis服务器使用的代码替换成sentinel使用的代码
比如:
在Sentinel模式下,redis服务器不能执行诸如set、dbsize、eval等命令,因为命令表中根本没有存这些命令,sentinel只能执行ping、info、sentinel、subscribe、unsubscribe、psubscribe、punsubscribe七个命令
应用了sentinel的代码后,会初始化一个sentinelState结构,这个结构保存了所有和sentinel有关的状态
masters字典记录了所有被哨兵监视的主服务器(这里的主指的是主服务器,主服务器对应的结构中有一个slaves属性保存的是从服务器)
字典的键是主服务器的名字
值是主服务器对应的sentinelRedisInstance结构
每个sentinelRedisInstance结构可以代表一个被sentinel监视的主服务器、从服务器或者另一个sentinel
sentinelRedisInstance结构的addr属性保存着实例的ip和端口
对sentinel状态的初始化会引起对masters属性的初始化,而masters属性是根据sentinel配置文件载入的
最后一步是创建连向被监视主服务器的网络连接,Sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息
Sentinel会创建两个连向主服务器的异步连接:
一个是命令连接,用来向主服务器发送命令,并且从命令回复中获取相关信息
一个是订阅连接,用来订阅主服务器的__sentinel__:hello频道
Sentinel默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并通过分析INFO命令的回复来获取主服务器的当前信息
通过分析主服务器返回的INFO命令回复,Sentinel可以获取以下两方面的信息:
根据run_id和服务器角色,Sentinel将对主服务器的sentinelRedisInstance结构进行更新
返回的从服务器信息,用于更新主服务器实例结构的slaves字典,这个字典记录了从服务器名单:
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的认知以及对被监视服务器的认知
Sentinel为主服务器创建的sentinelRedisInstance结构中的sentinels字典除了保存当前的sentinel外,还会保存监视该主服务器的其他sentinel
sentinels字典的键是sentinel的名字格式为ip:port,值是sentinel的实例结构
当一个sentinel接收到其他sentinel发来的信息时,目标sentinel会在sentinelState的masters属性中查找主服务器对应的sentinelRedisInstance实例,检查主服务器实例的sentinels属性中是否包含源sentinel:
当sentinel通过频道信息发现一个新的sentinel时,除了会为新的sentinel创建sentinelRedisInstance结构外,还会和新的sentinel创建命令连接
在默认情况下,Sentinel每隔一秒就会向所有创建了命令连接的服务器发送PING命令(包括主、从、其他sentinel),并通过其他服务器返回的PING命令回复判断服务器是否在线
服务器对PING命令的回复可以分为两类:
Sentinel配置文件中的down-after-miliiseconds选项指定了时间,如果在指定时间内服务器一直返回无效回复或者没有回复,那么sentinel会打开这个服务器的SRI_S_DOWN标识,认为这个服务器进入下线状态
当sentinel认为一个主服务器主观下线后,为了确定主服务器是否真的下线,会向监视该主服务器的其他sentinel进行询问,看它们是否也认为主服务已下线(可以是主观或者客观)。当sentinel从其他sentinel那里接收到足够的已下线判断后,sentinel就会判断主服务器为客观下线
Sentinel使用:
SENTINEL is-master-down-by-addr ip port current_epoch runid
询问其他sentinel是否认为主服务器下线
各个参数意义:
当一个sentinel接收到另一个sentinel发送的命令时,目标sentinel会根据主服务器的ip port检查主服务器是否下线,然后向源服务器返回一个包含三个参数的Multi回复
三个参数分别为:
根据其他sentinel返回的SENTINEL is-master-down-by-addr命令回复,sentinel会统计其他sentinel认为主服务器主观下线的数量,当这一数量达到配置的指定数量时,sentinel会将主服务器的SRI_O_DOWN标识打开,表示认为主服务器进入客观下线
当一个主服务器被判断为客观下线,监视这个主服务器的sentinel会协商选举出领头sentinel,领头sentinel会对主服务器进行故障转移
在选举出领头sentinel后,领头sentinel将对已下线的主服务器执行故障转移操作,包含以下三个步骤:
新的主服务器是如何选举出来的:
之后,领头Sentinel根据从服务器的优先级(选最高)对从服务器进行排序,对于相同优先级的从服务器再按照复制偏移量(选最大)排序,对于复制偏移量也相同的从服务器再按照运行id(选最小)排序
选出从服务器后,领头sentinel会向该从服务器发送SLAVEOF no one命令
发送命令后,领头sentinel会每隔一秒向从服务器发送INFO命令,观察返回的角色信息,当从服务器的角色从slave变成master时,领头sentinel就知道从服务器被转换成了主服务器
当新主出现后,领头sentinel向其他从服务器发送SLAVEOF 命令让其他从服务器复制新的主服务器
当旧主重新上线时,sentinel会向它发送SLAVEOF命令,让它成为新主的从服务器