数据结构与对象
简单动态字符串
Simple Dynamic String
struct sdshdr {
//记录buf数组中已使用字节的数量
//等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用的字节的数量
int free;
//字节数组,用于保存字符串
char[] buf;
}
SDS与C字符串的区别
- 常数复杂度获取字符串长度
- 杜绝缓冲区溢出
- 减少修改字符串时带来的内存重分配次数
- 空间预分配
- 如果对SDS修改之后,SDS长度将小于1MB,则程序分配和len属性同样大小的未使用空间。
- 如果修改之后,SDS长度大于等于1MB,则分配1MB的未使用空间
- 惰性空间释放
当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用
- 空间预分配
- 二进制安全
SDS的API都会以二进制的方式来处理buf数组里面的数据,程序不会对其中的数据做任何限制、过滤或者假设,数据在写入时是怎么样的,他被读取时就是什么样。 - 兼容部分C字符串函数
链表
typedef struct listNode{
//前置节点
struct listNode* prev;
//后置节点
struct listNode* next;
//节点的值
//void的字面意思是“无类型”,void *则为“无类型指针”,void *可以指向任何类型的数据。
void *value;
}listNode
typedef struct list {
//表头节点
listNode* head;
//表尾节点
listNode* tail;
//链表锁包含的节点数量
unsigned long len;
//节点值复制函数
void *(*dup)(void* ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值对比函数
int (*match)(void *ptr, void *key);
}list;
特性
双端、无环、带表头指针和表尾指针、带链表长度计数器、多态
字典
哈希表
typedef struct dicht {
//哈希表数组
dicEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值,总是等于size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
} dictht;
哈希表节点
typedef struct dictEntry {
//键
void *key;
//值
union {
void *val;
uint64_tu64;
int64_ts64;
}v;
//指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
字典
typedef struct dict {
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash索引,当rehash不在进行时,值为-1
int trehashidx;
}dict;
typedef struct dictType {
//计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
//复制键的函数
void *(*keyDup)(void *privdata, const void *key);
//复制值的函数
int (*keyCompare)(void *privdata, void *key);
//对比键的函数
void (*valDestructor)(void *privdata, void *key);
//销毁键的函数
void (*keyDestructor)(void *privdata, void *obj));
//销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
}dictType;
解决键的冲突
和java中的HashMap一样,不过redis使用的是头插法
rehash
redis对字典的哈希表执行rehash的步骤如下
- 为字典ht[1]哈希表分配空间,空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(ht[0].used属性的值)
- 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2的n次方幂
- 如果执行的时收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次方幂
- 将保存在ht[0]中的所有键值对都rehash到ht[1]上面
- 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变成空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备
哈希表的扩展与收缩
负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作
- 服务目前没有在执行BGSAVE指令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
- 服务目前正在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
根据BGSAVE指令或者BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE指令或者BGREWRITEAOF命令的过程中,redis需要创建当前服务器进程的子进程而大多数操作系统都采用写时复制技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能的避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存
另一方面,当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
渐进式rehash
为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehahs到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]
以下是哈希表渐进式rehash的详细步骤
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
- 在字典中维持一个索引计数器变量rehashidx,并将他设置为0,表示rehash工作正式开始
- 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehahs工作完成之后,程序将rehahsidx属性的值增一
- 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash到ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成
渐进式rehash的好处在于他采取分而治之的方式,将rehash键值对所需的计算工作均摊到字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehahs而带来的庞大计算量
渐进式rehash执行期间的哈希表操作
因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除、查找、更新等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会现在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找
另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表
跳跃表
skiplist是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的
redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构,除此之外,跳跃表在redis里面没有其他用途
跳跃表节点
typedef struct zskiplistNode{
//层
struct zskiplistLevel {
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
//后退指针
struct zskiplistNode* backward;
//分值
double score;
//成员对象
robj *obj;
} zskiplistNode;
-
层
跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他接地那的速度就越快每次创建一个新跳跃表及节点的时候,程序都根据幂次定理随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”
-
前进指针
每个层都有一个指向表尾方法的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。
-
跨度
层的跨度(level[i].span属性)用于记录两个节点之间的距离
- 两个节点之间的跨度越大,他们相距的就越远
- 指向null的所有区前进指针的跨度都为0,以为他们没有连向任何节点
-
后退指针
节点的后退指针(bacward属性)用于从表尾向表头方向访问节点;跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
-
分值和成员
节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序
节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分支却可以是相同的。分值相同的节点将按照成员对象在字典中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)
跳跃表
仅靠多个跳跃表节点就可以组成一个跳跃表
但通过使用一个zskilist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速的获取跳跃表节点的数量等信息
typedef struct zskiplist {
//表头节点和表尾节点
struct skiplistNode *header, *tail;
//表中节点的数量
unsigned long length;
//表中层数最大的节点的层数
int level;
} zskiplist;
整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,redis就会使用整数集合作为集合键的底层实现
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
//contents数组是整数集合的底层实现;整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项
encoding的取值,决定了contents数组的真正的类型
#define INTSET_ENC_INT16 (sizeof(int16_t)) //16位,2个字节,表示范围-32,768~32,767
#define INTSET_ENC_INT32 (sizeof(int32_t)) //32位,4个字节,表示范围-2,147,483,648~2,147,483,647
#define INTSET_ENC_INT64 (sizeof(int64_t)) //64位,8个字节,表示范围-9,223,372,036,854,775,808~9,223,372,036,854,775,807
升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面
升级整数集合并添加元素共分为三步
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变
- 将新元素添加到底层数组里面
升级的好处
整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能的节约内存
降级
整数集合不支持降级操作,一旦对数据进行了升级,编码就会一直保持升级后的状态
压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么redis就会使用压缩列表来做列表键的底层实现
压缩列表的构成
typedef struct ziplist {
//记录整个压缩列表占用的内存的字节数,在对压缩列表进行内存重分配或者计算zlend的位置时使用
uint32_t zlbytes;
//记录压缩列表表尾节点距离压缩列表的起始地址有多少字节
uint32_t zltail;
//记录了压缩列表包含的节点数量
uint16_t zllen;
//压缩列表包含的各个节点,节点的长度由节点保存的内容决定
entry entryx[];
//特殊值0xFF(255),用于标记压缩列表的末端
uint8_t zlend;
}
zllen:当这个属性的值小于UINT16_MAX的时候,这个属性的值就是压缩列表包含节点的数量;当这个值等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算得出
压缩列表节点的构成
previous_entry_length
节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节
- 如果前一字节的长度小于254,那么previous_entry_length属性的长度为1字节,前一字节的长度就保存在这一个字节里面
- 如果前一个节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节;其中属性的第一字节会被设置为0xFE(254),而之后的四个字节则用于保存前一节点的长度
encoding
节点的encoding属性记录了节点的content属性所保存数据的类型以及长度
- 一字节长、两字节长、五字节长,值的最高位为00、01、10的是字节数组的编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录
- 一字节长,值的最高位以11开头的是整数编码,这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录
content
节点的content属性负责保存节点的值,节点值可以是一个节点数组或者整数,值的类型和长度由节点的encoding属性决定
连锁更新
压缩列表里面要恰好有多个连续的、长度介于250字节至253字节之间的节点,而如果要将一个长度大于等于254字节的新节点new放在这些节点之前,连锁更新才有可能被引发
对象
对象的类型与编码
typedef struct redisObject {
//类型
unsigned type:4;
//编码
unsigned encoding:4;
//指向底层实现数据结构的指针
void *ptr;
//...
} robj;
对象的类型
类型常量 | 对象的名称 |
---|---|
REDIS_STRING | 字符串对象 |
REDIS_LIST | 列表对象 |
REDIS_HASH | 哈希对象 |
REDIS_SET | 集合对象 |
REDIS_ZSET | 有序集合对象 |
类型
对象的type属性记录了对象的类型,这个属性的值可以是表8-1列出的常量的其中一个
对于redis数据库保存的键值来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种,因此:
-
当我们称呼一个数据库键为“字符串键”时,我们指的是“这个数据库键所对应的值为字符串对象”
-
当我们称呼一个键为“列表键”时,我们指的是“这个数据库键所对应的值为列表对象”
TYPE命令的实现方式也与此类似,当我们对一个数据库键执行TYPE命令时,命令返回的结果为数据库键对应的值对象的类型,而不是键对象的类型
redis > TYEP msgstring
编码和底层实现
对象的ptr指针指向对对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定
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 | 使用整数值实现的字符串对象 |
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_ZIPLIST | 使用压缩列表实现的哈希对象 |
REDIS_HASH | REDIS_ENCODING_HT | 使用字典实现的哈希对象 |
REDIS_SET | REDIS_ENCODING_INTSET | 使用整数集合实现的集合对象 |
REDIS_SET | REDIS_ENCODING_HT | 使用字典实现的集合对象 |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的有序集合对象 |
使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码
字符串对象
字符串对象的拜纳姆可以是int、raw、embstr
如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里,并将字符串对象的编码设置为int
如果字符串对象保存的时一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串来保存这个字符串值,并将对象的编码设置为raw
如果字符串对象保存的时一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值
- embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次
- 释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数
- 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串兑现能够更好的利用缓存带来的优势
编码的转换
- 对于int编码的字符串,如果向他后面添加了新内容使他不再是整数值,则变为raw编码
- redis没有为embstr编码的字符串对象编写任何相应的修改程序,所以embstr实际上是只读的。当我们对embstr执行修改命令的时候,程序会先将embstr转换成raw,然后在执行修改指令。因为这个原因,embstr在执行修改命令之后,总会变成一个raw
列表对象
编码可以是ziplist或者linkedlist
编码转换
当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码
- 列表对象保存的所有字符串元素的长度都小于64字节
- 列表保存的元素数量小于512个
不能满足这两个条件的列表需要使用linkedlist编码
注意:以上两个条件的上限值是可以修改的,具体可以看配置文件中关于list-max-ziplist-value 和 list-max-ziplist-entries选项的说明
哈希对象
哈希对象的编码可以是ziplist或者hashtable
ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾
- 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后
- 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向
编码转换
当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码
- 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
- 哈希对象保存的键值对数量小于512个
不能满足这两个条件的哈希对象需要使用hashtable编码
注意:以上两个条件的上限值是可以修改的,具体可以看配置文件中关于list-max-ziplist-value 和 list-max-ziplist-entries选项的说明
集合对象
集合对象的编码可以是intset或者hashtable
intset编码的集合对象使用证书集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面
hashtable编码的集合对象使用字典作为字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL
编码的转换
当以下两个条件都满足时,使用intset编码
- 集合对象保存的所有元素都是整数值
- 集合对象保存的元素数量不超过512个
不能满足的需要使用hashtable
第二个条件上限值可以修改,set-max-intset-entries
有序集合对象
可以是ziplist或者skiplist
- ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值
压缩列表内的集合元素按分值从小到大进行排序,分值较小的靠近表头
-
skiplist编码的有序集合使用zset结构作为底层实现
typedef struct zset { zskiplist *zsl; dict *dict;} zset; }
zset结构中的zsl跳跃表按照从小到大的顺序保存。每个跳跃表节点都保存了一个集合元素,跳跃表节点的object保存了值,score保存了分数,通过这个跳跃表,程序可以对有序集合进行范围型操作
除此之外,zset结构中dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素;字典的键保存了元素的成员,而字典的值则保存了元素的分值。
有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。值得一提的是,虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复元素或者分值,也不会因此而浪费额外的内存
为什么有序集合需要同时使用跳跃表和字典来实现?
为了兼顾范围查找和定向查找
编码的转换
ziplist:
- 有序集合保存的元素数量小于128个
- 有序集合保存的所有元素成员的长度都小于64字节
不满足就用skiplist
但是可以通过zset-max-ziplist-entries和zset-max-ziplist-value修改其上限
类型检查与命令多态
类型检查的实现
为了确保只有指定类型的键可以执行某些特定的命令,在执行一个类型特定的命令之前,redis会检查输入键的类型是否正确,然后再决定是否执行给定的命令
类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的
- 在执行一个类型特定命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令
- 否则,服务器将拒绝执行命令,并向客户端返回一个类型错误
多态命令的实现
redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令
内存回收
redis在自己的对象系统中构建了一个引用计数器(reference counting)技术实现的内存回收机制,通过这一机制,程序可以通过追踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收
每个对象的引用计数信息由redisObject结构的refcount属性记录
typedef struct redisObject {
//引用计数
int refcount;
//...
} robj;
对象共享
redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象
创建共享字符串对象的数量可以通过修改redis.h/REDIS_SHARE_INTEGERS常量来修改
为什么redis不共享包含字符串的对象
当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗CPU时间也会越多
- 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为O(1)
- 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N)
- 如果共享对象是多个值(或者对象)的对象,那么验证操作的复杂度为O(N2)
因此,尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,redis只对包含整数值的字符串对象进行共享
对象的空转时长
typedef struct redisObject {
//记录了对象最后一次被命令程序访问的时间
unsigned lru:22;
//...
} robj;
OBJECT IDLETIME命令可以打印出给定键的空转时长,这一个空转时长就是通过将当前时间减去键的值对象的lru时间计算得出的
OBJECT IDLETIME命令的实现是特殊的,这个命令在访问键的值对象时,不会修改值对象的lru属性
除了可以被OBJECT IDLETIME命令打印出来之外,键的空转时长还有另外一项作用:如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时间较高的那部分键会优先被服务器释放,从而回收内存
单机数据库的实现
数据库
服务器中的数据库
redis服务器将所欲数据库都保存在服务器状态redis.h/redisServer结构的db数组,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库
struct redisServer {
//一个数组,保存着服务器中的所有数据库
redisDb *db;
//服务器的数据库数量
int dbnum;
} redisClient;
dbnum属性的值由服务器配置的database选项决定,默认情况下,该选项的值为16,所以redis服务器默认会创建16个数据库
切换数据库
默认情况下,redis客服端的目标数据库为0号数据库,但是客户端可以通过执行SELECT命令来切换目标数据库
谨慎处理多数据库程序
到目前为止,redis仍然没有可以返回客户端目标数据库的命令。虽然redis-cli客户端会在输入符旁边提示当前所使用的目标数据库[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7aGlRX61-1628578058177)(H:\Notes\redis\upload\image-20210728145411521.png)]但是如果你在使用其他语言的客户端中执行reids命令,并且该客户端没有像redis-cli那样一直显示目标的数据库的号码,那么在数次切换数据库之后,你很可能会忘记自己当前正在使用的是哪个数据库。当出现这种情况时,为了避免对数据库进行误操作,在执行redis命令特别是像FLUSHDB这样的危险命令之前,最好先执行一个SELECT命令,显式地切换到指定的数据库,然后才执行别的命令
数据库的键空间
reids是一个键值对数据库服务器,服务器中的每个数据库都由一个redis.h/redisDb结构表示,其中,redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称键空间(key space)
typedef struct redisDb {
//数据库键空间,保存着数据库中的所有键值对
dict *dict;
//...
} redisDb;
键空间和用户所见的数据库是直接对应的
- 键空间的键也就是数据库的键,每个键都是一个字符串对象
- 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象等
set 添加新键
del 删除键
set 更新键
get 对键取值
lrange 范围取值
fushdb 删除键空间中的所有键值对
randomkey 返回某个键
dbsize 返回数据库键数量
exists 判断键是否存在
rename 重命名键
keys 返回所有的键
读写键空间时的维护操作
- 在读取一个键之后,服务器会根据键是否存在来更新服务器的键空间命中(hit)次数或键空间不命中(miss)次数,这两个值可以再INFO stats命令的keyspace_hits属性和keyspace_misses属性中查看
- 在读取一个键之后,服务会更新键的LRU(最后一次使用)时间,这个值可以用于计算键的闲置时间
- 如果服务器在读取一个键时发现该键已经过期,那么服务会先删除这个过期键,然后才执行余下的其他操作
- 如果有客户端使用WATCH命令监视了某个键,你们服务器在对被监视的键进行修改之后,会将这个键标记为脏(dirty),从而让事务程序注意到这个键已经被修改
- 服务器每次修改一个键之后,都会对脏键计数器的值增1,这个计数器会出发服务的持久化以及复制操作
- 如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知
设置键的生存时间或过期时间
通过EXPIRE命令或者PEXPIRE命令,客户端可以以秒或者毫秒的精度为数据库中的某个键设置生存时间(Time To Live, TTL),在经过指定的秒数或者毫秒数之后,服务就会自动删除生存时间为0的键
SETEX命令可以在设置一个字符串键的同时为键设置过期时间,因为这个命令是一个类型限定命令(只能用于字符串键)
四个命令
redis有四个不同的命令可以用于设置键的生存时间或者过期时间
- EXPIRE命令将key的生存时间设置为ttl秒
- PEXPIRE命令将key的生存时间设为ttl毫秒
- EXPIREAT命令将key的过期时间设置为timestamp所指定的秒数时间戳
- PEXPIREAT命令将key的过期时间设置为timestamp所指定的毫秒数时间戳
虽然有多种不同单位和不同形式的设置命令,但实际上EXPIRE、PEXPIRE、EXPIREAT三个命令都是使用PEXPIREAT命令来实现的;无论客户端执行的是以上四个命令中的哪一个,经过转换之后,最终的执行效果都和执行PEXPIREAT命令一样
保存过期时间
typedef struct redisDb {
//过期字典,保存着键的过期时间
dict *expires;
//...
} redisDb;
移除过期时间
PERSIST就是PEXPOREAT命令的反操作,PERSIST命令在过期字典中查找给定的键,并解除键和值(过期时间)在过期字典中的关联
计算并返回剩余生存时间
TTL以秒为单位返回键的剩余生存时间,而PTTL命令则以毫秒为单位返回键的剩余生存时间
他们两都是通过计算键的过期时间和当前时间之间的差来实现的
过期键的判定
通过过期字典,程序可以用以下步骤检查一个给定键是否过期
- 检查给定键是否存在于过期字典,如果存在,获取键的过期时间
- 检查当前UNIX时间戳是否大于键的过期时间,如果是,那么键就过期
过期键删除策略
- 定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作
- 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键
- 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定
在这三种策略中,第一种和第三种为主动删除策略,而第二种则为被动删除策略
定时删除
对内存是最有好的,但是对CPU时间是最不友好的
创建一个定时器需要用到redis服务器中的时间事件,而当前时间事件的实现方式–无序链表,查找一个时间的时间复杂度为O(N)–并不能高效的处理大量时间事件
惰性删除
对CPU时间最友好,对内存最不友好
如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么他们也许永远也不会被删除(除非用户手动执行flushdb)
定期删除
- 定期删除策略每隔一段时间执行一次删除过期键擦操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU的影响
- 除此之外,定期删除过期键,定期删除策略有效的减少了因为过期键而带来的内存浪费
定期删除策略的难点是确定删除操作执行的时长和频率
- 如果删除执行的太频繁,或者执行的时间太长,就会退化成定时删除
- 如果执行的太少,或者时间太短,就会退化成惰性删除
redis的过期键删除策略
惰性删除
db.c/expireIfNeeded
- 如果键已经过期,这个函数就会将键从数据库中删除
- 如果输入键未过期,这个函数不做动作
定期删除
redis.c/activeExpireCycle
- 函数每次运行时,都从一定数量(默认16)的数据库中取出一定数量(默认20)的随机键进行检查,并删除其中的过期键
- 全局变量current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle调用时,接着上一次的进度进行处理
- 随着activeExpireCycle不断执行,服务器中所有数据库都会被检查一遍,这时将current_db=0,开始新一轮的检查工作
AOF、RDB和复制功能对过期键的处理
RDB
生成RDB文件
执行save或者bgsave命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中
载入RDB
- 如果服务器以主服务器模式运行。那么在载入的时候,会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期的键会被忽略
- 如果是从服务器,字载入文件的时候,文件中保存的所有的键,无论是否过期,都会被载入到数据库中。但是主从同步的时候,从服务器会被清空,所以不会造成影响
AOF
AOF文件写入
当服务以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期间而产生任何影响
当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加(append)一条DEL命令,来显式记录该键已被删除
AOF重写
AOF重写的过程中,程序会对数据库中键进行检查,已过期的键不会被保存到重写后的AOF文件中
复制
当服务器运行在复制模式下,从服务的过期键删除动作由主服务器控制
- 主服务器在删除一个过期键之后,会显式的向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键
- 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键
- 从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键
通过主服务器来控制从服务统一的删除过期键,可以保证主从服务器数据的一致性
数据库通知
数据库通知是在redis2.8版本新增的功能,这个功能可以让客户端通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况
#获取0号数据库对message这个键的所有操作
subscribe _ _keyspace@0_ _:message
#获取0号数据库所有执行了del命令的键
subscribe _ _keyevent@0:del
键空间通知(key-space notification):某个键执行了什么命令
键事件通知(key-event notification):某个命令被什么键执行了
服务器配置notify-keyspace-events选项决定了服务器所发送通知的类型
选项的值 | 发送通知类型 |
---|---|
AKE | 发送所有类型的键空间通知和键事件通知 |
AK | 发送所有类型的键空间通知 |
AE | 发送所有类型的键事件通知 |
K$ | 只发送和字符串键有关的键空间通知 |
E1 | 只发送和列表键有关的键事件通知 |
RDB持久化
redis是一个键值对数据库服务器,服务器中通常包含着任意个非空数据库,而每个非空数据库中又可以包含任意个键值对,为了方便起见,我们将服务器中的非空数据库以及它们的键值对统称为数据库状态
RDB文件的创建与载入
创建
有两个redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE
save会阻塞redis服务器进程,直到rdb文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求
bgsave会派生出一个子进程,然后由子进程负责创建rdb文件,服务器进程(父进程)继续处理命令请求
载入
RDB文件的载入工作是在服务器启动时自动执行的,所以redis并没有专门用于载入rdb文件的命令,只要redis服务器在启动时检测到rdb文件存在,他就会自动载入rdb文件
因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:
- 如果服务器开启了AOF持久化功能,那么服务器会优先使用aof文件来还原数据库状态
- 只有在aof持久化功能处于关闭状态时,服务器才会使用rdb文件来还原数据库状态
SAVE
当save命令执行时,redis服务器会被阻塞,所以当save命令正在执行时,客户端发送的所有命令请求都会被拒绝
只有在服务器执行完save命令的时候,重新开始接受命令请求之后,客服端发送的命令才会被处理
BGSAVE
因为是子进程执行的,所以在子进程创建RDB的过程中,redis服务器仍然可以继续处理客户端的命令请求,但是在BGSAVE命令执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF三个命令会和平时有所不同
在BGSAVE执行期间
save命令会被拒绝,避免父进程和子进程同时执行两个rbSave调用,防止产生竞争条件
bgsave命令也会被拒绝,两个bgsave也会产生竞争条件
bgrewriteaof命令会被延迟到bgsave执行完毕之后执行
但是如果bgrewriteaof命令正在执行,那么客户端会拒绝bgsave命令
服务器在载入rdb文件期间,会一直处于阻塞状态,直到载入工作完成为止
自动间隔性保存
保存条件
用户可以通过指定配置文件或者传入启动参数的方式设置save选项,如果用户没有主动设置save选项,那么服务器会为save选项设置默认条件:
- save 900 1
- save 300 10
- save 60 10000
接着,服务器会根据save选项所设置的保存条件,设置服务器状态redisServer结构的saveparams属性
struct redisServer {
//记录了保存条件的数组
struct saveparam *saveparams;
}
struct savaparam {
//秒数
time_t seconds;
//修改数
int changes;
}
dirty计数器和lastsave属性
除了saveparams数组之外,服务器状态还维持着一个dirty计数器,以及一个lastsave属性
- dirty计数器记录距离上一次成功执行save/bgsave命令之后,服务器对数据库状态进行了多少次修改(cud)
- lastsave是一个unix时间戳,记录了服务器上一次成功执行save/bgsave命令的时间
struct redisServer {
//修改计数器
long long dirty;
//上一次执行保存的时间
time_t lastsave;
}
当服务器成功执行一个数据库命令之后,程序就会对dirty计数器进行更新;命令修改了多少次数据库,dirty计数器的值就增加多少
检查保存条件是否满足
redis每个100毫秒就会执行一次serverCron,该函数会检查save选项所设置的保存条件是否已经满足,满足就执行bgsave
RDB文件结构
其中databases包含着0个或任意多个数据库,以及各个数9据库中的键值对数据
如果只有0号和3号数据库有数据,rdb文件如下
type可以是以下常量中的一个
key总是一个字符串对象
value的编码
-
字符串
编码可以是int或者raw
如果服务器打开了rdb文件压缩功能,如果字符串长度大于20字节,就会选择压缩再保存
2. 列表
3. 集合
- 哈希
5. 有序集合
-
INTSET编码的集合
先将整数结合转换为字符串对象,然后将这个字符串对象保存到RDB文件里面
-
ZIPLIST编码的列表、哈希表、有序集合
将压缩列表转换成一个字符串对象,将得到的字符串对象保存到RDB文件中
分析RDB文件
AOF持久化
AOF的实现
当AOF打开时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾
aof文件的写入与同步
redis服务器进程就是一个事件循环(loop),这个循环中的文件时间负责接受客户端的命令请求,以及向客户端发送命令回复,而时间事件则负责执行像serverCron函数这样需要定时运行的函数
flushAppendOnlyFile函数的行为由服务器配置的appendsync选项的值来决定
appendfsyn选项的值 | flushAppendOnlyFile函数的行为 |
---|---|
always | 将aof_buf缓冲区中的所有内容写入并同步到AOF文件 |
everysec | 将aof_buf缓冲区中的所有内容写入到AOF文件,如果上次同步AOF文件的时间距离现在超过一秒钟,那么再次对AOF文件进行同步,并且这个同步操作是由一个线程专门负责执行的 |
no | 将aof_buf缓冲区中的所有内容写入到AOF文件,但并不对AOF文件进行同步,何时同步由操作系统来决定 |
文件的载入与还原
AOF重写
为了解决AOF文件体积膨胀的问题,redis提供了AOF文件重写(rewrite)功能
因为aof_rewrite函数生成的新AOF文件只包含还原当前数据库状态所必须的命令,所以新AOF文件不会浪费任何硬盘空间
后台重写
因为aof_rewrite函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞,但是redis是单线程来处理命令请求的,所以如果由服务器直接调用这个函数,在执行期间,服务器将无法处理客户端发来的命令请求
所以redis决定将aof重写程序放到子进程里执行,这样做可以同时达到两个目的:
- 子进程进行aof重写期间,服务器进程可以继续处理命令请求
- 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以再避免使用锁的情况下,保证数据的安全性
但是,在子进程进行重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的aof文件所保存的数据库状态不一致
为了解决这个数据不一致的问题,redis服务器设置了一个aof重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当redis服务器执行完一个写命令之后,他会同时将这个写命令发送给aof缓冲区和aof重写缓冲区
- 执行客户端发来的指令
- 将执行后的写命令追加到aof缓冲区
- 将执行后的命令追加到aof重写缓冲区
这样可以保证:
- AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有的AOF文件的处理工作会如常进行
- 从创建子进程开始,服务执行的所有写命令都会被记录到AOF重写缓冲区里面
当子进程完成重写工作之后,他会向父进程发送一个信号,父进程在接收到该信号之后,会调用一个信号处理函数,并执行以下工作
- 将aof重写缓冲区中的所有内容写入到新aof文件中,这时aof文件所保存的数据库状态将和服务当前的数据库状态一致
- 对新的aof文件进行改名,原子的覆盖现有的aof文件,完成新旧两个aof文件的替换
事件
文件事件
redis服务器通过套接字与客户端进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作
时间事件
reids服务器中的一些操作需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象
客户端
通常情况下,redis只会将那些对数据库进行了修改的命令写入到aof文件,并复制到各个从服务器。如果一个命令没有对数据库进行任何修改,那么他就会被认为是只读命令,不会被写到aof文件,也不会被复制到 从服务
但是pubsub和script load命令是例外,他们虽然没有修改数据库,但是这两个行为带有副作用。因此服务器需要使用REDIS_FORCE_AOF标志,强制将这个命令写入AOF文件
服务器
命令请求的执行过程
- 客户端向服务发送命令请求 set key value
- 服务器接收并处理客户端发来的命令请求set key value,在数据库中进行设置操作,并产生命令回复OK
- 服务器将命令回复OK发送给客户端
- 客户端接收服务器返回的命令回复OK,并将这个回复打印给用户观看
发送命令请求
读取命令请求
- 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面
- 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv属性和argc属性里面
- 调用命令执行器,执行客户端指定的命令
查找命令
执行预备操作
进行各种检查
调用命令的实现函数
client->cmd->proc(client);
执行后续工作
是否添加一条慢查询日志,检查命令执行的时长,是否写入aof,是否传给从服务器
将命令回复发送给客户端
客户端接收并打印命令回复
初始化服务器
-
初始化服务器状态结构
-
载入配置选项
-
初始化服务器数据结构
-
还原数据库状态
-
执行事件循环
多机数据库实现
复制
旧版复制功能
- 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态
- 命令传播擦操作则用于在主服务器的数据库状态被修改,导致主从服务的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态
同步
- 从服务向主服务器发送SYNC命令
- 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令
- 当主服务器的BGSAVE命令执行完毕时,主服务会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态
- 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态
命令传播
主服务器对从服务器执行命令传播操作,主将造成主从不一致的命令,发送给从,当从执行了相同的命令之后,再次回到主从一致性状态
旧版的缺陷
主从断开重连之后,需要再一次同步,浪费了系统资源
新版复制功能的实现
用PSYNC命令代替了SYNC
PSYNC具有完整重同步和部分重同步
- 完整重同步用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,他们都是通过主服务创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步
- 而部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从连接断开期间执行的写命令发送给从,从只要接受并执行这些写命令,就可以将数据库更新至主当前所处的状态
部分重同步的实现
复制偏移量
- 主每次向从传播n个字节的数据时,就将自己的复制偏移量的值加上n
- 从每次收到主传播来的n个字节的数据时,就将自己的复制偏移量的值加上n
复制积压缓冲区
复制积压缓冲区是由主维护的一个固定长度先进先出队列,默认大小为1MB
当主进行命令传播时,他还会将命令写入这里面
当从重新连上主,从会通过psync命令将自己的复制偏移量offset发送给主服务器,主服务器会根据这个复制偏移量来决定对从服务器执行何种同步操作
- 如果offset偏移量之后的数据仍然存在于复制积压缓冲区里面,那么主服务器将对从服务器器执行部分重同步操作
- 如果不存在了,就执行完整重同步操作
根据需要调整复制积压缓冲区的大小
second * write_size_per_second
second为从服务器断线后重新连接上主所需的平均时间(s)
write_size_per_second是主平均每秒产生的写命令数据量(协议格式的写命令的长度总和)
服务器运行ID
- 每个服务都有自己的运行ID
- 当从对主进行初次复制时,主会将自己运行ID传给从,从将其保存
- 当从断开并重新连接上这个主服务器,主服务器可以继续尝试执行部分重同步操作
- 如果从保存的id和主相同,那么就尝试执行部分重同步操作
- 否则进行完整重同步操作
PSYNC命令的实现
从:
- 如果从没有复制过任何主,或者执行过slaveof no one,那么从在开始一次新的复制时将向主发送PSYNC ? -1命令,请求完整重同步
- 如果已经复制过,则发送PSYNC <runid> <offset>
主:
- 如果主返回 +FULLRESYNC <runid> <offset>回复,那么表示主服务器将与从服务器执行完整重同步操作
- 如果主返回 +CONTINUE回复,那么表示执行部分重同步
- -ERR,表示主服务器的版本低于Redis2.8,他识别不了PSYNC命令,从将向主服务器发送SYNC命令,并与主服务器执行完整同步操作
复制的实现
- 设置主服务器的地址和端口
- 建立套接字连接
- 发送PING命令
- 身份验证
- 发送端口信息
- 同步
- 命令传播
心跳检测
在命令传播阶段,从默认会以每秒一次的频率,向主发送命令
REPLCONF ACK <replication_offset>
其中replication_offset是服务器当前的复制偏移量
主要有三个作用
- 检测主从的网络连接状态
- 辅助实现min-slaves选项
- 检测命令丢失
检测主从的网络连接状态
主可以通过发送和接受REPLCONF ACK命令来检查两者之间的网络连接是否正常,如果一秒没收到从的REPLCONF ACK,那么主就知道从出问题了
通过向主发送info relication命令,在列出从服务器列表的lag一栏中,我们可以看到相应从最后一次向主发送REPLCONF ACK距离现在过了多少秒
辅助实现min-slaves配置选项
redis的min-slaves-to-write和min-slaves-max-lag两个选项可以防止主在不安全的情况下执行写命令
检测命令丢失
如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发送REPLCONF ACK命令时,主将发现从当前的复制偏移量少于自己,然后会在复制积压缓冲区里面找到从缺少的数据,并将这些数据重新发送给从
redis2.8版本以前的命令丢失
REPLCONF ACK命令和复制积压缓冲区都是2.8新增的,2.8以前,即使命令在传播过程中丢失,主和从都不会注意到,主更不会想从补发丢失的数据
Sentinel
Sentinel(哨岗、哨兵)是redis的高可用(high availability)解决方案:由一个或多个sentinel实例组成的sentinel系统可以监视任意多个主服务器,以这些主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求
启动并初始化 sentinel
初始化服务器
不会载入rdb或者aof
将普通redis服务器使用的代码替换成sentinel专用代码
普通服务器的命令
sentinel服务器的命令
初始化sentinel状态
根据给定的配置文件,初始化sentinel的监视主服务器列表
创建连向主服务器的网络连接
sentinel会创建两个连向主服务器的异步网络连接
- 一个是命令连接,这个链接专门用于向主服务器发送命令,并接收命令回复
- 另一个是订阅连接,这个链接专门用于订阅主服务器的_sentinel_:hello频道
为什么要有两个连接
被发送的信息不会保存在服务器里面,如果信息发送时,想要接收信息的客户端不在线或者断线,那么这个客户端就会丢失这条信息。因此,为了不丢失_sentinel_:hello的任何信息,seninle必须专门用一个订阅连接来接收该频道的信息
另一方面,除了订阅频道之外,sentinel还必须向主服务器发送命令,以此来与主服务器进行通信,所以sentinel还必须向主服务器创建命令连接
获取主服务器信息
sentinel默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送info命令,并通过分析info命令的回复来获取主服务器的当前信息
检查客观下线状态
当sentinle将一个主服务器判断为主观下线之后,为了确认这个主是否真的下线了,它会向同样监视这一主服务器的其他sentinel进行询问,看他们是否也认为主已经进入下线状态(可以是主观下线或者客观下线)。当sentinel从其他sentinel那里接收到足够数量的已下线判断之后,sentinel将会主服务器判定为客观下线,并对主服务器执行故障转移操作
发送SENTINEL is-master-down-by-addr命令
SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>
接受SENTINEL is-master-down-by-addr命令
会根据其中的ip和端口号,检查主是否已下线,然后向源sentinel返回一条包含三个参数的Multi Bulk回复
<down_state>
<leader_runid>
<leader_epoch>
接受SENTINEL is-master-down-by-addr命令的回复
统计回复的已下线数量,当这一数量达到配置指定的判断客观下线所需的数量时(quorum参数),sentinel会将主服务器实例结构flags属性的的SRI_O_DOWN标识打开,表示主服务器已经进入客观下线状态
选举领头sentinel
- 所有在线的sentinel都有被选为领头的资格
- 每次选举之后,无论是否成功,所有sentinel的配置纪元(configuration epoch)的值都会自增一次
- 在一个配置纪元里面,所有的sentinel都有一次将某个sentinel设置为领头的机会,并且局部领头一旦设置,在这个配置纪元里面就不能再更改
- 每个发现主服务器进入客观下线的sentinel都会要求其他sentinel将自己设置为局部领头
- 当一个sentinel向另一个发送SEN…并且命令中的runid不是*符号而是sentinel的运行id时,表示源sentinel都会要求sentinel将自己设置为局部领头
- 设置局部领头的规则是先到先得,后面接受到的所有设置要求都会被拒绝
- 收到SENTINEL is-master-down-by-addr后会进行会回复,回复中的leader_runid和leader_epoch分别记录了目标sentinel的局部领头sentinel的运行id和配置纪元
- 源在接收到命令回复之后,会检查leader_epoch是否和自己相同,如果相同,取出leader_runid参数,如果一直,表示目标将源设置为了领头
- 如果某个sentinel被半数以上的sentinel设置成了局部领头,那么就会成为领头
- 一个配置纪元里面只会出现一个领头
- 如果没有一个被选举为领头,各个sentinel将在一段时间之后再次进行选举
故障转移
选出新的主服务器
修改从服务器的复制目标
将旧的主服务变为从服务
集群
redis集群是redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能
节点
一个节点就是一个运行在集群模式西的redis服务器,redis服务器会根据clusster-enabled选项是否为yes来决定是否开启服务器的集群模式
struct clusterNode {
//创建节点的时间
mstime_t ctime;
//节点的名字,由40个十六进制字符组成
char name[REDIS_CLUSTER_NAMELEN];
//节点标识
//使用各种不同的表示记录节点的角色(比如主节点或从)
//节点目前所处的状态(在线或下线)
int flags;
//节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;
//节点的IP地址
char ip[REDIS_IP_STR_LEN];
//节点的端口号
int port;
//保存连接节点所需的有关信息
clustrLink *link;
//...
};
typedef struct clusterLink {
//连接的创建时间
mstime_t ctime;
//TCP套接字描述符
int fd;
//输出缓冲区,保存着等待发送给其他节点的消息(message)
sds sndbuf;
//输入缓冲区,保存着从其他节点接收到的消息
sds rcvbuf;
//与这个链接相关联的节点,如果没有的话,就为null
struct clusterNode *node;
} clusterLink;
typedef struct clusterState {
//指向当前节点的指针
clusterNode *myself;
//集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;
//集群当前的状态,是在线还是下线
int state;
//集群节点名单
dict *nodes;
//...
}clusterState;
redisClient结构和clussterLink结构的相同和不同之处
CLUSTER MEET <ip> <port>
收到命令的节点a将与节点b进行握手,以此来确定彼此的存在
- a为b创建一个clusterNode,并添加到自己的clusterState.nodes字典里
- a根据命令中的ip和port,向b发送一条meet消息
- 如果一切顺利,b收到a的meet,为a创建一个clusterNode结构,并添加到自己的clusterState.nodes字典里
- b向a返回一条pong消息
- a收到消息,并知道b收到了自己的消息
- a向b返回一条ping消息
- b收到a的ping,知道a收到了自己的pong,握手完成
之后a会将节点b的信息通过gossip协议传播给集群中的其他节点,然其他节点也与b握手,最终b会被集群中所有节点认识
槽指派
redis集群通过分片的方式来保存数据库中的键值对,集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或者最多16384个槽
当16384个槽都有节点在处理时,集群处于上线状态,相反的,有任何一个槽没有得到处理,那么集群处于下线状态
#将一个多多个槽(solt)指派(assign)给节点负责
CLUSTER ADDSLOTS <slot> [slot...]
集群中每个节点都会将自己的slots数组通过消息发送给集群中的其他节点,并且每个接收到slots数组的节点都会将数组保存到相应节点的clusterNode结构里面。因此,集群中的每个节点都会知道啊数据库中的16384个槽分别被指派给了集群中的那些节点
但是,如果只将槽指派信息保存在各个节点的clusterNode.slots数组里,会出现一些无法高效解决的问题,而clusterState.slots解决了这些问题
如果你要知道某个槽被指派给了哪个节点,程序需要遍历clusterState.nodes,时间复杂度为O(N)。但是如果只访问clusterState.slots[i],仅为O(1)
(用空间换时间)
在集群中执行命令
在对数据库中的16384个槽都进行指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了
计算键属于哪个槽
def slot_number(key):
return CRC16(key) & 16383;
计算键key的CRC16校验和,&16383则计算出一个介于0-16383的整数作为key的槽号
判断槽是否由当前节点负责处理
MOVED错误
MOVED <slot> <ip>:<port>
集群模式的redis-cli客户端在接收到MOVED错误时,并不会打印出MOVED错误,而是根据MOVED错误自动进行节点转向,并打印出转向信息
但是单机模式的客户端会将MOVED打印出来
独立功能实现
发布与订阅
redis的分布与订阅功能由PUBLISH、SUBSCRIBE、PSUBSCRIBE等命令组成
通过执行subscribe,客户端可以订阅一个或多个频道,从而成为这些频道的订阅者,每当有其他客户端向被订阅的频道发送消息时,频道的所有订阅者都会收到这条消息
事务
事务的实现
事务开始
multi可以将执行该命令的客户端从非事务状态切换至事务状态,这一切换是通过客户端状态的flags属性中打开REDIS_MULTI标识来完成的
命令入队
如果是exec、discard、watch、multi其中一个,服务器会立即执行这个命令
其他命令则会放到一个事务队列里面
事务队列
执行事务
当一个处于事务状态的客户端向服务器发送exec命令时,这个exec命令将立即被服务器这行。服务器会遍历这个客户端的事务队列,执行队列中保存的所以命令,最后将执行命令所得的结果全部返回给客户端
watch
watch命令是一个乐观锁,他可以在exec命令执行之前,监视任意数量的数据库键,并在exec命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,并向客户端发挥代表事务执行失败的空回复
Lua脚本
排序
二进制位数组
慢查询日志
监视器
未完待续