Redis拾枝杂谈
1. 数据结构和对象
1. 简单动态字符串SDS
redis没有直接使用c字符串,而是构建了一种为sds的抽象类型。且sds为redis的默认字符串表示。
redis里,c字符串只会作为字符串字面量用在一些无须对字符串值修改的地方,如打印日志。
set hello "world"
hello键为一字符串对象,实现为一保存字符串"hello"的sds。world值为一字符串对象,实现为一保存字符串"world"的sds。
sds的作用: 1. 保存数据库中的字符串值,2. 被用作缓冲区buffer,如AOF缓冲区、客户端状态的输入缓冲区。
sds的定义: sds.h/sdshdr
struct sdshdr { int len; //记录buf数组中已使用的字节数 int free; //记录buf数组中未使用的字节数 char buf[ ]; //字节数组,保存字符串 };
sds遵循c字符串以空字符结尾的习惯,保存空字符的1字节不计算在len中。为空字符分配额外的1字节空间和将空字符添加到buf尾部等都是由sds函数自动完成的。
采用这一习惯的好处是,sds可以直接重用一部分c字符串的函数。
sds和c字符串的区别: c字符串无法满足redis对字符串在安全性、效率、功能的要求。
1. 常数复杂度获取字符串len
设置更新sds长度是sds api在执行时自动完成的,所以,c字符串获取len为O(N),sds获取len为O(1)。
2. 杜绝缓冲区溢出
c字符串容易造成缓冲区溢出,而sds的空间分配策略完全杜绝了缓冲区溢出。
当sds api对sds进行修改时,api会先检查空间是否足够操作进行,若不满足,api会自动将sds的空间扩展。所以使用sds既不需要手动修改sds空间大小,也不会造成缓冲区溢出问题。
sds的空间分配策略在区别3中谈及。
3. 减少修改字符串带来的内存重分配次数
c字符串len和底层数组长度len+1存在此关联性,且c字符串不记录本身len,所以每次增长或缩短c字符串,都会对保存c字符串的数组进行一次内存重分配操作。
增长: 内存重分配扩展底层数组空间大小(若无,缓冲区溢出),缩减: 内存重分配释放字符串不再使用的空间(若无,内存泄漏)。
因为内存重分配涉及复杂的算法,且可能需要执行系统调用,所以通常是一个比较耗时的操作。
若程序中修改字符串操作较少,是可以接受的。但redis作为数据库,常被用在速度要求严苛,修改操作众多的场合,若每次修改字符串都进行内存重分配,那么内存重分配消耗的时间会占去修改字符串所用时间的一大部分。且,若频繁发生,可能还会对性能造成影响。
为了避免c字符串的这种问题,sds通过free解除了字符串len和底层数组len的关联。通过free,sds实现了空间预分配、惰性空间释放策略来优化。
1. 空间预分配: 优化字符串增长操作。减少连续增长字符串操作所需的内存重分配次数。
当sds api对sds进行修改且须进行扩展时,api不但分配修改所用的空间,还会分配额外的free空间。
若修改前,len < 1Mb,则分配和len相同大小空间的free。
若修改前,len > 1Mb,则分配1Mb大小空间的free。
2. 惰性空间释放: 优化字符串缩短操作。
当sds api对sds进行缩短时,程序并不立即使用内存重分配来回收缩短后多出来的字节。而是用free记录下来,等待将来使用。
同时,sds也提供了相应的api,让我们在需要时真正释放sds未使用的空间,所以不必担心惰性空间释放会造成空间浪费。
4. 二进制安全
c字符串必须符合某种编码,且中间不能有空字符,否则程序读入空字符误认为是字符串尾。这些限制使得c字符串只能板寸文本数据,不能保存图片、音视频、压缩文件这样的二进制文件。虽然数据库一般用于保存文本数据,但保存二进制数据也不在少数,为了使redis适用于各种场景,sds api都是二进制安全的。都会以处理二进制的方式来处理buf数据。
5. 兼容部分c字符串函数
2. 链表list
redis使用的c并没有内置list数据结构,redis构建了自己的链表实现。
list的作用: 1. 列表键的底层实现之一,2. 发布、订阅、慢查询、监视器等功能也用到了list,3. 保存多个客户端的状态信息,4. 构建客户端输出缓冲区buffer。
list结点的定义: adlist.h/listNode
typedef struct listNode //链表结点 { struct listNode *prev; //前置结点 struct listNode *next; //后置结点 void *value; //结点的值 }listNode;
list的定义: adlist.h/list
typedef struct list { listNode *head; //表头结点 listNode *tail; //表尾结点 unsigned long len; //链表结点数 void *(*dup)(void *ptr); //结点复制函数 void (*free)(void *ptr); //结点值释放函数 void (*match)(void *ptr,void *key); //结点值对比函数 }list;
redis链表实现的特性:
1. 双端
2. 无环
3. 带表头、表尾指针
4. 带表长计数器
5. 多态: 链表结点用void * 保存结点值,且通过list的dup、free、match三个属性为结点值设置类型特定函数,所以list可以保存各种不同类型的值。
3. 字典dict
字典又称符号表、关联数组、映射。是一种保存键值对的抽象数据结构。每个键都是唯一的。
redis使用的c语言没有内置dict,redis构建了自己的字典实现。
字典的作用: 1. 表示数据库,2. 哈希键的底层实现之一等。
字典采用哈希表作为底层实现。
哈希表结点的定义: dict.h/dictEntry
typedef struct dictEntry { void *key; //键 union //值,3选1 { void *val; uint64_t u64; int64_t s64; } v; struct dictEntry *next; //下一结点,将多个哈希值相同的结点连接起来,解决键冲突问题 }dictEntry;
哈希表的定义: dict.h/dictht
typedef struct dictht { dictEntry **table; //指针数组,每个元素指向一个哈希表结点 unsigned long size; //哈希表大小 unsigned long sizemask; //哈希表大小掩码,用于计算索引值,总==size-1 unsigned long used; //哈希表已有结点数 }dictht;
字典的定义: dict.h/dict
typedef struct dict { dictType *type; //类型特定函数 void *privdata; //私有数据 dictht ht[2]; //哈希表 int rehashidx; //rehash索引,当rehash不在进行时,==-1 }dict; typedef struct dictType //保存一簇用于操作特定类型键值对的函数 { unsigned int (*hashFunction)(const void *key); //计算哈希值的函数 void *(*keyDup)(void *privdata,const void *key); //复制键的函数 void *(*valDup)(void *privdata,const void *obj); //复制值的函数 int (*keyCompare)(void privadata,const void *key1,const void *key2); //对比键的函数 void (*keyDestructor)(void *privdata,void *key); //销毁键的函数 void (*valDestructor)(void *privdata,void obj); //销毁值的函数 }dictType;
type和privdata是针对不同类型的键值对,为创建多态字典而设置的。type指向dictType结构,redis会为用途不同的字典设置不同的类型特定函数。而privdata保存的可选参数。
ht[0]哈希表存储数据,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
rehashidx记录目前rehash的进度。
哈希算法:
字典添加新的键值对,先计算出哈希值和索引值,后将键值对结点存放至哈希表相应索引处。
hash = dict->type->hasdFunction(key); //哈希值
index = hash & dict->ht[t].sizemask; //索引值
当字典被用作数据库的底层实现、哈希键的底层实现时,redis使用murmurhash2算法来计算哈希值。这种算法的优点是即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。
解决键冲突: 当有两个\以上的键被分配到哈希表同一索引上时,即发生了键冲突。redis用链地址法来解决,每个hash结点都有一next指针。且因为hash结点没有指向链表表尾的指针,所以为了速度,总是进行头插法添加。
rehash重新散列: 随着操作哈希表结点越来越多\少,为了让哈希表的负载因子维持在一个合理的范围内,当哈希表结点太多\少时,需要对哈希表进行扩展\收缩。
1. 为ht[1]分配空间。
1. 扩展: ht[1]大小为第一个>=ht[0].used*2的2^n。
2. 收缩: ht[1]大小为第一个>=ht[0].used的2^n。
2. 将ht[0]的所有键值对重新散列到ht[1]中。
3. 所有键值对都迁移完后,即ht[0]为空表时,释放ht[0],ht[1]设置为ht[0],并在ht[1]创建一个空白哈希表,为下一次rehash做准备。
哈希表的扩展和收缩:
1. 扩展:
1. 服务器当前没有在执行BGSAVE、BGREWRITEAOF命令,且哈希表的负载因子>=1时。
2. 服务器当前正在执行BGSAVE、BGREWRITEAOF命令,且哈希表的负载因子>=5时。
负载因子的不同,是因为执行上述命令过程中,redis需要创建当前服务器进程的子进程,而大多数os都采用写时复制来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,避免在子进程存在期间进行哈希表扩展操作。
这可以避免不必要的内存写入操作,最大限度节约内存。
2. 收缩; 当哈希表的负载因子<0.1时,程序自动开始对哈希表进行收缩操作。
load_factor = ht[0].used / ht[0].size; //负载因子 = 已保存结点数/哈希表大小
渐进式rehash: 由于扩展\收缩哈希表须将所有键重新散列,若键的数量众多,庞大的计算量可能会导致服务器停止服务一段时间。所以rehash不是一次性、集中式的完成的。而是分多次、渐进式的完成。
1. 为ht[1]分配空间
2. 字典的rehashidx置位0,表示rehash开始
3. 在rehash期间,每次对字典的增删改查时,程序除了执行指定操作外,还会将ht[0]中的rehashidx索引上的所有哈希结点rehash到ht[1]上,rehash完成后,rehashidx++
4. 随着字典操作不断进行,最终ht[0]内所有结点都完成rehash,将rehashidx置位-1,表示rehash完成
渐进式rehash的好处是,采用了分而治之的方式,将rehash所需的计算工作平均到每次字典的增删改查上,避免了众多数据时的带来的庞大计算量。
在渐进式rehash期间,字典进行删改查操作时,会在ht[0]、ht[1]两个哈希表中都进行操作。执行添加操作时,只会在ht[1]上进行,这保证了ht[0]随着rehash最终会变成一个空表。
4. 跳跃表skiplist
跳跃表是一种有序数据结构,他通过每个结点中维持多个指向其他结点的指针,从而达到快速访问结点的目的。redis使用跳跃表作为有序集合键的底层实现之一。
大部分情况下,跳跃表的效率可以和平衡树相媲美,并且跳跃表的实现比平衡树简单,所以不少程序使用跳跃表代替平衡树。
redis只在两个地方用到了跳跃表,有序集合键的底层实现之一和在集群结点中用作内部数据结构。
跳跃表结点的定义: redis.h.zskiplistNode
typedef struct zskiplistNode { struct zskiplistNode *backward; //后退指针,指向前一结点 double score; //分值 robj *obj; //成员对象 struct zskiplistLevel //层 { struct zksiplistNode *forward; //前进指针 unsigned int span; //跨度 }level[ ]; }zskiplistNode;
1. 层: level数组可包含多个元素,每个元素指向其他结点,可以通过这些层来加快其他节点的访问速度。
每创建一个新跳跃表结点时,程序会根据幂次定律随机生成一个介于1和32之间的值作为level的大小,即层的高度。
2. 跨度: 两结点之间的距离,查找某结点时途径的跨度和就是其在跳跃表中的排位。
3. 分值: 跳跃表中所有结点都是按分值从小到大排序的。
4. 成员对象: 指向一个字符串对象,保存着一sds,同一跳跃表中,成员对象必须唯一,分值可以相同。分值相同时,按成员对象字典序从小到大排序。
跳跃表的定义: redis.h/zskiplis
typedef struct zskiplist { struct zskiplistNode *header,*tail; //表头结点,表尾结点 unsigned long length; //结点数,表头节点不计其中 int level; //表中结点最大层数 }zskiplist;
5. 整数集合
整数集合是集合键的底层实现之一
整数集合的定义: intset.h/intset
typedef struct inset { uint32_t encoding; //编码方式 uint32_t length; //集合包含元素数 int8_t contents[ ]; //保存集合元素 }intset;
content数组是整数集合的底层实现,各值按从小到大排序,虽然声明为int8_t,但并不存储int8_t的数据,数据类型由encoding决定,
encoding为INTSET_ENC_INT16时,类型为int16_t,为INTSET_ENC_INT32时,类型为int32_t,为INTSET_ENC_64时,类型为int64_t。
升级: 当将新元素添加到整数集合中时,新元素类型比整数集合中所有类型都长时,整数集合需要先升级再添加新元素。
1. 根据新元素类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
2. 将底层数组所有元素转化为新元素类型,并将其放置到正确的位置上。
3. 添加新元素。
因为每次向整数集合中添加新元素都可能引起升级,且每次升级会对底层数组所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(n)。
升级后新元素的位置: 要么大于所有现有元素放在底层数组最尾处,要么小于所有现有元素放在底层数组最前端。
升级的好处:
1. 提升整数集合的灵活性(底层数组自动升级)。
2. 尽可能的节约内存。
降级: 整数集合不支持降级操作,一旦对底层数组升级,就无法再回退。即使位长的数被删除,其余元素也不会降级。
6. 压缩列表
压缩列表是列表键和哈希键的底层实现之一。
压缩列表的定义: redis为了节约内存开发了压缩列表,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个结点,每个结点可以包含一个字符数组\一整数值。
| zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
| uint32_t,4B,整个压缩列表内存字节数,对压缩列表内存重分配、计算zlend位置时使用 | uint32_t,4B,表尾距起始地址多少B,确定表尾结点地址 | uint16_t,2B,节点数,<65535时,值为真。否则,须遍历整个压缩列表 | 结点1 | 结点2 | ... | 结点n | uint8_t,1B,标记列表末端,特殊值0xFF |
压缩列表结点的定义: 保存一字节数组/一整数值。
1. 字节数组长度:
1. <=2^6-1
2. <=2^14-1
3. <=2^32-1
2. 整数长度:
1. 4b,uint(0~12)
2. 1B,int
3. 3B,int
4. int16_t
5. int32_t
6.int64_t
| previous_entry_lenght 前一个结点长度 | ecoding 数据类型和长度 | content 数据 |
3. previous_entry_length: 1B/5B,1B为上一结点的大小,5B前一字节为0xFE,后4B为上一结点大小。
压缩列表的从表尾向表头遍历以此实现。
4. ecoding: 1B/2B/5B,最高位为00,01,10,表示content保存字节数组,类型长度由后几位决定。1B,最高位为11,表示content保存整数,类型长度由后几位决定
| 编码 | 编码长度 | content保存的值 |
| 00xxxxxx | 1B | 2^6-1字节数组 |
| 01xxxxxx xxxxxxxx | 2B | 2^14-1字节数组 |
| 10_ _ _ _ _ _ xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx | 5B | 2^32-1字节数组 |
| 编码 | 编码长度 | content保存的值 |
| 1100 0000 | 1B | int16_t |
| 1101 0000 | 1B | int32_t |
| 1110 0000 | 1B | int64_t |
| 1111 0000 | 1B | 24b int |
| 1111 1110 | 1B | 8b int |
| 1111 xxxx | 1B | xxxx保存了一0~12的数,无相应的content属性 |
5. content: 保存压缩列表结点的值。
连锁更新:
1. 添加新节点可能引发的连锁更新:
前一结点len<254,须1B来保存,>=254须5B来保存。所以会出现这样的情况:
| zlbytes | zltail | zllen | e1 | e2 | e3 | ... | eN | zlend |
若e1...eN为介于250~254之间len的值,只需1B来记录上一结点的len,若在e1之前插入一>=254len的eX,e1无法保存eX的长度,引发空间重分配。1B扩展至5B,总len>=254,故而e2无法保存e1的长度,再次引发空间重分配。以此类推,反复引发空间重分配,形成连锁更新。
2. 删除结点可能引发的连锁更新:
| zlbytes | zltail | zllen | big | small | e1 | e2 | e3 | ... | eN | zlend |
若big>=254,small<254,e1...eN子250~254之间,此时若删除small,也会造成e1~eN的连锁更新。
3. 连锁更新在最坏情况下须对压缩列表进行N次空间重分配,每次最坏O(N),所以连锁更新最坏O(n^2)。
尽管连锁更新的复杂度较高,但现实真正造成性能问题的几率很低。
1. 压缩列表须恰好有多个连续的、介于250~254B之间的结点,连锁更新才可能发生。
2. 即使出现连锁更新,但只要被更新的结点数不多,就不会对性能造成任何影响。
所以实际中,不必担心连锁更新会影响压缩列表的性能。
7. 对象
redis没有直接使用上述数据结构来实现键值对数据库,采用基于这些数据结构创建了一个对象系统。包含字符串对象、列表对象、哈希对象、集合对象、有序集合对象。
采用5种对象,有以下好处:
1. redis在执行命令前,可以根据对象类型来判断对象是否可以执行给定命令。
2. 可以针对不同的使用场景,为对象设置多种不同的数据结构实现。
3. redis的对象系统实现了基于引用计数的内存回收机制。当程序不再使用某个对象时,该对象所占用的空间会被自动释放。
4. redis通过引用计数实现了对象共享机制,通过让多个数据库键共享同一对象来节约内存。
5. redis的对象带有访问时间记录信息,该信息可用于计算数据库键的空转时长,在服务器启用了maxmemory时,空转时长较大的那些键可能会优先被服务器删除。
redis使用对象来表示数据库的键和值,当在其中创建一个键值对时,就会至少创建两个对象,一个键对象,一个值对象。
1. 对象类型和编码
类型: redis所保存的键值对,键总是一个字符串对象,值为字符串对象,列表对象,哈希对象,集合对象,有序集合对象的一种。
所以我们称一数据库键为字符串键时,表示的是其键值对的值为字符串对象。type命令返回的是键值对中值对象的类型。
编码: 对象的ptr指针指向对象的底层实现数据结构,这些数据结构由对象的encoding属性决定。
| 编码常量 | 对应底层数据结构 |
| REDIS_ENCODING_INT | long int |
| REDIS_ENCODING_EMBSTR | embstr编码的sds |
| REDIS_ENCODING_RAW | sds |
| REDIS_ENCODING_HT | 字典 |
| REDIS_ENCODING_LINKEDLIST | 双向列表 |
| REDIS_ENCODING_ZIPLIST | 压缩列表 |
| REDIS_ENCODING_INTSET | 整数集合 |
| REDIS_ENCODING_SKIPLIST | 跳跃表和字典 |
每个类型的对象都至少由两种不同编码,即底层实现。
1. 字符串对象: long int,embstr的sds,sds。
2. 列表对象: 压缩列表,双向列表。
3. 哈希对象: 压缩列表,字典。
4. 集合对象: 整数集合,字典。
5. 有序集合对象: 压缩列表,跳跃表和字典。
通过encoding来设置对象的编码,不是固定编码。极大地提升了redis的灵活性和效率。使redis可在不同的场景为对象设置不同编码,提高效率。
2. 字符串对象
1.字符串对象: long int ------ 保存的是一整数值,且在long的范围内。sds ------ 保存的是一字符串,且字符串长度>39B。embstr sds ------ 保存的是一字符串,且字符串长度<=39B。
| redisObject |
| type类型 |
| encoding编码 |
| ptr底层实现 |
| ... |
embstr编码是一种专门保存短字符串的优化编码方式。raw编码会调用两次内存分配函数来分别创建redisObject、sdshdr结构来保存字符串,而embstr编码则是通过调用一次内存分配函数来分配一块连续的内存空间,依次包含redisObject、sdshdr。
使用embstr编码的字符串对象保存短字符串的优点: 只需一次内存分配,只需一次内存释放,由于是一块连续内存空间,所以可以更好的利用缓存带来的优势。
long double的值在redis中用字符串对象保存。
2.编码转换: int和embstr编码在一定条件下会转换为raw编码的字符串对象。
int编码的字符串对象,在执行命令使其不再为一整数值时,会转换为raw。
redis没有为embstr编码的字符串编写任何相应修改程序,所以embstr编码实际上是只读的。在对embstr编码的字符串进行修改时,程序会先将其转换为raw再进行操作,所以embstr编码在进行修改后,总会转换为一raw编码的字符串对象。
3. 字符串命令的实现: (待更)
3. 列表对象
1.列表对象: ziplist、linkedlist。
ziplist编码的列表对象,每个压缩列表结点保存一列表元素。
linkedlist编码的列表对象,使用双向链表保存个列表元素,每个链表结点保存一字符串对象,字符串对象保存相应列表元素。
2.编码转换:
当保存元素的长度都<=64B,且保存元素数量<=512个时,使用ziplist编码实现的列表对象。否则,使用linkedlist编码实现的列表对象。
注: 两个上限值是可以修改的,在配置文件中的,list-max-ziplist-value、list-max-ziplist-entries。
3.列表命令的实现:(待更)
4. 哈希对象
1.哈希对象: ziplist、hashtable。
ziplist编码的哈希对象,键值顺序添加,先键后值,程序将压缩列表结点依次推入到压缩列表表尾。
hashtable编码的哈希对象,字典的每个键都是一个字符串对象,保存键,字典的每个值都是一个字符串对象,保存值。
2.编码转换:
当保存元素的长度都<=64B,且保存元素数量<=512个时,使用ziplist编码实现的哈希对象。否则,使用hashtable编码实现的哈希对象。
注: 两个上限值是可以修改的,在配置文件中的,hash-max-ziplist-max、hash-max-ziplist-entries。
3.哈希命令的实现:(待更)
5. 集合对象
1.集合对象: intset、hashtable。
intset编码的集合对象,集合对象的所有元素都保存在整数集合里。
hashtable编码的集合对象,字典的每一个键都为一字符串对象,字符串对象保存集合元素,字典的每一个值都为NULL。
2.编码转换:
当集合对象保存的所有元素都为整数,且保存元素数量<=512个时,使用intset实现的集合对象。否则,使用hashtable编码实现的集合对象。
注: 上限值可以修改,在配置文件中的,set-max-intset-entries。
3.集合命令的实现:(待更)
6. 有序集合对象
1.有序集合对象: zipist、skiplist。
ziplist编码的有序集合对象,每个集合元素保存在压缩列表结点中,第一个结点为成员,第二个为分数,依次类推。压缩列表中的元素按分值从小到大进行排序。
skiplist编码的有序集合对象,使用zset结构,zset结构包含一个字典和一个跳跃表。跳跃表中,集合元素从小到大依次保存,object属性为一字符串对象,保存成员,sorce属性保存分数。通过这个跳跃表,程序可对有序集合进行范围性操作。
字典中,为一个成员到分值的映射,字典的键为一字符串对象,保存成员,值保存分数。通过这个字典,程序可用O(1)的复杂度找到相应给定成员分数。
注: 有序集合的每一个元素的成员都为一字符串对象,值为double浮点数。虽然dict和skiplist同时保存元素,但这两种数据结构都会通过指针来共享相同元素的分值和成员,所以并不会产生任何重复成员或分值,也不会因此造成额外的内存浪费。
1. 为什么要同时使用字典和跳跃表来实现有序集合?
理论上,可以只使用两个中的一种就可实现有序集合,但性能上,比同时使用两者要差很多。若只使用字典,虽然可以O(1)获取分值,但每次执行范围性操作,都会进行排序(至少需要O(nlogn)的时间、和额外的O(n)的空间)。若只使用跳跃表,虽然有序,但查找分值须O(logn)。
所以redis同时使用dict和skiplist来实现有序集合。
2.编码的转换:
当有序集合对象保存的元素长度都<=64B,且元素数量<=128个时,使用ziplist实现的有序集合对象。否则,使用skiplist编码实现的有序集合对象。
注: 两个上限条件是可以修改的,在配置文件中的,zset-max-ziplist-value、zset-max-ziplist-entries。
3.有序集合命令的实现:(待更)
7. 类型检查和命令多态
1. 类型检查: redis中用于操作键的命令可分为两种:可对任何键操作的命令,只对指定键操作。
当用指定操作对非指定键进行操作时,会引发错误。为了确保只有指定类型的键可以执行特定命令,在执行命令前,redis会先检查输入键的类型是否正确,然后再决定是否执行。
类型特定命令进行的类型检查通过redisObject结构的type实现: 执行命令前,先检查键的值对象是否为指定对象,是执行,否不执行。
2. 命令多态: redis除了会根据键的值对象的类型来判断能否执行指定命令,还会根据键的值对象的编码方式来选择合适的代码实现来执行命令。
即,redis对象有多个编码,会根据对象编码采用合适的实现执行命令。
3. 对任何键操作的命令也称为多态命令,即,其是基于类型的多态,而指定操作命令是基于编码的多态。
8. 内存回收
c语言不具备自动内存回收功能,所以,redis基于引用计数技术实现了内存回收机制。每个对象的引用计数信息在redisObject的refcount记录。
引用计数信息会随对象的使用不断变化,对象创建是初始化为1,当对象被一新程序使用时其+1,当对象不再被一程序使用时其-1,当其为0时,对象所占用的内存会被释放。
| func | 作用 |
| incrRefCount | +1 |
| decrRefCount | -1,=0时释放 |
| resetRefCount | 设为0,不释放对象,常在重置引用计数值时使用 |
9. 对象共享
引用计数值除了可用于内存回收外,还可用于对象共享。
在redis中,让多个对象键共享一个值对象: 将键的值指针指向值对象,被共享的值对象的引用计数+1。redis会在初始化服务器时创建1万个字符串对象(0~9999的所有整数值)。
创建共享对象字符串对象的数量可通过redis.h/REDIS_SHARED_INTEGERS常量来修改。
不仅字符串对象可使用对象共享,那些包含字符串对象的对象也可以使用这些共享对象。
1. 为什么redis不共享包含字符串的对象?
当服务器将一个共享对象设置为键的值对象时,会先检查共享对象和键要创建的对象是否完全相同,只有完全相同,才会将共享对象设为键的值对象。而共享对象保存的值越复杂,验证检查所需复杂度就越高,消耗cpu时间就越高。
所以,尽管共享越多复杂对象可以节约更多内存,但受到cpu时间的限制,redis只对包含整数值的字符串对象进行共享。
10. 对象的空转时长
redisObject的最后一属性lru,记录对象最后一次被命令访问的时间。object idletime命令可打印出键的空转时长,该命令实现比较特殊,其访问键的值对象时,不会修改值对象的lru属性。
键的空转时长还有一项作用。如果服务器打开了maxmemory选项,且服务器用于回收内存的算法为volatile-lru、allkeys-lru,那么当服务器所占用的内存数超过maxmemory时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。
2. 单机数据库
1. 数据库
redis服务器中的所有数据库都保存在服务器状态redis.h/redisServer结构的db数组里,db数组的每一项是一个redis.h/redisDb结构。服务器数量由服务器状态的dbnum属性决定。
1. 切换数据库
默认情况rerdis客户端的目标数据库为0号数据库,可根据select命令切换目标数据库。在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库。
select命令实现原理: 通过改变客户端状态的db指针的指向不同数据库。
谨慎处理多数据库程序: 目前为止,redis没有可以返回客户端目标数据库的命令。避免对数据库进行误操作。
2. 数据库键空间
redis是一个键值对数据库服务器,服务器的每一个数据库都由一redis.h/redisDb结构表示,redisDb结构里的字典保存了数据库中所有的键值对,将字典称为键空间。
键空间对应操作: 添加新键、删除键、更新键、对键取值,其他键空间操作(如fkushdb)等。
1. 读写键空间时的维护操作
当使用redis命令对数据库进行读写时,服务器不仅会对键空间执行指定的读写操作,还会执行一些额外的维护操作。
1. 在读取一个键后,服务器会根据键是否存在来更新键空间中hit中次数或键空间miss次数。
2. 在读取一个键后,服务器会更新键的LRU时间。
3. 若读取一个键时发现键已经过期,服务器会先删除这个键,后执行余下操作。
4. 若客户端使用watch监视某个键,服务器在对这个键进行修改后,会将这个键标记为dirty,从而让事务程序注意到这个键已经被修改过。
5. 服务器每次修改一个键后,都会对dirty键计数器+1,这个计数器会触发服务器的持久化和复制操作。
6. 若服务器开启了数据库通知功能,在对键修改之后,服务器将按配置发送相应的数据库通知。
3. 键的生存\过期时间
通过expire、pexpire命令,客户端以s、ms为精度为数据库的某键设置生存时间ttl。指定时间后,服务器会自动删除生存时间为0的键。
通过expireat、pexpireat命令,客户端以s、ms为精度为数据库的某键设置过期时间,到达指定时间时,服务器会自动删除这个键。
setex命令可在设置一字符串键的同时为键设置过期时间,但仅限字符串键。
1. 设置过期时间
虽然有多种不同单位和形式的设置命令,但实际上,expire、pexpire、expireat三个命令都是使用pexpireat命令来实现的。
2. 保存过期时间
redisdb结构的expires字典保存了数据库中所有键的过期时间,即过期字典。字典的键是一指针,指向键空间的某个键对象。字典的值是一long long的整数,保存了键所指向的数据库键的过期时间(ms)。
3. 移除过期时间
persist命令可以移除一个键的过期时间。
4. 计算并返回剩余生存时间
ttl、pttl以s、ms为单位返回键的剩余生存时间。
5. 过期键的判定
通过过期字典,程序可以检查给定键是否过期。还可以使用ttl、pttl命令来实现,若命令返回值>0,则未过期。但由于直接访问字典比执行一个命令稍快些,所以redis采用前者实现过期键的判定。
4. 过期键删除
定时删除: 在设置过期时间的同时,创建一个定时器,让定时器在键过期时,立即对键进行删除。
惰性删除: 放任过期键不管,但每次从键空间获取键时,都检查取得的键是否过期,若过期就删除。
定期删除: 每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。删除多少删除那些由算法所定。
定时删除对内存友好,但占用太多cpu时间,影响服务器的响应时间和吞吐量。
惰性删除对cpu友好,但浪费太多的内存,有内存泄露的危险。
定期删除是两者的折中整合,但确定删除操作的时长和频率较难确定。
redis服务器使用惰性删除和定期删除。通过配合使用其,服务器可以很好的在合理使用cpu时间和避免内存浪费之间取得平衡。
1. 惰性删除的实现
由dx.c/expireIfNeeded函数实现。所有读写数据库的命令在执行前都会调用其对键进行检查。若键过期删除。
因每个被访问的键都可能过期被删除,所以须每个命令都可以处理过期不过期两种操作。
2. 定期删除的实现
由redis.c/activeExpireCycle函数实现。每当redis服务器周期性操作redis.c/serverCron时,其就被调用。在规定时间内,分多次遍历各个数据库,从过期字典中随机检查一部分键的过期时间,过期删除。
5. AOF、RDB、复制功能对过期键的处理
1. 生成rdb文件: 执行save、bgsave时,程序会对键进行检查,若过期则不会保存到rdb文件中。所以过期键不会对生成新的rdb文件造成影响。
2. 载入rdb文件: 若为主服务器,在载入rdb文件时,程序会对键进行检查,过期键不会被载入到数据库中。
若为从服务器,在载入rdb文件时,程序不会对键进行检查键,所有键都会载入数据库中。
但,主从服务器进行数据同步时,从服务器的数据库会被清空,所以一般来说,过期键不会对载入rdb文件造成影响。
3. aof文件写入: 当服务器以aof模式运行时,若某键过期却未删除,aof文件不会因这个键产生任何影响。当其删除后,程序会向aof文件中追加一条del命令,来显示记录该键被删除。
4. aof重写: 在执行aof重写过程中,程序会对键进行检查,若键过期,则不会被保存到重写的aod文件中。
5. 复制: 当服务器在复制模式下时,从服务器的过期键删除由主服务器控制。
1. 主服务器在删除一个过期键后,会显式的向所有从服务器发送一个del命令,告知从服务器删除这个过期键。
2. 从服务器在执行客户端发送的读命令时,即使键已过期也不会删除,当做未删除操作。
3. 从服务器只有在接到主服务器发来的del命令时,才会删除过期键。
通过主服务器来控制从服务器统一的删除过期键,可以保证主从服务器数据的一致性,所以当一个过期键仍存在于主服务器的数据库时,这个过期键在从服务器里的复制品页会继续存在。
6. 数据库通知
redis数据库通知可以让客户端通过订阅给定频道、模式,来获知数据库中键的变化,以及数据库中命令的执行情况。
键空间通知: 某个键执行了什么命令,键事件通知: 某个命令被什么键执行了。
服务器配置的notify-keyspace-events选项决定了服务器所发送的通知类型。
1. 服务器发送所有类型的键空间通知、键事件通知,设为AKE。
2. 服务器发送所有类型的键空间通知,设为AK。
3. 服务器发送所有类型的键事件通知,设为AE。
4. 服务器只发送和字符串键有关的键空间通知,设为K$。
5. 服务器只发送和列表键有关的键事件通知,设为E1。
1. 发送通知
发送通知功能由notify.c/notifyKeyspaceEvent函数实现。(待更)
2. RDB持久化
redis是内存数据库,将其数据库状态储存在内存里。若不想办法将存储在内存中的数据库状态保存到磁盘里,一旦服务器进程退出,服务器中的数据库状态会消失。
所以redis提供了rdb持久化功能,可将redis在内存中的服务器状态保存到磁盘中。
rdb可以手动执行,也可根据服务器配置选项定期执行。rdb持久化生成的是一个经压缩的二进制文件。
1. rdb文件的创建、载入
redis使用save、bgsave命令来进行rdb持久化。save命令会阻塞redis服务器进程,直到rdb文件创建完毕,在服务器进程阻塞期间,服务器不能处理任何命令请求。
bgsave命令派生一个子进程,由子进程创建rdb文件,服务器进程继续处理命令请求。
创建rdb文件由rdb.c/rdbsave函数实现。save、bgsave以不同方式调用。
rdb文件在服务器启动时,自动载入。所以redis没有专门用于载入rdb文件的命令。只要redis服务器在启动时检测到rdb文件存在,就会自动载入rdb文件。
由于aof文件的更新频率常比rdb文件的更新频率高,所以若服务器开启了aof持久化,服务器会优先使用aof文件来还原数据库状态。只有在aof持久化功能处于关闭状态时,服务器才会使用rdb文件来还原数据库状态。
载入rdb文件由rdb.c/rdbload函数实现。
2. rdb持久化服务器状态
save命令时,服务器会被阻塞,客户端发送的命令请求都会被阻塞。当服务器执行完save后,重新开始接受命令请求时,客户端发送的命令才会被处理。
bgsave命令时,服务器仍可继续处理客户端的命令请求。但在bgsave执行期间,
1. 客户端发送的save命令会被服务器拒绝,服务器拒绝同时save和bgsave是因为,为了避免服务器进程和子进程同时执行两个rdbsave调用,防止产生竞争关系。
2. 客户端发送的bgsave命令会被服务器拒绝,服务器同时执行两个bgsave页会产生竞争关系。
3. bgsave和bgrewriteaof命令不能同时执行。若bgsave正在执行那么客户端发送的bgrewriteaof命令会被延迟到bgsave命令执行完毕之后。若bgrewriteaof命令正在执行那么客户端发送的bgsave会被拒绝。
因为bgsave、bgrewriteaof实际都是由子进程执行,所以在操作方面没有冲突,不同时执行只是为了考虑性能。并发两个子进程,且都同时执行大量的磁盘写入操作,不为一种好主意。
3. rdb载入时,服务器处于阻塞状态,直到载入完成。
4. 自动间隔性保存
因为bgsave可以不阻塞服务器进程执行rdb持久化,所以redis持久化允许用户设置服务器配置save选项,让服务器每隔一段时间自动执行一次rdb持久化。
用户可以设置save的多个保存条件,只要一个条件被满足,就会执行bgsave。
5, 设置保存条件
当服务器启动时,用户可以通过配置文件、传入启动参数去设置save选项,若用户没有主动设置,服务器为save设置默认条件。
随后,服务器根据save所设置的条件,设置服务器状态redisServer的saveparams,其是一个数组,每个元素都保存一个save选项的保存条件。
6. dirty计数器、lastsave属性
除了saveparams,服务器还维持一个dirty计数器(记录距上一次成功rdb持久化,服务器对数据库状态进行了多少次修改),和一个lastsave属性(记录上一次rdb持久化的时间)。
7. 检查保存条件是否满足
redis服务器周期性操作函数serverCron函数每100ms就会执行一次,用于对正在运行的服务器进行维护,其中一项工作就是检查save选项所设置的条件是否已经满足,满足就执行bgsave。
8. rdb文件结构
| REDIS | db_version | databases | EOF | check_sum |
| "REDIS"5个字符 | 字符串表示的整数,rdb的版本号 | 0至任意个数据库,及其中的键值对数据 | rdb正文结束符,377 | 校验和 |
| 5B | 4B | 1B | 8B无符号整数 |
databases部分:
| selectdb | db_number | key_value_paires |
| 当程序到此,知道下来是一个数据库号码 | 数据库号码 | 保存数据库中所有键值对数据 |
| 1B | 1B\2B\5B |
key_value_paires部分:
rdb文件中的每个key_value_paires部分保存了一个或以上的键值对,若键值对带有过期时间,则过期时间也会被保存在内。
1. 不带过期键的键值对
| type | key | value |
| 记录value的编码类型 | 键对象 | 值对象 |
| 1B |
2. 带过期键的键值对
| expiretime_ms | ms | type | key | value |
| 告知程序,下来是一个ms的过期时间 | 过期时间 | |||
| 1B | 8B的带符号整数 |
value的编码: (这里不赘述,有需要私。)
3. AOF持久化
除了rdb持久化,redis还提供了aof持久化。rdb持久化通过保存数据库键值对来记录数据库状态。aof持久化通过保存redis服务器所执行的写命令来记录数据库状态。
1. aof持久化的实现
aof持久化的功能实现可分为命令追加、文件写入、文件同步三步骤。
1. 命令追加
当aof持久化功能开启时,服务器在执行完一个写命令后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾。
2. 文件写入、同步
redis的服务器进程是一个事件循环。这个循环中的文件事件负责接受客户段的命令请求,以及向客户端发送命令回复,而时间时间则负责执行像serverCron这样的须定时运行的函数。
因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到aof_buf缓冲区中,所以在服务器每次结束一个时间循环之前,都会调用flushAppendOnlyFile函数,考虑是否需要将aof_buf缓冲区里的内容写入保存到aof文件中。
flushAppendOnlyFile函数的行为由服务器配置里的appendfsync选项决定:
| 选项 | 函数行为 | |
| always | 将aof_buf缓冲区的所有内容写入到aof文件中,并同步。 | 效率低,安全性高 |
| everysec | 将aof_buf缓冲区的所有内容写入到aof文件中,若距上次同步aof文件超过1s,再次对aof文件进行同步,且这个同步操作是由一个线程专门负责的。 | 效率足够快了,就算宕机也只丢失1s的命令数据 |
| no | 将aof_buf缓存区的所有内容写入到aof文件中,不同步,何时同步由os决定。 | 效率最快,安全性差 |
文件的写入和同步: 为了提高文件的写入效率,现代os当用户将一些数据写入文件中时,os常会将写入的数据暂时保存在一个缓冲区里面,等到缓冲区的空间被填满、超过了指定的时限,才真正的将缓冲区里的数据写入到磁盘里。
这种做法虽然提高了效率,但为写入数据带来了安全性问题。若计算机宕机,那么保存在内存缓冲区里的写入数据就会丢失。
所以,os提供了fsync、fdatasync两个同步函数,可以强制让os立即将缓冲区里的数据写入到磁盘,从而确保了写入数据的安全性。
2. aof文件的载入、数据还原
因为aof文件里包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍aof文件里保存的写命令,就可以还原服务器关闭之前的数据库状态。
1. 创建一个不带网络连接的伪客户端。因为redis的命令只能在客户端上下文中执行,而载入aof文件时所使用的命令直接来源于aof文件而不是网络连接。所以服务器使用一个没有网络连接的伪客户端来执行aof文件保存的写命令。
2. 从aof文件中分析并读取一条写命令。
3. 使用微客户端执行被读出的写命令。
4. 一直执行23,直至aof文件中的所有命令都被处理完毕为止。
3. aof重写
因为aof持久化是通过保存被执行的写命令来记录数据库状态,所以随着时间流失,aof文件中的内容会越来越多。文件的体积会越来越大。若不加以控制,aof文件很可能对redis服务器、甚至整个宿主计算机造成影响。且aof文件的体积越大,使用aof文件来进行还原的时间就越多。
为了解决aof文件体积膨胀问题,redis提供了aof文件重写功能。redis服务器可创建一个新的aof文件代替旧的aof文件。两个aof文件保存的数据库状态相同,但新的aof文件不会包含任何空间浪费的冗余命令。所以新的aof文件会比旧的aof文件小的多。
1. aof重写功能的实现
虽然名为aof重写,但新的aof文件并不需要对旧的aof文件进行任何读取、分析、写入操作,而是通过读取服务器当前数据库状态来实现的。
实现: (不再赘述,有需要私)。
实际中,为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序在处理列表、哈希表、集合、有序集合这种可能会带有多个元素的键时,会先检查键所包含的元素数量。若元素数量超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD常量时,
重写程序将使用多条命令记录键的值,而不是单使用一条命令。
2. aof后台重写
aof重写程序aof_rewrite函数可以很好的完成创建一个新的aof文件,但因为这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞。
因为redis服务器使用单个线程来处理命令请求,所以如果由服务器直接调用aof_rewrite,那么在重写aof文件期间,服务器将无法处理客户端发来的命令请求。
所以,redis将aof重写程序放到进程里执行,1. 子进程进行aof重写期间,服务器进程可以继续处理命令请求。2. 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。
但使用子进程存在一个问题,即因为子进程在进行aof重写期间,服务器还须继续处理命令请求,而新的命令可能会对现有数据库状态进行修改,从而导致服务器当前的数据库状态和重写后的aof文件所保存的数据库状态不一致。
为了解决这一问题,redis服务器设置了一个aof重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当redis服务器执行完一个写命令后,它会同时将这写命令发送给aof缓冲区和aof重写缓冲区。
所以,这样可保证,1. aof缓冲区的内容会定期被写入和同步到aof文件,对现有aof文件的处理工作会正常进行。2. 从创建子进程开始,服务器所执行的所以写命令都会被记录到aof重写缓冲区里。
当子进程完成aof重写后,会向服务器进程发送一个信号,父进程在接到信号后,会调用信号处理函数,并执行:
1. 将aof重写缓冲区里的所有内容写入到新aof文件中,这时新aof文件保存的数据库状态和服务器当前状态一致。
2. 对新的aof文件进行改名,原子的覆盖现有的aof文件,完成新旧aof文件的替换。
在整个aof后台重写过程中,只有信号处理函数执行时会对服务器进程造成阻塞,在其他时候,aof后台重写都不会阻塞父进程。这将aof重写对服务器性能造成的影响降到了最低。
4. 事件
redis服务器是一个事件驱动程序,服务器须处理两类事件:
1. 文件事件: redis服务器通过套接字和客户端\其他服务器进行连接,文件事件就是服务器对套接字操作的抽象。服务器的通信会产生相应的文件事件,服务器通过监听并处理这些事件来完成一系列网络通信操作。
2. 时间事件: redis服务器中的一些操作需要在给定时间点执行,时间事件就是服务器对这类定时操作的抽象。
1. 文件事件:
redis基于reactor模式开发了自己的网络事件处理器,称为文件事件处理器。
1. 文件事件处理器使用I/O多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同事件处理器。
2. 当监听的套接字准备好执行连接应答、读取、写入、关闭等操作时,与操作相对应的文件事件就会产生,文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
虽然文件事件处理器以单线程方式运行,但通过使用I/O多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好的与reids服务器中同样以单线程方式运行的模块进行对接,保持了redis服务器内部线程设计的简单性。
1. 文件事件处理器的构成:
文件事件处理器的四个组成部分: 套接字、I/O多路复用程序、文件事件分派器、事件处理器。
文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答、写入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器常会连接多个套接字,所以多个文件事件有可能会并发的出现。
I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。
尽管多个文件事件可能会并发的出现,但I/O多路福星程序会将所有产生事件的套接字都放到一个队列里,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕后,I/O多路复用程序才将传送下一个套接字。
文件事件分派器接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。
服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,定义了某事件发生时,服务器该执行的动作。
2. I/O多路复用程序的实现:
redis的I/O多路复用程序的所有功能都是通过包装select、epoll、evport、kqueue这些I/O多路复用函数库来实现的,每个函数库都对应一个单独的文件。
因为redis为每个I/O多路复用函数库都实现了相同的API,所有I/O多路复用程序的底层实现是可以互换的。
redis在I/O多路复用程序的实现中用#include宏定义了相应规则,程序会在编译时自动选择系统中性能最高的I/O多路复用函数作为其底层实现。
3. 事件的类型:
I/O多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件:
1. 当套接字变得可读时、或有新的可应答套接字出现时,套接字产生AE_READABLE事件。
2. 当套接字变得可写时,套接字产生AE_RAEDABLE事件。
I/O多路复用允许服务器同时监听套接字的以上两个事件,若一套接字同时产生这两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等其处理完后,才处理AE_WRITABLE事件。
即,若一个套接字可读可写时,服务器会先读套接字,后写套接字。
4. API: 在ae.c下:
1. aeCreateFileEvevt: 函数接受一个socket_fd,一个事件类型,一个事件处理器为参数。将给定套接字的给定事件加入到I/O多路复用程序的监听范围内,并对事件和事件处理器进行关联。
2. aeDeleteFileEvent: 函数接受一个socket_fd,一个监听事件类型为参数。让I/O多路复用程序取消对给定套接字的给定事件的监听,并取消事件和事件处理器之间的关联。
3. aeGetFileEvents: 函数接受一个socket_fd,返回该fd正被监听的事件类型:
1. 若没被监听,返回AE_NODE。
2. 若被读事件监听,返回AE_READABLE。
3. 若被写事件监听,返回AE_WRITABLE。
4. 若被读、写事件监听,返回AE_READABLE | AE_WRITABLE。
4. aeWait: 函数接受一个socket_fd,一个事件类型,一个ms为参数,在给定时间内阻塞并等待套接字的给定类型事件产生,若事件成功产生、或等待超时后,函数返回。
5. aeApipoll: 函数接受一个sys/time.h/struct timeval结构为参数,在指定时间内,阻塞并等待所有被aeCreateFileEvenet函数设置为监听状态的套接字产生文件事件,当有至少一个事件产生、或等待超时,函数返回。
6. aeProcessEvents: 函数是文件事件分派器,它先调用aeApipoll函数来等待事件产生,然后遍历所有已产生的事件,并调用相应事件处理器来处理这些事件。
7. aeGetApilName: 函数返回I/O多路复用程序所使用的I/O多路复用函数库的名称。
5. 文件事件的处理器:
redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求:
如: 1. 对连接服务器的各个客户端进行应答,服务器为监听套接字关联连接应答处理器。
2. 对接收客户端传来的命令请求,服务器为客户端套接字关联命令请求处理器。
3. 对向客户端返回命令的执行结果,服务器为客户端套接字关联命令回复处理器。
4. 对主从服务器进行复制操作时,主从服务器都要关联复制处理器。
1. 连接应答处理器:
networking.c/acceptTcpHandler函数是redis的连接应答处理器。这个处理器用于对连接服务器监听套接字的客户端进行应答。具体实现为sys/socket.h/accept函数的包装。
当redis服务器进行初始化时,程序会将这个连接应答处理器和服务器监听套接字的AE_READABLE事件关联。当客户端用sys/socket.h/connect函数连接服务器监听套接字时,套接字会产生AE_READABLE事件,
引发连接应答处理器执行,并执行相应的套接字应答操作。
2. 命令请求处理器:
networking.c/readQueryFromClient函数是redis的命令请求处理器。这个处理器负责从套接字中读取客户端发送的命令请求内容。具体实现为unistd.h/read函数的包装。
当一个客户端通过连接应答处理器成功连接到服务器后,服务器会将客户端套接字的AE_READABLE事件和命令请求处理器关联。当客户端向服务器发送命令请求时,套接字会产生AE_READABLE事件,
引发命令请求处理器执行,并执行相应的套接字成读入操作。
在客户端连接服务器的整个过程中,服务器都会一直为客户端套接字的AE_READABLE事件关联命令请求处理器。
3. 命令回复处理器:
networking.c/sendReplyToClient函数是redis的命令回复处理器。这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端。具体实现为unistd.h/write函数的包装。
当服务器又命令回复要传送给客户端时,服务器会将客户端套接字的AE_WRITABLE事件和命令回复处理器关联。当客户端准备好接收服务器传回的命令回复时,就会产生AE_WRITABLE事件,
引发命令回复处理器执行,并执行相应套接字写入操作。
在命令回复发送完后,服务器会解除命令回复处理处理器和客户端套接字AE_WRITABLE事件之间的关联。
4. 一次完整的客户端与服务器的连接:
客户端向服务器发送连接请求,服务器执行连接应答处理器。
客户端向服务器发送命令请求,服务器执行命令请求处理器。
服务器向客户端发送命令回复,服务器执行命令回复处理器。
2. 时间事件:
redis时间事件分为两类:
1. 定时事件: 让一段程序在指定时间后执行一次。
2. 周期性时间: 让一段程序每个指定时间执行一次。
一个时间事件由三个属性组成:
1. id: 服务器为时间事件创建的全局唯一id,从小到大,新的时间id比旧的大。
2. when: ms精度的unix时间戳,记录时间事件的到达时间。
3. timeproc: 时间事件处理器,一函数,当时间事件到达时,服务器会调用相应处理器来处理事件。
一个时间事件的类别取决于时间事件处理器的返回值:
1. 若事件处理器返回ae.h/AE_NOMORE,该事件为定时事件。该事件达到一次后被删除。
2. 若事件处理器返回一个非AE_NOMORE的整数值,该事件为周期性事件。当该事件到达后,服务器会根据事件处理器返回的值,对该事件的when属性进行更新,依次循环。
目前版本redis只使用周期性事件,没有使用定时事件。
1. 实现:
服务器将所有时间事件存放在一无序链表中,每当时间事件执行器运行时,就会遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器。
无序不是不按id排序,因为头插链表,所以该链表倒序。无序指不按when属性大小排序。所以当时间事件处理器运行时,它必须遍历整个链表。
无序链表并不影响时间事件处理器的性能。
目前版本,正常模式下,redis服务器只使用serverCron一个时间事件。在benchmark模式下,也只是使用两个时间事件。
所以,服务器几乎是将无序链表退化为一个指针来使用,所以无序链表保存时间时间并不影响事件执行的性能。
2. API: 在ae.c下
1. aeCreateTimeEvent: 函数接受一ms,一时间事件处理器proc为参数。将一个新的时间事件添加到服务器,该事件在ms后到达,事件的处理器为proc。
2. aeDeleteFileEvent: 函数接受以时间事件id为参数,从服务器中删除该id所对应的时间事件。
3. aeSearchNearestTimer: 函数返回到达时间据当前最近的那个时间事件。
4. processTimeEvents: 函数是时间事件的执行器,该函数会遍历所有时间事件,并调用时间处理器来处理已到达的时间事件。
3. 时间事件实例:serverCron函数
持续运行的redis服务器需定期对自身的资源、状态进行检查和调整,从而保证服务器可以长期、稳定运行。这些定期操作由redis.c/serverCron函数负责执行,主要工作有:
1. 更新服务器各类统计信息。
2. 清理数据库中的过期键。
3. 关闭和清理连接失效的客户端。
4. 尝试进行aof或rdb持久化。
5. 若服务器为主服务器,对从服务器进行定期同步。
6. 若处于集群模式,对集群进行定期同步和连接测试。
redis服务器以周期性事件的方式来运行serverCron函数,在服务器运行期间,每隔一段时间,serverCron函数就会执行一次,直到服务器关闭。
3. 事件的调度、执行:
因为服务器中同时存在文件事件和时间事件,所以服务器服务器必须对其进行调度。
事件的调度和执行由ae.c/aeprocessEvents函数负责。将其置于循环里,加上初始化和清理函数,就构成了redis服务器的主函数。
从事件角度看,redis服务器的运行流程为: 启动服务器-是否关闭服务器-(否)等待文件事件产生-处理已产生的文件事件-处理已到达的事件事件-循环至2。
事件的调度和执行规则:
1. aeApipoll函数的最大阻塞时间由到达时间最接近当前的时间事件决定。这样既避免服务器对时间事件进行频繁的轮询(忙等待),也确保aeApipoll函数不会阻塞太长时间。
2. 文件事件是随机出现的,若等待并处理完一次文件事件后,仍没有时间事件到达,那么服务器将再次等待并处理文件事件。
3. 对文件事件、时间事件的处理都是同步、有序、原子的执行的。服务器不会中途中断处理,也不会对事件进行抢占。
故,文件事件、时间事件处理器都会尽可能的减少程序的阻塞时间,并在不需要时主动让出执行权,从而降低造成事件饥饿的可能性。
4. 因为时间事件在文件事件之后执行,且事件之间不会出现抢占,所以时间事件的实际处理时间,通常会比时间事件设定的到达时间稍晚些。
5. 客户端
redis服务器是典型的一对多服务器程序: 一个服务器可以与多个客户端建立网络连接,每个客户端可以向服务器发送命令请求。而服务器则接收并处理客户端发送的命令请求,并向客户端返回命令回复。
通过使用由I/O多路复用技术实现的文件事件处理器。redis服务器使用单线程单进程的方式来处理命令请求,并与多个客户端进行网络通信。
对于每个与服务器连接的客户端,服务器都为这些客户端建立了相应的redis.h/redisClient,保存了客户端当前的状态信息,和执行相关功能时须用到的数据结构。
redis服务器状态结构的clients属性是一个链表,保存了与服务器连接的各个客户端的状态结构。
1. 客户端属性:
客户端包含的属性可分为两类:
1. 比较通用的属性。这些属性很少与特定功能相关,无论客户端执行什么工作,都要用到这些属性。
2. 和特定功能相关的属性。
1. 套接字描述符: int fd属性。根据客户端类型的不同,fd属性的值可为-1、大于-1的整数。
伪客户端的fd为-1。伪客户端处理的命令请求来源与aof文件、lua脚本,而不是网络。所以这种客户端不需要套接字连接。所以不需要记录套接字描述符。
目前,redso服务器会在两个地方用到伪客户端:
1. 用于载入aof文件并还原数据库状态。
2. 用于执行lua脚本中包含的redis命令。
普通客户端的fd为大于-1的整数。普通客户端使用套接字来与服务器进行通信,所以服务器会用fd属性来记录客户端套接字的描述符。
2. 名字: 默认情况下,一个连接到服务器的客户端是没有名字的。可以使用client setname命令为客户端设置一个名字,让客户端的身份明了。
robj *name属性。若客户端没有为自己设置名字,那么相应客户端状态的name属性指向null,若设置了名字,则指向一个字符串对象。字符串对象保存客户端名字。
3. 标志: int flag属性。记录了客户端的角色,及客户端目前所处的状态。可以是单个标志,也可是多个标志的二进制或。
每个标志使用一个常量来表示。一部分记录的客户端的角色:
1. 在主从服务器进行复制操作时,主服务器会成为从服务器的客户端,而从服务器也会成为主服务器的客户端。REDIS_MASTER标志表示客户端代表的是一个主服务器,REDIS_SLAVE标志表示客户端是一个从服务器。
2. REDIS_PRE_PSYNC标志表示客户端代表的是一个低于redis2.8版本的从服务器。主服务器不能使用PSYNC命令与这个从服务器进行同步。这个标志只能在REDIS_SLAVE标志处于打开时使用。
3. REDIS_LUA_CLIENT标志表示客户端是专门用于处理lua脚本里的redis命令的伪客户端。
另一部分记录了客户端当前所处的状态:
1. REDIS_MONITOR标志表示客户端正在执行monitor命令。
2. REDIS_UNIX_SOCKET标志表示服务器使用unix套接字来连接客户端。
3. REDIS_BLOCKED标志表示客户端正在被brpop、blpop等命令阻塞。
4. REDIS_UNBLOCKED标志表示客户端已从REDIS_BLOCK标志所表示的阻塞状态脱离,不再阻塞。该标志只能在上一标志已经打开时使用。
5. REDIS_MULTI标志表示客户端正在执行事务。
6. REDIS_DIRTY_CAS标志表示事务使用watch命令监视的数据库键已经被修改。
7. REDIS_DIRTY_EXEC标志表示事务在命令入队时出现了错误。(67命令都表示事务的安全性已经被破坏,任意一个被打开,exec命令必然会执行失败。这两标志只能在客户端打开5标志时使用)。
8. REDIS_CLOSE_ASAP标志表示客户端的输出缓冲区大小超出了服务器允许的范围。服务器会在下一次执行serverCron时关闭这个客户端。以免服务器的稳定性受到这一客户端的影响。积存在输出缓冲区的所有内容都会被直接释放。不会返回给客户端。
9. REDIS_CLOSE_AFTER_REPLY标志表示由用户对这个客户端执行了client kill命令,或者客户端发送给服务器的命令请求中包含了错误的协议内容。服务器会将客户端积存的输出缓冲区中所有的内容发送给客户端,然后关闭客户端。
10. REDIS_ASKING标志表示客户端向集群结点(运行在集群模式下的服务器),发送了asking命令。
11. REDIS_FORCE_AOF标志强制服务器将当前执行的命令写入到aof文件里。
12. REDIS_FORCE_REPL标志强制主服务器将当前执行的命令复制给所有从服务器。(执行pubsub命令还会使客户端打开11标志,执行script load命令会使客户端打开11、12标志)
13. 在主从服务器进行命令传播期间,从服务器需要向主服务器发送replication ack命令,在发送命令之前,从服务器必须打开主服务器对应的客户端的REDIS_MASTER_FORCE_REPLY标志,否则发送操作会被拒绝执行。
上诉标志都在redis.h里定义。
PUBSUB、SCRIPT LOAD命令的特殊性
通常,redis只会将写命令写入到aof文件里,读命令不会写入到aof文件里。但,PUBSUB虽然没有修改数据库键,但其向频道的所有订阅者发送消息这一行为带有副作用,接收到消息的所有客户端状态都会因这个命令而改变。所以,服务器须使用11标志,强制将这个命令写入到aof文件中。这样在载入aof文件时,服务器可以再次执行相同命令并产生相同副作用。SCRIPT lOAD虽然没有修改数据库键,但修改了服务器状态,是一个带有副作用的命令,服务器需要用11标志,强制将其写入到aof文件里,且用12标志,强制将其复制给所有从服务器,以保证主服务器和从服务器都可以正确的载入其命令指定的脚本。
4. 输入缓冲区: sds querybuf属性。
客户端状态的输入缓冲区保存客户端发送的命令请求。
输入缓冲区的大小会根据输入内容动态的缩小或扩大。最大不能超过1GB,否则服务器将关闭这个客户端。
5. 命令、命令参数: robj **argv; int argc;。
在服务器将客户端发送的命令请求保存到客户端状态的输入缓冲区中后,服务器将对命令请求的内容进行分析,并将得出的命令参数以及命令参数的个数分别保存到客户端状态的argc、argv属性。
argv是一数组,保存字符串对象。argv[0]是所要执行的命令。之后其他项是所传给命令的参数。argc是argv的长度。
6. 命令的实现函数: struct redisCommand *cmd属性。
当服务器从协议内容中分析并得出argv、argc属性的值后,服务器会根据arhv[0]的值,在命令表中查找命令所对应的命令实现函数。当程序在命令表中成功找到argv[0]对应的redisCommand结构时,会将客户端状态的cmd指针指向这个结构。
7. 输出缓冲区:
执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里。每个客户端都有两个输出缓冲区可用。一个缓冲区大小是固定的,另一个缓冲区大小是可变的。
1. 固定大小的缓冲区用于保存那些长度比较小的回复。
2. 可变大小的缓冲区用于保存那些长度比较大的回复。
客户端的固定大小缓冲区由char buf[len],int bufpos组成。buf为一len长的字节数组,bufpos记录buf已使用的字节数量。len默认为16kb。
客户端的可变大小缓冲区有哦list reply链表和一个或多个字符串对象组成。通过链表连接多个字符串对象,服务器可以为客户端保存一个非常长的命令回复。而不必受固定缓冲区16kb的限制。
8. 身份验证: int authenticated属性。
若其=0,则未通过身份验证,若=1,则通过身份验证。未通过身份验证时,出auth命令外其他所以命令都会被服务器拒绝执行。该属性仅在服务器启用了身份验证功能时使用。若服务器没有启用身份验证功能,即使=0,服务器也不会拒绝客户端发送的命令请求。
9. 时间:
time_t ctime属性记录了创建客户端的时间,可用来计算客户端与服务器已经连接了多少s。
time_t lastinteraction属性记录了客户端与服务器最后一次进行互动的时间。互动可以是客户端向服务器发送命令请求,也可以是服务器向客户端发送命令的回复。可以用来计算客户端的空转时间,即距客户端和服务器最后一次进行互动,已经过去了多少s。
time_t obuf_soft_limit_reached_time属性记录了输出缓冲区第一次到达软性限制的时间。
2. 客户端的创建、关闭
服务器使用不同方式来创建和关闭不同类型的客户端。
1. 创建普通客户端:
如果客户端是通过网络连接与服务器进行连接的普通客户端,那么在客户端使用connect函数连接到服务器时,服务器会调用事件处理器,为客户端创建相应的客户端状态,并将这个新的客户端添加到服务器结构clents链表的末尾。
2. 关闭普通客户端:
一个普通客户端可以因为多种原因被关闭:
1. 若客户端进程退出\被杀死,那么客户端与服务器之间的网络连接将被关闭,从而造成客户端被关闭。
2. 若客户端选服务器发送了带有不符合协议格式的命令请求,那么这个客户端会被服务器关闭。
3. 若客户端成为了client kill命令的目标,那么客户端会被关闭。
4. 若用户为服务器设置了timeout配置选项,那么当客户端的空转时间超过timeout选项设置的值时,客户端将被关闭。
但存在特殊情况,若客户端是主服务器(打开了redis_master标志),从服务器(打开了redis_slave标志)正在被blpop等命令阻塞(打开了redis_blocked标志)或正在执行subscribe等订阅命令,那么即使客户端的空转时间超过了timeout的值,客户端也不会被服务器关闭。
5. 若客户端发送的命令请求的大小超过了输入缓冲区的限制大小,那么这客户端会被服务器关闭。
6. 若要发给客户端的命令回复大小超过了输出缓冲区的限制大小,那么客户端会被服务器关闭。
理论上可变大小输出缓冲区可以无限大,但是为了避免客户端的回复过大,占用过多的服务器资源,服务器会时刻检查客户端的输出缓冲区大小,并在超出缓冲区大小的范围时,执行相应限制操作。
1. 硬性限制: 若输出缓冲区的大小超过了硬性限制所设置的大小,那么服务器会立即关闭客户端。
2. 软性限制: 若输出缓冲区的大小超过了软性限制所设置的大小,但还没超过硬性限制所设置的大小,那么服务器将使用客户端状态结构的obuf_soft_limit_reacged_time属性记录下客户端达到软性限制的起始时间,之后服务器会继续监视客户端,
若输出缓冲区的大小一直超出软性限制,且持续时间超过服务器设定的时长,那么服务器将关闭客户端。相反,若输出缓冲区大小在指定时间内,不再超出软性限制,那么客户端就不会被关闭。且obuf_soft_limit_reacged_time属性被清0。
使用client-output-buffer-limit选项可以为普通客户端,从服务器客户端,执行发布订阅功能的客户端分别设置不同的软\硬性限制。在配置文件redis.conf中。
3. lua脚本的伪客户端:
服务器会在初始化时创建负责执行lua脚本中包含的redis命令的伪客户端,并将这伪客户端关联在服务器状态的redisclient *lua_client属性中,该伪客户端在服务器运行的整个生命周期中会一直存在。只有服务器被关闭时,这个客户端才会被关闭。
4. aof文件的伪客户端:
服务器在载入aof文件时,会创建用于执行aof文件包含的redis命令的伪客户端,并在载入完成后,关闭这个伪客户端。
6. 服务器
redis服务器负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令所产生的数据,并通过资源管理器来维持服务器自身的运转。
1. 命令请求的执行过程:
1. 发送命令请求: redis服务器的命令请求来自redis客户端。当用户在客户端中键入一个命令请求时,客户端会将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器。
2. 读取命令请求: 当客户端与服务器之间的套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:
1. 读取套接字中协议格式的命令请求,将其保存到客户端状态的输入缓冲区中。
2. 对输入缓冲区里面的命令请求进行分析,提取出命令请求中包含的命令参数、及命令参数的个数,分别将参数和参数个数保存到客户端状态的argv和argc中。
3. 调用命令执行器,执行客户端指定的命令。
3. 命令执行器:
1. 查找命令实现: 命令执行器要做的第一件事就是根据客户端状态的argv[0]参数,在命令表中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性。
命令表是一个字典,键为一命令名字,值为一个redisCommand结构,该结构记录一个redis命令的实现信息。下表记录了该结构的主要信息。
| 属性名 | 类型 | 作用 |
| name | char * | 命令名称 |
| proc | redisCommandProc * | 函数指针,指向命令的实现函数 |
| arity | int | 命令参数的个数,用于检查命令请求格式是否正确。 |
| sflags | char * | 字符串形式的标识值,这个值记录了命令的属性。 |
| flags | int | 对sflags标识进行分析得出的二进制标识。由程序自动生成。服务器对命令标识检查时使用。 |
| calls | long long | 服务器共执行多少次这个命令 |
| milliseconds | long long | 服务器执行这个命令所耗费的总时长 |
因为命令表使用的是大小写无关的查找算法,无论输入的命令名字大小写或混合使用。
2. 执行预备操作: 在真正执行命令之前,程序还需进行一些预备操作,从而确保命令可以正确、顺利地被执行。包括:
1. 检查客户端状态的cmd指针是否指向null。若是说明用户输入的命令名字找不到相应的命令实现,服务器不再执行后续操作,并向客户端返回一个错误。
2. 根据客户端cmd属性指向的redisCommand结构的arity属性,检查命令请求所给定的参数个数是否正确。若不正确,服务器不再执行后续操作,并向客户端返回一个错误。
3. 检查客户端是否已经通过了身份验证。未通过验证的客户端只能执行auth命令。若未通过身份验证的客户端试图执行不是auth的其他命令,服务器会向客户端返回一个错误。
4. 若服务器打开了maxmemory功能,那么在执行命令之前,先检查服务器额内存占用情况,并在由需要时进行内存回收,从而使得接下来的命令可以顺利执行。若内存回收失败,那么不再执行后续操作,向客户端返回一个错误。
5. 若服务器上一次执行bgsave时出错,并且服务器打开了stop-writes-on-bgsave-error功能,且服务器将要执行的命令是一个写命令,那么服务器将拒绝执行这个命令,并向客户端返回一个错误。
6. 若客户端当前正使用subscribe命令订阅频道,或正使用psubscribe命令订阅模式,那么服务器只会执行客户端发来的subscribe、psubscribe、unsubscribe、unpsubcribe四个命令,其他命令都会被服务器拒绝。
7. 若服务器正在进行数据载入,那么客户端发送的命令必须带有L标识才会被服务器执行,其他命令都会被服务器拒绝。
8. 若服务器因为执行lua脚本而超时并进入阻塞状态,那么服务器只会执行客户端发来的shutdown nosave命令和script kill命令,其他命令都会被服务器拒绝。
9. 若客户端正在执行事务,那么服务器只会执行客户端发来的exec、discard、multi、watch四个命令。其他命令都会被放进事务队列。
10. 若服务器打开了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器。
当完成以上预备操作后,服务器就可开始执行命令了。(只列出了服务器在单机模式下执行命令时的检查操作,当服务器在复制或集群模式下执行命令时,预备操作还会更多些。)
3. 调用命令的实现函数:
执行语句client->cmd->proc(client),相当于setCommand(client)。
被调用的命令实现函数会执行指定操作,并产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区里,之后实现函数还会为客户端的套接字关联相应命令回复处理器,负责将命令回复返回给客户端。
4. 执行后续操作:
在执行完实现函数后,服务器还需执行一些后续操作。
1. 若服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志。
2. 根据刚刚执行命令所耗费的时长,更新被执行命令的redisCommand的milliseconds属性,并将calls属性计数器+1。
3. 若服务器开启了aof持久化功能,那么aof持久化模块会将刚刚执行的命令请求写入到aof缓冲区中。
4. 若有其他从服务器正在复制当前服务器,那么服务器会将刚刚执行的命令传播给所有从服务器。
当执行完以上操作,服务器对于当前命令的执行就告一段落了。之后服务器可继续从文件事件处理器中取出并处理下一命令请求。
4. 将命令回复发送给客户端
命令实现函数会将命令回复保存到客户端状态的输出缓冲区里,并为客户端的套接字关联命令回复处理器,当客户端套接字变成可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。
当命令回复发送完毕之后,回复处理器会清空客户端的输出缓冲区,为处理下一命令请求准备。
5. 客户端接收并打印命令回复
当客户端接收到协议格式的命令回复后,会将命令回复转换为可读格式并打印。
2. serverCron函数
redis服务器中的serverCron函数默认每隔100ms执行一次,该函数负责管理服务器资源,并保持服务器自身的良好运转。
1. 更新服务器时间缓存:
redis服务器中有不少功能需要获取系统当前时间,而每次获取系统当前时间要执行一次系统调用,为了减少系统调用的执行次数,服务器状态中的unixtime、mstime属性被用作记录当前时间的缓存。
time_t unixtime;秒精度的时间戳,long long mstime;毫秒精度的时间戳。
因为serverCron函数默认100ms执行一次,所以这两属性的精度并不高。
所以,服务器只会在打印日志、更新服务器lur时钟、决定是否进行持久化任务、计算服务器上线时间等这类时间精度不高的功能上使用这两个时间属性。
而,对于为键设置过期时间、添加慢查询日志等高时间精度的功能上还是会再次执行系统调用,从而获得最准确的系统当前时间。
2. 更新lru时钟:
服务器属性中的unsigned lruclock:22属性保存了服务器的lru时钟。每个redis对象都会有一个lru属性,记录该对象最后一次被命令访问的时间unsigned lru:22;
当服务器要计算一个数据库键的空转时长时,程序会用服务器属性的lruclock-对象的lru。
serverCorn函数默认会以10s一次的频率更新lruclock的值,由于该值不是实时的,所以所得的lru时间只是一个模糊的估计值。
3. 更新服务器每秒执行命令的次数:
serverCron函数中的trackOpreationsPerSecond函数会以每100ms一次的频率执行,该函数的功能是以抽样计算的方式,估计并记录服务器在最近1s处理的命令请求数量。
该函数和服务器状态的4个属性有关:
long long ops_sec_last_sample_time;上一次的抽样时间。
long long ops_sec_last_sample_ops;上一次抽样时,服务器已执行的命令数量。
long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES];默认16的环形数组,每项为一次抽样结果。
int ops_sec_idx;每次抽样后+1,为16重置0,上面数组的索引值。
该函数每次运行会据,上一次抽样时间、服务器当前时间、上一次抽样所执行命令数、当前已执行命令数计算出服务器1s内能能处理多少个命令请求的估计值。该估计值被放入唤环形数组里。
当服务器执行命令查询时,服务器调用该函数根据环形数组的抽样结果计算出平均值。只是一个估算值。
4. 更新服务器内存峰值记录:
size_t stat_peak_memory属性记录了已使用内存峰值。
每次执行serverCron函数,程序都会查看服务器当前使用的内存数量,并与stat_peak_memory保存的数值进行比较,若当前使用的内存数量比其大,就将当前使用的内存数量记录到stat_peak_memory中。
5. 处理SIGTERM信号:
在启动服务器时,redis会为服务器进程的SIGTREM信号关联处理器sigtermHandler函数,这个信号处理器负责在服务器接受到SIGTREM信号时,打开服务器的shutdown_asap标识。
每次serverCron运行时,程序都会对服务器状态的shutdown_asap属性进行检查,根据其值决定是否关闭服务器。int shutdown_asap;关闭服务器标识,为1关闭,为0不关闭。
服务器在关闭自身之前可进行rdb持久化等操作,这也是服务器拦截SIGTREM信号的原因。若服务器一接到SIGTREM信号就立即关闭,就无法进行持久化操作。
6. 管理客户端资源:
serverCron函数每次执行都会调用clientsCron函数,clientsCron函数会对一定数量的客户端进行下面两个检查:
1. 若客户端与服务器之间连接已经超时,那么程序释放这个客户端。
2. 若客户端在上一次执行命令请求后,输入缓冲区的大小超过了一定长度,那么程序会释放客户端的输入缓冲区,并重新为其创建一个默认大小的输入缓冲区,以防止客户端的输入缓冲区耗费了过多的内存。
7. 管理数据库资源:
serverCron函数每次执行都会调用databasesCron函数,该函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时,对字典进行收缩。
8. 执行被延迟的BGREWRITEAOF:
在服务器执行bgsave期间,若客户端向服务器发送bgrewriteaof命令,那么服务器将bgrewriteaof的执行时间延迟到bgsave执行完后。
服务器状态的int aof_rewrite_scheduled属性记录了是否延迟,为1有延迟。
每次执行serverCron函数时,函数都会检查bgsave和bgrewriteaof是否正在执行,若都没有执行且aof_rewrite_scheduled为1,那么服务器就会执行之前被推延的bgrewriteaof命令。
9. 检查持久化操作的运行状态:
服务器状态使用rdb_child_pid、aof_child_pid属性记录执行bgsave、bgrewriteaof的子进程id。也可用于检查命令是否正在执行。
pid_t rdb_child_pid;记录执行bgsave命令子进程的id。为-1表示服务器没有进行bgsave。
pid_t aof_child_pid;记录执行bgrewriteaof命令子进程的id。为-1表示服务器没有进行bgrewriteaof。
每次执行serverCron函数时,程序都会检查两个id的值:
1. 若只要其中一个属性不为-1,程序就会执行一次wait3函数,检查子进程是否由信号发来服务器进程。
1. 若有信号到达,表示相应文件生成完毕,服务器需要进行后续的操作。
2. 若无信号到达,表示持久化操作未完成,程序不做动作。
2. 若两个属性都为-1,表示服务器没有在进行持久化,此时,程序进行一下三个检查:
1. 查看是否有bgsaverewriteaof被延迟,若有,那么开始一次新的bgrewriteaof操作(8)。
2. 检查服务器的自动保存条件是否被满足,若被满足,并且服务器没有在进行其他持久化操作(1.会引起新的持久化,程序会再次确认),那么服务器会开始一次新的bgsave操作。
3. 检查服务器设置的aof重写条件是否满足,若被满足,并且服务器没有在进行其他持久化操作(1.2.会引起新的持久化,程序会再次确认),那么服务器会开始一次新的bgrewriteaof操作。
10. 将aof缓冲区中的内容写入到aof文件中:
若服务器开启了aof持久化功能,且aof缓冲区里面还有待写入的数据,那么serverCron函数会调用相应程序,将aof缓冲区里的内容写入到aof文件里。
11. 关闭异步客户端:
服务器会关闭那么输出缓冲区大小超出限制的客户端。
12. 增加cronloops计数器的值:
服务器状态的int cronloops属性记录了serverCron函数执行的次数。
该属性目前在服务器中的唯一作用是,在复制模块中实现’每执行serverCron函数n次就执行一次指定代码’的功能。
3. 初始化服务器
一个redis服务器从启动到能够接受客户端的命令请求,须经过一系列的初始化和设置过程。
1. 初始化服务器状态结构:
初始化服务器第一步创建一个struct redisServer类型的实例遍历server作为服务器的状态,并为结构中的各个属性设置默认值。
初始化server变量的工作由redis.c/initServerConfig函数完成。该函数完成的主要工作:
1. 设置服务器的运行id。
2. 设置服务器的默认运行频率。
3. 设置服务器的默认配置文件路径。
4. 设置服务器的运行架构。
5. 设置服务器的默认端口号。
6. 设置服务器的默认rdb持久化条件、aof持久化条件。
7. 初始化服务器的lru时钟。
8. 创建命令表。
initServerConfig函数设置的服务器状态属性基本而都是一些整数、浮点数、字符串属性。除了命令表外,该函数没有创建服务器状态的其他数据结构。数据库、慢查询日志、lua环境、共享对象这些数据结构在之后步骤才被创建。
当该函数执行完后,服务器进入下一初始化阶段,载入配置选项。
2. 载入配置选项:
在启动服务器时,用户可以通过给定配置参数、指定配置文件来修改服务器的默认配置。
服务器在用initServerConfig函数初始化完server变量之后,就会开始载入用户给定的配置参数和配置文件,并根据用户设定的配置,对server变量相关属性的进行修改。
上述操作执行完后,服务器初始化进入下一阶段,初始化服务器数据结构。
3. 初始化服务器数据结构:
在1.执行的initServerConfig初始化server状态时,程序只创建了命令表一个数据结构,除命令表外,服务器状态还包含其他数据结构,
1. server.clients链表,记录所有与服务器相连的客户端状态结构。
2. server.db数组,数组包含服务器所有数据库。
3. server.pubsub_channels字典,保存频道订阅信息。
4. server.pubsub_patterns链表,保存模式订阅信息。
5. 执行lua脚本的lua环境server.lua。
6. 保存慢查询日志的server.slowlog属性。
当初始化服务器进行到此,服务器将调用initServer函数,为以上数据结构分配内存,并在有需要时,为这些数据结构设置或关联初始化值。
服务器到现在才初始化数据结构是因为,服务器必须先载入用户指定的配置选项,然后才能正确的对数据结构进行初始化。若在1.时就对数据结构初始化,那么一旦用户通过配置选项修改了和数据结构有关的服务器状态属性,
服务器就须重新调整和修改已创建的数据结构。为了避免这种情况,服务器将server分为1.3.两步进行。1.主要负责初始化一般属性,3.主要负责初始化数据结构。
除初始化数据结构外,initServer函数还进行了非常重要的设置操作:
1. 为服务器设置进程信号处理器。
2. 创建共享对象。
3. 打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接受客户端的连接。
4. 为serverCron函数创建时间事件,等待服务器正式运行时执行serverCron函数。
5. 若aof持久化功能已经打开,那么打开现有的aof文件,若aof文件不存在,那么创建并打开一个新的aof文件,为aof写入做好准备。
6. 初始化服务器的后台i/o模块,为将来的i/o操作做好准备。
4. 还原数据库状态:
在完成对服务器状态server变量的初始化后,服务器需要载入rdb文件/aof文件,并根据文件记录的内容来还原服务器的数据库状态。
1. 若服务器启用了aof持久化,服务器使用aof文件来还原数据库状态。
2. 若服务器没启用aof持久化,服务器使用rdb文件来还原数据库状态。
5. 执行事件循环:
在初始化的最后一步,开始执行服务器的事件循环。
至此,服务器初始化工作完成,服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求。
3. 多机数据库
1. 复制
在redis中,用户可以通过执行slaveof命令、设置slaveof选项,让一服务器去复制另一服务器。被复制的为主服务器,另一为从服务器。
1. 旧版复制功能的实现:
redis的复制功能分为同步sync和命令传播两个操作:
1. 同步: 用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
2. 命令传播: 用于主服务器的数据库状态被修改,导致主从服务器的数据库状态不一致,让主从服务器状态重新一致。
1. 同步:
当客户端向从服务器发送slaveof命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作。
从服务器对主服务器的同步操作,需要通过向主服务器发送sync命令来完成,sync命令的执行步骤:
1. 从服务器向主服务器发送sync命令。
2. 收到sync命令的主服务器执行bgsave命令,在后台生成一个rdb文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
3. 当主服务器的bgsave命令执行完毕时,主服务器会将bgsave生成的rdb文件发送给从服务器,从服务器接受并载入这个rdb文件,将自己的数据库状态更新至主服务器执行bgsave时的数据库状态。
4. 主服务器记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。
2. 命令传播:
在同步操作执行完毕之后,主从服务器的数据库状态一致,但这种一致并不是一成不变的,每当主服务器执行客户端发送的写命令时,主服务器的数据库就可能被修改,导致主从服务器状态不一致。
为了让主从服务器状态一致,主服务器需要对从服务器进行命令传播操作:
主服务器会将自己执行的写命令发送给从服务器,从服务器执行命令达到主从服务器状态一致。
2. 旧版复制功能的缺陷:
在redis2.8之前,从服务器对主服务器的复制可分为两种情况:
1. 初次复制: 从服务器以前没有复制过任何主服务器,或从服务器当前要复制的主服务器和上一次复制的主服务器不同。
2. 断线后重复制: 处于命令传播阶段的主从服务器因为网络原因而中断了复制,但从服务器通过自动连接后重新连上了主服务器,并继续复制主服务器。
对于初次复制,旧版复制功能可以很好的完成,但对于断线后重复制,旧版复制虽可使主从服务器一致,但效率非常低。
sync命令是一个非常耗费资源的操作。
每次执行sync命令,主从服务器需要执行下列操作:
1. 主服务器需要执行bgsave命令生成rdb文件,这个生成操作会耗费主服务器大量的cpu时间、内存、磁盘i/o资源。
2. 主服务器需要将自己的生成的rdb文件发送给从服务器,这个发送操作会耗费主服务器大量的网络资源,并对主服务器响应命令请求的时间产生影响。
3. 接收到rdb文件的从服务器需要载入主服务器发来的rdb文件,且在载入期间,从服务器会因为阻塞而没办法处理命令请求。
所以sync命令很费资源,故redis须在真正需要时才执行sync命令。
3. 新版复制功能的实现:
为了解决旧版复制的低效,redis2.8之后,使用psync代替sync来执行复制时的同步操作。
PSYNC命令具有完整重同步、部分重同步两种模式:
1. 完整重同步: 用于处理初次复制。和sync执行步骤基本一样。
2. 部分重同步: 用于处理断线后重复制情况。当服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器。从服务器只要接收并执行这些写命令,就可以和主服务器状态一致。
PSYNC的部分重同步解决了旧版复制功能在断线重复制时出现的低效情况。
4. 部分重同步的实现:
部分重同步功能由下列三部分构成:
1. 主服务器的复制偏移量、从服务器的复制偏移量。
2. 主服务器的复制积压缓冲区。
3. 服务器运行id。
1. 复制偏移量:
执行复制的双方,主从服务器会分别维护一个复制偏移量。
主服务器每次向从服务器传播n字节数据时,就将自己的复制偏移量+n。从服务器每次接收主服务器n字节数据时,就将自己的复制偏移量+n。
通过对比主从服务器的复制偏移量,程序可以知道主从服务器是否一致。若相同一致,不同不一致。
2. 复制积压缓冲区:
复制积压缓冲区是由主服务器维护的一个固定长度、先进先出队列。默认大小1MB。
固定长度队列出入队规则和普通队列一样,不同的时,当入队元素大于丢咧长度时,最先入队的元素会被弹出,新元素会被入队。
当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里。
所以,复制积压缓冲区里保存着最近传播的写命令,且会为队列中的每个字节记录相应的复制偏移量。
当从服务器重新连接上主服务器时,从服务器会通过psync命令将自己的复制偏移量发送给主服务器,主服务器根据这个偏移量觉决定对从服务器执行那种同步操作:
1. 若复制偏移量之后的数据仍在复制积压缓冲区里,那么主服务器对从服务器进行部分重同步操作。
2. 若复制偏移量之后的数据不在复制积压缓冲区里,那么主服务器对从服务器进行完整重同步操作。
根据需要调整复制积压缓冲区的大小。
redis为复制积压缓冲区设置的默认大小为1MB。若主服务器需要执行大量写命令或主从服务器断线重连所需时间较长,默认大小就会不太合适。
复制积压缓冲区大小可据公式second(s) * write_size_per_second(主服务平均每秒写命令数)来估算。
为了安全起见,可将估算结果×2,这样可以保证绝大部分断线情况都能用部分重同步来处理。
3. 服务器运行id:
每个redis服务器,不论主服务器还是从服务器,都会有自己的运行id。运行id在服务器启动时自动生成,由40个随机16进制字符组成。
当从服务器对主服务器初次复制时,主服务器会将自己的运行id发送给从服务器,从服务器会保存该id。当从服务器断线重连上一个主服务器时,从服务器会向当前连接的主服务器发送保存的i运行d,
1. 若该id和当前主服务器id相同,主服务器可以尝试进行部分重同步操作。
2. 若该id和当前主服务器id不同,主服务器将对从服务器进行完正重同步操作。
5. PSYNC命令的实现:
psync命令的调用由两种方法:
1. 若从服务器以前没有复制过任何主服务器,或者之前执行过slaveof no one命令,那么从服务器在开始一次新的复制时将向主服务器发送psync ?-1 命令,主动请求主服务器进行完整重同步。
2. 若从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复制时将向主服务器发送 psync <runid>(上一次复制的主服务器运行id) <offset>(复制偏移量) 命令,接受到这个命令的主服务器会根据两个参数决定对从服务器进行那种同步操作。
接收到从服务器发送的命令请求的主服务器,会向从服务器回复下列其中一中:
1. 若主服务器回复 +FULLRESYNC <runid> <offset>,表示主服务器将与从服务器执行完整重同步操作,从服务器将offset作为自己的初始化复制偏移量。
2. 若主服务器回复 +CONTINUE,表示主服务器将与从服务器执行部分重同步操作,从服务器只需等待主服务器将自己缺少的那部分数据发过来即可。
3. 若主服务器回复 -ERR,表示主服务器版本低于redis2.8,识别不了psync命令,从服务器将向主服务器发送sync命令,并与主服务器执行完整重同步操作。
6. 复制的实现:
通过向从服务器发送slaveof命令,可使一个从服务器区复制一个主服务器。
1. 设置主服务器的地址和端口:
slaveof <主服务器ip地址> <主服务器端口>
从服务器首先将主服务器ip地址保存到服务器状态的char *masterhost;属性中,将主服务器端口保存到服务器状态的int masterport;属性。
slaveof是一个异步命令,在将两个属性保存完成后,从服务器向客户端回复ok,表示复制指令已经别接收,而实际的复制工作将在ok返回后才真正开始执行。
2. 建立套接字连接:
在slaveof命令执行后,从服务器会根据ip地址和端口,创建连向主服务器的套接字连接。
若从服务器创建的套接字可以成功连接到主服务器,那么从服务器将为这个套接字关联一个专门用于处理复制工作的文件时间处理器。这个处理器将负责执行后续的复制工作。
而主服务器在接受从服务器的套接字连接后,将为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端来看待。这时从服务器将同时具有客户端和服务器两个身份。
3. 发送ping命令:
从服务器成为主服务器的客户端后的第一件事就是,向主服务器发送一个ping命令,这个ping命令有两个作用:
1. 虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信,通过发送ping命令可以检查套接字的读写状态是否正常。
2. 因为复制工作接下来的步骤必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送ping命令可以检查主服务器能否正常处理命令请求。
从服务器在发送ping命令后将遇到三种情况之一:
1. 若主服务器向从服务器返回了一个命令回复,但从服务器却不能在规定时间内读取出命令回复的内容,那么表示主从服务器间的网络连接不佳,不能继续执行复制工作的后续步骤。此时,应从服务器断开并重新创建连向主服务器的套接字。
2. 若主服务器向从服务器返回一个错误,那么表示主服务器暂时没办法处理从服务器的命令请求,不能执行后续操作,此时,应从服务器断开并重新创建连向主服务器的套接字。
3. 若从服务器读取到pong回复,表示主从服务器之间的网络连接正常,且主服务器可以正常的处理从服务器发送的命令请求。此时,可以进行复制工作的下一步骤。
4. 身份验证:
从服务器在接收到主服务器回复的pong后,接下来做的是决定是否进行身份验证:
1. 若从服务器设置了masterauth选项,那么进行身份验证。
2. 若主服务器没有设置masterauth选项,那么不进行身份验证。
在需要身份验证时,从服务器将向主服务器发送一条auth命令,参数为从服务器masterauth的值。从服务器在身份验证可能遇到下列情况:
1. 若主服务器没有设置requirepass选项,且从服务器也没有设置masterauth选项,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行。
2. 若从服务器通过auth发送的密码和主服务器requirepass所设置的密码相同,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行。若不同,那么主服务器将返回一个invalid password错误。
3. 若主服务器设置了requirepass,从服务器没有设置masterauth,那么主从服务器将返回一个Noauth错误,反之主服务器返回一个no password is set错误。
所有的错误情况都会令从服务器终止目前的复制操作,并从创建套接字开始重新执行复制,知道身份验证通过,过着从服务器放弃执行复制。
5. 发送端口信息:
在身份验证之后,从服务器执行命令replconf listening-port <监听端口号>,向主服务器发送从服务器的监听端口号。
主服务器在接受到这个命令之后,将端口号记录咋从服务器所对应的客户端状态的int slave_listening_port属性中。
该属性目前唯一的作用就是在主服务器执行打印命令时,打印出从服务器的端口号。
6. 同步:
从服务器向主服务器发送psync命令,执行同步操作,并将自己的数据库更新至主服务器数据库状态。
在同步操作之前,只有从服务器是主服务器的客户端,但在执行同步操作之后,主服务器也会称为从服务器的客户端。
1. 若psync执行的是完整重同步,那么主服务器需要成为从服务器的客户端,才能将保存在缓冲区里的写命令发送给从服务器执行。
2. 若psync执行的部分重同步,那么主服务器需要称为从服务器的客户端,才能将保存在复制积压缓冲区里的写命令发送给从服务器执行。
所以,在同步操作之后,主从服务器双方都是对方的客户端,他们可以互相向对方发送命令请求,或互相向对方返回命令回复。
因为主服务器成为了从服务器的客户端,多以主服务器才能通过发送写命令来改变从服务器的数据库状态,不仅同步操作要用到这点,这依然是主服务器对从服务器进行命令传播的基础。
7. 命令传播:
当完成同步之后,主从服务器就会进入命令传播阶段,主服务器只要一直将自己写命令发送给从服务器,从服务器只要一直接受并执行发来的写命令,就可以保证主从服务器状态的一致。
以上就是redis2.8以上的复制功能的实现步骤。
7. 心跳检测:
在命令传播阶段,从服务器默认会以每s一次的频率,向主服务器发送命令replconf ack <从服务器当前的复制偏移量>。发送该命令对主从服务器有三个作用:
1. 检测主从服务器的网络连接状态。
若主服务器超过1s没有收到从服务器发来的replconf ack命令,那么主服务器就知道主从服务器之间的连接出现问题了。
2. 辅助实现min-slaves选项。
redis的min-slaves-to-write,min-slaves-max-lag选项可以防止主从服务器在不安全的情况下执行写命令。当从服务器数量小于write时,或write个从服务器的延迟大于lag时,主服务器将拒绝执行写命令。
3. 检测命令丢失。
若因网络故障,主服务器传播给从服务器的写命令在半路丢失。那么当从服务器向主服务器发送replcong ack命令时,主服务器就会发现从服务器的复制偏移量与自己的复制偏移量不一致,然后主服务器根据复制偏移量,
在复制积压缓冲区里找到从服务器缺失的数据,并将这些数据重新发送给从服务器。
注: 主服务器向从服务器补发缺失数据的操作的原理和部分重同步原理相似。两个操作区别在于:
补发缺失数据操作是在主从服务器没有断线的情况下执行的,而部分重同步操作是在主从服务器断线重连后执行的。
redis2.8之前的命令丢失。
replconf ack命令和复制积压缓冲区都是在redis2.8新增的,在其之前的版本,即使命令在传播过程中丢失,主从服务器都不会注意到,主服务器更不会向从服务器补发丢失数据,
所以为了保证复制时主从服务器的数据一致性,最好使用redis2.8以上的版本。
2. 哨兵
哨兵Sentinel是redis的高可用性解决方案: 由一个或多个哨兵实例组成的哨兵系统可以监视任意多个主服务器,以及这些主服务器下的所有从服务器,且在被监视的主服务器进入下线状态时,自动将下线主服务器下的某一服务器升级为新的主服务器,
然后由新的主服务器代替已下线的主服务器继续处理命令请求。
1. 启动并初始化Sentinel。
启动哨兵可以使用命令redis-sentinel /path/to/your/sentinel.conf,或redis-server /path/to/your/sentinel.conf --sentinel。
当一个Sentinel启动时,他要执行下列步骤:
1. 初始化服务器。
因为Sentinel本质上只是一个运行在特殊模式下的redis服务器,所以启动Sentinel的第一步,就是初始化一个普通的redis服务器。
因为Sentinel执行的工作和普通redis服务器执行的工作不同,所以Sentinel的初始化过程和普通redis服务器初始化过程并不完全相同。如哨兵并不使用数据库,所以初始化时不会载入aof、rdb文件。
下列为哨兵模式下redis服务器主要功能的使用情况:
| 数据库和键值对方面的命令 | 不使用 |
| 事务命令 | 不使用 |
| 脚本命令 | 不使用 |
| rdb持久化命令 | 不使用 |
| aof持久化命令 | 不使用 |
| 复制命令 | Sentinel内部可以使用,但客户端不可以使用 |
| 发布订阅命令 |
subscribe、psubscribe、punsubcribe、unsubscribe命令在Sentinel内部和客户端都可以使用, 但publish命令只能在Sentinel内部使用 |
| 文件事件处理器(负责发送命令请求、处理命令回复) | Sentinel内部使用,但关联的文件事件处理器和普通redis服务器不同 |
| 时间事件处理器(负责执行serverCron函数) |
Sentinel内部使用,时间处理器仍然是serverCron函数,serverCron函数内部会调用 sentinel.c/sentinelTimer函数,其中包含Sentinel要执行的所有操作 |
2. 将普通redis服务器使用的代码替换成Sentinel专用代码。
启动Sentinel的第二步是将一部分普通redis服务器使用的代码替换成Sentinel专用代码。
ping、sentinel、info、subscribe、unsubscribe、psubscrebe、punsubscribe七个命令是客户端可以对Sentinel执行的所有命令了。
3. 初始化Sentinel状态。
在应用Sentinel专用代码后,接下来服务器会初始化一个sentinel.c/sentinelState结构,该结构保存了服务器中所有和Sentinel功能有关的状态(服务器的一般状态仍由redis.c/redisServer结构保存)。
struct sentinelState { unit64_t current_epoch; //当前纪元,用于实现故障转移。 dict *masters; //保存所有被这个哨兵监听的主服务器,键为主服务器名字,值为一个指向sentinel.c/sentinelRedisInstance结构的指针。 int tilt; //是否进入tilt模式。 int running_scripts; //目前正在执行的脚本数量。 mstime_t title_start_time; //进入titl模式的时间。 mstime_t previous_time; //最后一次执行时间处理器的时间。 list *scripts_queue; //一个fifo队列,包含所有要执行的用户脚本。 }sentinel;
4. 根据给定的配置文件,初始化Sentinel的监视主服务器列表。
每个masters值指向的结构代表一个被Sentinel监视的redis服务器实例,这个实例可以是主服务器、从服务器、另一个Sentinel。
实例结构包含的属性众多,下列为部分属性:
typedef struct sentinelRedisInstance { int flag; //标识值,记录实例类型,实例当前状态 char *name; //实例名字,主服务器名字在用户设置的配置文件中,从服务器、哨兵名字由Sentinel自动设置 char *runid; //实例的运行id unit64_t config_epoch; //配置纪元,用于实现故障转移 sentinelAddr *addr; //实例的地址 mstime_t down_after_period; //实例无响应多久ms会被判断为主观下线 int quorum; //判断这个实例客观下线所需的支持投票票数 int parallel_syncs; //在执行故障转移时,可以同时对新的主服务器进行同步的从服务器数量 mstime_t failover_timeout; //刷新故障迁移状态的最大时限 ... ... };
addr属性是一个执行sentinel.c/sentinelAddr的指针,保存实例的ip地址char * ip,和实例的端口号int port。
对Sentinel状态的初始化将引发对masters字典的初始化,而masters字典的初始化是根据被载入的Sentinel配置文件进行的。
5. 创建连向主服务器的网络连接。
初始化Sentinel的最后一步是,创建连续被监视主服务器的网络连接,Sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关信息。
对于每个被Sentinel监视的主服务器,Sentinel会创建两个连向主服务器的异步网络连接:
1. 命令连接,用于向主服务器发送命令,接收命令回复。
2. 订阅连接,用于订阅主服务器的__sentinel__:hello频道。
为什么有两个连接?
在redis目前的发布和订阅功能中,被发送的信息都不会保存在redis服务器里面,如果在信息发送时,想要接收信息的客户端不在线或断线,那么这个客户端就会丢失这条信息,
因此为了不丢失__sentinel__:hello频道的任何信息,Sentinel必须专门用一个订阅连接来接收该频道的信息。
另一方面,除了订阅频道外,Sentinel还必须向主服务器发送命令,以此来与主服务器通信,所以Sentinel还必须向主服务器创建命令连接。
因为Sentinel需要与多个实例创建多个网络连接,所以Sentinel使用的是异步连接。
2. 获取主服务器信息。
Sentinel默认会以每10s一次的频率,通过命令连接向被监视的主服务器发送Info命令,并通过分析info命令的回复来获取主服务器的当前信息。
通过分析主服务器返回的info命令回复,Sentinel可以获取两方面信息:
1. 关于主服务器本身的信息。
2. 关于主服务器下所有从服务器的信息。
根据run_id域和role域的信息,Sentinel将对主服务器的实例进行更新。根据主服务器返回的从服务器信息,更新主服务器实例的slave字典,该字典记录了主服务器下所有从服务器。
字典键为从服务器名字,Sentinel根据信息自动设置,字典的值是从服务器的实例结构。
Sentinel在分析返回信息中包含的从服务器信息时,会检查从服务器对应的是咧结构是否已经存在于slave字典,若存在,进行更新,若不存在,在slave中为这从服务器创建一个实例结构。
主服务器实例结构和从服务器实例结构区别:
1. 主服务器实例结构的flag属性为SRI_MASTER,从服务器实例结构的flag属性为SRI_SLAVE。
2. 主服务器实例结构的name属性的值是用户使用Sentinel配置文件设置的,从服务器实例结构的name属性是Sentinel根据从服务器ip地址和端口号自动设置的。
3. 获取从服务器信息。
当Sentinel发现主服务器有新的从服务器出现时,Sentinel除了为其创建相应实例,还会创建连接到从服务器的命令连接和订阅连接。
根据info返回信息,Sentinel会提取出下列信息:
1. run_id,从服务器运行id。
2. role,从服务器角色。
3. master_host,主服务器ip地址,master_port,主服务器端口号。
4. master_link_status,主从服务器的连接状态。
5. slave_priority,从服务器的优先级。
6. slave_repl_offset,从服务器的复制偏移量。
4. 向主服务器和从服务器发送信息。
默认情况下,Sentinel会2s一次的,通过命令连接向所有被监视的主服务器和从服务器发送以下格式命令:
Publish __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"
这条命令向服务器的__sentinel__:hello频道发送了一条信息,s_开头的参数为Sentinel本身信息,m_开头的参数为主服务器的信息。
若Sentinel正在监视的是主服务器,那么参数就是主服务器的信息,若正在监视的是从服务器,那么参数就是从服务器的信息。
5. 接受来自主服务器和从服务器的频道信息。
当Sentinel与一个主服务器或从服务器建立起订阅连接后,Sentinel会通过订阅连接,向服务器发送以下命令:
SUBSCRIBE __sentinel__:hello
Sentinel对该频道的订阅会一直持续到Sentinel与服务器的连接断开为止。
也就是说,对于每个和Sentinel连接的服务器,Sentinel既通过命令连接向服务器的__sentinel__:hello频道发送消息,还通过订阅连接从服务器的__sentinel__:hello频道接收信息。
对于监视同一个服务器的多个Sentinel,一个Sentinel发送的信息会被其他所有Sentinel接收到,这些信息会被用于更新其他Sentinel对发送信息Sentinel的认知,也会被用于更新其他Sentinel对监视服务器的认知。
当一个Sentinel从__sentinel__:hello收到一条信息时,会对这条信息进行分析,提取出ip、端口号等8个参数,并检查:
1. 若信息中的Sentinel运行id和接受信息的Sentinel相同,则为自己发送的,就丢弃这条信息,不做进一步处理。
2. 若信息中的Sentinel运行id和接受信息的Sentinel不同,则是监视同一服务器的其他Sentinel发送的,就会根据各个参数,对相应主服务器的实例结构进行更新。
1. 更新sentinels字典:
Sentinel为主服务器创建的实例结构中的sentinels字典保存了除本身Sentinel外的所有监视该主服务器的其他Sentinel的资料。
sentinels字典的键为其他Sentinel的名字,值为键所对应的sentinel实例。
当目标Sentinel接收到源Sentinel发送的信息时,目标Sentinel会从信息中分析并提取出下列两方面参数:
1. 与Sentinel有关的参数:源Sentinel的ip地址、端口号、运行id、配置纪元。
2. 与主服务器有关的参数:源Sentinel正在监视的主服务器的名字、ip地址、端口号、配置纪元。
根据信息中提取出的主服务器参数,目标Sentinel会在自己的Sentinel状态的masters字典中查找相应的主服务器实例结构,
然后根据信息中提取出的Sentinel参数,目标Sentinel检查主服务器实例结构的Sentinels字典中,源Sentinel实例结构是否存在。若存在,那么对源Sentinel实例结构进行更新。若不存在,目标Sentinel会为源Sentinel创建一个新的实例结构,将其添加到sentinels字典里。
2. 创建连向其他sentinel的命令连接。
当Sentinel通过频道信息发现一个新的Sentinel时,不仅会为新Sentinel在sentinels字典里创建相应实例,还会创建一个连向新Senteinel的命令连接,而新Sentinel也会创建连向这个Sentinel的命令连接。最终监视同一主服务器的多个Sentinel将形成相互连接的网络。
Sentinel之间不会创建订阅连接
Sentinel在连接主服务器或从服务器时,会同时创建命令连接和订阅连接,但在连接其他Sentinel时,只会创建命令连接。
是因为Sentinel需要通过接收主服务器或从服务器发送来的频道信息来发现未知的新Sentinel,所以才需要创建订阅连接。
而相互已知的Sentinel只要使用命令连接来进行通信就可以了。
6. 检测主观下线状态。
默认情况下,Sentinel会以每1s一次的频率向所有与它创建了命令连接的实例(主服务器、从服务器、其他Sentinel)发送ping命令,并通过实例回复来判断实例是否在线。
实例对ping命令的回复可分为以下两种情况:
1. 有效回复,实例返回+pong,-loading,-masterdown三种一种。
2. 无效回复,实例返回除上述三种外的其他回复,或在指定时间内没有返回任何回复。
Sentinel配置文件的down-after-milliseconds选项指定了Sentinel判断实例主观下线的时间长度,若实例在指定时间内没有返回有效回复,Sentinel会修改这个实例的实例结构,将在结构的flag属性中打开SRI_S_DOWN标识,表示该实例已经进入主观下线状态。
主观下线时长选项的作用范围
Sentinel不仅以此判断主服务器的主观下线状态,还会用于判断主服务器下的所有从服务器的主观下线状态,及所有监视这个主服务器的其他Sentinel的主观下线状态。
对于监视同一个主服务器的多个Sentinel,这些Sentinel所设置的主观下线时长可能不同,所以,当一个Sentinel认为主服务器主观下线时,会有其他Sentinel仍认为主服务器处于在线状态。
7. 检查客观下线状态。
当Sentinel将一个主服务器判断主观下线后,为了确认这个主服务器是否真的下线,他会向同样监视主服务器的其他Sentinel进行询问,看他们是否也认为主服务器进入了下线状态(主观、客观都可),
当同意数量足够时,Sentinel会将主服务器判定为客观下线,并对主服务器进行故障转移操作。
1. 发送sentinel is-master-down-by-addr命令:
sentinel is-master-down-by-add <ip>(被其认为主观下线的主服务器的ip地址) <port>(主服务器端口号) <current_epoch>(Sentinel配置纪元,用于选举领头Sentinel) <runid>(*(代表命令仅检测客观下线)/Sentinel的运行id(用于选举领头Sentinel))。
2. 接收sentinel is-master-down-by-addr命令:
当目标Sentinel接收到源Sentinel发送的该命令后,会分析并取出命令请求的各个参数,根据其中的服务器ip和端口号,检查主服务器是否下线,然后向源Sentinel回复一条包含三个参数的Multi Bulk回复。
1. <down_state> 返回对主服务器的检查结果,1下线,0未下线。
2. <leader_runid> */目标Sentinel的局部领头Sentinel的运行id,*表示命令仅用于检测服务器下线状态,局部领头Sentinel的运行id用于选举领头Sentinel。
3. <leader_epoch> 目标Sentinel的局部领头Sentinel的配置纪元,用于选举领头Sentinel,若2.为*,该值总为0。
3. 接收sentinel is-master-down-by-addr命令的回复:
根据其他Sentinel的该命令回复,Sentinel统计同意主服务器下线的数量,当数量达到配置指定客观下线所需数量时,Sentinel会将主服务器实例结构的flag属性的SRI_O_DOWN标识打开,表示主服务器已经进入客观下线状态。
不同Sentinel判断客观下线的条件可能不同
对于监视同一主服务器的多个Sentinel,他们将主服务器判断为客观下线的数量条件可能不同,当一Sentinel认为主服务器已经客观下线时,可能有其他Sentinel认为主服务器没有客观下线。
8. 选举领头Sentinel。
当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头的Sentinel,并由其对下线主服务器执行故障转移操作。
redis选举领头Sentinel的规则方法:
1. 所有在线的Sentinel都有被选为领头Sentinel的资格。
2. 每次进入领头Sentinel选举之后,不论选举是否成功,所有Sentinel的配置纪元+1,及配置纪元其实是一个计数器。
3. 在一个配置纪元里,所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,且局部领头一旦设置,在这个配置纪元里就不能再更改了。
4. 每个发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头Sentinel。
5. 当源Sentinel向目标Sentinel发送sentinel is-master-down-by-addr命令,且命令中的run_id为自身运行id,表示源Sentinel要求目标Sentinel将源Sentinel设置为自己的局部领头Sentinel。
6. Sentinel设置局部领头Sentinel是先到先得的,最先向目标Sentinel发送的源Sentinel将成为其的局部领头Sentinel。之后目标Sentinel接收到的设置请求都会被拒绝。
7. 目标Sentinel接收到上面命令后,向源Sentinel回复,回复中的leader_runid、leader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行id和配置纪元。
8. 源Sentinel在接收到目标Sentinel回复后,检查回复中的配置纪元和自己的是否相同,若相同,继续检查运行id是否相同,若一致,表示目标Sentinel将自己设置为了其局部领头Sentinel。
9. 若有某个Sentinel被半数以上的Sentinel设置为了局部领头,则其称为了领头Sentinel。
10. 因为需要半数以上,所以领头Sentinel只会有一个。
11. 若在给定时间内,没有一个Sentinel被选举为领头Sentinel,则各个Sentinel将在一段时间后再次进行选举,知道选出为止。
选举出后,该领头Sentinel就可以开始对主服务器进行故障转移操作。
9. 故障转移。
在选举出领头Sentinel后,领头Sentinel将对已下线的主服务器执行故障转移操作:
1. 在已下线的主服务器下的从服务器里,选出一个从服务器,并将其转换为主服务器。
2. 让已下线的主服务器下的所有从服务器改为复制新的主服务器。
3.将已下线的主服务器设置为新的主服务器的从服务器,当这个旧的从服务器重新上线时,它就会成为新的主服务器的从服务器。
1. 选出新的主服务器:
故障转移第一步是在已下线的主服务器的所有从服务器里,选出一个状态良好、数据完整的从服务器,并向这个从服务器发送slaveof no one命令,将这个从服务器转换为主服务器。
新的主服务器是如何挑选的
领头Sentinel会将已下线的主服务器的所有从服务器保存到一个列表里,然后按照以下规则,一项一项对列表过滤:
1. 删除列表中所有处于下线或断线状态的从服务器,保证列表中剩余的从服务器都是正常在线的。
2. 删除列表中所有最近5s内没有回复过领头Sentinel的info命令的从服务器,保证列表中剩余的从服务器都是最近成功进行过通信的。
3. 删除所有与已下线主服务器连接断开超过down-after-milliseconds * 10ms的从服务器,保证列表中剩余从服务器都没有过早的和主服务器断开连接。即列表中剩余的从服务器保存的数据都是比较新的。
之后,领头Sentinel会根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。
若有多个具有相同最高优先级的从服务器,那么领头Sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的从服务器进行排序,并选出其中偏移量最大的从服务器,因为这个从服务器保存着最新数据。
最后,若有多个优先级最高、复制偏移量最大的从服务器,那么领头Sentinel将按照运行id对这些从服务器进行排序,并选出其中运行id最小的从服务器。
在发送slaveof no one命令之后,领头Sentinel会以每秒一次(原为10s一次)的频率,向被升级的从服务器发送info命令,并观察命令回复中的角色信息,当被升级服务器的role从原来的slave变为master时,领头Sentinel知道被选中的从服务器已被升级为主服务器。
2. 修改从服务器的复制目标
当新的主服务器出现后,领头Senetinel下一步要让已下线主服务器下的所有从服务器区复制新的主服务器,这一动作通过向从服务器发送slaveof命令来实现。
3. 将旧的主服务器变为从服务器
故障转移的最后一步,要将已下线的主服务器设置为新的主服务器的从服务器。
因为旧的主服务器已下线,所以这种设置是保存在旧主服务器的实例结构里面的,当旧主服务器重新上线时,Sentinel就会向它发送slaveof命令,让它成为新主服务器的从服务器。
3. 集群
redis集群是redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。
1. 节点
一个redis集群通常由多个节点组成,起初每个节点都是相互独立的,他们都处于一个只包含自己的集群中,组建一个集群,须将各个独立的节点连接起来,构成一个包含多个节点的集群。
连接节点的工作可以使用cluster meet命令实现:
cluster meet <ip> <port>
向一个节点发送cluster meet命令,可以让节点与ip,port所指定的节点进行握手,当握手成功时,节点将ip,port所指定的节点添加到节点当前的集群中。
1. 启动节点:
一个节点就是一个运行在集群模式下的redis服务器,redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启集群模式。
1. yes,开启服务器的集群模式,成为一个节点。
2. no,开启服务器单机模式,成为一个普通的redis服务器。
节点会继续使用所有在单机模式中使用的服务器组件。至于那么只有在集群模式下才会用到的数据,节点将他们保存到了cluster.h/clusterNode结构、cluster.h/clusterLink结构、cluster.h/clusterState结构里面。
2. 集群数据结构:
clusterNode结构保存了一个节点的当前状态。
每个节点都会用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点(主节点、从节点)都创建一个相应的clusterNode结构,以此来记录其他节点状态。
struct clusterNode { mstime_t ctime; //创建节点的时间 char name[REDIS_CLUSTER_NAMELEN]; //节点的名字,由40个十六进制字符组成 int flags; //节点标识,使用各种不同的标识值记录节点的角色(主从节点),以及节点目前所处的状态(在线下线) uint64_t configEpoch; //节点的配置纪元,用于实现故障转移 char ip[REDIS_IP_STR_LEN]; //节点的ip地址 int port; //节点的端口号 clusterLink *link; //保存连接节点所需的有关信息 ... ... };
clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息。如套接字、输入输出缓冲区:
typedef struct clusterLink { mstime_t ctime; //连接创建时间 int fd; //tcp套接字描述符 sds sndbuf; //输出缓冲区,保存着等待发送给其他节点的消息 sds rcvbuf; //输入缓冲区,保存着从其他节点接收到的消息。 struct clusterNode *node; //与这个连接相关联的节点,若没有就NULL }clusterLink;
redisClient结构和clusterLink结构的相同、不同。
redisClient结构和clusterLink结构都有自己的套接字描述符和输入、输出缓冲区。
两个结构的区别在于,redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字和缓冲区使用于连接节点的。
最后,每个节点都保存这一个clusterState结构,该结构记录了当前节点的视角下,集群目前所处的状态。
typedef struct clusterState { clusterNode *myself; //指向当前节点的指针 uint64_t currentEpoch; //集群当前的配置纪元,用于实现故障转移 int state; //集群当前的状态(在线下线) int size; //集群中至少处理着一个槽的节点的数量 dict *nodes; //集群节点名单包括自己,字典键为节点的名字,字典的值为节点对应的ClusterNode结构。 ... ... }clusterState;
3. cluster meet命令的实现:
通过向节点A发送cluster meet命令,客户端可以让接收命令的节点A将另一个节点B添加到节点A当前所在的集群里面。
收到命令的节点A将与节点B进行握手,以此来确认彼此的存在,并为将来的进一步通信做基础:
1. 节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.node字典里面。
2. 节点A将根据cluster meet命令给定的ip地址和端口号,向节点B发送一条meet消息。
3. 若顺利,节点B将接收到节点A发送的meet消息,节点B会为节点A创建一个clusterNode结构,并将该结构添加到自己的clusterState.node字典里面。
4. 之后,节点B向节点A返回一条pong消息。
5. 若顺利,节点A会收到节点B返回的消息,通过这条消息,节点A可知道节点B成功接收到自己发送的meet消息。
6. 之后,节点A将向节点B返回一条ping消息。
7. 若顺利,节点B将接收到节点A返回的ping消息,通过这条消息,节点B可以知道节点A已经成功接收到自己返回的pong消息,握手完成。
之后,节点A将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节点也与节点B进行握手,最终,节点B会被集群中的所有节点认识。
2. 槽指派
redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的一个,集群中的每个节点可以处理0/最多16384个槽。
当数据库中的16384个槽都有节点在处理时,集群处于上线状态,若数据库中有任何一个槽没有得到处理,那么集群处于下线状态。
通过向节点发送cluster addslots命令,可以将一个/多个槽指派给节点负责。
1. 记录节点的槽指派信息:
clusterNode结构slots属性和numslot属性记录了节点负责处理的哪些值。
struct clusterNode { unsigned char slots[16384/8]; //二进制数组,2048个字节。 int numslots; //节点负责处理的槽的数量 };
redis以索引0~16384,对slots数组中16384个二进制位进行编号,根据索引i上的二进制位的值判断节点是否负责处理槽i。若值为1,表示节点负责处理槽i,若为0,表示节点不负责处理槽i。
因为取出和设置slots数组中任意二进制位的值的复杂度仅为O(1),所以对于一个给定节点的slots数组来说,程序检查节点是否负责处理某个槽、或将某个槽指派给节点负责,这两个操作的负责度都为O(1)。
2. 传播节点的槽指派信息:
一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性外,还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。
当节点A通过消息从节点B那里接收到B的slots数组时,会在自己的clusterState.nodes字典里查找到节点B对应的clusterNode结构,并对结构里的slots数组进行保存/更新。
因为集群中的每一个节点都会将自己的slots数组通过消息发送给集群中的其他节点,并且接收到slots数组的节点都会将数组保存到相应节点的clusterNode结构里面。所以,集群中的每个节点都会知道数据库中的每一个槽分别被指派给了哪个节点。
3. 记录集群所有槽的指派信息:
clusterState结构中的clusterNode *slots[16384]数组记录了集群里所有16384个槽的指派信息。
数组的每一项都指向一个clusterNode结构。若slots[i]指向NULL,表示i槽没有被指派给任何节点。
若只将槽指派信息保存在各个节点的clusterNode.slots数组里,会出现一些无法高效解决的问题,而clusterState.slots数组的存在解决了这些问题:
1. 若节点只使用clusterNode.slots数组来记录槽的指派信息,那么为了知道槽i是否被指派、或槽i被指派给了哪个节点,程序须遍历clusterState.node字典中所有的clusterNode结构,检查结构的slots数组。直到找到负责处理槽I的节点为止。复杂度为O(N)。
而通过,clusterState.slots数组保存了槽的指派信息,那么进行上两操作的复杂度为O(1)。
虽然clusterState.slots数组记录了集群中所有槽的指派信息,但使用clusterNode.slots数组记录单个节点的槽指派信息仍很有必要:
1. 当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需将相应节点的clusterNode.slots数组整个发送出去就可。
若redis不使用clusterNode.slots数组,那么每次要将节点A的槽指派信息传播给其他节点时,程序须遍历整个clusterState.slots数组,是麻烦低效的。
4. cluster addslots命令的实现:
cluster addslots命令接收一个或多个槽为参数,将所有输入的槽指派给接收该命令的节点负责:
cluster addslots <slot>[slot ...],实现过程:
遍历所有槽,检查他们是否都是未指派槽:
若有一个槽已经被指派给了节点
客户端返回错误,并终止命令执行
若所有输入槽都是未指派槽:
再次遍历所有槽,将这些槽指派给当前节点
3. 在集群中执行命令
在对数据库的16384个槽都进行了指派后,集群就进入了上线状态。这时客户端就可以向集群中的节点发送数据命令了。
当客户端向节点发送数据库有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查该槽是否指派给了自己。若指派给了自己,节点直接执行这个命令。若没有,节点会向客户端返回一个MOVED错误,指引客户端转向正确的客户端,再次发送之前的命令。
1. 键属于哪个槽:
节点使用以下算法计算给定键属于哪个槽:
return CRC16(key) & 16384 ,CRC16()用于计算key的CRC-16校验和,&16384用于计算出一个介于0~16384的整数作为键的槽号。
cluster keyslot <key> 命令可以查看一个给定键的槽号。
2. 判断槽是否由当前节点负责处理:
当节点计算出键所属的槽i之后,节点就会检查clusterState.slots数组中的i项是否为自己,若为自己,节点就可执行客户端发送的命令,若不为自己,节点根据i项所记录的节点Ip和端口号,向客户端返回moved错误,指引客户端转向至相应节点。
3. MOVED错误:
当节点发现键所在的槽不是自己时,就会向客户端返回一个moved错误,指引客户端转向相应节点。
move <slot> <ip>:<port> ,当客户端接收到moved错误时,客户端会根据moved提供的ip和端口号,转向相应节点,并向该节点重新发送要执行的命令。
一个集群客户端通常会与集群的多个节点创建套接字连接,而所谓的节点转向实际是换了一个套接字来发送命令。
若客户端与要转向的节点没有创建套接字连接,那么客户端会先根据moved错误提供的ip地址和端口号来连接节点,然后再进行转向。
被隐藏的moved错误
集群模式的redis-cli客户端在接收到moved时,并不会打印出moved错误,而是根据错误自动进行节点转向,并打印出转向信息,所以并不能看见节点返回的moved错误。
但,若我们使用单机模式下的redis-cli客户端,再次向节点发送命令,那么moved错误就会被客户端打印出来,
因为,单机模式的redis-cli客户端并不清楚moved错误的作用,所以只会直接将moved错误直接打印出来,而不会自动转向。
4. 节点数据库的实现:
节点和单机服务器在数据库的一个区别为,节点只能使用0号数据库,但单机redis服务器没有这一限制。
除了将键值对保存在数据库里外,节点还用clusterState中的zskipliat *slots_to_keys跳跃表来保存槽和键之间的关系。
跳跃表每个节点的分值是一个槽号,每个节点的成员都是一个数据库键。
每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到slots_to_keys跳跃表。
当节点删除数据库中的某个键值对时,节点就会在slots_to_keys跳跃表中删除槽号和键的关联。
通过该跳跃表,节点可以很方便的对属于某个/某些槽的数据库键进行批量操作。
4. 重新分片
redis集群的重新分片操作可以将任意数量已经被指派的槽改为指派给另一个节点,且相关槽所属的键值对也会从源节点被移动到目标节点。
重新分片可以在线进行,在重新分片的过程中,集群不需要下线,且源节点和目标节点都可以继续处理命令请求。
重新分片的实现原理:
redis集群的重新分片是由redis的集群管理软件redis-trib负责执行的,redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片。
redis-trib对集群的单个槽slot进行重新分片的步骤:
1. redis-trib对目标节点发送cluster setslot <slot> importing <source_id>,让目标节点准备好从源节点导入属于槽slot的键值对。
2. redis-trib对源节点发送cluster serslot <slot> migrating <target_id>命令,让源节点准备好将属于槽slot的键值对迁移到目标节点。
3. redis-trib向源节点发送cluster getkeysinslot <slot> <count>命令,获得最多cpunt个属于槽slot的键值对的键名。
4. 对于3.获得的键名,redis-trib向源节点发送一个migrate <target_ip> <target_port> <key_name> 0 <timeout>命令,将被选中的键原子的从源节点迁移至目标节点。
5. 重复3.4.,直至源节点保存的所有属于槽slot的键值对都被迁移到目标节点为止。
6. redis-trib向集群中的任意一个节点发送cluster setslot <slot> node <target_id>命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中所有节点都会知道槽slot已经指派给了目标节点。
若重新分片涉及多个槽,那么redis-trib将对每个给定的槽进行上述步骤。
5. ASK错误
在重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现: 属于被迁移槽的一部分键值对保存在源节点里面,另一部分键保存在目标节点里面。
当客户端向源节点发送一个与数据库键有关的命令,且命令要处理的数据库键恰好就属于正在被迁移的槽时:
1. 源节点会先在自己的数据库里面查找指定的键,若找到,就直接执行客户端发送的命令。
2. 若源节点在自己的数据库里面没有查找到指定的键,那么这个键可能被迁移到了目标节点,源节点则向客户端返回一个ask错误,指引客户端转向正在导入槽的目标节点,并再次发送想要执行的命令。
被隐藏的ask错误
和接到moved错误时类似,集群模式的redis-cli在接到ask错误时也不会打印错误,而是自动根据错误提供的ip地址和端口进行转向动作。
若想看到节点发送的ask错误,可在单机模式下。
1. cluster setslot importing命令的实现:
clusterState结构的clusterNode *importing_slots_from[16384],数组记录了当前节点正在从其他结点导入的槽。
若importing_slots_from[i]不为NULL,而是指向一个clusterNode结构,表示当前节点正从clusterNode所代表节点导入槽i。
在对集群重新分片时,向目标节点发送命令cluster setslot <i> importing <source_id>,可已将目标节点的importing_slots_from[i]的值设置为source_id所代表的节点的clusterNode结构。
2. cluster setslot migrating命令的实现:
clusterState结构的clusterNode *migrating_slots_to[16384],数组记录了当前结点正在迁移至其他节点的槽。
若migrating_slots_to[i]不为NULL,而是指向一个clusterNode结构,表示当前节点正将槽i迁移至clusterNode所代表的节点。
在对集群重新分片时,向源节点发送命令cluster setslot <i> migrating <target_id>,可将源节点的migrating_slots_to[i]的值设置为target_id所代表节点的clusterNode结构。
3. ask错误:
若节点收到一个关于键k的命令请求,且键k所属的槽i恰好指派给了这个节点,那么节点会尝试在自己的数据库中查找键k,若找到,节点直接执行客户端发送的命令,
若没有找到,那么节点会检查自己的migrating_slots_to[i],看槽i是否正在进行迁移,若槽i正在迁移,那么节点会向客户端发送一个ask错误,指引客户端到正在导入槽i的节点去查找键k。
接到ask错误的客户端,根据错误提供的ip地址和端口,转到相应节点,然后首先向目标节点发送一个asking命令,之后再重新发送原本想要执行的命令。
4. asking命令:
asking命令唯一要做的是,打开发送改名了的客户端的REDIS_ASKING标识,即client.flags |= REDIS_ASKING。
一般情况下,若客户端向节点发送一个关于槽i的命令,而槽i又没有指派给这个节点的话,那么节点将向客户端返回一个moved错误。但,若节点的importing_slots_from[i]显示节点正在导入槽i,且发送命令的客户端带有REDIS_ASKING标识,那么节点破例执行这个命令一次。
当客户端接收到ask错误并转向正在导入的槽的节点时,客户端会先向节点发送一个asking请求,然后才重新发送要执行的命令。
这是因为,客户端若不发送asking请求,而直接发送命令,那么客户端发送的命令会被节点拒绝执行,并返回moved错误。
注,客户端的REDIS_ASKING标识是一个一次性标识,当节点执行了一个带有REDIS_ASKING标识的客户端发送的命令后,客户端的REDIS_ASKING标识就会被移除。
5. ask错误和moved错误的区别:
ask错误和moved错误都会导致客户端转向,区别在于:
1. moved错误代表槽的负责权已经从一个节点转移到另一个节点了。
2. ask错误只是两个节点在迁移槽的过程中,使用的一种临时措施。
6. 复制和故障转移
redis集群中的节点分为主节点和从节点,其中主节点用于处理槽,从节点则用于复制某个主节点,并在被复制主节点下线时,代替下线主节继续处理命令请求。
1. 设置从节点:
向一个节点发送命令cluster replicate <node_id>,可以让接收命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制。
1. 接收到该命令的节点首先会在自己的clusterState.node字典里找到node_id所对应节点的clusterNode结构,并将自己的clusterState.myself.slaveof指针指向这个结构,以此记录这个节点正在复制的主节点。
2. 然后节点会修改自己在clusterState.myself.flags中的属性,关闭原本的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识,表示这个节点已经从主节点变为了从节点。
3. 最后,节点会调用复制代码,并根据clusterState.myself.slaveof指向的clusterNode结构所保存的ip地址和端口号,对主节点进行复制。
因为节点的复制功能和单机redis服务器的复制功能使用了相同代码,所有从节点复制主节点相当于向从节点发送命令slaveof <master_ip> <master_port>。
一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点。
集群中的所有节点都会在代表主节点的clusterNode结构的int numslaves;和struct clusterNode **slaves;属性中记录正在复制这个主节点的从节点名单。
2. 故障检测:
集群中的每个节点都会定期的向集群中的其他节点发送ping消息,以此来检测对方是否在线,若接收ping消息的节点没有在规定时间内,向发送ping消息的节点返回pong消息,那么发送ping消息的节点就会将接收ping消息的节点标记为疑似下线pfail。
集群里的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息。
当一个主节点A通过消息得知主节点B认为主节点C进入了疑似下线状态时,主节点A会在自己的clusterState.nodes字典中找到主节点C所对应的clusterNode结构,并将主节点B的下线报告添加到clusterNode结构的fail_reports链表里。
每个下线报告由一个clusterNodeFailReport结构表示,
struct clusterNode *node; 报告目标节点已经下线的节点。
mstime_t time; 最后一次从node节点收到下线报告的时间,程序使用其来检查下线报告是否过期,与当前时间相差太久的下线报告会被删除。
若一个集群里,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线状态,那么主节点x被标记为已下线状态。将主节点x标记为已下线的节点会向集群广播一条关于主节点x的fail消息,所有收到消息的节点都立即将主节点x标记为已下线。
3. 故障转移:
当一个从节点发现自己正在复制的主节点进入了已下线的状态时,从节点将开始对下线主节点进行故障转移,步骤如下:
1. 复制下线主节点的所有从节点里面,会有一个从节点被选中。
2. 被选中的从节点会执行slaveof no one命令,成为新的主节点。
3. 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
4. 新的主节点向集群广播一条pong消息,这条pong消息可以让集群中的其他节点立即知道这个节点已经从从节点变成了主节点,且这个主节点已经接管了原本由已下线节点负责处理的槽。
5. 新的主节点开始接收和自己腐恶处理的槽有关的命令,故障转移完成。
4. 选举新的主节点:
新的主节点是通过选举产生的,步骤:
1. 集群的配置纪元是一个自增计数器,它的初始值为0。
2. 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会+1。
3. 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,第一个向主节点要求投票的从节点将获得主节点的投票。
4. 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条clustermsg_type_fallover_request消息,要求所有收到消息、具有投票权的主节点向这个节点投票。
5. 若一个主节点具有投票权,且该节点没有投票那么主节点向从节点返回一条clustermsg_type_fallover_auth_ack消息,表示主节点支持从节点称为新的主节点。
6. 每个参与选举的从节点都会接收clustermsg_type_fallover_auth_ack消息,并根据自己收到了多少个这种消息来统计自己获得了多少个主节点支持。
7. 若集群里有N个具有投票权的主节点,当一个从节点获得半数以上的支持时,这个节点就会当选为新的主节点。
8. 若在一个配置纪元里没有一个从节点获得半数以上的支持,那么集群进入一个新的配置纪元,并再次进行选举,知道选举出新的主节点为止。
该选举与sentinel选举相似,都使用基于raft算法的领头选举方法来实现。
7. 消息
集群中的各个节点通过发送和接收消息来进行通信,节点发送的消息主要有5中:
1. meet消息: 当发送者接收到客户端发送的cluster meet命令时,发送者会向接收者发送meet消息,请求接收者加入到发送者当前所处的集群里面。
2. ping消息: 集群里的每个结点默认每隔1s就会从已知节点列表随机选出5个节点,然后对这5个节点中最长时间没有发送过ping消息的节点发送ping消息,以此来检测被选中的节点是否在线。
除此之外,若节点A最后一次收到结点B发送的pong消息时间,距当前时间超过了结点A的cluster-node-timeout的一半时,节点A也会向节点B发送ping消息,防止节点A因长时间没有随机选中节点B作为ping消息的发送对象,而导致对节点B的信息更新滞后。
3. pong消息: 当接收者收到发送者发来的meet消息、ping消息时,为了向发送者确认这条消息已到达,接收者会向发送者返回一条pong消息。
另外,一个节点也可以通过向集群广播自己的pong消息来让集群中其他节点立即刷新关于这个节点的认知。
4.fail消息: 当一个主节点A判断另一个主节点B已经进入了fail状态时,节点A会向集群广播一条关于节点B的fail消息,所有接收到消息的节点都会立即将节点B标记为已下线。
5.publish消息: 当节点接收到一个publish命令时,节点会执行这个命令,并向集群广播一条publish消息,所有接收到这条publish消息的节点都会执行相同的publish命令。
一条消息由消息头和消息正文组成。
1. 消息头:
2. meet、ping、pong消息的实现:
3. fail命令的实现:
4. publish消息的实现:
浙公网安备 33010602011771号