本人本科毕业,21届毕业生,一年工作经验,简历专业技能如下,现根据简历,并根据所学知识复习准备面试。
记录日期:2022.1.15
大部分知识点只做大致介绍,具体内容根据推荐博文链接进行详细复习。
书籍推荐:《Redis的设计与实现》
博客面试文章推荐:全网最硬核 Redis 高频面试题解析(2021年最新版)
Redis这篇主要要讲解的内容包括:数据结构
、redis持久化(aof、rdb)
、文件事务处理器
、redis内存淘汰机制
、事务
、redis集群(一致性hash等...)
、redis分布式锁
都放在Redis的文章里说明。
还有一部分缓存问题,比如缓存设计以及缓存数据一致性
、解决方案-缓存雪崩缓存穿透缓存击穿
等另起一篇写。
Redis主要有5种数据类型,包括String,List,Set,Zset,Hash,满足大部分的使用要求。
数据类型 | 可以存储的值 | 操作 | 应用场景 |
---|---|---|---|
STRING | 字符串、整数或者浮点数 | 对整个字符串或者字符串的其中一部分执行操作;对整数和浮点数执行自增或者自减操作。 | 做简单的键值对缓存 |
LIST | 列表 | 从两端压入或者弹出元素;对单个或者多个元素进行修剪;只保留一个范围内的元素 | 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的数据 |
SET | 无序集合 | 添加、获取、移除单个元素;检查一个元素是否存在于集合中;计算交集、并集、差集;从集合里面随机获取元素 | 交集、并集、差集的操作,比如交集,可以把两个人的粉丝列表整一个交集 |
HASH | 包含键值对的无序散列表 | 添加、获取、移除单个键值对;获取所有键值对;检查某个键是否存在 | 结构化的数据,比如一个对象 |
ZSET | 有序集合 | 添加、获取、删除元素;根据分值范围或者成员来获取元素;计算一个键的排名 | 去重但可以排序,如获取排名前几名的用户 |
另外还有高级的4种数据类型:
主要是讲述上述五种基本类型的底层编码实现:
类型 | 编码 | 对象 |
---|---|---|
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 | 使用压缩列表实现的有序集合对象 |
REDIS_ZSET | REDIS_ENCODING_SKIPLIST | 使用跳跃表和字典实现的有序集合对象 |
参考《Redis设计与实现》第一部分
数据结构与对象
的 第八章对象
,p63。
通过上面的整理我们就可以知道他们的具体编码实现了,整理如下:
在Redis中我们可以通过 OBJECT ENCODING
命令来查看一个数据库键的值对象的编码:
redis> SET msg "hello world"
OK
redis> OBJECT ENCODING msg
"embstr"
关于他们具体在什么时候使用什么编码格式,我们在下文详细说明!
主要说明七种对象:简单动态字符串
、链表
、字典
、跳跃表
、整数集合
、压缩列表
。
简单动态字符串(SDS
),用作Redis的默认字符串表示。
每个 sds.h/sdshdr
结构标识一个SDS值:
struct sdshdr {
int len; // 记录buf数组中已使用的字节数量,等于SDS所保存字符串的长度
int free; // 记录buf数组中未使用的字节数量
char buf[]; // 字节数组,用于保存字符串
}
tip:buf数组最后一个字节会用来保存’/0’,这也是遵循C字符串以空字符结尾的惯例,但是这个字符不会被计算在len长度中。
遵循的好处就是它可以直接重用一部分C字符串函数库里面的函数。
如果一张表来说明,即:
C字符串 | SDS |
---|---|
获取字符串长度的复杂度为O(N) | 获取字符串长度的复杂度为O(1) |
API是不安全的,可能会造成缓冲区溢出 | API是安全的,不会造成缓冲区溢出 |
修改字符串长度N次必然需要执行N次内存重分配 | 修改字符串长度N次最多需要执行N次内存重分配 |
只能保存文本数据 | 可以保存文本数据或者二进制数据 |
可以使用所有的 |
可以使用一部分 |
那我们根据这五点来说明,这五大区别的产生原因:
原因如下:
其中SDS长度的设置与更新是由SDS的API执行时自动完成的。
因为C字符串没有记录字符串长度,所以如果使用如下方法:
char *strcat(char *dest, const char *src);
当开发者已经为 dest
字符串分配了一定的内存,此时如果 src
字符串中内容拼接进去后的内存大于分配的内存,则会造成缓冲区溢出。
那么SDS字符串是如何解决的呢?
当 SDS API
需要对 SDS
进行修改时,API
会先检查 SDS
的空间是否满足所需的要求,如果不满足的话,API
会自动将 SDS
的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS
既不需要后动修改 SDS
的空间大小,也不会出现C字符串中的缓冲区溢出问题。
因为C字符串的底层实现总是 N + 1 个字符串长度的数组。所以每次执行 增长字符串
或是 缩短字符串
时,都要先通过重分配扩展底层数组的空间大小
或是 释放字符串不再使用的空间
,来防止缓冲区溢出
或者 内存泄漏
。
那么SDS字符串是如何解决的呢?
SDS
中使用free
属性记录未使用空间的字节数量。
通过未使用的空间,SDS
实现了 空间预分配 和 惰性空间释放 两种优化策略。
空间预分配的操作是:当 SDS
的 API
对一个 SDS
进行修改,并且需要对 SDS
进行空间扩展的时候,程序不仅会为 SDS
分配修改所必须要的空间,还会为 SDS
分配额外的未使用空间。
这里存在两种修改情况:
len值
会和 free值
相同。此时 buf数组
实际长度是 len + free + 1
。1MB
未使用空间,比如 len值
为30MB时,此时 buf数组
实际长度是 30MB + 1MB + 1byte
。惰性空间释放的操作是:当 SDS
的 API
对 一个 SDS
进行修改,并且需要对 SDS
所保存的字符串进行缩短时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free属性
将这些字节的数量记录起来,并等待将来使用。
当然,如果需要真正地释放 SDS
的未使用空间,会有 API
去实现,这里不说明。
C字符串的字符必须符合某种编码(比如ASCII),并且除了末尾空字符外,不能包含任何空字符,否则会被程序误认为是末尾,这使得C字符串只能保存文本数据,而不能保存二进制数据。
那么SDS字符串是如何解决的呢?
SDS
的 API
都是二进制安全的,所有的 SDS API
都会以处理二进制的方式来处理 SDS
存放的 buf数组
里的数据。
所以SDS 的 buf属性被称为字节数组,就是因为它是用来保存一系列二进制数据。
上面说过了,SDS
也遵循C字符串以空字符结尾的惯例,就是为了能让它使用部分
的函数。
每个链表节点使用一个 adlist.h/listNode
结构来表示:
typedef struct listNode {
struct listNode *prev; // 前置指针
struct listNode *next; // 后置指针
void *value; // 节点的值
}
说明该链表是一个双向链表。
当我们使用多个 listNode
组成链表,就会直接使用 adlist.h/list
来持有该链表进行操作:
typedef struct list {
listNode *head; // 表头节点
listNode *tail; // 表尾节点
unsigned long len; // 链表所包含的节点数量
void *(*dup) (void *ptr); // 节点值复制函数
void *(*free) (void *ptr); // 节点值释放函数
int (*match) (void *ptr, void *key); // 节点值对比函数
}
即数组 + 链表实现。
Redis 字典所使用的哈希表由 dict.h/dictht
结构定义:
typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值,总是等于 size - 1
unsigned long used; // 哈希表已有节点数量
}
哈希表节点使用 dictEntry
结构表示,每个 dictEntry
结构都保存着一个kv对:
typedef struct dictEntry {
void *key; // 键
union { // 值
void *val;
uint64_t u64;
uint64_t s64;
}
struct dictEntry *next; // 指向下个哈希表节点,形成链表
}
Redis 中的字典由 dict.h/dict
结构表示:
typedef struct dict {
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 哈希表
int trehashidx; // rehash索引,当rehash不在进行时,值为1
}
在添加新的键值到字典里是,要先进行对key的哈希,根据哈希值计算出索引值,根据索引将新的kv对放到哈希表数组的指定索引上。
index = hash&dict -> ht[0].sizemask
Redis 使用 MurmurHash
算法。
Redis 的哈希表使用链地址法解决哈希冲突,并且使用的是头插法。
hash 对象在扩容时使用了一种叫 “渐进式 rehash”
的方式。
扩展和收缩哈希表的工作都是通过执行 rehash
来完成的。
reash的步骤如下:
计算新表(ht[1]
)的空间大小,取决于旧表(ht[0]
)当前包含的键值以及数量。
ht[1]
)的大小为第一个大于等于 ht[0].used * 2 的 2^N。ht[1]
)的大小为第一个大于等于ht[0].used 的 2^N。将保存在旧表(ht[0]
)的所有键值rehash到新表(ht[1]
)上。
当旧表(ht[0]
)全部迁移完成后,释放旧表(ht[0]
),将新表设置为 ht[0]
并在 ht[1]
重新创建一张空白哈希表。
这两个哈希表的套路是不是有点像jvm运行时数据区的年轻代的幸存者区?可以引申一下。
当下面两个条件任意一个被满足时,程序就会自动开始对哈希表进行扩展操作:
BGSAVE
命令或 BGREWRITEAOF
指令,并且哈希表的负载因子大于等于1
。BGSAVE
命令或 BGREWRITEAOF
指令,并且哈希表的负载因子大于等于5
。【5是因为已保存节点数量包括冲突节点】为什么这两个命令的是否正在执行,和服务器执行扩展操作的负载因子并不相同?
答:是因为在执行BGSAVE命令或者BGREWRITEAOF命令的过程中,Redis需要fork子线程,而大多数os都采用与时复制技术来优化子进程的使用效率,所以子进程存在的期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希扩容,可以避免不必要的内存写入操作,节约内存。
与时复制
:copy-on-write,即不用复制写入直接引用父进程的物理过程。
BGSAVE命令
:fork子进程去完成备份持久化。(区别于SAVE命令,阻塞线程去完成备份持久化)
BGREWRITEAOF命令
:异步执行AOF重写,优化原文件大小(该命令执行失败不会丢失数据,成功才会真正修改数据,2.4以后手动触发该命令)
渐进式rehash的详细步骤:
ht[1]
分配空间,让字典同时持有ht[0]
和ht[1]
两个哈希表。rehashidx
,并将它的值设为0
,表示rehash工作正式开始。ht[0]
在rehashidx
索引上的所有键值对rehash到ht[1]
上,当rehash工作完成时,rehashidx
属性值加一。ht[0]
的所有键值对都会被rehash到ht[1]
上,这是将rehashidx
的值设为-1
,表示rehash操作已完成。渐进式hash采取 分而治之
的思想,将rehash键值对所需的计算工作均摊到字典的每个添加、删除、查找、更新操作上,避免集中式hash。
[0]
上查找,如果ht[0]
上没有就去ht[1]
上查找。ht[1]
中,而ht[0]
不进行操作,这样保证ht[0]
只减不增。扩容期开始时,会先给 ht[1]
申请空间,所以在整个扩容期间,会同时存在 ht[0]
和 ht[1]
,会占用额外的空间。
扩容期间同时存在 ht[0]
和 ht[1]
,查找、删除、更新等操作有概率需要操作两张表,耗时会增加。
redis 在内存使用接近 maxmemory
并且有设置驱逐策略的情况下,出现 rehash 会使得内存占用超过 maxmemory
,触发驱逐淘汰操作,导致 master/slave
均有有大量的 key 被驱逐淘汰,从而出现 master/slave
主从不一致。
可以把他理解为一个可以二分查找的链表。
它在Redis中只用到过两处:一是有序集合zset
;二是集群节点的内部数据结构
。
这块的实现就不整理,看博客 或者 看书吧,《Redis设计与实现》p38。
参考博客链接一:面试准备 – Redis 跳跃表
参考博客链接二:Redis中的跳跃表
参考博客链接三:跳跃表以及跳跃表在redis中的实现
skiplist
操作要复杂。
skiplist
上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。skiplist
的插入和删除只需要修改相邻节点的指针,操作简单又快速。skiplist
每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。Map
或 dictionary
结构,大都是基于哈希表实现的。skiplist
比平衡树要简单得多。每个 intset.h/intset
结构表示一个整数集合:
typedef struct intset {
uint32_t encoding; // 编码方式
uint32_t length; // 集合包含的元素数量
int8_t contents[]; // 保存元素的数组
}
其中 contents[]
就是整数集合的底层实现:整数集合的每个元素都是该数组的一个数组项,各个项在数组中是从小到大有序排列,并且不重复。
虽然 contents[]
属性声明是 int8_t
,但是真正类型取决于 encoding
。
整数升级,即当我们将一个新元素添加到集合中时,新元素的类型比原集合的类型都要长时,整数集合需要升级,然后才能将新元素添加到集合中
。
具体升级并添加元素的步骤分为三步:
该过程的复杂度为 O(N)
。
整数集合不支持降级操作!
它的存在意义就是为了节约内存。
压缩列表就是一个由一系列特殊编码的连续内存块组成的顺序型数据结构
。
压缩列表的各个组成部分说明如下表:
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4字节 | 记录整个压缩链表占用的字节数,在对压缩列表进行内存重分配,或者计算zlend的位置时使用。 |
zltail | uint32_t | 4字节 | 记录压缩列表表尾节点 距离压缩列表起始地址 有多少个字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定尾节点的地址。 |
zllen | uint16_t | 2字节 | 记录了压缩列表包含的字节数量,该属性小于UINT16_MAX(65535)时,该值为压缩列表包含节点的数量;该属性等于UINT16_MAX(65535)时,节点的真实数量需要遍历压缩列表获得。 |
entryX | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容而定。 |
zlend | uint8_t | 1字节 | 特殊值0xFF(十进制255),用于标记压缩列表的末端。 |
每个压缩列表节点可以保存一个字节数组
或者一个整数值
。其中,字节数组可以是以下三种长度之一:
而整数值则可以是以下六种长度的其中一种:
每个压缩列表节点都由 previous_entry_length
、encoding
、content
三个部分组成:
节点的 previous_entry_length
属性以字节为单位,记录了压缩列表中前一个节点的长度。
previous_entry_length
属性的长度可以是1字节 或者 5字节:
previous_entry_length
属性的长度为1字节:前一节点的长度就保存在这一个字节里面。previous_entry_length
属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制254),而之后的四个字节则用于保存前一节点的长度。它的好处就是,因为节点的 previous_entry_length
属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一节点的起始地址。
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的 previous_entry_length
属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。
节点的 encoding
属性记录了节点的 content
属性所保存数据的类型以及长度:
content
属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录;content
属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录。节点的 content
属性负责保存节点的值,节点值可以是一个字节数组或者整数值,值的类型和长度由节点的 encoding
属性决定。
redis中的压缩列表在插入数据的时候可能存在连锁扩容的情况。
在压缩列表中,节点需要存放上一个节点的长度:当上一个entry节点长度小于254个字节的时候,将会一个字节的大小来存放entry中的数据;但是当上一个entry节点长度大于等于254个字节的时候,就会需要更大的空间来存放数据。
在压缩列表中,会把大于等于254字节长度用5个字节来存储,第一个字节是254,当读到254的时候,将会确认接下来的4个字节大小将是entry的长度数据。当第一个字节为255的时候,就证明压缩列表已经到达末端。
由于表示长度的字节大小不一样,当新节点的插入可能会导致下一个节点原本存放表示上一节点的长度的空间大小不够导致需要扩容这一字段。相应的该字段将会由一个字节扩容到五个字节,四个字节的长度变化,当发生变化的节点原本长度在250到253之间的时候,将会导致下一个节点存储上节点长度的空间发生变化,引起一个连锁扩容的情况,这一情况将会直到一个不需要扩容的节点为止。
扩容逻辑代码如下,可参考:
while (p[0] != ZIP_END) {
zipEntry(p, &cur);
rawlen = cur.headersize + cur.len;
rawlensize = zipStorePrevEntryLength(NULL,rawlen);
/* Abort if there is no next entry. */
if (p[rawlen] == ZIP_END) break;
zipEntry(p+rawlen, &next);
/* Abort when "prevlen" has not changed. */
if (next.prevrawlen == rawlen) break;
if (next.prevrawlensize < rawlensize) {
/* The "prevlen" field of "next" needs more bytes to hold
* the raw length of "cur". */
offset = p-zl;
extra = rawlensize-next.prevrawlensize;
zl = ziplistResize(zl,curlen+extra);
p = zl+offset;
/* Current pointer and offset for next element. */
np = p+rawlen;
noffset = np-zl;
/* Update tail offset when next element is not the tail element. */
if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
}
/* Move the tail to the back. */
memmove(np+rawlensize,
np+next.prevrawlensize,
curlen-noffset-next.prevrawlensize-1);
zipStorePrevEntryLength(np,rawlen);
/* Advance the cursor */
p += rawlen;
curlen += extra;
} else {
if (next.prevrawlensize > rawlensize) {
/* This would result in shrinking, which we want to avoid.
* So, set "rawlen" in the available bytes. */
zipStorePrevEntryLengthLarge(p+rawlen,rawlen);
} else {
zipStorePrevEntryLength(p+rawlen,rawlen);
}
/* Stop here, as the raw length of "next" has not changed. */
break;
}
}
代码逻辑是:首先,从新插入的节点的下一个节点开始,如果下一个节点存放上一个字节的空间大小大于或等于当前的节点长度,那么在存放了这一长度数据之后,该次连锁扩容直接宣告结束。如果下一个节点存放长度的空间不能容纳当前节点的长度,那么就会将下一个节点进行扩容,并重新申请内存大小,并复制数据,移动指向尾部节点的指针。最后移动到下一个节点,在下一个循环中判断是否需要继续扩容。
Redis中的每个对象都由一个 redisObject 结构来表示:
typedef struct redisObject {
unsigned type:4; // 类型
unsigned encoding:4; // 编码
void *ptr; // 指向底层实现数据结构的指针
}
类型包括基本的五种,编码指对应类型下的不同编码实现。
Redis可以根据不同的使用场景,来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。
字符串的编码可以是 int
、 raw
或者是 embstr
。
如果一个字符串对象保存的是整数值,并且这个整数值可以用long
类型来表示,那么这个字符串对象会将整数值保存在字符串对象结构的 ptr
属性中(将 void*
转换成 long
),并将字符串对象的编码设置为int
。
如果一个字符串对象保存的是一个字符串值,并且长度大于44
字节,那么这个字符串对象将使用简单动态字符串(SDS)来保存,并且编码设置为 raw
。
如果一个字符串对象保存的是一个字符串值,并且长度小于等于44
字节,那么同上,但是编码设置为embstr
。
embstr
是专门用于保存短字符串的优化编码方式。它和 raw
的区别在于,raw编码会调用两次内存分配函数来分别创建 redisObject
和 sdshdr
结构,而embstr
编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含 redisObject
和 sdshdr
结构。
使用 embstr
的好处:
embstr
保存在连续的内存中,它可以更好地利用缓存带来的优势。不过,embstr
编码没有任何相应的修改程序,它实际上只是只读的,当 embstr
编码的字符串执行修改命令时,总会变成 raw
。
如果看过书的同学有疑问很正常,因为在《Redis的设计与实现》中,它写的临界值是39字节,但是实际上经过查找资料,在3.2版本之后就改成了44字节了。主要原因是为了内存优化,具体解释如下:
我们知道对于每个 sds
都有一个 sdshdr
,里面的 len
和 free
记录了这个 sds
的长度和空闲空间,但是这样的处理十分粗糙,使用的 unsigned int
可以表示很大的范围,但是对于很短的 sds
有很多的空间被浪费了(两个unsigned int 8个字节
)。而这个 commit
则将原来的 sdshdr
改成了 sdshdr16
, sdshdr32
, sdshdr64
,里面的 unsigned int
变成了 uint8_t
,uint16_t
…(还加了一个char flags
)这样更加优化小 sds
的内存使用。
本身就是针对短字符串的 embstr
自然会使用最小的 sdshdr8
,而 sdshdr8
与之前的 sdshdr
相比正好减少了5个字节(sdsdr8 = uint8_t * 2 + char = 1*2+1 = 3, sdshdr = unsigned int * 2 = 4 * 2 = 8
),所以其能容纳的字符串长度增加了5个字节变成了44。
列表的编码可以是 ziplist
或者 linkedlist
。(压缩列表
或者 双向链表
)
如果列表对象保存的所有字符串元素的长度都小于64字节,并且列表对象保存的元素数量小于512个时,编码为 ziplist
。
上面两个条件,只要一个不满足,就采取 linkedlist
编码。
哈希对象的编码可以是 ziplist
或者 hashtable
。(压缩列表
或者 字典
)
如果哈希对象保存的所有键值对的键和值的字符串长度都小于64字节,并且哈希对象保存的键值对数量小于512个时,编码为 ziplist
。
上面两个条件,只要一个不满足,就采取 hashtable
编码。
集合对象的编码可以是 intset
或者 hashtable
。
如果集合对象保存的所有元素都是整数值,并且哈希对象保存的元素数量小于512个时,编码为 intset
。
上面两个条件,只要一个不满足,就采取 hashtable
编码。
有序集合的编码可以是 ziplist
或者 skiplist
。
如果有序集合对象保存的所有元素成员的长度都小于64字节,并且有序集合对象保存的元素数量小于128个时,编码为 ziplist
。
上面两个条件,只要一个不满足,就采取 skiplist
编码。
详细了解参考文章:Redis的两种持久化RDB和AOF(超详细)
Redis对数据的操作都是基于内存的,当遇到了进程退出、服务器宕机等意外情况,如果没有持久化机制,那么Redis中的数据将会丢失无法恢复。有了持久化机制,Redis在下次重启时可以利用之前持久化的文件进行数据恢复。
Redis支持的两种持久化机制:
RDB
:把当前数据生成快照保存在硬盘上。AOF
:记录每次对数据的操作到硬盘上。混合持久化
:在 redis 4 引入,RDB
+ AOF
混合使用的方式,RDB
持久化全量数据,AOF
持久化增量数据。RDB
(Redis DataBase)持久化是把当前Redis中全部数据生成快照保存在硬盘上。RDB持久化可以手动触发
,也可以自动触发
。
save
和 bgsave
命令都可以手动触发RDB持久化。
save
命令会手动触发RDB持久化,但是save
命令会阻塞Redis服务,直到RDB持久化完成。当Redis服务储存大量数据时,会造成较长时间的阻塞,不建议使用。bgsave
命令也会手动触发RDB持久化,和save
命令不同是:Redis服务一般不会阻塞。Redis进程会执行fork操作创建子进程,RDB持久化由子进程负责,不会阻塞Redis服务进程。Redis服务的阻塞只发生在fork阶段,一般情况时间很短。
bgsave
命令,Redis进程先判断当前是否存在正在执行的RDB或AOF子线程,如果存在就是直接结束。fork
操作创建子进程,在fork操作的过程中Redis进程会被阻塞。fork
完成后, bgsave
命令就结束了,自此Redis进程不会被阻塞,可以响应其他命令。简单说明,
save
命令会全程阻塞,bgsave
只在创建子线程时会阻塞。
在以下几种场景下,会自动触发RDB持久化:
save
的相关配置,如sava m n
,它表示在 m
秒内数据被修改过 n
次时,自动触发 bgsave
操作。bgsave
操作,并且把生成的RDB文件发送给从节点。debug reload
命令时,也会自动触发 bgsave
操作。shutdown
命令时,如果没有开启AOF持久化也会自动触发 bgsave
操作。bgsave
操作都要执行fork操作创建子经常,属于重量级操作,频繁执行成本过高,所以无法做到实时持久化,或者秒级持久化。使用 bgrewriteaof
命令。
根据 auto-aof-rewrite-min-size
和 auto-aof-rewrite-percentage
配置确定自动触发的时机。
auto-aof-rewrite-min-size
表示运行AOF重写时文件大小的最小值,默认为64MB。auto-aof-rewrite-percentage
表示当前AOF文件大小和上一次重写后AOF文件大小的比值的最小值,默认为100。只用前两者同时超过阈值时才会自动触发文件重写。
AOF持久化流程中的文件同步有以下几个策略:
always
:每次写入缓存区都要同步到AOF文件中,硬盘的操作比较慢,限制了Redis高并发,不建议配置。no
:每次写入缓存区后不进行同步,同步到AOF文件的操作由操作系统负责,每次同步AOF文件的周期不可控,而且增大了每次同步的硬盘的数据量。eversec
:每次写入缓存区后,由专门的线程每秒钟同步一次,做到了兼顾性能和数据安全。是建议的同步策略,也是默认的策略。# appendonly改为yes,开启AOF
appendonly yes
# AOF文件的名字
appendfilename "appendonly.aof"
# AOF文件的写入方式
# everysec 每个一秒将缓存区内容写入文件 默认开启的写入方式
appendfsync everysec
# 运行AOF重写时AOF文件大小的增长率的最小值
auto-aof-rewrite-percentage 100
# 运行AOF重写时文件大小的最小值
auto-aof-rewrite-min-size 64mb
推荐博客文章:Redis全面解析一:redis是单线程结构为何还可以支持高并发
我们经常说Redis是单线程的,但是为什么这么说呢?
因为 Redis 内部用的是基于 Reactor
模式开发的文件事件处理器,文件事件处理器是以单线程方式运行的,所以redis才叫单线程模型。
基于 Reactor
模式设计的四个组成部分的结构如下所示:
它们分别是:
文件事件处理器大致可分为三个处理流程:
关于Redis6.0的多线程升级博客参考链接:Redis6 新特性多线程解析
Redis 缓存使用内存保存数据,避免了系统直接从后台数据库读取数据,提高了响应速度。由于缓存容量有限,当缓存容量到达上限,就需要删除部分数据挪出空间,这样新数据才可以添加进来。Redis 定义了「淘汰机制」用来解决内存被写满的问题。
缓存淘汰机制,也叫缓存替换机制,它需要解决两个问题:
截至在 4.0 之后,Redis定义了「8种内存淘汰策略」用来处理 redis 内存满的情况:
noeviction
:不会淘汰任何数据,当使用的内存空间超过 maxmemory 值时,返回错误。volatile-ttl
:筛选设置了过期时间的键值对,越早过期的越先被删除。volatile-random
:筛选设置了过期时间的键值对,随机删除。volatile-lru
:使用 LRU 算法筛选设置了过期时间的键值对。volatile-lfu
:使用 LFU 算法选择设置了过期时间的键值对。allkeys-random
:在所有键值对中,随机选择并删除数据。allkeys-lru
:使用 LRU 算法在所有数据中进行筛选。allkeys-lfu
:使用 LFU 算法在所有数据中进行筛选。根据它们的名称
和前缀
我们就能如下分类:
noeviction
。volatile-ttl
、volatile-random
、volatile-lru
、volatile-lfu
。allkeys-random
、allkeys-lru
、allkeys-lfu
。noeviction
策略,也是 Redis 的默认策略,它要求 Redis 在使用的内存空间超过 maxmemory
值时,也不进行数据淘汰。一旦缓存被写满了,再有写请求来的时候,Redis 会直接返回错误。
我们实际项目中,一般不会使用这种策略。因为我们业务数据量通常会超过缓存容量的,而这个策略不淘汰数据,导致有些热点数据保存不到缓存中,失去了使用缓存的初衷。
volatile-random
、volatile-ttl
、volatile-lru
、volatile-lfu
这四种淘汰策略。它们淘汰数据的时候,只会筛选设置了过期时间的键值对上。
比如,我们使用 EXPIRE
命令对一批键值对设置了过期时间,那么会有两种情况会对这些数据进行清理:
maxmemory
阈值,Redis 会根据 volatile-random
、volatile-ttl
、volatile-lru
、volatile-lfu
这四种淘汰策略,具体的规则进行淘汰;这也就是说,如果一个键值对被删除策略选中了,即使它的过期时间还没到,也需要被删除。其中 volatile-ttl
、volatile-random
的筛选规则比较简单,而volatile-lru
、volatile-lfu
分别用到了 LRU
和 LFU
算法。
allkeys-random
,allkeys-lru
,allkeys-lfu
这三种策略跟上述四种策略的区别是:淘汰时数据筛选的数据范围是所有键值对。
其中allkeys-random
的筛选规则比较简单,而allkeys-lru
,allkeys-lfu
分别用到了LRU
和 LFU
算法。
LRU
算法全称 Least Recently Used
,一种常见的页面置换算法。按照「最近最少使用」的原则来筛选数据,筛选出最不常用的数据,而最近频繁使用的数据会留在缓存中。
RU
会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU
端和 LRU
端,分别代表「最近最常使用」的数据和「最近最不常用」的数据。
每次访问数据时,都会把刚刚被访问的数据移到 MRU
端,就可以让它们尽可能地留在缓存中。
如果此时有新数据要写入时,并且没有多余的缓存空间,那么该链表会做两件事情:
MRU
端。LRU
端的数据删除。简单说明,即它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在
MRU
端;LRU
端的数据被认为是长久不访问的数据,在缓存满时,就优先删除它。
Redis 3.0 前,随机选取 N 个淘汰法。
Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject
中的 lru
字段记录)。
在 Redis 决定淘汰的数据时,随机选 N(默认5) 个 key,把空闲时间(idle time)最大的那个 key 移除。这边的 N 可通过 maxmemory-samples
配置项修改:
config set maxmemory-samples 100
当需要再次淘汰数据时,Redis 需要挑选数据进入「第一次淘汰时创建的候选集合」。
挑选的标准是:能进入候选集合的数据的 lru 字段值必须小于「候选集合中最小的 lru 值」。
当有新数据进入备选数据集后,如果备选数据集中的数据个数达到了设置的阈值时。Redis 就把备选数据集中 lru
字段值最小的数据淘汰出去。
Redis3.0后,引入了缓冲池(默认容量为16)概念。
当每一轮移除 key 时,拿到了 N(默认5)个 key 的 idle time,遍历处理这 N 个 key,如果 key 的 idle time 比 pool 里面的 key 的 idle time 还要大,就把它添加到 pool 里面去。
当 pool 放满之后,每次如果有新的 key 需要放入,需要将 pool 中 idle time 最小的一个 key 移除。这样相当于 pool 里面始终维护着还未被淘汰的 idle time 最大的 16 个 key。
当我们每轮要淘汰的时候,直接从 pool 里面取出 idle time 最大的 key(只取1个),将之淘汰掉。
整个流程相当于随机取 5 个 key 放入 pool,然后淘汰 pool 中空闲时间最大的 key,然后再随机取 5 个 key放入 pool,继续淘汰 pool 中空闲时间最大的 key,一直持续下去。
在进入淘汰前会计算出需要释放的内存大小,然后就一直循环上述流程,直至释放足够的内存。
在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用内存空间。这种情况,就是缓存污染
。
为了应对缓存污染
问题,Redis 从 4.0 版本开始增加了 LFU
淘汰策略。
LFU
缓存策略是在 LRU
策略基础上,为每个数据增加了一个「计数器」,来统计这个数据的访问次数。
我们在前面说过,为了避免操作链表的开销,Redis 在实现 LRU 策略时使用了两个近似方法:
RedisObject
结构中设置了 lru
字段,用来记录数据的访问时间戳。在此基础上,Redis 在实现 LFU 策略的时候,只是把原来 24bit 大小的 lru 字段,又进一步拆分成了两部分:
ldt 值
:lru 字段的前 16bit,表示数据的访问时间戳
。counter 值
:lru 字段的后 8bit,表示数据的访问次数
。但是我们会发现一个问题,counter 值
的最大记录值只有255。当几个缓存数据的 counter 值
都达到255值,就无法正确根据访问次数来决定数据的淘汰了。
所以Redis 针对这个问题进行了优化:在实现 LFU 策略时,Redis 并没有采用数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。
Redis 实现 LFU 策略时采用计数规则:
lfu_log_factor
,再加 1;取其倒数,得到一个 p 值。Redis的部分源码实现如下:
double r = (double)rand() / RAND_MAX; // 随机数 r 值
// ......
// baseval 是计数器当前的值,初始值默认是 5,是由代码中的 LFU_INIT_VAL 常量设置
double p = 1.0 / (baseval * server.lfu_log_factor + 1); // ((计数器当前值 * 配置项参数) + 1 )的倒数
if (r < p) counter++;
为什么
baseval
的初始值是5,而不是0?是因为这样可以避免数据刚被写入缓存,就因为访问次数少而被立即淘汰。
使用了这种计算规则后,我们可以通过设置不同的 lfu_log_factor
配置项,来控制计数器值增加的速度,避免 counter 值
很快就到 255 了。
这张表是根据Redis官网获得的,进一步说明 LFU 策略计数器递增的效果。
它记录了当 lfu_log_factor
取不同值时,在不同的实际访问次数情况下,计数器值的变化情况。
lfu_log_factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits |
---|---|---|---|---|---|
0 | 104 | 255 | 255 | 255 | 255 |
1 | 18 | 49 | 255 | 255 | 255 |
10 | 10 | 18 | 142 | 255 | 255 |
100 | 8 | 11 | 49 | 143 | 255 |
通过上表的分析:
1
时,实际访问次数为 100K
后,counter 值就达到 255 了,无法再区分实际访问次数更多的数据了。100
时,当实际访问次数为 10M
时,counter 值才达到 255。使用这种非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU 策略也可以有效的区分不同的访问次数,从而合理的进行数据筛选。
从刚才的表中,我们可以看到,当 lfu_log_factor
取值为 10
时,百、千、十万级别的访问次数对应的 counter 值
已经有明显的区分了。所以,我们在应用 LFU 策略时,一般可以将 lfu_log_factor
取值为 10
。
但是对于一些业务场景,上方的设计会存在问题:比如说有些数据在「短时间内被大量访问后就不会再被访问了」。
那么再按照访问次数来筛选的话,这些数据会被留存在缓存中,但不会提升缓存命中率。
为此,Redis 在实现 LFU 策略时,还设计了一个「 counter 值的衰减机制」。
简单来说,LFU 策略使用 lfu_decay_time
(衰减因子配置项) 来控制访问次数的衰减。
lfu_decay_time
值,所得的结果就是数据 counter 要衰减的值。通过上方的第二点,我们就能知道一个规律,lfu_decay_time
值越大,那么相应的衰减值会变小,衰减效果也会减弱;反之相应的衰减值会变大,衰减效果也会增强。
所以,如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time
值设置为 1
。
allkeys-lru
策略。这样,可以充分利用 LRU 算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。allkeys-random
策略,随机选择淘汰的数据。volatile-lru
策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。Redis 事务相对于Mysql 事务来说较为简单,大家可以将二者进行对比,下文也会整理。
Redis 事务的本质是一组命令的集合
。
事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。
简单理解,Redis 中的事务,就是具有一次性、顺序性、排他性地在命令序列中执行多个命令。
它的主要作用就是
串联多个命令防止别的命令插队
。
我们可以把Redis 事务的执行分为三个阶段:
从输入Multi
命令开始,输入的命令都会依次进入命令队列中,但不会执行,直到输入 Exec
后,Redis会将之前的命令队列中的命令依次执行。组队的过程中可以通过 discard
。
事务的错误分为两种情况:
这说明在 Redis 中,虽然单条命令是原子性执行的,但是
事务不保证原子性,且没有回滚
。事务中任意命令执行失败,其余的命令仍会被执行。
Redis 中的 悲观锁
和 乐观锁
,简单提及以下:
悲观锁(Pessimistic Lock
),每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁
,表锁
等,读锁
,写锁
等,都是在做操作之前先上锁。
乐观锁(Optimistic Lock
),每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。Redis就是利用这种check-and-set
机制实现事务的。
在执行 multi
之前,先执行watch key1 [key2]
,可以监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
举例说明:
假如我账户上有100元,此时我们准备再给账户充值50元,准备买149元的传说皮肤。
但是此时,以一位糟糕的程序员修改了我们的账户,改成了999元。
我很生气,因为我充值失败了,但是我去账户上一看,变成999元了,我马上给自己一巴掌,“在生气什么呢?”…
模拟上方情景,这是控制台1
的操作:
模拟上方情景,这是控制台2
的操作:
注意:只要执行了EXEC,之前加的监控锁都会被取消!Redis的事务不保证原子性,一条命令执行失败了,其他的仍然会执行,且不会回滚。
取消 WATCH
命令对所有 key 的监视。
如果在执行 WATCH
命令之后,EXEC
命令或 DISCARD
命令先被执行了的话,那么就不需要再执行 UNWATCH
了。
redis 的事务不推荐在实际中使用,如果要使用事务,推荐使用 Lua 脚本,redis 会保证一个 Lua 脚本里的所有命令的原子性。
除去Redis的单例模式
,Redis 的集群模式可以分为三种:主从复制
、哨兵模式
、集群模式
。
Redis 官方文档【主从复制】:REDIS sentinel-old – Redis中国用户组(CRUG)
主从复制,将 Redis 实例分为两中角色,一种是被复制的服务器称为主服务器(master
),而对主服务器进行复制的服务器被称为从服务器(slave
)。
当主数据库有数据写入,会将数据同步复制给从节点,一个主数据库可以同时拥有多个从数据库,而从数据库只能拥有一个主数据库。值得一提的是,从节点也可以有从节点,呈现级联结构。
我们可以看到,在主从复制
中,只有一个是主机,其他的都是从机,并且从机下面还可以有任意多个从机。
主数据库可以进行读写操作,从数据库只能有读操作(并不一定,只是推荐这么做)。
通过slaveof
命令,将 127.0.0.1:6380
的redis实例成为 127.0.0.1:6379
的redis实例的从服务器:
slaveof 127.0.0.1 6379
测试如下:
通过编写配置文件,例如先为主配置文件命名为 master.conf
进行编写配置:
# 通用配置
# bind 127.0.0.1 # 绑定监听的网卡IP,注释掉或配置成0.0.0.0可使任意IP均可访问
port 6379 # 设置监听端口
#是否开启保护模式,默认开启。
# 设置为no之后最好设置一下密码
protected-mode no
#是否在后台执行,yes:后台运行;no:不是后台运行
daemonize yes
# 复制选项,slave复制对应的master。
# replicaof
#如果master设置了requirepass,那么slave要连上master,需要有master的密码才行。masterauth就是用来
# 配置master的密码,这样可以在连上master后进行认证。
# masterauth
在启动节点时输入命令
redis-server master.conf
redis-server slave1.conf
redis-server slave2.conf
不过在docker容器中的Redis镜像配置存在一些问题,大家自己找一下资料吧。
参考博客链接:redis启动命令及集群创建
例如客户端操作从服务器执行如下命令:
127.0.0.1> SLAVEOF 127.0.0.1 6379
从服务器会将客户端给定的主服务器IP地址
以及端口号
保存到当前从服务器状态的 masterhost
属性和 masterport
属性中。
SLAVEOF
命令是一个异步命令,在完成属性的设置工作后,从服务器会向客户端返回"OK"
,之后开始执行真正的复制工作。
从服务器根据指定的 IP地址
和端口号
,创建连向主服务器套接字(socket)连接。
主服务器在接受(accept) 从服务器的套接字连接之后,为该套接字创建相应的客户端状态。
这个时候可以将
从服务器
理解为主服务器
的客户端。
从服务器向主服务器发送一个 PING
命令,以检査套接字的读写状态是否正常
、 主服务器能否正常处理命令请求
。
从服务器在发送 PING
命令后,会遇到三种情况:
"PONG"
,表示主从之间网络连接状态正常,主服务器可以正常处理从服务器的命令请求。存在这一步的前提是:从服务器设置了 masterauth
选项,那么就要进行这一步的身份验证,否则跳过。
从服务器将 masterauth
选项的值封装成AUTH password
命令并向主服务器发送来进行身份验证。
从服务器在身份验证阶段可能会遇到以下几种情况:
requirepass
选项,并且从服务器也没有设置 masterauth
选项,那么继续执行复制工作。AUTH
命令发送的密码和主服务器 requirepass
选项的值相同,那么继续执行复制工作;反之,主服务器返回 invalid password
错误。requirepass
选项,但是从服务器没有设置 masterauth
选项,那么主服务器返回 NOAUTH
错误;如果主服务器没有设置 requirepass
选项,但是从服务器设置 masterauth
选项,那么主服务器返回 no password is set
错误。从服务器向主服务器发送当前服务器的监听端口号, 主服务器收到后记录在从服务器所对应的客户端状态的 slave_listening_port
属性中。
执行命令为 REPLCONF listening-port
,port-number
即为端口号。
目前 slave_listening_port
唯一的作用就是在主服务器执行 INFO replication
命令时打印从服务器端口号。
从服务器向主服务器发送 PSYNC
命令,执行同步操作,此时两者互为客户端。
PSYNC
命令有两种执行情况:
slaveof no one
命令,那么从服务器在开始一次新的复制时,会给主服务器发送 PSYNC ? -1
命令。主动请求进行完整重同步。PSYNC
命令,runid
是上次主服务器的运行ID,offset是从服务器的复制偏移量。主服务器返回从服务器也有三种情况:
+FULLRESYNC
回复,表示主服务器执行完整重同步操作,runid
为主服务器的ID,从服务器会将其保存,offset
是主服务器的复制偏移量,从服务器会将其当作自己的起始复制偏移量。+CONTINUE
回复,表示主服务器执行部分重同步操作,从服务器只要等待主服务器发送缺少的那部分数据过来即可。+ERR
回复,那么表示 Redis 版本低于2.8,识别不了 PSYNC
命令,那么从服务器向主服务器发送 SYNC
命令,并与之执行完整同步操作。从上方可知,主要包括全量数据同步 和 增量数据同步的情况,这跟Redis是否第一次连接
和在连接过程中是否离线
有关。
当完成了同步之后,就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从一致了。
其实redis的主从模式很简单,在实际的生产环境中很少使用,不建议在实际的生产环境中使用主从模式来提供系统的高可用性,之所以不建议使用都是由它的缺点造成的,在数据量非常大的情况,或者对系统的高可用性要求很高的情况下,主从模式也是不稳定的。虽然这个模式很简单,但是这个模式是其他模式的基础,所以理解了这个模式,对其他模式的学习会很有帮助。
命令传播阶段后的
心跳检测
以及PSYNC
的实现,具体参照书中,不多解释了。
Redis官方文档【高可用】:REDIS sentinel-old – Redis中国用户组(CRUG)
参考公众号文章:全面分析Redis高可用的奥秘 - Sentinel
哨兵(Sentinel) 是 Redis 的高可用性解决方案:由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器。
Sentinel 可以在被监视的主服务器进入下线状态时,自动将下线主服务器的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。
哨兵(Sentinel)其实也是Redis 实例,只不过它在启动时初始化将 Redis 服务器使用的代码替换成 Sentinel 专用代码。
Monitoring
): 哨兵(sentinel) 会不断地检查你的Master和Slave是否运作正常。Notification
):当被监控的某个Redis节点出现问题时, 哨兵(sentinel) 可以通过 API 向管理员或者其他应用程序发送通知。Automatic failover
):当一个Master不能正常工作时,哨兵(sentinel) 会开始一次自动故障迁移操作。特殊模式
下的 Redis 服务器。sentinel.c/sentinelState
结构,用于保存所有和 Sentinel 功能相关的状态,比如其中的 masters
字典记录了所有被 Sentinel 监视的主服务器相关信息。这一步是初始化 Sentinel 的最后一步,Sentinel 成为主服务器的客户端,可以向主服务器发送命令。
每个sentinel都会创建两个连向主服务器的异步网络连接。
__sentinel__:hello
频道。为什么有两个连接?
命令连接的原因是:Sentinel 必须向主服务器发送命令,以此来与主服务器通信。
订阅连接的原因是:目前Redis版本的发布订阅功能无法保存被发送的信息,如果接收信息的客户端离线,那么这个客户端就会丢失这条信息,为了不丢失
__sentinel__:hello
频道的任何信息,Sentinel 专门用一个订阅连接来接收该频道的信息。【简单理解:不仅需要发信息,也需要收信息】
Sentinel 默认会以10秒一次通过命令连接向被监视的主服务器发送 INFO
命令,主服务器收到后回复自己的run_id
、IP
、端口、对应的主服务器信息及主服务器下的所有从服务器信息。
Sentinel 根据返回的主服务器信息更新自身的 *masters
实例结构;至于主服务器返回的从服务器信息用于更新对应的slaves
字典列表。
更新 slaves
字典时有两种情况:
Sentinel 同样会和从服务器建立异步的命令连接和订阅连接,并也会默认10秒一次向从服务器发送 INFO
命令,从服务器会回复自己的运行run_id
、角色role
、从服务器复制偏移量offset
、主服务器的ip和port
、主从服务器连接状态
、从服务器优先级
等信息,sentinel会根据返回信息更新对应的 slave
实例结构。
Sentinel 默认会以2秒一次通过命令连接向所有被监控的主服务器和从服务器的_sentinel:hello
频道发送信息,信息的内容包含两种参数:
s_
开头的参数,代表 Sentinel 自身的信息。m_
开头的参数,代表主服务器的信息。
参数列表展示参考:
参数 | 意义 |
---|---|
s_ip | Sentinel 的 IP地址 |
s_port | Sentinel 的端口号 |
s_runid | Sentinel 的运行ID |
s_epoch | Sentinel 当前的配置纪元(configuration epoch) |
m_name | 主服务器的名字 |
m_ip | 主服务器的IP地址 |
m_port | 主服务器的端口号 |
m_epoch | 主服务器当前的配置纪元 |
Sentinel通过订阅连接向服务器发送命令 SUBSCRIBE __sentinel__:hello
,保证对_sentinel_:hello
的订阅一直持续到 Sentinel 与 服务器的连接断开为止。
_sentinel_:hello
频道 与 Sentinel 的关系是一对多的关系,作用在于发现多个监控同一master的sentinel。
在接收到其他 sentinel 发送的频道信息后,会根据信息更新 master 对应的 Sentinel 。
与 master
数据结构绑定后,会建立 Sentinel 与 Sentinel 的命令连接,为后续通讯做准备。
Sentinel 默认会以1秒一次的频率向与它建立命令连接的所有实例(包括master、slave以及发现的其他sentinel)发送 PING
命令,对方接收后返回两种回复:
+PONG
)、正在加载(-LOADING
)、和主机下线(-MASTERDOWN
)。在固定时间内,即 down-after-milliseconds
(默认单位为毫秒) 配置的时间内收到的都是无效回复,Sentinel 就会标记 master 为主观下线。与此同时,Sentinel 会将 master 数据结构中对应的flags
属性更新为 SRI_S_DOWN
标识,表示被监控的master在当前sentinel中已经进入主观下线状态。
down-after-milliseconds
的值,不仅是sentinel 用来判断主服务器主观下线状态,还用来判断主服务器下所有从服务器,以及所有同样监视这个主服务器的其他Sentinel的主观下线状态。简单说明,即
down-after-millsseconds
配置是作用于当前sentinel所监控的所有服务上的,也就是对应master下的slave,以及其他sentinel。另外每个sentinel可以配置不同down-after-millsenconds
,所以判定主观下线的时间也就是不同的。
判定 master 为主观下线状态的 Sentinel,通过命令询问其他同样监控这一主服务器的 Sentinel,看它们是否认为该 master 真的进入了下线状态。
Sentinel 发送给其他 Sentinel 的命令为:
SEBTUBEL is-master-down-by-addr
参数说明:
Ip
:被 Sentinel 判断为主观下线的主服务器的IP地址。port
:被 Sentinel 判断为主观下线的主服务器的端口号。current_epoch
:Sentinel 当前的配置纪元,用于选举领头 Sentinel。runid
:可以是 *
符号 或 Sentinel 的 run_id
,用 *
符号仅用于检测主服务器的客观下线状态;用Sentinel 的 run_id
是用于选举领头 Sentinel。其他 Sentinel 接收到 SEBTUBEL is-master-down-by-addr
命令后,会根据其中的主服务器IP
和端口号
,检查主服务器是否已下线,然后向源 Sentinel 返回一条包含三个参数的 Multi Bulk
回复:
参数说明:
down_state
:返回目标 Sentinel 对主服务器的检查结果,1
代表已下线,0
代表为下线。
leader_runid
:可以是 *
符号 或 目标 Sentinel 的 run_id
,用 *
符号仅用于检测主服务器的下线状态;用局部领头 Sentinel 的 run_id
是用于选举局部领头 Sentinel。
leader_epoch
:目标 Sentinel 的局部领头 Sentinel 的配置纪元,用于选举领头 Sentinel。【仅在 leader_runid
的值不为 *
时有效,如果 leader_runid
的值为 *
,则 leader_epoch
总为0】
当 Sentinel 收到从其他 Sentinel 返回的足够数量的已下线判断之后,Sentinel会将主服务器实例结构的 flags
属性的 SRI_O_DOWN
标识打开,表示主服务器已经进入客观下线状态。
足够数量的已下线判断是多少呢?
不同的 Sentinel 判断客观下线状态的条件是不同的,具体不解释了,看《Redis设计与实现》P238。
当一个主服务器被判断为客观下线时,监测这个下线主服务器的各个 Sentinel 会进行协商,选举出一个领头 Sentinel,并由领头 Sentinel 对下线主服务器执行故障转移操作。
下面尽量直白地介绍选举领头 Sentinel 的规则和方法:
每个在线的 Sentinel 都有被选为领头 Sentinel 的资格。
同一个配置纪元内(本质是计数器,在每次选举后自增一次),每个 Sentinel 都有一次将某个 Sentinel 设置为局部领头 Sentinel 的机会,并且设置后,在这个配置纪元里不能再更改。
每个发现主服务器进入客观下线 的Sentinel 都会要求其他 Sentinel 将自己设置为局部领头Sentinel。
拉票方式为发送 SEBTUBEL is-master-down-by-addr
命令,刚才的 *
号替换为源 Sentinel的 run_id
,表示希望目标 Sentinel 设置自己为它的局部领头 Sentinel。
接收拉票命令的目标 Sentinel 可是非常单纯,谁的命令先发给它,它就选谁当自己的局部领头 Sentinel,之后的拉票全部拒绝。
当然,既然目标 Sentinel根据先到先得确定了局部领头 Sentinel,那也得和大家回个话,它会为发送拉票命令的源 Sentinel 回复命令,记录了自身选择的局部领头 Sentinel的 run_id
和 配置纪元
。
如果某个 Sentinel 被半数以上的 Sentinel 设置为了局部领头 Sentinel,那么这个局部领头sentinel就变成了领头sentinel,同一个配置纪元内可能会出现多个局部领头sentinel,但是领头sentinel只会产生一个。
如果在给定的时限内,没有任何一个 Sentinel 被选举为领头 Sentinel,那么各个 Sentinel 会在一段时间后再次选举,直到选出领头 Sentinel 为止。
在选举出领头 Sentinel 之后,领头 Sentinel 会对已下线的主服务器执行故障转移操作,可分为三个步骤:
(一)、新的主服务器是从原主服务器下的从服务器中选择的,所以需要选择状态良好
、数据完整
的从服务器。领头 Sentinel 的数据结构中保存了原master对应的 slave ,Sentinel 会删除状态较差的slave。过滤执行顺序如下:
断线
或者下线
的从服务器。5
秒内没有回复过领头 Sentinel 的 INFO
命令的从服务器。down-after-millisecond * 10
毫秒的从服务器,这样可以排除从服务器与原主服务器过早断开连接,保证备选从服务器的数据都是比较新的。对应第三条,我可以解释一下,前面提到过,在
down-after-millisecond
设置的时长内没有收到有效回复,可以判定当前复制的主服务器主观下线。所以,越迟和主服务器断开连接的从服务器,数据越新。
(二)、现在过滤出的都是健康的从服务器了,然后 Sentinel 开始选择新的主服务器,有以下三个优先级顺序:
优先级
进行排序,选出优先级最高的服务器。复制偏移量
来进行排序。run_id
最小的从服务器。(三)、选出新的主服务器后,领头 Sentinel 向被选中的从服务器发送 SLAVEOF no one
命令。
在发送 SLAVEOF no one
命令后,领头 Sentinel 会以每秒一次的频率(平时是十秒一次)向被选中的从服务器发送 INFO
命令,当被升级的服务器的 role
字段从 slave
变为 master
时,领头 Sentinel 就知道它已经顺利成为新主服务器了。
领头 Sentinel 给已下线主服务器下的所有从服务器发送 SLAVEOF
命令,让它们去复制新的主服务器。
因为旧主服务器下线,领头Sentinel 会修改它对应主服务器下的实例结构中的设置。
等旧主服务器重新上线时,Sentinel 就会向它发送 SLAVEOF
命令,让他成为新的主服务器的从服务器。
《Redis设计与实现》第十七章 集群 p245;
官方文档【集群教程】:REDIS cluster-tutorial – Redis中文资料站 – Redis中国用户组(CRUG)
官方文档【集群规范】:REDIS cluster-spec – Redis中文资料站 – Redis中国用户组(CRUG)
官方文档【分区】:REDIS 分区 – Redis中国用户组(CRUG)
哨兵模式最大的缺点就是所有的数据都放在一台服务器上,无法较好的进行水平扩展。
为了解决哨兵模式的痛点,集群模式应运而生。在高可用上,集群基本是直接复用的哨兵模式的逻辑,并且针对水平扩展进行了优化。
它具有的特点有:
crc16(key,keylen) & 16383
。PING-PONG
机制来进行节点间的心跳检测。复制
和故障恢复
功能。在实际应用场景下,通常会将主从分布在不同服务器,避免单个服务器出现故障导致整个分片出问题,下图的 内网IP
代表不同的服务器。下面将会根据它的特点逐步说明该集群的核心技术。
使用 clusterNode
结构保存一个节点的当前状态,比如创建时间
、名称
、配置纪元
、IP
、端口号
等。
每个节点都会为自己和集群中所有其他节点都创建一个对应的 clusterNode
结构来记录各自的节点状态。
struct clusterNode {
// 创建节点的时间
mstime_t ctime;
// 节点的名称,由40个十六进制字符组成,例如68eef66df23420a5862208ef5...f2ff
char name[REDIS_CLUSTER_NAMELEN];
// 节点标识,使用各种不同表示值记录节点的角色(主节点或从节点);以及节点目前的状态(在线或下线)
int flags;
// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;
// 节点的IP地址
char ip[REDIS_IP_STR_LEN];
// 节点的端口号
int port;
// 保存连接节点所需的相关信息
clusterLink *link;
// ...
};
其中的 link
属性是一个 clusterLink
结构,该结构保存连接节点所需的相关信息,包括套接字描述符、输入缓冲区、输出缓冲区。
typedef struct clusterLink {
// 连接的创建时间
mestime_t ctime;
// TCP 套接字描述符
int fd;
// 输出缓冲区,保存着待发送给其他节点的信息(message)
sds sndbuf;
// 输入缓冲区,保存着从其他节点接收到的信息
sds rcvbuf;
// 与这个连接相关联的节点,如果没有的话就为 NULL
struct clusterNode *node;
}
最后一点,每个节点都保存着一个 clusterState
结构,这个结构记录了当前节点视角下,所在集群目前所处的状态。
例如集群在线或下线状态、包含节点个数、集群当前的配置纪元等信息。
typedef struct clsterState {
// 指向当前节点的指针
clusterNode *myself;
// 集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;
// 集群当前的状态,是在线还是下线
int state;
// 集群节点名单(包含myself节点)
// 字典的key是节点的名字,value是节点对应的 clusterNode 结构
dict *nodes;
}
通过发送 CLUSTER MEET
命令,可以让目标节点A将另一个命令携带的节点B添加到目标节点A当前所在的集群中。
CLUSTER MEET
收到命令后开始进行节点A和节点B的握手阶段,以此来确认彼此的存在,为后面的通信打好基础,该过程简单说明:
CLUSTER MEET
命令后,节点A向节点B发送 MEET
信息,给节点B创建 clusterNode
结构,并更新自己的 clusterState
结构。PONG
信息。PING
信息。之后,节点A和节点B会通过Gossip 协议传播给集群其他的节点,让他们也和节点B握手,最终整个集群达成共识。
一般集群元数据的维护有两种方式:集中式、Gossip 协议。在Redis集群中采用Gossip 协议进行通信,所以说它是去中心化的集群。
下面说一下这两种方式的区别:
集中式:是将集群元数据(节点信息、故障等等)几种存储在某个节点上。集中式元数据集中存储的一个典型代表,就是大数据领域的
storm
。它是分布式的大数据实时计算引擎,是集中式的元数据存储的结构,底层基于 zookeeper(分布式协调的中间件)对所有元数据进行存储维护。gossip 协议:所有节点都持有一份元数据,不同的节点如果出现了元数据的变更,就不断将元数据发送给其它的节点,让其它节点也进行元数据的变更。
集中式的好处在于,元数据的读取和更新,时效性非常好,一旦元数据出现了变更,就立即更新到集中式的存储中,其它节点读取的时候就可以感知到;不好在于,所有的元数据的更新压力全部集中在一个地方,可能会导致元数据的存储有压力。
gossip 协议的好处在于,元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续打到所有节点上去更新,降低了压力;不好在于,元数据的更新有延时,可能导致集群中的一些操作会有一些滞后。
如果会的同学可以跳过,这里只做引申说明。
一般分布式寻址算法有下列几种:
来了一个 key,首先计算 hash 值,然后对节点数取模。然后打在不同的 master 节点上。一旦某一个 master 节点宕机,所有请求过来,都会基于最新的剩余 master 节点数去取模,尝试去取数据。这会导致大部分的请求过来,全部无法拿到有效的缓存,导致大量的流量涌入数据库。
一致性 hash 算法将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织,下一步将各个 master 节点(使用服务器的 ip 或主机名)进行 hash。这样就能确定每个节点在其哈希环上的位置。
一致性 hash 算法也是使用取模的方法 hash算法
的取模法是对服务器的数量进行取模,而一致性 hash 算法
是对 **2^32 ** 取模:
hash(服务器A的IP地址) % 2^32
hash(服务器B的IP地址) % 2^32
hash(服务器C的IP地址) % 2^32
来了一个 key,首先计算 hash 值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,遇到的第一个 master 节点就是 key 所在位置。
使用 hash 算法时,服务器数量发生改变时,所有服务器的所有缓存在同一时间失效了,而使用一致性哈希算法时,服务器的数量如果发生改变,并不是所有缓存都会失效,而是只有部分缓存会失效,例如如果一个节点挂了,受影响的数据仅仅是此节点到环空间前一个节点(沿着逆时针方向行走遇到的第一个节点)之间的数据,其它不受影响。增加一个节点也同理。
然而当一致性 hash 算法在节点太少或是节点位置分布不均匀时,容易造成大量请求都集中在某一个节点上,而造成缓存热点的问题。如果i此时该热点节点出现故障,那么失效缓存的数量也将达到最大值,在极端情况下,有可能引起系统的崩溃,这种情况被称之为 数据倾斜
。
为了预防 数据倾斜
的问题,一致性 hash 算法引入了虚拟节点机制,即对每一个节点计算多个 hash,每个计算结果位置都放置一个虚拟节点。这样就实现了数据的均匀分布,负载均衡。
具体说明,每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点。具体做法可以在服务器ip或主机名的后面增加编号来实现。可以为每台服务器计算三个虚拟节点,于是可以分别计算 “Node1#1”、“Node1#2”、“Node1#3”、“Node2#1”、“Node2#2”、“Node2#3”的哈希值,这样可以让hash 环中存在多个节点,使节点的分布更均匀,当然可以虚拟出更多的虚拟节点,以便减小hash环偏斜所带来的影响,虚拟节点越多,hash环上的节点就越多,缓存被均匀分布的概率就越大。
图就不画了…理解理解TAT
redis 集群采用数据分片的哈希槽来进行数据存储和数据的读取。
redis 集群中有固定的 16384 个槽(slot),对每个 key 计算 CRC16
值,然后对 16384
取模,可以获取 key 对应的 hash slot
。
redis 集群中每个 master 都会被指派部分的槽(slot),假如说当前集群中有3个节点服务器,可能是这样分配的 [0,5000]、[5001,10000]、[10001,16383]。
槽位的实现其实就是一个长度为 16384 的二进制数组,根据指定索引位上的二进制位值来判断节点是否处理指定索引的槽位。
所以槽位的迁移非常简单:
移动槽位的成本是非常低的。客户端的 api,可以对指定的数据,让他们走同一个槽位,通过 hash tag
来实现。
在Redis中通过 CLUSTER ADDSLOTS
命令来指派负责的槽位,后面会详细说明。
每个节点都会记录哪些槽指派给了自己,哪些槽指派给了其他节点。客户端向节点发送键命令,节点要计算这个键属于哪个槽。如果是自己负责这个槽,那么直接执行命令,如果不是,向客户端返回一个 MOVED
错误,指引客户端转向正确的节点。
任何一台机器宕机,另外两个节点,不影响的。因为 key 找的是 hash slot,不是机器。
架构图参照上方《集群模式架构》中。
可能有人问,为什么一致性hash算法是65535(2^32)个位置,而hash slot 算法却是16384(2^14)个位置?【翻译官方回答】
- 正常的心跳包携带节点的完整配置,可以用幂等方式替换旧节点以更新旧配置。 这意味着它们包含原始形式的节点的插槽配置,它使用 16384 个插槽只占用 2k 空间,但使用 65535 个插槽时将占用高达8k 的空间。
- 同时,由于其他设计权衡,Redis Cluster不太可能扩展到超过1000个主节点。
因此,16384个插槽处于正确的范围内,以确保每个主站有足够的插槽,最多1000个节点,但足够小的数字可以轻松地将插槽配置传播为原始位图。 请注意,在小型集群中,位图难以压缩,因为当N很小时,位图将设置插槽/ N位,这是设置的大部分位。
它并不是闭合的,key的定位规则是根据 CRC-16(key) % 16384
的值来判断属于哪个槽区,从而判断该key属于哪个节点,而一致性 hash 算法是根据 hash(key)
的值来顺时针找第一个 hash(ip或主机名)
的节点,从而确定key存储在哪个节点。
一致性 hash 算法是创建虚拟节点来实现节点宕机后的数据转移并保证数据的安全性和集群的可用性的。
redis 集群是采用master节点有多个slave节点机制来保证数据的完整性的。master节点写入数据,slave节点同步数据。当master节点挂机后,slave节点会通过选举机制选举出一个节点变成master节点,实现高可用。但是这里有一点需要考虑,如果master节点存在热点缓存,某一个时刻某个key的访问急剧增高,这时该mater节点可能操劳过度而死,随后从节点选举为主节点后,同样宕机,一次类推,造成缓存雪崩。(简单说明就是,都是被大量请求一套秒的,谁上来都一样QAQ…)
一致性 hash 算法在新增和删除节点后,数据会按照顺时针自动来重新分布节点。
redis 集群的新增和删除节点都需要手动来分配槽区。
Redis集群通过分片来保存数据库的键值对:集群整个数据库被分为16384个槽(slot
),数据库的每个键都属于这16384个槽其中的一个,集群中的每个节点可以处理0个到16384个槽。
当集群使用 CLUSTER MEET
命令,整个集群仍处于下线状态,此时必须通过它们指派槽,通过发送 CLUSTER ADDSLOTS
命令给节点,将一个或多个槽指派给节点负责:
CLUSTER ADDSLOTS [slot...]
比如说将 0 到 5000 个槽指派给节点7000负责:
CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
然后以此类推给其他节点指派槽。
槽位是在 clusterNode
结构中的 slots
属性和 numslot
属性记录的,记录当前节点负责处理哪些槽:
struct clusterNode {
//...
// 二进制位数组
unsigned char slots[16384/8];
// 记录节点负责处理的槽的数量,即slots数组中值为1的二进制位的数量
int numslots;
}
在上面小节《分布式寻址算法》的《hash slot 算法》中说过,槽的本质就是一个二进制位数组,通过对[0,16383]上的对应索引为标记来判断是否处理该槽位:如果slots数组上在指定索引位的二进制位的值为1,标识节点负责处理该槽,反之同理。
CLUSTER ADDSLOTS
命令的实现也比较简单:
clusterState.slot[i]
索引位的指针指向 clusterState.myself
。(如果不了解它先看下面再回来)执行完毕后,开始广播通知给集群中的其他节点,自己目前处理的槽位。
节点会将自己的 slots
数组通过消息发送给集群中的其他节点,告知它们自己目前负责的槽位。
当其他节点接收到消息,会更新自己的在 clusterState.nodes
字典中对应节点的 clusterNode
结构中的 slots
数组。
在 clusterState
结构中的 slots
数组记录了集群中所有 16384 个槽的指派信息:
typedef struct clusterState {
//...
clusterNode *slots[16384];
//...
}
slots
数组包含 16384 个项,每个数组项都是一个指向 clusterNode
的指针:对应指针指向 NULL 时,说明还未分配;指向 clusterNode
结构时,说明已经指派给了对应结构所代表的节点。
clusterState.slots
和使用 clusterNode.slots
保存指派信息相比的好处?使用clusterState.slots
比使用 clusterNode.slots
能够更高效地解决问题。
clusterNode.slots
来记录,每次都需要遍历所有 clusterNode
结构,复杂度为O(N)。clusterState.slots
来记录,只需要访问 clusterState.slots
对应的索引位即可,复杂度为O(1)。建立集群
,并且分配完槽位
,此时集群就会进入上线状态,这时候客户端就可以向集群中的节点发送数据指令了。
客户端在向节点发送与数据库键有关的命令时,接收命令的节点就会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派个了自己:
MOVED
错误,指引客户端向正确的节点,并再次发送之前想要执行的命令。节点会使用以下算法来给指定 key 进行计算:
def slot_number(key):
return CRC16(key) & 16383
CRC16(key)
:计算键 key 的 CRC-16 校验和。& 16383
:计算出介于0至16383之间的整数作为键 key 的槽号。当节点计算出键所属的槽后,节点会检查自己 clusterState.slots
数组中的指定槽位,判断是否由自己负责:
clusterState.slot[i]
等于 clusterState.myself
,说明是由当前节点负责的。clusterState.slot[i]
不等于 clusterState.myself
,说明不是由当前节点负责的,会根据 clusterState.slot[i]
指向的 clusterNode
结构中所记录的 IP 和 端口号,返回客户端 MOVED
错误,指引客户端转向正在处理该槽的节点。MOVED
错误的格式为:
MOVED :
slot
:键所在的槽。ip:port
:负责处理该槽节点的IP地址和端口号。MOVED 错误一般是不会打印的,而是根据该错误自动进行节点转向,并打印转向信息。
如果在单机 redis 的情况下,是会被客户端打印出来的。
节点只能使用0号数据库,而单机Redis服务器则没有限制。
节点除了将键值对保存在数据库中之外,还会用 clusterState
结构中的 slots_to_keys
跳跃表来保存槽和键之间的关系:
typedef struct clusterState {
//...
zskiplist *slots_to_keys;
//...
}
slots_to_keys
跳表中每个节点的分值(score)都是一个槽位号;每个节点的成员(member)都是一个数据库键。
slot_to_keys
跳表中。slot_to_keys
跳表中解除它们的关联关系。Redis 集群的重新分片操作可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,并且相关联槽位的键值对也会从源节点移动到目标节点。
重新分片的操作是可以在线进行的,保证了高可用。
我们就以在线扩容节点的情况来说吧:比如现在准备在集群中增加一个节点,如何将原有分片中的若干个槽位指派给新添加的节点?
Redis 集群的重新分片操作是由 Redis 集群管理软件 redis-trib
负责执行的:Redis 提供重新分配的所有命令,而 redis-trib
通过向源节点和目标接待你发送命令来进行重新分片操作。
redis-trib
对集群的单个槽进行重新分片的步骤如下:
redis-trib
给目标节点发送 CLUSTER SETSLOT IMPORTING
命令,让目标节点准备好从源节点导入对应槽位的键值对。redis-trib
对源节点发送 CLUSTER SETSLOT MIGRATING
命令,让源节点准备好将对应槽位的键值对迁移到目标节点。redis-trib
向源节点发送CLUSTER GETKEYSINSLOT
命令,获取最多 count
个对应槽的键值对的键名称。redis-trib
都向源节点发送 MIGRATE 0
命令,将被选中的键原子性地迁移到目标节点 。redis-trib
向集群中的任意一个节点发送 CLUSTER SETSLOT NODE
命令,将对应槽指派给了目标节点,这个信息会被广播发给整个集群,最终整个集群都知道了对应槽被指派给了目标节点。如果涉及多个槽,则给每个槽重复执行上述本步骤。
在重新分片操作期间,可能会出现一部分键值对被迁出,一部分键值还未被迁出,即在源节点和目标节点都由对应槽的数据。
当节点向源节点发送一个与数据库键相关的命令,并且该键的槽位正好处在重新分片的过程中:
ASK
错误。ASK 错误同 MOVED 错误类似,也是不会打印的,也会根据错误提供的 IP 和 端口号自动进行转向操作。
同理,单机模式下会打印错误。
那 ASK 错误 和 MOVED 错误有什么区别呢?
虽然它们能导致客户端转向,但是 MOVED 错误代表槽的负责权已经交给另一个节点了;而 ASK 错误只是两个节点在迁移槽的过程中使用的临时措施。
clusterState
结构的 importing_slots_from
数组记录了当前节点正在从其他节点导入的槽:
typedef struct clusterState {
//...
clusterNode *importing_slots_from[16384];
//...
}
如果 importing_slots_from[i]
的值不为 NULL,而是指向一个 clusterNode
结构,那么表示当前节点正在从 clusterNode
所代表的节点导入该槽。
在对集群重新分片的时候,向目标节点发送 CLUSTER SETSLOT IMPORTING
命令:
CLUSTER SETSLOT IMPORTING
可以将目标节点 clusterState.importing_slots_from[i]
的值设置为 source_id
所代表的节点的 clusterNode
结构。
clusterState
结构的 migrating_slots_to
数组记录了当前节点正在迁移至其他节点的槽:
typedef struct clusterState {
//...
clusterNode *migrating_slots_to[16384];
//...
}
如果 migrating_slots_to[i]
的值不为 NULL,而是指向一个 clusterNode
结构,那么表示当前节点正在将该槽迁移到 clusterNode
所代表的节点。
当客户端接收到 ASK
错误并转向正在导入槽的节点时,客户端会先向节点发送一个 ASKING
命令,然后才重新发送要执行的命令,这是因为客户端如果不发送 ASKING
命令,而直接发送想要执行的命令的话,那么客户端发送的命令会被节点拒绝执行,并返回 MOVED
错误。
Redis 集群中节点可分为主节点(master)和从节点(slave)。
主节点用于处理槽;从节点用于复制某个主节点,并在主节点下线时,代替下线主节点继续处理命令请求。
向一个节点发送命令:
CLUSTER REPLICATE
可以让接收命令的节点成为 node_id
所指定的节点的从节点,并开始对主节点进行复制操作,具体步骤如下:
clusterState.nodes
字典中对应 node_id
所对应节点的 clusterNode
结构,并将自身的 clusterState.myself.slaveof
指针指向这个结构,来记录正在复制的主节点。clusterState,myself.flags
属性,关闭原来的 REDIS_NODE_MASTER
标识,打开 REDIS_NODE_SLAVE
标识,表明该节点已经从主节点变成从节点。SLAVEOF
命令。集群中每个节点都会定期向其他节点发送 PING
信息,以此检测对方是否在线,如果接收 PING
信息的节点没有在规定时间内返回 PONG
信息,那么发送消息的节点会将接收消息的节点标记为疑似下线(PFALL)。
如果在集群中,半数以上负责槽的主节点都将某个主节点标记为疑似下线,那么这个主节点就会被标记为已下线(FALL)。
将该主节点标记为已下线的节点会向集群广播关于该节点的 FALL
消息,所有收到这条 FALL
信息的节点都会立即将该节点标记为已下线。
当一个从节点发现自己正在复制的主节点进入了下线状态时,从节点会对下线主节点进行故障转移,按照以下的执行步骤:
SLAVE no one
命令,成为新的主节点。PONG
信息,这条信息可以啊让其他主节点直到这个节点已经成为主节点,并且接管了所有已下线的主节点负责处理的槽。新的主节点也是通过选举产生的,简单介绍一下它的选举过程:
CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
信息,要求所有收到信息,并且有投票权的主节点给它投票。REQUEST
信息时,会返回 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
信息,表示它支持该从节点。ACK
信息,并且统计自己获得的支持数。N /2 + 1
(具有投票权的节点的一半数量加一)时,这个从节点成为新节点。【在一个配置纪元中,只有一个从节点能达到这个数目,确保了主节点只有一个】类似于领头 Sentinel 的选举,可以对比来看。它们都是基于 Raft 算法的领头选举方法来实现的。
有的小伙伴可能觉得 领头Sentinel 的选举不算 Raft,因为它最后是通过领头 Sentinel 来控制故障迁移的具体过程,这个就是仁者见仁智者见智了。
Raft 算法的实现可以参考一下Nacos 源码中
RaftCore
类的实现,比较通俗易懂。有时间我会发一下 Nacos 源码中Raft选举的实现。
官方文档:REDIS distlock – Redis中国用户组(CRUG)
我最早觉得比较好的实现分布式锁思路文章:10分钟精通Redis分布式锁中的各种门道
我们在开发项目时,如果需要在同进程内的不同线程并发访问某项资源,可以使用各种互斥锁、读写锁。
如果一台主机上的多个进程需要并发访问某项资源,则可以使用进程间同步的原语,例如信号量、管道、共享内存等。
但如果多台主机需要同时访问某项资源,就需要使用一种在全局可见并具有互斥性的锁了。
这种锁就是分布式锁
,可以在分布式场景中对资源加锁,避免竞争资源引起的逻辑错误
。
一般我们使用分布式锁有两个场景:
大部分特性其实都类似于 Java 中的锁,包括互斥性、可重入、锁超时、公平锁和非公平锁、一致性。
在Redis中加锁一般都是使用 SET
命令,使用 SET
命令完成 SETNX
和 EXPIRE
操作,并且这是一个原子操作:
set key value [EX seconds] [PX milliseconds] [NX|XX]
上面这条指令是 SET
指令的使用方式,参数说明如下:
key
、value
:键值对。EX seconds
:设置失效时长,单位秒。PX milliseconds
:设置失效时长,单位毫秒。NX
:key不存在时设置value,成功返回OK,失败返回(nil),SET key value NX
效果等同于 SETNX key value
。XX
:key存在时设置value,成功返回OK,失败返回(nil)。其中,NX
参数用于保证在多个线程并发 set 下,只会有1个线程成功,起到了锁的“唯一”性。
举例:
// 设置msg = helloword,失效时长1000ms,不存在时设置
1.1.1.1:6379> set msg helloworld px 1000 nx
解锁一般使用 DEL
命令,但是直接删除锁可能存在问题。
一般解锁需要两步操作:
查询当前“锁”是否还是我们持有,因为存在过期时间,所以可能等你想解锁的时候,“锁”已经到期,然后被其他线程获取了,所以我们在解锁前需要先判断自己是否还持有“锁”。
如果“锁”还是我们持有,则执行解锁操作,也就是删除该键值对,并返回成功;否则,直接返回失败。
由于当前 Redis 还没有原子命令直接支持这两步操作,所以当前通常是使用 Lua 脚本来执行解锁操作,Redis 会保证脚本里的内容执行是一个原子操作。
以下是 Redis 官方给出的 Lua 脚本:
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
参数说明如下:
KEYS[1]
:我们要解锁的 key。ARGV[1]
:我们加锁时的 value,用于判断当“锁”是否还是我们持有,如果被其他线程持有了,value 就会发生变化。一般为了防止死锁,比如服务器宕机或断线的情况下无法手动解锁,此时就需要给分布式锁加上过期时间。
但是假如在我们业务执行的过程中,Redis 分布式锁过期了,业务还没处理完怎么办?
首先,我们在设置过期时间时要结合业务场景去设计,尽量设置一个比较合理的值,就是理论上正常处理的话,在这个过期时间内是一定能处理完毕的。
然后我们需要应对一些特殊恶劣情况进行设计。
目前的解决方案一般有两种:
守护线程“续命”
:额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。Redisson 里面就实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间。超时回滚
:当我们解锁时发现锁已经被其他线程获取了,说明此时我们执行的操作已经是“不安全”的了,此时需要进行事务回滚,并返回失败。同时,需要进行告警,人为介入验证数据的正确性,然后找出超时原因,是否需要对超时时间进行优化等等。
Redisson 使用看门狗(守护线程)“续命”的方案在大多数场景下是挺不错的,也被广泛应用于生产环境,但是在极端情况下还是会存在问题。
问题例子如下:
解决方法:上述问题的根本原因主要是由于 Redis 异步复制带来的数据不一致问题导致的,因此解决的方向就是保证数据的一致。
当前比较主流的解法和思路有两种:
这里我们来说一下第一种 RedLock 的解决思路。
红锁是Redis作者提出的一致性解决方案。红锁的本质是一个概率问题:如果一个主从架构的Redis在高可用切换期间丢失锁的概率是k%
,那么相互独立的 N 个 Redis 同时丢失锁的概率是多少?如果用红锁来实现分布式锁,那么丢锁的概率是(k%)^N
。鉴于Redis极高的稳定性,此时的概率已经完全能满足产品的需求。
说明红锁的实现并非这样严格,一般保证M(1
算法很易懂,起 5 个 master 节点,分布在不同的机房尽量保证可用性。为了获得锁,client 会进行如下操作:
key
和 random value
,这里一个 client
需要合理设置与 master
节点沟通的 timeout
大小,避免长时间和一个 fail
了的节点浪费时间。client
在大于等于 3 个 master
上成功申请到锁的时候,且它会计算申请锁消耗了多少时间,这部分消耗的时间采用获得锁的当下时间减去第一步获得的时间戳得到,如果锁的持续时长(lock validity time
)比流逝的时间多的话,那么锁就真正获取到了。lock validity time
应该是 origin
(lock validity time
) - 申请锁期间流逝的时间。client
申请锁失败了,那么它就会在少部分申请成功锁的 master
节点上执行释放锁的操作,重置状态。如果一个 client
申请锁失败了,那么它需要稍等一会在重试避免多个 client
同时申请锁的情况,最好的情况是一个 client
需要几乎同时向 5 个 master
发起锁申请。另外就是如果 client
申请锁失败了它需要尽快在它曾经申请到锁的 master
上执行 unlock
操作,便于其他 client
获得这把锁,避免这些锁过期造成的时间浪费,当然如果这时候网络分区使得 client
无法联系上这些 master
,那么这种浪费就是不得不付出的代价了。