Redis概述

Redis概述

第一部分《基本原理》

最详细的Redis五种数据结构详解(理论+实战),建议收藏。 - 知乎 (zhihu.com)

img

1.1 数据类型-struct

  1. String:字符串类型:Commands | Redis
  2. List:列表类型 :Commands | Redis
  3. Set:无序集合类型:SADD | Redis
  4. ZSet:有序集合类型
  5. Hash:哈希表类型
  6. HyperLogLog : bitMap类型,计数统计
  7. BitMap :位图类型,判断存在与否,将10亿个int (4G)压缩为 (4G / 32b)128MB
  8. 布隆过滤器:判断有无 — 有则一定有没有的可能有 – 解决缓存穿透问题 (提前将数据库数据映射到布隆过滤器种)

Redis核心对象

在Redis中有一个「核心的对象」叫做redisObject ,是用来表示所有的key和value的,用redisObject结构体来表示String、Hash、List、Set、ZSet五种数据类型。

img

1. 简单动态字符串

第一篇文章我们就说过 Redis 是用 C 语言写的,但是对于Redis的字符串,却不是 C 语言中的字符串(即以空字符’\0’结尾的字符数组),它是自己构建了一种名为 简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 作为 Redis的默认字符串表示。

SDS 定义:

1
2
3
4
5
6
7
8
9
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
}

用SDS保存字符串 “Redis”具体图示如下:

img

我们看上面对于 SDS 数据类型的定义:

  1. len 保存了SDS保存字符串的长度
  2. buf[] 数组用来保存字符串的每个元素
  3. free j记录了 buf 数组中未使用的字节数量

上面的定义相对于C语言对于字符串的定义,多出了 len 属性以及 free 属性。为什么不使用C语言字符串实现,而是使用 SDS呢?这样实现有什么好处?

1. 常数复杂度获取字符串长度

由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。而对于 C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。通过 strlen key 命令可以获取 key 的字符串长度。

2. 杜绝缓冲区溢出

我们知道在 C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。

3. 减少修改字符串的内存重新分配次数

C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。而对于SDS,由于len属性和free属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:

  1. 空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。
  2. 惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)

4. 二进制安全

因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。

5. 兼容部分 C 字符串函数

虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h> 中的一部分函数。

6. 总结

img

一般来说,SDS 除了保存数据库中的字符串值以外,SDS 还可以作为缓冲区(buffer):包括 AOF 模块中的AOF缓冲区以及客户端状态中的输入缓冲区。后面在介绍Redis的持久化时会进行介绍。

2. 链表

链表是一种常用的数据结构,C 语言内部是没有内置这种数据结构的实现,所以Redis自己构建了链表的实现。

链表定义:

1
2
3
4
5
6
7
8
typedef  struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode

通过多个 listNode 结构就可以组成链表,这是一个双端链表,Redis还提供了操作链表的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct list{
//表头节点
listNode *head;
//表尾节点
listNode *tail;
//链表所包含的节点数量
unsigned long len;
//节点值复制函数
void (*free) (void *ptr);
//节点值释放函数
void (*free) (void *ptr);
//节点值对比函数
int (*match) (void *ptr,void *key);
}list;

img

Redis链表特性:

  1. 双端:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。
  2. 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。 
  3. 带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。
  4. 多态:链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。

3. 字典

字典又称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键 key 都是唯一的,通过 key 可以对值来进行查找或修改。C 语言中没有内置这种数据结构的实现,所以字典依然是 Redis自己构建的。Redis 的字典使用哈希表作为底层实现。

字典结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct dict {

// 类型特定函数
dictType *type;

// 私有数据
void *privdata;

// 哈希表
dictht ht[2];

// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */

} dict;

哈希表结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于 size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;

}dictht

哈希表是由数组 table 组成,table 中每个元素都是指向 dict.h/dictEntry 结构,dictEntry 结构定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}v;

//指向下一个哈希表节点,形成链表
struct dictEntry *next;
}dictEntry

key 用来保存键,val 属性用来保存值,值可以是一个指针,也可以是uint64_t整数,也可以是int64_t整数。

注意:这里还有一个指向下一个哈希表节点的指针,我们知道哈希表最大的问题是存在哈希冲突,如何解决哈希冲突,有开放地址法和链地址法。这里采用的便是链地址法,通过next这个指针可以将多个哈希值相同的键值对连接在一起,用来解决哈希冲突

img

1. 哈希算法:Redis计算哈希值和索引值方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#1、使用字典设置的哈希函数,计算键 key 的哈希值



hash = dict->type->hashFunction(key);



#2、使用哈希表的sizemask属性和第一步得到的哈希值,计算索引值



index = hash & dict->ht[x].sizemask;

2. 解决哈希冲突:

这个问题上面我们介绍了,方法是链地址法。通过字典里面的 *next 指针指向下一个具有相同索引值的哈希表节点。

3. 扩容和收缩:

当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤:

  1. 如果执行扩展操作,会基于原哈希表创建一个大小等于 ht[0].used*2n 的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。
  2. 重新利用上面的哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
  3. 所有键值对都迁徙完毕后,释放原哈希表的内存空间。

4. 触发扩容的条件:

  1. 服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于1。
  2. 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于5。
  3. 负载因子 = 哈希表已保存节点数量 / 哈希表大小。

5. 渐进式rehash

什么叫渐进式 rehash?也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。如果保存在Redis中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成Redis一段时间内不能进行别的操作。所以Redis采用渐进式 rehash,这样在进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行 增加操作,一定是在新的哈希表上进行的。

4. 整数集合

整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,它可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素。

定义如下:

1
2
3
4
5
6
7
8
9
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];

}intset;

整数集合的每个元素都是 contents 数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。

length 属性记录了 contents 数组的大小。

需要注意的是虽然 contents 数组声明为 int8_t 类型,但是实际上contents 数组并不保存任何 int8_t 类型的值,其真正类型有 encoding 来决定。

升级

当我们新增的元素类型比原集合元素类型的长度要大时,需要对整数集合进行升级,才能将新元素放入整数集合中。具体步骤:

  1. 根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转成与新元素相同类型的元素,并将转换后的元素放到正确的位置,放置过程中,维持整个元素顺序都是有序的。
  3. 将新元素添加到整数集合中(保证有序)。

升级能极大地节省内存。

降级

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

5. 跳跃表

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。具有如下性质:

  1. 由很多层结构组成;
  2. 每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的nil节点;
  3. 最底层的链表包含了所有的元素;
  4. 如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集);
  5. 链表中的每个节点都包含两个指针,一个指向同一层的下一个链表节点,另一个指向下一层的同一个链表节点;

img

Redis中跳跃表节点定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct zskiplistNode {
//层
struct zskiplistLevel{
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
}level[];

//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;

} zskiplistNode

多个跳跃表节点构成一个跳跃表:

1
2
3
4
5
6
7
8
9
typedef struct zskiplist{
//表头节点和表尾节点
structz skiplistNode *header, *tail;
//表中节点的数量
unsigned long length;
//表中层数最大的节点的层数
int level;

}zskiplist;

img

1. 搜索:

从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。

2. 插入:

首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确定插入的层数k后,则需要将新元素插入到从底层到k层。

3. 删除:

在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。

7. 压缩列表

压缩列表(ziplist)是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

压缩列表的原理:压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。

img

压缩列表的每个节点构成如下:

img

previous_entry_ength:记录压缩列表前一个字节的长度。previous_entry_ength的长度可能是1个字节或者是5个字节,如果上一个节点的长度小于254,则该节点只需要一个字节就可以表示前一个节点的长度了,如果前一个节点的长度大于等于254,则previous length的第一个字节为254,后面用四个字节表示当前节点前一个节点的长度。利用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始位置,压缩列表可以从尾部向头部遍历。这么做很有效地减少了内存的浪费。

encoding:节点的encoding保存的是节点的content的内容类型以及长度,encoding类型一共有两种,一种字节数组一种是整数,encoding区域长度为1字节、2字节或者5字节长。

content:content区域用于保存节点的内容,节点内容类型和长度由encoding决定。

8. 总结

大多数情况下,Redis使用简单字符串SDS作为字符串的表示,相对于C语言字符串,SDS具有常数复杂度获取字符串长度,杜绝了缓存区的溢出,减少了修改字符串长度时所需的内存重分配次数,以及二进制安全能存储各种类型的文件,并且还兼容部分C函数。

  通过为链表设置不同类型的特定函数,Redis链表可以保存各种不同类型的值,除了用作列表键,还在发布与订阅、慢查询、监视器等方面发挥作用(后面会介绍)。

  Redis的字典底层使用哈希表实现,每个字典通常有两个哈希表,一个平时使用,另一个用于rehash时使用,使用链地址法解决哈希冲突。

  跳跃表通常是有序集合的底层实现之一,表中的节点按照分值大小进行排序。

  整数集合是集合键的底层实现之一,底层由数组构成,升级特性能尽可能的节省内存。

  压缩列表是Redis为节省内存而开发的顺序型数据结构,通常作为列表键和哈希键的底层实现之一。

ZipList 和 Hash

1
2
3
4
5
6
7
8
9
ziplist 编码的哈希对象使用压缩列表作为底层实现, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾, 因此:

保存了同一键值对的两个节点总是紧挨在一起, 保存键的节点在前, 保存值的节点在后;
先添加到哈希对象中的键值对会被放在压缩列表的表头方向, 而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。

作者:one_zheng
链接:https://www.jianshu.com/p/2095df8ae4a8
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

1.2 底层实现

image-20230411075253724

String类型的数据结构存储方式有三种int、raw、embstr

Hash对象的实现方式有两种分别是ziplist、hashtable

Set的底层实现是「ht和intset」

List列表在3.2之前的版本是使用ziplistlinkedlist进行实现的。在3.2之后的版本就是引入了quicklist

ZSet是有序集合,从上面的图中可以看到ZSet的底层实现是ziplistskiplist实现的,

Raw 和 intset

Raw进制安全,可以存放图片信息

img

ZipList

缩列表中每一个节点表示的含义如下所示:

  1. zlbytes:4个字节的大小,记录压缩列表占用内存的字节数。
  2. zltail:4个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。
  3. zllen:2个字节的大小,记录压缩列表中的节点数。
  4. entry:表示列表中的每一个节点。
  5. zlend:表示压缩列表的特殊结束符号'0xFF'

img

entry节点又有三部分组成,包括previous_entry_ength、encoding、content

  1. previous_entry_ength表示前一个节点entry的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。
  2. encoding:这里保存的是content的内容类型和长度。
  3. content:content保存的是每一个节点的内容。

img

LinkedList 和 QuickList

Redis中链表的特性:

  1. 每一个节点都有指向前一个节点和后一个节点的指针。
  2. 头节点和尾节点的prev和next指针指向为null,所以链表是无环的。
  3. 链表有自己长度的信息,获取长度的时间复杂度为O(1)。

img

HashTable - 字典

rehash - 渐近式hash

类似于标记复制算法,两倍空间H[0] - source, H[1] - taget

1
2
在渐进式rehash的过程「更新、删除、查询会在ht[0]和ht[1]中都进行」,比如更新一个值先更新ht[0],然后再更新ht[1]
而新增操作直接就新增到ht[1]表中,ht[0]不会新增任何的数据,这样保证「ht[0]只减不增,直到最后的某一个时刻变成空表」,这样rehash操作完成。

SkipList

数据结构与算法——跳表 - 知乎 (zhihu.com)

在跳跃表的结构中有head和tail表示指向头节点和尾节点的指针,能后快速的实现定位。level表示层数,len表示跳跃表的长度,BW表示后退指针,在从尾向前遍历的时候使用。

BW下面还有两个值分别表示分值(score)和成员对象(各个节点保存的成员对象)。

跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。

跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,条表所体现的查询的效率就越高,和平衡树的查询效率相差无几

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
package skiplist;

import java.util.Random;

/**
* 跳表的一种实现方法。
* 跳表中存储的是正整数,并且存储的是不重复的。
*/
public class SkipList {

private static final int MAX_LEVEL = 16;

private static final float SKIPLIST_P = 0.5f;

private int levelCount = 1;

private Node head = new Node(); // 带头链表

private Random r = new Random();

public Node find(int value) {
Node p = head;
// 从head节点的最上层开始寻找
// 找到最后一个小于当前value的 索引数组
// 然后下层递减,直到为 0
for (int i = levelCount - 1; i >= 0; --i) {
// p = 向前的下一个 Node
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
}
// 判断是否找到
if (p.forwards[0] != null && p.forwards[0].data == value) {
return p.forwards[0];
} else {
return null;
}
}
// 插入 -- 新建一个
public void insert(int value) {
// 将当前啊节点随机插入level级索引
int level = randomLevel();
Node newNode = new Node();
newNode.data = value;
newNode.maxLevel = level;
Node update[] = new Node[level];
for (int i = 0; i < level; ++i) {
update[i] = head;s
}
// 记录当前值的每层索引,保存在update
// record every level largest value which smaller than insert value in update[]
Node p = head;
for (int i = level - 1; i >= 0; --i) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
update[i] = p;// use update save node in search path
}
// 将需要更新的节点 的索引换为 newNode
// in search path node next node become new node forwords(next)
for (int i = 0; i < level; ++i) {
newNode.forwards[i] = update[i].forwards[i];
update[i].forwards[i] = newNode;
}

// update node hight
if (levelCount < level) levelCount = level;
}

public void delete(int value) {
Node[] update = new Node[levelCount];
Node p = head;
for (int i = levelCount - 1; i >= 0; --i) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
update[i] = p;
}

if (p.forwards[0] != null && p.forwards[0].data == value) {
for (int i = levelCount - 1; i >= 0; --i) {
if (update[i].forwards[i] != null && update[i].forwards[i].data == value) {
update[i].forwards[i] = update[i].forwards[i].forwards[i];
}
}
}
}

// 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。
// 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。
// 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
// 50%的概率返回 1
// 25%的概率返回 2
// 12.5%的概率返回 3 ...
private int randomLevel() {
int level = 1;

while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
level += 1;
return level;
}

public void printAll() {
Node p = head;
while (p.forwards[0] != null) {
System.out.print(p.forwards[0] + " ");
p = p.forwards[0];
}
System.out.println();
}
// data存值,数组存Node指针
public class Node {
private int data = -1;
// 向前 :指向下一个更大的索引
private Node forwards[] = new Node[MAX_LEVEL];
private int maxLevel = 0;

@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("{ data: ");
builder.append(data);
builder.append("; levels: ");
builder.append(maxLevel);
builder.append(" }");

return builder.toString();
}
}

}

《第二部分》 持久化

2.1 AOF

2.2 RDB

第三部分《Redis集群模式》

Redis Cluster 原理分析 - 简书 (jianshu.com)

Redis两种持久化机制RDB和AOF详解(面试常问,工作常用) - 知乎 (zhihu.com)

面试必问的 Redis:RDB、AOF、混合持久化 - 知乎 (zhihu.com)

3.1 主从复制

1、人为修复故障

2、第一次复制

3、全量复制(replication_buffer) 和 增量复制(repl_backlog_buffer)

4、涉及到缓冲区:

主从复制是怎么实现的? | 小林coding (xiaolincoding.com)

  • Redis 2.8以前的复制功能不能高效地处理断线后重复制情况,但 Redis 2.8新添加的部分重同步功能可以解决这个问题。
  • ·部分重同步通过复制偏移量、复制积压缓冲区、服务器运行ID三 个部分来实现。
  • ·在复制操作刚开始的时候,从服务器会成为主服务器的客户端, 并通过向主服务器发送命令请求来执行复制步骤,而在复制操作的后 期,主从服务器会互相成为对方的客户端。
  • ·主服务器通过向从服务器传播命令来更新从服务器的状态,保持 主从服务器一致,而从服务器则通过向主服务器发送命令来进行心跳检 测,以及命令丢失检测

概述

不过,由于数据都是存储在一台服务器上,如果出事就完犊子了,比如:

  • 如果服务器发生了宕机,由于数据恢复是需要点时间,那么这个期间是无法服务新的请求的;
  • 如果这台服务器的硬盘出现了故障,可能数据就都丢失了。

要避免这种单点故障,最好的办法是将数据备份到其他服务器上,让这些服务器也可以对外提供服务,这样即使有一台服务器出现了故障,其他服务器依然可以继续提供服务。

图片

多台服务器要保存同一份数据,这里问题就来了。

这些服务器之间的数据如何保持一致性呢?数据的读写操作是否每台服务器都可以处理?

Redis 提供了主从复制模式,来避免上述的问题。

这个模式可以保证多台服务器的数据一致性,且主从服务器之间采用的是「读写分离」的方式。

主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。

图片

也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。

同步这两个字说的简单,但是这个同步过程并没有想象中那么简单,要考虑的事情不是一两个。

我们先来看看,主从服务器间的第一次同步是如何工作的?

#第一次同步

多台服务器之间要通过什么方式来确定谁是主服务器,或者谁是从服务器呢?

我们可以使用 replicaof(Redis 5.0 之前使用 slaveof)命令形成主服务器和从服务器的关系。

比如,现在有服务器 A 和 服务器 B,我们在服务器 B 上执行下面这条命令:

1
2
# 服务器 B 执行这条命令
replicaof <服务器 A 的 IP 地址> <服务器 A 的 Redis 端口号>

接着,服务器 B 就会变成服务器 A 的「从服务器」,然后与主服务器进行第一次同步。

主从服务器间的第一次同步的过程可分为三个阶段:

  • 第一阶段是建立链接、协商同步;
  • 第二阶段是主服务器同步数据给从服务器;
  • 第三阶段是主服务器发送新写操作命令给从服务器。

为了让你更清楚了解这三个阶段,我画了一张图。

图片

接下来,我在具体介绍每一个阶段都做了什么。

第一阶段:建立链接、协商同步

执行了 replicaof 命令后,从服务器就会给主服务器发送 psync 命令,表示要进行数据同步。

psync 命令包含两个参数,分别是主服务器的 runID复制进度 offset

  • runID,每个 Redis 服务器在启动时都会自动生产一个随机的 ID 来唯一标识自己。当从服务器和主服务器第一次同步时,因为不知道主服务器的 run ID,所以将其设置为 “?”。
  • offset,表示复制的进度,第一次同步时,其值为 -1。

主服务器收到 psync 命令后,会用 FULLRESYNC 作为响应命令返回给对方。

并且这个响应命令会带上两个参数:主服务器的 runID 和主服务器目前的复制进度 offset。从服务器收到响应后,会记录这两个值。

FULLRESYNC 响应命令的意图是采用全量复制的方式,也就是主服务器会把所有的数据都同步给从服务器。

所以,第一阶段的工作时为了全量复制做准备。

那具体怎么全量同步呀呢?我们可以往下看第二阶段。

第二阶段:主服务器同步数据给从服务器

接着,主服务器会执行 bgsave 命令来生成 RDB 文件,然后把文件发送给从服务器。

从服务器收到 RDB 文件后,会先清空当前的数据,然后载入 RDB 文件。

这里有一点要注意,主服务器生成 RDB 这个过程是不会阻塞主线程的,因为 bgsave 命令是产生了一个子进程来做生成 RDB 文件的工作,是异步工作的,这样 Redis 依然可以正常处理命令。

但是,这期间的写操作命令并没有记录到刚刚生成的 RDB 文件中,这时主从服务器间的数据就不一致了。

那么为了保证主从服务器的数据一致性,主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里

  • 主服务器生成 RDB 文件期间;
  • 主服务器发送 RDB 文件给从服务器期间;
  • 「从服务器」加载 RDB 文件期间;

第三阶段:主服务器发送新写操作命令给从服务器

在主服务器生成的 RDB 文件发送完,从服务器收到 RDB 文件后,丢弃所有旧数据,将 RDB 数据载入到内存。完成 RDB 的载入后,会回复一个确认消息给主服务器。

接着,主服务器将 replication buffer 缓冲区里所记录的写操作命令发送给从服务器,从服务器执行来自主服务器 replication buffer 缓冲区里发来的命令,这时主从服务器的数据就一致了。

至此,主从服务器的第一次同步的工作就完成了。

#命令传播

主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 连接。

图片

后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。

而且这个连接是长连接的,目的是避免频繁的 TCP 连接和断开带来的性能开销。

上面的这个过程被称为基于长连接的命令传播,通过这种方式来保证第一次同步后的主从服务器的数据一致性。

#分摊主服务器的压力

在前面的分析中,我们可以知道主从服务器在第一次数据同步的过程中,主服务器会做两件耗时的操作:生成 RDB 文件和传输 RDB 文件。

主服务器是可以有多个从服务器的,如果从服务器数量非常多,而且都与主服务器进行全量同步的话,就会带来两个问题:

  • 由于是通过 bgsave 命令来生成 RDB 文件的,那么主服务器就会忙于使用 fork() 创建子进程,如果主服务器的内存数据非大,在执行 fork() 函数时是会阻塞主线程的,从而使得 Redis 无法正常处理请求;
  • 传输 RDB 文件会占用主服务器的网络带宽,会对主服务器响应命令请求产生影响。

这种情况就好像,刚创业的公司,由于人不多,所以员工都归老板一个人管,但是随着公司的发展,人员的扩充,老板慢慢就无法承担全部员工的管理工作了。

要解决这个问题,老板就需要设立经理职位,由经理管理多名普通员工,然后老板只需要管理经理就好。

Redis 也是一样的,从服务器可以有自己的从服务器,我们可以把拥有从服务器的从服务器当作经理角色,它不仅可以接收主服务器的同步数据,自己也可以同时作为主服务器的形式将数据同步给从服务器,组织形式如下图:

图片

通过这种方式,主服务器生成 RDB 和传输 RDB 的压力可以分摊到充当经理角色的从服务器

那具体怎么做到的呢?

其实很简单,我们在「从服务器」上执行下面这条命令,使其作为目标服务器的从服务器:

1
replicaof <目标服务器的IP> 6379

此时如果目标服务器本身也是「从服务器」,那么该目标服务器就会成为「经理」的角色,不仅可以接受主服务器同步的数据,也会把数据同步给自己旗下的从服务器,从而减轻主服务器的负担。

#增量复制

主从服务器在完成第一次同步后,就会基于长连接进行命令传播。

可是,网络总是不按套路出牌的嘛,说延迟就延迟,说断开就断开。

如果主从服务器间的网络连接断开了,那么就无法进行命令传播了,这时从服务器的数据就没办法和主服务器保持一致了,客户端就可能从「从服务器」读到旧的数据。

图片

那么问题来了,如果此时断开的网络,又恢复正常了,要怎么继续保证主从服务器的数据一致性呢?

在 Redis 2.8 之前,如果主从服务器在命令同步时出现了网络断开又恢复的情况,从服务器就会和主服务器重新进行一次全量复制,很明显这样的开销太大了,必须要改进一波。

所以,从 Redis 2.8 开始,网络断开又恢复后,从主从服务器会采用增量复制的方式继续同步,也就是只会把网络断开期间主服务器接收到的写操作命令,同步给从服务器。

网络恢复后的增量复制过程如下图:

图片

主要有三个步骤:

  • 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1;
  • 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据;
  • 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。

那么关键的问题来了,主服务器怎么知道要将哪些增量数据发送给从服务器呢?

答案藏在这两个东西里:

  • repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;
  • replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「」到的位置,从服务器使用 slave_repl_offset 来记录自己「」到的位置。

那 repl_backlog_buffer 缓冲区是什么时候写入的呢?

在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 缓冲区里,因此 这个缓冲区里会保存着最近传播的写命令。

网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,然后来决定对从服务器执行哪种同步操作:

  • 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式;
  • 相反,如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式。

当主服务器在 repl_backlog_buffer 中找到主从服务器差异(增量)的数据后,就会将增量的数据写入到 replication buffer 缓冲区,这个缓冲区我们前面也提到过,它是缓存将要传播给从服务器的命令。

图片

repl_backlog_buffer 缓行缓冲区的默认大小是 1M,并且由于它是一个环形缓冲区,所以当缓冲区写满后,主服务器继续写入的话,就会覆盖之前的数据。因此,当主服务器的写入速度远超于从服务器的读取速度,缓冲区的数据一下就会被覆盖。

那么在网络恢复时,如果从服务器想读的数据已经被覆盖了,主服务器就会采用全量同步,这个方式比增量同步的性能损耗要大很多。

因此,为了避免在网络恢复时,主服务器频繁地使用全量同步的方式,我们应该调整下 repl_backlog_buffer 缓冲区大小,尽可能的大一些,减少出现从服务器要读取的数据被覆盖的概率,从而使得主服务器采用增量同步的方式。

那 repl_backlog_buffer 缓冲区具体要调整到多大呢?

repl_backlog_buffer 最小的大小可以根据这面这个公式估算。

图片

我来解释下这个公式的意思:

  • second 为从服务器断线后重新连接上主服务器所需的平均 时间(以秒计算)。
  • write_size_per_second 则是主服务器平均每秒产生的写命令数据量大小。

举个例子,如果主服务器平均每秒产生 1 MB 的写命令,而从服务器断线之后平均要 5 秒才能重新连接主服务器。

那么 repl_backlog_buffer 大小就不能低于 5 MB,否则新写地命令就会覆盖旧数据了。

当然,为了应对一些突发的情况,可以将 repl_backlog_buffer 的大小设置为此基础上的 2 倍,也就是 10 MB。

关于 repl_backlog_buffer 大小修改的方法,只需要修改配置文件里下面这个参数项的值就可以。

1
repl-backlog-size 1mb

#总结

主从复制共有三种模式:全量复制、基于长连接的命令传播、增量复制

主从服务器第一次同步的时候,就是采用全量复制,此时主服务器会两个耗时的地方,分别是生成 RDB 文件和传输 RDB 文件。为了避免过多的从服务器和主服务器进行全量复制,可以把一部分从服务器升级为「经理角色」,让它也有自己的从服务器,通过这样可以分摊主服务器的压力。

第一次同步完成后,主从服务器都会维护着一个长连接,主服务器在接收到写操作命令后,就会通过这个连接将写命令传播给从服务器,来保证主从服务器的数据一致性。

如果遇到网络断开,增量复制就可以上场了,不过这个还跟 repl_backlog_size 这个大小有关系。

如果它配置的过小,主从服务器网络恢复时,可能发生「从服务器」想读的数据已经被覆盖了,那么这时就会导致主服务器采用全量复制的方式。所以为了避免这种情况的频繁发生,要调大这个参数的值,以降低主从服务器断开后全量同步的概率。

3.2 Sentinel - 哨兵机制

。PING、SENTINEL、INFO、 SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE和PUNSUBSCRIBE这七 个命令就是客户端可以对Sentinel执行的全部命令了。

总结:

  • Sentinel只是一个运行在特殊模式下的Redis服务器,它使用了和 普通模式不同的命令表,所以Sentinel模式能够使用的命令和普通Redis 服务器能够使用的命令不同。
  • ·Sentinel会读入用户指定的配置文件,为每个要被监视的主服务器 创建相应的实例结构,并创建连向主服务器的命令连接和订阅连接,其 中命令连接用于向主服务器发送命令请求,而订阅连接则用于接收指定 频道的消息。
  • ·Sentinel通过向主服务器发送INFO命令来获得主服务器属下所有 从服务器的地址信息,并为这些从服务器创建相应的实例结构,以及连 向这些从服务器的命令连接和订阅连接。
  • ·在一般情况下,Sentinel以每十秒一次的频率向被监视的主服务器 和从服务器发送INFO命令,当主服务器处于下线状态,或者Sentinel正 在对主服务器进行故障转移操作时,Sentinel向从服务器发送INFO命令 的频率会改为每秒一次。
  • ·对于监视同一个主服务器和从服务器的多个Sentinel来说,它们会 以每两秒一次的频率,通过向被监视服务器的__sentinel__:hello频道发送 消息来向其他Sentinel宣告自己的存在。
  • ·每个Sentinel也会从__sentinel__:hello频道中接收其他Sentinel发来 的信息,并根据这些信息为其他Sentinel创建相应的实例结构,以及命令 连接。
  • ·Sentinel只会与主服务器和从服务器创建命令连接和订阅连接, Sentinel与Sentinel之间则只创建命令连接。
  • ·Sentinel以每秒一次的频率向实例(包括主服务器、从服务器、其 他Sentinel)发送PING命令,并根据实例对PING命令的回复来判断实例 是否在线,当一个实例在指定的时长中连续向Sentinel发送无效回复时, Sentinel会将这个实例判断为主观下线。
  • ·当Sentinel将一个主服务器判断为主观下线时,它会向同样监视这 个主服务器的其他Sentinel进行询问,看它们是否同意这个主服务器已经 进入主观下线状态。
  • ·当Sentinel收集到足够多的主观下线投票之后,它会将主服务器判 断为客观下线,并发起一次针对主服务器的故障转移操作。

image-20231002102642702

为什么要有哨兵? | 小林coding (xiaolincoding.com)

img

为什么要有哨兵机制?

在 Redis 的主从架构中,由于主从模式是读写分离的,如果主节点(master)挂了,那么将没有主节点来服务客户端的写操作请求,也没有主节点给从节点(slave)进行数据同步了。

主节点挂了

这时如果要恢复服务的话,需要人工介入,选择一个「从节点」切换为「主节点」,然后让其他从节点指向新的主节点,同时还需要通知上游那些连接 Redis 主节点的客户端,将其配置中的主节点 IP 地址更新为「新主节点」的 IP 地址。

这样也不太“智能”了,要是有一个节点能监控「主节点」的状态,当发现主节点挂了 ,它自动将一个「从节点」切换为「主节点」的话,那么可以节省我们很多事情啊!

Redis 在 2.8 版本以后提供的哨兵(*Sentinel*)机制,它的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。

#职责

哨兵其实是一个运行在特殊模式下的 Redis 进程,所以它也是一个节点。从“哨兵”这个名字也可以看得出来,它相当于是“观察者节点”,观察的对象是主从节点。

当然,它不仅仅是观察那么简单,在它观察到有异常的状况下,会做出一些“动作”,来修复异常状态。

哨兵节点主要负责三件事情:监控、选主、通知

哨兵的职责

所以,我们重点要学习这三件事情:

  • 哨兵节点是如何监控节点的?又是如何判断主节点是否真的故障了?
  • 根据什么规则选择一个从节点切换为主节点?
  • 怎么把新主节点的相关信息通知给从节点和客户端呢?

故障判断(主观 + 客观)

主观:单个哨兵

客观:多个哨兵协同判断

哨兵会每隔 1 秒给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。

哨兵监控主从节点

如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「主观下线」。这个「规定的时间」是配置项 down-after-milliseconds 参数设定的,单位是毫秒。

主观下线?难道还有客观下线?

是的没错,客观下线只适用于主节点。

之所以针对「主节点」设计「主观下线」和「客观下线」两个状态,是因为有可能「主节点」其实并没有故障,可能只是因为主节点的系统压力比较大或者网络发送了拥塞,导致主节点没有在规定时间内响应哨兵的 PING 命令。

所以,为了减少误判的情况,哨兵在部署的时候不会只部署一个节点,而是用多个节点部署成哨兵集群最少需要三台机器来部署哨兵集群),通过多个哨兵节点一起判断,就可以就可以避免单个哨兵因为自身网络状况不好,而误判主节点下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

具体是怎么判定主节点为「客观下线」的呢?

当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。

img

当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。

例如,现在有 3 个哨兵,quorum 配置的是 2,那么一个哨兵需要 2 张赞成票,就可以标记主节点为“客观下线”了。这 2 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

PS:quorum 的值一般设置为哨兵个数的二分之一加1,例如 3 个哨兵就设置 2。

哨兵判断完主节点客观下线后,哨兵就要开始在多个「从节点」中,选出一个从节点来做新主节点。

哨兵选主- leader负责故障转移

前面说过,为了更加“客观”的判断主节点故障了,一般不会只由单个哨兵的检测结果来判断,而是多个哨兵一起判断,这样可以减少误判概率,所以哨兵是以哨兵集群的方式存在的

问题来了,由哨兵集群中的哪个节点进行主从故障转移呢?

所以这时候,还需要在哨兵集群中选出一个 leader,让 leader 来执行主从切换。

选举 leader 的过程其实是一个投票的过程,在投票开始前,肯定得有个「候选者」。

那谁来作为候选者呢?

哪个哨兵节点判断主节点为「客观下线」,这个哨兵节点就是候选者,所谓的候选者就是想当 Leader 的哨兵。

举个例子,假设有三个哨兵。当哨兵 B 先判断到主节点「主观下线后」,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他哨兵会根据自己和主节点的网络连接情况,做出赞成投票或者拒绝投票的响应。

img

当哨兵 B 收到赞成票数达到哨兵配置文件中的 quorum 配置项设定的值后,就会将主节点标记为「客观下线」,此时的哨兵 B 就是一个Leader 候选者。

候选者如何选举成为 Leader?

候选者会向其他哨兵发送命令,表明希望成为 Leader 来执行主从切换,并让所有其他哨兵对它进行投票。

每个哨兵只有一次投票机会,如果用完后就不能参与投票了,可以投给自己或投给别人,但是只有候选者才能把票投给自己。

那么在投票过程中,任何一个「候选者」,要满足两个条件:

  • 第一,拿到半数以上的赞成票;
  • 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

举个例子,假设哨兵节点有 3 个,quorum 设置为 2,那么任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以选举成功了。如果没有满足条件,就需要重新进行选举。

这时候有的同学就会问了,如果某个时间点,刚好有两个哨兵节点判断到主节点为客观下线,那这时不就有两个候选者了?这时该如何决定谁是 Leader 呢?

每位候选者都会先给自己投一票,然后向其他哨兵发起投票请求。如果投票者先收到「候选者 A」的投票请求,就会先投票给它,如果投票者用完投票机会后,收到「候选者 B」的投票请求后,就会拒绝投票。这时,候选者 A 先满足了上面的那两个条件,所以「候选者 A」就会被选举为 Leader。

为什么哨兵节点至少要有 3 个?

如果哨兵集群中只有 2 个哨兵节点,此时如果一个哨兵想要成功成为 Leader,必须获得 2 票,而不是 1 票。

所以,如果哨兵集群中有个哨兵挂掉了,那么就只剩一个哨兵了,如果这个哨兵想要成为 Leader,这时票数就没办法达到 2 票,就无法成功成为 Leader,这时是无法进行主从节点切换的。

因此,通常我们至少会配置 3 个哨兵节点。这时,如果哨兵集群中有个哨兵挂掉了,那么还剩下两个个哨兵,如果这个哨兵想要成为 Leader,这时还是有机会达到 2 票的,所以还是可以选举成功的,不会导致无法进行主从节点切换。

当然,你要问,如果 3 个哨兵节点,挂了 2 个怎么办?这个时候得人为介入了,或者增加多一点哨兵节点。

再说一个问题,Redis 1 主 4 从,5 个哨兵 ,quorum 设置为 3,如果 2 个哨兵故障,当主节点宕机时,哨兵能否判断主节点“客观下线”?主从能否自动切换?

  • 哨兵集群可以判定主节点“客观下线”。哨兵集群还剩下 3 个哨兵,当一个哨兵判断主节点“主观下线”后,询问另外 2 个哨兵后,有可能能拿到 3 张赞同票,这时就达到了 quorum 的值,因此,哨兵集群可以判定主节点为“客观下线”。
  • 哨兵集群可以完成主从切换。当有个哨兵标记主节点为「客观下线」后,就会进行选举 Leader 的过程,因为此时哨兵集群还剩下 3 个哨兵,那么还是可以拿到半数以上(5/2+1=3)的票,而且也达到了 quorum 值,满足了选举 Leader 的两个条件, 所以就能选举成功,因此哨兵集群可以完成主从切换。

如果 quorum 设置为 2 ,并且如果有 3 个哨兵故障的话。此时哨兵集群还是可以判定主节点为“客观下线”,但是哨兵不能完成主从切换了,大家可以自己推演下。

如果 quorum 设置为 3,并且如果有 3 个哨兵故障的话,哨兵集群即不能判定主节点为“客观下线”,也不能完成主从切换了。

可以看到,quorum 为 2 的时候,并且如果有 3 个哨兵故障的话,虽然可以判定主节点为“客观下线”,但是不能完成主从切换,这样感觉「判定主节点为客观下线」这件事情白做了一样,既然这样,还不如不要做,quorum 为 3 的时候,就可以避免这种无用功。

所以,quorum 的值建议设置为哨兵个数的二分之一加1,例如 3 个哨兵就设置 2,5 个哨兵设置为 3,而且哨兵节点的数量应该是奇数

redis选主 - 具体过程(故障转移)

在哨兵集群中通过投票的方式,选举出了哨兵 leader 后,就可以进行主从故障转移的过程了,如下图:

img

主从故障转移操作包含以下四个步骤:

  • 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点。
  • 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;
  • 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;
  • 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;

#步骤一:选出新主节点

故障转移操作第一步要做的就是在已下线主节点属下的所有「从节点」中,挑选出一个状态良好、数据完整的从节点,然后向这个「从节点」发送 SLAVEOF no one 命令,将这个「从节点」转换为「主节点」。

那么多「从节点」,到底选择哪个从节点作为新主节点的?

随机的方式好吗?随机的方式,实现起来很简单,但是如果选到一个网络状态不好的从节点作为新主节点,那么可能在将来不久又要做一次主从故障迁移。

所以,我们首先要把网络状态不好的从节点给过滤掉。首先把已经下线的从节点过滤掉,然后把以往网络连接状态不好的从节点也给过滤掉。

怎么判断从节点之前的网络连接状态不好呢?

Redis 有个叫 down-after-milliseconds * 10 配置项,其down-after-milliseconds 是主从节点断连的最大连接超时时间。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从节点的网络状况不好,不适合作为新主节点。

至此,我们就把网络状态不好的从节点过滤掉了,接下来要对所有从节点进行三轮考察:优先级、复制进度、ID 号。在进行每一轮考察的时候,哪个从节点优先胜出,就选择其作为新主节点。

  • 第一轮考察:哨兵首先会根据从节点的优先级来进行排序,优先级越小排名越靠前,
  • 第二轮考察:如果优先级相同,则查看复制的下标,哪个从「主节点」接收的复制数据多,哪个就靠前。
  • 第三轮考察:如果优先级和下标都相同,就选择从节点 ID 较小的那个。

#第一轮考察:优先级最高的从节点胜出

Redis 有个叫 slave-priority 配置项,可以给从节点设置优先级。

每一台从节点的服务器配置不一定是相同的,我们可以根据服务器性能配置来设置从节点的优先级。

比如,如果 「 A 从节点」的物理内存是所有从节点中最大的, 那么我们可以把「 A 从节点」的优先级设置成最高。这样当哨兵进行第一轮考虑的时候,优先级最高的 A 从节点就会优先胜出,于是就会成为新主节点。

#第二轮考察:复制进度最靠前的从节点胜出

如果在第一轮考察中,发现优先级最高的从节点有两个,那么就会进行第二轮考察,比较两个从节点哪个复制进度。

什么是复制进度?主从架构中,主节点会将写操作同步给从节点,在这个过程中,主节点会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置(如下图中的「主服务器已经写入的数据」的位置),而从节点会用 slave_repl_offset 这个值记录当前的复制进度(如下图中的「从服务器要读的位置」的位置)。

img

如果某个从节点的 slave_repl_offset 最接近 master_repl_offset,说明它的复制进度是最靠前的,于是就可以将它选为新主节点。

#第三轮考察:ID 号小的从节点胜出

如果在第二轮考察中,发现有两个从节点优先级和复制进度都是一样的,那么就会进行第三轮考察,比较两个从节点的 ID 号,ID 号小的从节点胜出。

什么是 ID 号?每个从节点都有一个编号,这个编号就是 ID 号,是用来唯一标识从节点的。

到这里,选主的事情终于结束了。简单给大家总结下:

img

在选举出从节点后,哨兵 leader 向被选中的从节点发送 SLAVEOF no one 命令,让这个从节点解除从节点的身份,将其变为新主节点。

如下图,哨兵 leader 向被选中的从节点 server2 发送 SLAVEOF no one 命令,将该从节点升级为新主节点。

img

在发送 SLAVEOF no one 命令之后,哨兵 leader 会以每秒一次的频率向被升级的从节点发送 INFO 命令(没进行故障转移之前,INFO 命令的频率是每十秒一次),并观察命令回复中的角色信息,当被升级节点的角色信息从原来的 slave 变为 master 时,哨兵 leader 就知道被选中的从节点已经顺利升级为主节点了。

如下图,选中的从节点 server2 升级成了新主节点:

img

#步骤二:将从节点指向新主节点

当新主节点出现之后,哨兵 leader 下一步要做的就是,让已下线主节点属下的所有「从节点」指向「新主节点」,这一动作可以通过向「从节点」发送 SLAVEOF 命令来实现。

如下图,哨兵 leader 向所有从节点(server3和server4)发送 SLAVEOF ,让它们成为新主节点的从节点。

img

所有从节点指向新主节点后的拓扑图如下:

img

#步骤三:通知客户的主节点已更换

经过前面一系列的操作后,哨兵集群终于完成主从切换的工作,那么新主节点的信息要如何通知给客户端呢?

这主要通过 Redis 的发布者/订阅者机制来实现的。每个哨兵节点提供发布者/订阅者机制,客户端可以从哨兵订阅消息。

哨兵提供的消息订阅频道有很多,不同频道包含了主从节点切换过程中的不同关键事件,几个常见的事件如下:

img

客户端和哨兵建立连接后,客户端会订阅哨兵提供的频道。主从切换完成后,哨兵就会向 +switch-master 频道发布新主节点的 IP 地址和端口的消息,这个时候客户端就可以收到这条信息,然后用这里面的新主节点的 IP 地址和端口进行通信了

通过发布者/订阅者机制机制,有了这些事件通知,客户端不仅可以在主从切换后得到新主节点的连接信息,还可以监控到主从节点切换过程中发生的各个重要事件。这样,客户端就可以知道主从切换进行到哪一步了,有助于了解切换进度。

#步骤四:将旧主节点变为从节点

故障转移操作最后要做的是,继续监视旧主节点,当旧主节点重新上线时,哨兵集群就会向它发送 SLAVEOF 命令,让它成为新主节点的从节点,如下图:

img

至此,整个主从节点的故障转移的工作结束。

哨兵搭建 - 原理

前面提到了 Redis 的发布者/订阅者机制,那就不得不提一下哨兵集群的组成方式,因为它也用到了这个技术。

在我第一次搭建哨兵集群的时候,当时觉得很诧异。因为在配置哨兵的信息时,竟然只需要填下面这几个参数,设置主节点名字、主节点的 IP 地址和端口号以及 quorum 值。

1
sentinel monitor <master-name> <ip> <redis-port> <quorum> 

不需要填其他哨兵节点的信息,我就好奇它们是如何感知对方的,又是如何组成哨兵集群的?

后面才了解到,哨兵节点之间是通过 Redis 的发布者/订阅者机制来相互发现的

在主从集群中,主节点上有一个名为__sentinel__:hello的频道,不同哨兵就是通过它来相互发现,实现互相通信的。

在下图中,哨兵 A 把自己的 IP 地址和端口的信息发布到__sentinel__:hello 频道上,哨兵 B 和 C 订阅了该频道。那么此时,哨兵 B 和 C 就可以从这个频道直接获取哨兵 A 的 IP 地址和端口号。然后,哨兵 B、C 可以和哨兵 A 建立网络连接。

img

通过这个方式,哨兵 B 和 C 也可以建立网络连接,这样一来,哨兵集群就形成了。

哨兵集群会对「从节点」的运行状态进行监控,那哨兵集群如何知道「从节点」的信息?

主节点知道所有「从节点」的信息,所以哨兵会每 10 秒一次的频率向主节点发送 INFO 命令来获取所有「从节点」的信息。

如下图所示,哨兵 B 给主节点发送 INFO 命令,主节点接受到这个命令后,就会把从节点列表返回给哨兵。接着,哨兵就可以根据从节点列表中的连接信息,和每个从节点建立连接,并在这个连接上持续地对从节点进行监控。哨兵 A 和 C 可以通过相同的方法和从节点建立连接。

img

正式通过 Redis 的发布者/订阅者机制,哨兵之间可以相互感知,然后组成集群,同时,哨兵又通过 INFO 命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。

#总结

Redis 在 2.8 版本以后提供的哨兵(*Sentinel*)机制,它的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。

哨兵一般是以集群的方式部署,至少需要 3 个哨兵节点,哨兵集群主要负责三件事情:监控、选主、通知

哨兵节点通过 Redis 的发布者/订阅者机制,哨兵之间可以相互感知,相互连接,然后组成哨兵集群,同时哨兵又通过 INFO 命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。

1、第一轮投票:判断主节点下线

当哨兵集群中的某个哨兵判定主节点下线(主观下线)后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。

当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。

2、第二轮投票:选出哨兵leader

某个哨兵判定主节点客观下线后,该哨兵就会发起投票,告诉其他哨兵,它想成为 leader,想成为 leader 的哨兵节点,要满足两个条件:

  • 第一,拿到半数以上的赞成票;
  • 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

3、由哨兵 leader 进行主从故障转移

选举出了哨兵 leader 后,就可以进行主从故障转移的过程了。该操作包含以下四个步骤:

  • 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点,选择的规则:
    • 过滤掉已经离线的从节点;
    • 过滤掉历史网络连接状态不好的从节点;
    • 将剩下的从节点,进行三轮考察:优先级、复制进度、ID 号。在每一轮考察过程中,如果找到了一个胜出的从节点,就将其作为新主节点。
  • 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;
  • 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;
  • 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;

完!

参考资料:

  • 《Redis 核心技术与实战》
  • 《Redis 设计与实现》

3.3 Redis Cluster

image-20231001202937544

书籍总结:

  • 节点通过握手来将其他节点添加到自己所处的集群当中。
  • ·集群中的16384个槽可以分别指派给集群中的各个节点,每个节 点都会记录哪些槽指派给了自己,而哪些槽又被指派给了其他节点。
  • ·节点在接到一个命令请求时,会先检查这个命令请求要处理的键 所在的槽是否由自己负责,如果不是的话,节点将向客户端返回一个 MOVED错误,MOVED错误携带的信息可以指引客户端转向至正在负 责相关槽的节点。
  • ·对Redis集群的重新分片工作是由redis-trib负责执行的,重新分片 的关键是将属于某个槽的所有键值对从一个节点转移至另一个节点
  • 。 ·如果节点A正在迁移槽i至节点B,那么当节点A没能在自己的数据 库中找到命令指定的数据库键时,节点A会向客户端返回一个ASK错 误,指引客户端到节点B继续查找指定的数据库键。
  • ·MOVED错误表示槽的负责权已经从一个节点转移到了另一个节 点,而ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施。
  • ·集群里的从节点用于复制主节点,并在主节点下线时,代替主节 点继续处理命令请求。
  • ·集群中的节点通过发送和接收消息来进行通信,常见的消息包括 MEET、PING、PONG、PUBLISH、FAIL五种。

Gossip

分布式一致性协议 Gossip 和 Redis 集群原理解析 - 知乎 (zhihu.com)

我们都知道Redis的集群有三种方案:

  • 1、主从复制模式
  • 2、Sentinel(哨兵)模式
  • 3、Redis Cluster模式

当然使用随着海量数据的存储要求,单台Redis配置有限,已经满足不了我们的需求。我们考虑采用分布式集群方案

Redis Cluster 采用数据分片机制,定义了 16384个 Slot槽位,集群中的每个Redis 实例负责维护一部分槽以及槽所映射的键值数据。

客户端可以连接集群中任意一个Redis 实例,发送读写命令,如果当前Redis 实例收到不是自己负责的Slot的请求时,会将该slot所在的正确的Redis 实例地址返回给客户端。

客户端收到后,自动将原请求重新发到这个新地址,自动操作,外部透明。

是不是有点似曾相识的感觉,HTTP 协议也有重定向功能。玩法跟这个差不多。HTTP 响应头有一个**Location字段,当状态码是301或者302**时,客户端会自动读取 **Location**中的新地址,自动重定向发送请求。

Redis key的路由计算公式:slot = CRC16(key) % 16384

添加、删除或者修改某一个节点,都不会造成集群不可用的状态。使用哈希槽的好处就在于可以方便的添加或移除节点。

当需要增加节点时,只需要把其他节点的某些哈希槽挪到新节点;当需要移除节点时,只需要把移除节点上的哈希槽挪到其他节点。

图片

CRC16的算法原理:

  • 根据CRC16的标准选择初值CRCIn的值
  • 将数据的第一个字节与CRCIn高8位异或
  • 判断最高位,若该位为 0 左移一位,若为 1 左移一位再与多项式Hex码异或
  • 重复3直至8位全部移位计算结束。
  • 重复将所有输入数据操作完成以上步骤,所得16位数即16位CRC校验码。

CRC16 算法最大值

CRC16 算法,产生的hash值有 16 bit 位,可以产生 65536(2^16)个值 ,也就是说值分布在 0 ~ 65535 之间

这时候,疑问来了,槽位总数为什么是 16384 ?65536 不可以吗?

图片

这个问题,Redis 官方 Issues 也有朋友提出来过

地址:

https://github.com/redis/redis/issues/2576

图片

antirez 大神对这个问题做了回复,简单归纳起来,有以下原因:

  • 正常的心跳数据包携带节点的完整配置,它能以幂等方式来更新配置。如果采用 16384 个插槽,占空间 2KB (16384/8);如果采用 65536 个插槽,占空间 8KB (65536/8)。
  • Redis Cluster 不太可能扩展到超过 1000 个主节点,太多可能导致网络拥堵。
  • 16384 个插槽范围比较合适,当集群扩展到1000个节点时,也能确保每个master节点有足够的插槽,

8KB 的心跳包看似不大,但是这个是心跳包每秒都要将本节点的信息同步给集群其他节点。比起 16384 个插槽,头大小增加了4倍,ping消息的消息头太大了,浪费带宽。

Redis主节点的哈希槽配置信息是通过 bitmap 来保存的

图片

传输过程中,会对bitmap进行压缩,bitmap的填充率越低,压缩率越高。

bitmap 填充率 = slots / N (N表示节点数),

所以,插槽数偏低的话, 填充率会降低,压缩率会升高。

综合下来,从心跳包的大小、网络带宽、心跳并发、压缩率等维度考虑,16384 个插槽更有优势且能满足业务需求。

万事万物,都是相互制衡的,”大“ 不一定是最好的,合适最重要。

接下来,我们看下master节点间心跳数据包格式:

消息格式分为:消息头和消息体。消息头包含发送节点自身状态数据,接收节点根据消息头就可以获取到发送节点的相关数据,

代码位置:

/usr/src/redis/redis-5.0.7/src/cluster.h

图片

其中,消息头有一个myslots的char类型数组,unsigned char myslots[CLUSTER_SLOTS/8];,数组长度为 16384/8 = 2048 。底层存储其实是一个bitmap,每一个位代表一个槽,如果该位为1,表示这个槽是属于这个节点。

消息体中,会携带一定数量的其他节点信息用于交换,约为集群总节点数量的1/10,节点数量越多,消息体内容越大。10个节点的消息体大小约1kb。

划重点:

细心的同学可能会有疑问,char不是占2个字节吗?数组长度为什么是 16384/8?不应该是 16384/16 吗?

因为,Redis 是 C 语言开发的,char 占用一个 字节;而 Java 语言 char 占用 两个 字节。

master节点间心跳通讯

Redis 集群采用 Gossip(流言)协议, Gossip 协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,类似流言传播。

集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,只要这些节点彼此可以正常通信,最终它们会达到一致的状态。当节点出现故障、新节点加入、主从角色变化、槽信息变更等事件发生时,通过不断的 ping/pong 消息通信,经过一段时间后所有的节点都会知道整个集群 全部节点的最新状态,从而达到集群状态同步的目的。

具体规则如下:

  • 1、每秒会随机选取5个节点,找出最久没有通信的节点发送ping消息
  • 2、每隔 100毫秒 都会扫描本地节点列表,如果发现节点最近一次接受pong消息的时间大于cluster-node-timeout/2 ,则立刻发送ping消息

因此,每秒单master节点发出ping消息数量:

= 1 + 10 * num(node.pong_received>cluster_node_timeout/2)

总结:

1、每秒 redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为 65536,这个ping消息的消息头太大了,浪费带宽。

2、业务上看,集群主节点数量基本不可能超过1000个。集群节点越多,心跳包的消息体携带的数据越多。如果节点超过1000个,会导致网络拥堵。因此redis作者,不建议redis cluster节点数量超过1000个。

3、槽位越小,节点少的情况下,压缩率更高

3.4 其他补充

集群同步策略

RDB、AOF、RDB+AOF

RDB - (Redis Database)

触发方式:

save : 该命令会阻塞当前Redis服务器,执行save命令期间,Redis不能处理其他命令,直到RDB过程完成为止。

bgsave : 执行该命令时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。

img

自动触发 : 自动触发是由我们的配置文件来完成的。在redis.conf配置文件中,里面有如下配置,我们可以去设置:

1
2
3
4
5
6
7
8
#表示900 秒内如果至少有 1 个 key 的值变化,则保存
save 900 1
#表示300 秒内如果至少有 10 个 key 的值变化,则保存
save 300 10
#表示60 秒内如果至少有 10000 个 key 的值变化,则保存
save 60 10000

## 不需要持久化,那么你可以注释掉所有的 save 行来停用保存功能。

RDB 的优势和劣势

①、优势

(1)RDB文件紧凑,全量备份,非常适合用于进行备份和灾难恢复。

(2)生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。

(3)RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。

②、劣势

 RDB快照是一次全量备份,存储的是内存数据的二进制序列化形式,存储上非常紧凑。当进行快照持久化时,会开启一个子进程专门负责快照持久化,子进程会拥有父进程的内存数据,父进程修改内存子进程不会反应出来,所以在快照持久化期间修改的数据不会被保存,可能丢失数据。

AOF – (Append Only File)

三种触发机制

(1)每修改同步always:同步持久化 每次发生数据变更会被立即记录到磁盘 性能较差但数据完整性比较好

(2)每秒同步everysec:异步操作,每秒记录 如果一秒内宕机,有数据丢失

(3)不同no:从不同步

优点

(1)AOF可以更好的保护数据不丢失,一般AOF会每隔1秒,通过一个后台线程执行一次fsync操作,最多丢失1秒钟的数据。
(2)AOF日志文件没有任何磁盘寻址的开销,写入性能非常高,文件不容易破损。

(3)AOF日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写。

(4)AOF日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如某人不小心用flushall命令清空了所有数据,只要这个时候后台rewrite还没有发生,那么就可以立即拷贝AOF文件,将最后一条flushall命令给删了,然后再将该AOF文件放回去,就可以通过恢复机制,自动恢复所有数据

缺点

(1)对于同一份数据来说,AOF日志文件通常比RDB数据快照文件更大

(2)AOF开启后,支持的写QPS会比RDB支持的写QPS低,因为AOF一般会配置成每秒fsync一次日志文件,当然,每秒一次fsync,性能也还是很高的

(3)以前AOF发生过bug,就是通过AOF记录的日志,进行数据恢复的时候,没有恢复一模一样的数据出来。

混合持久化

​ 混合持久化只发生于 AOF 重写过程。使用了混合持久化,重写后的新 AOF 文件前半段是 RDB 格式的全量数据,后半段是 AOF 格式的增量数据。

​ 混合持久化本质是通过 AOF 后台重写(bgrewriteaof 命令)完成的,不同的是当开启混合持久化时,fork 出的子进程先将当前全量数据以 RDB 方式写入新的 AOF 文件,然后再将 AOF 重写缓冲区(aof_rewrite_buf_blocks)的增量命令以 AOF 方式写入到文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

AOF 重写

描述:Redis 生成新的 AOF 文件来代替旧 AOF 文件,这个新的 AOF 文件包含重建当前数据集所需的最少命令。具体过程是遍历所有数据库的所有键,从数据库读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令。

命令:有两个 Redis 命令可以用于触发 AOF 重写,一个是 BGREWRITEAOF 、另一个是 REWRITEAOF 命令;

流程很复杂

问题:

AOF重写期间数据不一致问题 —- 通过 AOF重写缓冲区解决;

AOF缓冲区过大问题 —- Redis 会创建一组用于父子进程间通信的管道,同时会新增一个文件事件,该文件事件会将写入 AOF 重写缓冲区的内容通过该管道发送到子进程。

img

同步步骤

1、全量同步

当一个redis服务器初次向主服务器发送salveof命令时,redis从服务器会进行一次全量同步,同步的步骤如下图所示:

在这里插入图片描述

  • slave服务器向master发送psync命令(此时发送的是psync ? -1),告诉master我需要同步数据了。
  • master接收到psync命令后会进行BGSAVE命令生成RDB文件快照。
  • 生成完后,会将RDB文件发送给slave。
  • slave接收到文件会载入RDB快照,并且将数据库状态变更为master在执行BGSAVE时的状态一致。
  • master会发送保存在缓冲区里的所有写命令,告诉slave可以进行同步了
  • slave执行这些写命令。

2.命令传播

slave已经同步过master了,那么如果后续master进行了写操作,比如说一个简单的set name redis,那么master执行过当前命令后,会将当前命令发送给slave执行一遍,达成数据一致性。

3.重新复制

当slave断开重连之后会进行重新同步,重新同步分完全同步和部分同步

首先来看看部分同步大致的走向

在这里插入图片描述

  • 当slave断开重连后,会发送psync 命令给master。
  • master收到psync后会返回+continue回复,表示slave可以执行部分同步了。
  • master发送断线后的写命令给slave
  • slave执行写命令。

部分同步判断

实际上当slave发送psync命令给master之后,master还需要根据以下三点判断是否进行部分同步。

先来介绍一下是哪三个方面:

1、服务器运行ID

每个redis服务器开启后会生成运行ID。

当进行初次同步时,master会将自己的ID告诉slave,slave会记录下来,当slave断线重连后,发现ID是这个master的就会尝试进行部分重同步。当ID与现在连接的master不一样时会进行完整重同步。

2、复制偏移量

复制偏移量包括master复制偏移量和slave复制偏移量,当初次同步过后两个数据库的复制偏移量相同,之后master执行一次写命令,那么master的偏移量+1,master将写命令给slave,slave执行一次,slave偏移量+1,这样版本就能一致。

3、复制积压缓冲区

复制积压缓冲区是由master维护的固定长度的先进先出的队列。

当slave发送psync,会将自己的偏移量也发送给master,当slave的偏移量之后的数据在缓冲区还存在,就会返回+continue通知slave进行部分重同步。

当slave的偏移量之后的数据不在缓冲区了,就会进行完整重同步。

4、总结:

  • 当slave断开重连后,会发送psync 命令给master。
  • master首先会对服务器运行进行判断,如果与自己相同就进行判断偏移量
  • master会判断自己的偏移量与slave的偏移量是否一致。
  • 如果不一致,master会去缓冲区中判断slave的偏移量之后的数据是否存在。
  • 如果存在就会返回+continue回复,表示slave可以执行部分同步了。
  • master发送断线后的写命令给slave
  • slave执行写命令。
    5.主从同步最终流程

在这里插入图片描述

一、主从复制模式

作用

master写,slave读

1
2
3
4
5
读写分离:master写,slave读,提高服务器的读写负载能力
负载均衡:基于主从结构,配合读写分离,由slave分担master负载,并根据需求的变化,改变slave的数据,通过多个结点分担数据读取负载,大大提高Redis服务器并发量与数据吞吐量
故障恢复:当master出现问题时,由slave提供服务,实现快速的故障恢复
数据冗余:实现数据热备份,是持久化之外的一种数据冗余方式
高可用基石:基于主从复制,构建哨兵模式与集群,实现Redis的高可用方案

二、哨兵模式

哨兵其实是一个运行在特殊模式下的 Redis 进程,所以它也是一个节点。(哨兵就是一个redis 服务端节点)

1
哨兵(sentinel)是一个分布式系统,用于对主从结构中的每台服务器进行监控,当出现故障时通过投票机制选择新的master并将所有slave连接到新的master

为什么有哨兵
主机宕机,如何能够高可用的恢复正常,不用人为进行干预

作用

1
2
3
4
5
6
7
1.监控 :
不断地检查masterslave是否正常运行
master存活检测,masterslave运行清空检测
2.通知(提醒) :
当被监控地服务器出现问题时,向其他(哨兵、客户端)发送通知
3.自动故障转移 :
断开masterslave连接,选取一个slave作为master,将其他slave连接到新地master,并告知客户端新地服务器地址

缺点

不能动态扩充,哨兵模式还是只有一个master

三、Redis-Cluster

1
使用哨兵,redis 每个实例也是全量存储,每个 redis 存储的内容都是完整的数据,浪费内存且有木桶效应。为了最大化利用内存,可以采用 cluster 群集,就是分布式存储。

职责

Master – 读写, Slave - 备份

在 cluster 架构下,默认的,一般 redis-master 用于接收读写,而 redis-slave 则用于备份,当有请求是在向 slave 发起时,会直接重定向到对应 key 所在的 master 来处理。但如果不介意读取的是 redis-cluster 中有可能过期的数据并且对写请求不感兴趣时,则亦可通过 readonly 命令,将 slave 设置成可读,然后通过 slave 获取相关的 key,达到读写分离。

Slot槽

1
2
3
redis-cluster 架构中,被设计成共有 16384 个 hash slot。每个 master 分得一部分 slot.

Redis 集群没有并使用传统的一致性哈希来分配数据,而是采用另外一种叫做哈希槽 (hash slot)的方式来分配的。redis cluster 默认分配了 16384 个slot,当我们set一个key 时,会用CRC16算法来取模得到所属的slot,然后将这个key 分到哈希槽区间的节点上,具体算法就是:CRC16(key) % 16384

3.5 分布式一致性协议 Gossip 和 Redis 集群原理解析

Redis 是一个开源的、高性能的 Key-Value 数据库。基于 Redis 的分布式缓存已经有很多成功的商业应用,其中就包括阿里 ApsaraDB,阿里 Tair 中的 RDB 引擎,美团 MOS 以及腾讯云 CRS。本文我将着重介绍 Redis Cluster 原理、类 Codis 分布式方案以及分布式信息一致性协议 Gossip,以帮助大家深入理解 Redis。

1. Redis 单机模式

顾名思义,单机模式指 Redis 主节点以单个节点的形式存在,这个主节点可读可写,上面存储数据全集。在3.0版本之前,Redis 只能支持单机模式,出于可靠性考量,通常单机模式为“1主 N 备”的结构,如下所示:

img

需要说明的是,即便有很多个 Redis 主节点,只要这些主节点以单机模式存在,本质上仍为单机模式。单机模式比较简单,足以支撑一般应用场景,但单机模式具有固有的局限性:不支持自动故障转移,扩容能力极为有限(只能 Scale Up,垂直扩容),存在高并发瓶颈。

1.1 不支持自动故障转移

Redis 单机模式下,即便是“1主 N 备”结构,当主节点故障时,备节点也无法自动升主,即无法自动故障转移(Failover)。故障转移需要“哨兵”Sentinel 辅助,Sentinel 是 Redis 高可用的解决方案,由一个或者多个 Sentinel 实例组成的系统可以监视 Redis 主节点及其从节点,当检测到 Redis 主节点下线时,会根据特定的选举规则从该主节点对应的所有从节点中选举出一个“最优”的从节点升主,然后由升主的新主节点处理请求。具有 Sentinel 系统的单机模式示意图如下:

img

1.2 扩容能力极为有限

这一点应该很好理解,单机模式下,只有主节点能够写入数据,那么,最大数据容量就取决于主节点所在物理机的内存容量,而物理机的内存扩容(Scale Up)能力目前仍是极为有限的。

1.3 高并发瓶颈

Redis 使用单线程的 IO 复用模型,对于单纯的 IO 操作来说,单线程可以将速度优势发挥到最大,但 Redis 也提供了一些简单的计算功能,比如排序、聚合等,对于这些操作,单线程模型实际会严重影响整体吞吐量,CPU 计算过程中,整个 IO 调度都会被阻塞住。因此,单机模式下并发支持能力很容易陷入瓶颈。

2. Redis Cluster

单实例 Redis 虽然简单,但瓶颈明显。一是容量问题,在一些应用场景下,数据规模可达数十 G,甚至数百 G,而物理机的资源却是有限的,内存无法无限扩充;二是并发性能问题,Redis 号称单实例10万并发,但也仅仅是10万并发。鉴于单机模式的局限性,历时三年,Redis Cluster 应运而生。

2.1 Redis Cluster 特点

自3.0版本起,Redis 官方推出了一个原生的分布式方案—— Redis Cluster。它是一个分布式、容错的 Redis 实现。Redis Cluster中不存在中心节点或者代理节点,集群主要设计目标之一是实现线性可扩展性

Redis Cluster 具有以下特点:

  • 节点互通:所有的 Redis 节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽;

  • 去中心化:Redis Cluster 不存在中心节点,每个节点都记录有集群的状态信息,并且通过 Gossip 协议,使每个节点记录的信息实现最终一致性;

  • 客户端直连:客户端与 Redis 节点直连,不需要中间 Proxy 层,客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可;

  • 数据分片:Redis Cluster 的键空间被分割为 16384 个 Slot,这些 Slot 被分别指派给主节点,当存储 Key-Value 时,根据 CRC16(key) Mod 16384的值,决定将一个 Key-Value 放到哪个 Slot 中;

  • 多数派原则:对于集群中的任何一个节点,需要超过半数的节点检测到它失效(pFail),才会将其判定为失效(Fail);

  • 自动 Failover:当集群中某个主节点故障后(Fail),其它主节点会从故障主节点的从节点中选举一个“最佳”从节点升主,替代故障的主节点;

  • 功能弱化:集群模式下,由于数据分布在多个节点,不支持单机模式下的集合操作,也不支持多数据库功能,集群只能使用默认的0号数据库;

  • 集群规模:官方推荐的最大节点数量为 1000 个左右,这是因为当集群规模过大时,Gossip 协议的效率会显著下降,通信成本剧增。

2.2 Redis-Cluster 实现基础:分片

Redis 集群实现的基础是分片,即将数据集有机的分割为多个片,并将这些分片指派给多个 Redis 实例,每个实例只保存总数据集的一个子集。利用多台计算机内存和来支持更大的数据库,而避免受限于单机的内存容量;通过多核计算机集群,可有效扩展计算能力;通过多台计算机和网络适配器,允许我们扩展网络带宽。

基于“分片”的思想,Redis 提出了 Hash Slot。Redis Cluster 把所有的物理节点映射到预先分好的16384个 Slot 上,当需要在 Redis 集群中放置一个 Key-Value 时,根据 CRC16(key) Mod 16384的值,决定将一个 Key 放到哪个 Slot 中。

img

2.3 Redis Cluster 请求路由方式

客户端直连 Redis 服务,进行读写操作时,Key 对应的 Slot 可能并不在当前直连的节点上,经过“重定向”才能转发到正确的节点。如下图所示,我们直接登录 127.0.0.1:6379 客户端,进行 Set 操作,当 Key 对应的 Slot 不在当前节点时(如 key-test),客户端会报错并返回正确节点的 IP 和端口。Set 成功则返回 OK。

img

以集群模式登录 127.0.0.1:6379 客户端(注意命令的差别:-c 表示集群模式),则可以清楚的看到“重定向”的信息,并且客户端也发生了切换:“6379” -> “6381”。

img

以三节点为例,上述操作的路由查询流程示意图如下所示:

img

和普通的查询路由相比,Redis Cluster 借助客户端实现的请求路由是一种混合形式的查询路由,它并非从一个 Redis 节点到另外一个 Redis,而是借助客户端转发到正确的节点。

实际应用中,可以在客户端缓存 Slot 与 Redis 节点的映射关系,当接收到 MOVED 响应时修改缓存中的映射关系。如此,基于保存的映射关系,请求时会直接发送到正确的节点上,从而减少一次交互,提升效率。

目前,包括 Lettuce、Jedis、Redission 在内的许多 Redis Client,都已经实现了对 Redis Cluster 的支持,关于客户端的内容,将在第05课中详细介绍。

3. Redis Cluster 节点通信原理:Gossip 算法

在第02课中曾介绍,Redis 自3.0版本起,支持 Redis Cluster,真正意义上实现了分布式。在分布式系统中,节点间的通信十分重要,是构建集群的基石。那么 Redis Cluster 中,节点间是如何通信的呢?又是如何保障一致性、可用性的呢?欲知答案,必先了解 Gossip 算法。

3.1 Gossip 简介

Gossip 算法源自流行病学的研究,经过不断的发展演化,作为一种分布式一致性协议而得到广泛应用,如 Cassandra、Akka、Redis 都有用到。

Gossip 背景

Gossip 算法如其名,在办公室,只要一个人八卦一下,在有限的时间内所有的人都会知道该八卦的信息,这种方式也与病毒传播类似,因此 Gossip 有众多的别名,如“闲话算法”、“疫情传播算法”、“病毒感染算法”、“谣言传播算法”。但 Gossip 并不是一个新东西,之前的泛洪查找、路由算法都归属于这个范畴,不同的是 Gossip 给这类算法提供了明确的语义、具体实施方法及收敛性证明。

Gossip 特点

Gossip 算法又被称为反熵(Anti-Entropy),熵是物理学上的一个概念,代表杂乱无章,而反熵就是在杂乱无章中寻求一致,这充分说明了 Gossip 的特点:在一个有界网络中,每个节点都随机地与其它节点通信,经过一番杂乱无章的通信,最终所有节点的状态都会达成一致。每个节点可能知道所有其它节点,也可能仅知道几个邻居节点,只要这些节可以通过网络连通,最终它们的状态都是一致的。

要注意到的一点是,即使有的节点因宕机而重启,有新节点加入,但经过一段时间后,这些节点的状态也会与其他节点达成一致,也就是说,Gossip 天然具有分布式容错的优点。

Gossip 本质

Gossip 是一个带冗余的容错算法,更进一步,Gossip 是一个最终一致性算法。虽然无法保证在某个时刻所有节点状态一致,但可以保证在“最终”所有节点一致,“最终”是一个现实中存在,但理论上无法证明的时间点。

因为 Gossip 不要求节点知道所有其它节点,因此又具有去中心化的特点,节点之间完全对等,不需要任何的中心节点。实际上 Gossip 可以用于众多能接受“最终一致性”的领域:失败检测、路由同步、Pub/Sub、动态负载均衡。

但 Gossip 的缺点也很明显,冗余通信会对网路带宽、CUP 资源造成很大的负载,而这些负载又受限于通信频率,该频率又影响着算法收敛的速度,下文中,我将结合 Redis 源码详细解释。

3.2 Gossip 在 Redis Cluster 中的作用

在分布式系统中,需要提供维护节点元数据信息的机制,所谓元数据是指节点负责哪些数据、主从属性、是否出现故障等状态信息。常见的元数据维护方式分为集中式和无中心式。Redis Cluster 采用 Gossip 协议实现了无中心式。

Redis Cluster 中使用 Gossip 主要有两大作用:

  • 去中心化,以实现分布式和弹性扩展;
  • 失败检测,以实现高可用;

3.3 节点通信基础

Redis Cluster 中的每个 Redis 实例监听两个 TCP 端口,6379(默认)用于服务客户端查询,16379(默认服务端口+10000)用于集群内部通信。集群中节点通信方式如下:

  • 每个节点在固定周期内通过特定规则选择几个节点发送 Ping 消息;
  • 接收到 Ping 消息的节点用 Pong 消息作为响应。

集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,只要这些节点彼此可以正常通信,最终它们会达到一致的状态。当节点故障、新节点加入、主从关系变化、槽信息变更等事件发生时,通过不断的 Ping/Pong 消息通信,经过一段时间后所有的节点都会知道集群全部节点的最新状态,从而达到集群状态同步的目的。

3.4 Gossip 消息种类

Gossip 协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的Gossip 消息,常用的 Gossip 消息可分为:Ping 消息、Pong 消息、Meet 消息、Fail 消息。

  • Meet 消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,Meet 消息通信正常完成后,接收节点会加入到集群中并进行周期性的 Ping、Pong 消息交换;

  • Ping 消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其它节点发送 Ping 消息,用于检测节点是否在线和交换彼此状态信息。Ping 消息发送封装了自身节点和部分其它节点的状态数据;

  • Pong 消息:当接收到 Ping、Meet 消息时,作为响应消息回复给发送方确认消息正常通信。Pong 消息内部封装了自身状态数据。节点也可以向集群内广播自身的 Pong 消息来通知整个集群对自身状态进行更新;

  • Fail 消息:当节点判定集群内另一个节点下线时,会向集群内广播一个 Fail 消息,其他节点接收到 Fail 消息之后把对应节点更新为下线状态。

4. Redis Cluster 节点通信:成本与效率的权衡

由于集群内部需要频繁地进行节点信息交换,而 Ping/Pong 消息携带当前节点和部分其它节点的状态数据,势必会加重带宽和计算的负担。Redis 集群内节点通信采用固定频率(定时任务每秒执行10次),因此,节点每次选择需要通信的节点列表变得非常重要。通信节点选择过多虽然可以做到信息及时交换但成本过高。节点选择过少则会降低集群内所有节点彼此信息交换的频率,从而影响故障判定、新节点发现等需求的速度。因此 Redis 集群的 Gossip 协议需要兼顾信息交换实时性和成本开销。

4.1 节点间是如何交换信息的?

Redis 节点启动之后,会每间隔 100ms 执行一次集群的周期性函数 clusterCron()。在 Redis 源码 server.c 中可见:

1
2
3
4
/* Run the Redis Cluster cron. */
run_with_period(100) {
if (server.cluster_enabled) clusterCron();
}

clusterCron() 中又会调用 clusterSendPing() 函数,该函数用于将随机选择的节点的信息加入到 Ping 消息体中,然后发送出去。部分源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
void clusterSendPing(clusterLink *link, int type) {
unsigned char *buf;
clusterMsg *hdr;
int gossipcount = 0; /* Number of gossip sections added so far. */
int wanted; /* Number of gossip sections we want to append if possible. */
int totlen; /* Total packet length. */
// freshnodes = 集群总节点数 - (2=当前节点+发送消息的目的节点)
// freshnodes 的值是ping消息体中可以携带节点信息的最大值
int freshnodes = dictSize(server.cluster->nodes)-2;
// wanted 的值是集群节点的十分之一向下取整,并且最小等于3
// wanted 表示ping消息体中期望携带的其它节点信息个数
wanted = floor(dictSize(server.cluster->nodes)/10);
if (wanted < 3) wanted = 3;
// 因此 wanted 最多等于 freshnodes。
if (wanted > freshnodes) wanted = freshnodes;

// 计算分配消息的最大空间
totlen = sizeof(clusterMsg)-sizeof(union clusterMsgData);
totlen += (sizeof(clusterMsgDataGossip)*wanted);
// 消息的总长最少为一个消息结构的大小
if (totlen < (int)sizeof(clusterMsg)) totlen = sizeof(clusterMsg);
// 分配空间
buf = zcalloc(totlen);
hdr = (clusterMsg*) buf;
// 设置发送PING命令的时间
if (link->node && type == CLUSTERMSG_TYPE_PING)
link->node->ping_sent = mstime();
// 构建消息的头部
clusterBuildMessageHdr(hdr,type);
int maxiterations = wanted*3;
// 循环体,构建消息内容
while(freshnodes > 0 && gossipcount < wanted && maxiterations--) {
// 随机选择一个集群节点
dictEntry *de = dictGetRandomKey(server.cluster->nodes);
clusterNode *this = dictGetVal(de);
clusterMsgDataGossip *gossip;
int j;

// 1. 跳过当前节点,不选myself节点,myself代表当前节点
if (this == myself) continue;

// 2. 偏爱选择处于下线状态或疑似下线状态的节点
if (maxiterations > wanted*2 &&
!(this->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL)))
continue;

// 以下节点不能作为被选中的节点:
/*
1. 处于握手状态的节点
2. 带有NOADDR标识的节点
3. 因为不处理任何槽而断开连接的节点
*/
if (this->flags & (CLUSTER_NODE_HANDSHAKE|CLUSTER_NODE_NOADDR) ||
(this->link == NULL && this->numslots == 0))
{
freshnodes--; /* Tecnically not correct, but saves CPU. */
continue;
}
}
//(中间部分代码省略.............)
// 发送消息
clusterSendMessage(link,buf,totlen);
zfree(buf);
}

根据上述代码,相信读者可以明确以下两点:

  1. 当前节点向另一个节点发送 Ping 消息时,携带的其它节点的消息数量至少为3,最大等于集群节点总数-2;
  2. 为 Ping 消息体中选择携带的其它节点的信息时,采用的是混合选择模式:随机选择+偏好性选择,这样不仅可以保证 Gossip 协议随机传播的原则,还可以尽量将当前节点掌握的其它节点的故障信息传播出去。

4.2 如何保证消息传播的效率?

前面已经提到,集群的周期性函数 clusterCron() 执行周期是 100ms,为了保证传播效率,每10个周期,也就是 1s,每个节点都会随机选择5个其它节点,并从中选择一个最久没有通信的节点发送 ing消息,源码如下:

img

当然,这样还是没法保证效率,毕竟5个节点是随机选出来的,其中最久没有通信的节点不一定是全局“最久”。因此,对哪些长时间没有“被” 随机到的节点进行特殊照顾:每个周期(100ms)内扫描一次本地节点列表,如果发现节点最近一次接受 Pong 消息的时间大于 cluster_node_timeout/2,则立刻发送 Ping 消息,防止该节点信息太长时间未更新。源码如下:

img

4.3 规模效应——无法忽略的成本问题

关键参数 cluster_node_timeout

从上面的分析可以看出,cluster_node_timeout 参数对消息发送的节点数量影响非常大。当带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降低带宽占用率。但是,过度调大 cluster_node_timeout 会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度,因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。

消息体与集群规模

每个 Ping 消息的数据量体现在消息头和消息体中,其中消息头空间占用相对固定。消息体会携带一定数量的其它节点信息用于信息交换,消息体携带数据量跟集群的节点数息息相关,更大的集群每次消息通信的成本也就更高,因此对于 Redis 集群来说并不是越大越好。

5. Redis Cluster 故障转移

上面已经介绍过单机模式的故障转移(主节点下线后,对应从节点升主并替代原主节点继续工作)过程,单机模式下故障转移需要 Sentinel 系统的辅助,与之不同,Redis 集群模式故障转移并不需要 Sentinel 系统辅助,而是通过集群内部主节点选举完成,是一个“自治”的系统。

Redis Cluster 的故障转移可划分为三大步骤:故障检测、从节点选举以及故障倒换,以下详细介绍。

5.1 故障检测

故障检测需要经历单节点视角检测、检测信息传播、下线判决三个步骤,下文将详细介绍。

单点视角检测

集群中的每个节点都会定期通过集群内部通信总线向集群中的其它节点发送 Ping 消息,用于检测对方是否在线。如果接收 Ping 消息的节点没有在规定的时间内向发送 Ping 消息的节点返回 Pong 消息,那么,发送 Ping 消息的节点就会将接收 Ping 消息的节点标注为疑似下线状态(Probable Fail,Pfail)。

检测信息传播

集群中的各个节点会通过相互发送消息的方式来交换自己掌握的集群中各个节点的状态信息,如在线、疑似下线(Pfail)、下线(Fail)。例如,当一个主节点 A 通过消息得知主节点 B 认为主节点 C 疑似下线时,主节点 A 会更新自己保存的集群状态信息,将从 B 获得的下线报告保存起来。

基于检测信息作下线判决

如果在一个集群里,超过半数的持有 Slot(槽)的主节点都将某个主节点 X 报告为疑似下线,那么,主节点 X 将被标记为下线(Fail),并广播出去,所有收到这条 Fail 消息的节点都会立即将主节点 X 标记为 Fail。至此,故障检测完成。

5.2 选举

主节点被标记为 Fail 后,对应的从节点会发起投票,竞争升主。历经从节点拉票、主节点投票、投票裁决等环节,最终完成选举。以下是详细过程。

从节点拉票

基于故障检测信息的传播,集群中所有正常节点都将感知到某个主节点下线的信息,当然也包括这个下线主节点的所有从节点。当从节点发现自己复制的主节点状态为已下线时,从节点就会向集群广播一条请求消息,请求所有收到这条消息并且具有投票权的主节点给自己投票。

拉票优先级

严格的讲,从节点在发现其主节点下线时,并非立即发起故障转移流程而进行“拉票”的,而是要等待一段时间,在未来的某个时间点才发起选举。这个时间点有如下计算表达式:

1
mstime() + 500ms + random()%500ms + rank*1000ms

其中,固定延时 500ms,是为了留出时间,使主节点下线的消息能传播到集群中其他节点,这样集群中的主节点才有可能投票;随机延时是为了避免两个从节点同时开始故障转移流程;rank 表示从节点的排名,排名是指当前从节点在下线主节点的所有从节点中的排名,排名主要是根据复制数据量来定,复制数据量越多,排名越靠前,因此,具有较多复制数据量的从节点可以更早发起故障转移流程,从而更可能成为新的主节点。

主节点投票

如果一个(其他)主节点具有投票权(负责处理 Slot 的主节点),并且这个主节点尚未投票给其它从节点,那么这个主节点将向请求投票的从节点返回一条回应消息,表示支持该从节点升主。

根据投票结果决策

在一个具有 N 个主节点投票的集群中,理论上每个参与拉票的从节点都可以收到一定数量的主节点投票,但是,在同一轮选举中,只可能有一个从节点收到的票数大于 N/2 + 1,也只有这个从节点可以升级为主节点,并代替已下线的主节点继续工作。

选举失败

跟生活中的选举一样,选举可能失败——没有一个候选从节点获得超过半数的主节点投票。遇到这种情况,集群将会进入下一轮选举,直到选出新的主节点为止。

选举算法

选举新主节点的算法是基于 Raft 算法的 Leader Election 方法来实现的,本文就不展开了,在本课程后面介绍 ETCD 的章节中会详述 Raft 算法。

5.3 故障转移

选举完成后,获胜的从节点将发起故障转移(Failover),角色从 Slave 切换为 Master,并接管原来主节点的 Slots,详细过程如下。

身份切换

通过选举晋升的从节点会执行一系列的操作,清除曾经为从的信息,改头换面,成为新的主节点。

接管职权

新的主节点会通过轮询所有 Slot,撤销所有对已下线主节点的 Slot 指派,消除影响,并且将这些 Slot 全部指派给自己。

广而告之

升主了嘛,必须让圈子里面的都知道,新的主节点会向集群中广播一条 Pong 消息,将自己升主的信息通知到集群中所有节点。

履行义务

在其位谋其政,新的主节点开始处理自己所负责 Slot 对应的请求,至此,故障转移完成。

6. Redis Cluster 扩容

随着应用场景的升级,缓存可能需要扩容,扩容的方式有两种:垂直扩容(Scale Up)和水平扩容(Scale Out)。垂直扩容无需详述。实际应用场景中,采用水平扩容更多一些,根据是否增加主节点数量,水平扩容方式有两种。

方式1:主节点数量不变。

比如,当前有一台物理机 A,构建了一个包含3个 Redis 实例的集群;扩容时,我们新增一台物理机 B,拉起一个 Redis 实例并加入物理机 A 的集群;B 上 Redis 实例对 A 上的一个主节点进行复制,然后进行主备倒换;如此,Redis 集群还是3个主节点,只不过变成了 A2-B1 的结构,将一部分请求压力分担到了新增的节点上,同时物理容量上限也会增加,主要步骤如下:

  1. 将新增节点加入集群;

  2. 将新增节点设置为某个主节点的从节点,进而对其进行复制;

  3. 进行主备倒换,将新增的节点调整为主。

方式2:增加主节点数量。

不增加主节点数量的方式扩容比较简单,但是,从负载均衡的角度来看,并不是很好的选择。例如,如果主节点数量较少,那么单个节点所负责的 Slot 的数量必然较多,很容易出现大量 Key 的读写集中于少数节点的现象,而增加主节点的数量,可以更有效的分摊访问压力,充分利用资源。主要步骤如下:

  1. 将新增节点加入集群;
  2. 将集群中的部分 Slot 迁移至新增的节点。

7. 其它分布式 Redis 方案

作为 Redis 官方出品,Redis Cluster 有很多优点,但是,当集群规模超过百节点级别后,Gossip 协议的效率将会显著下降,通信成本越来越高。此外,Redis Cluster 模式下,16384个 Slot 中只要有任意一个 Slot 不可用,整个集群都将不可用,换言之,任何一个被指派 Slot 的主节点故障,在其恢复期间,集群都是不可用的。鉴于此,Redis Cluster 并不适合超大规模商用场景,国内 IT 巨头基本上采用的都是自研的集群方案,如阿里云 ApsaraDB for Redis/ApsaraCache,腾讯的 CRS。当然,Redis Cluster 也有很成功的商用案例,像亚马逊采用的就是 Redis Cluster。

注意,通常 Redis Cluster 的规模都控制在400个节点以内,以单节点支撑 8W QPS 计算,400个节点可支持 200*8=1600W QPS(每个主节点配置一个从节点),足以支撑绝大多数应用场景。此外,Redis Cluster 使用简单,无需深度定制便可实现商用,成本较低,因此,即便它不适合超大规模商用,仍然是一个非常优秀的集群方案。

7.1 预备知识

分片是 Redis 实现分布式架构的基础,主流的分片方式有:客户端分片和代理分片。欲理解 Redis 各个分布式方案,必先了解两种分片方式的原理。

基于客户端的分片

如下图所示,客户端与 Redis 节点直连,为了提高可用性,每个主节点挂一个从节点,故障倒换可由“哨兵”系统实现(其它方案也可实现)。客户端对任何一个主节点的读写操作本质上就是单机模式下的读写操作;对于一个 Key-Value,其读写节点完全由客户端决定。比如,采用 Hash 算法:

hash(key)%N = 目标节点编号, 其中 N 为 Redis 主节点的数量,哈希取余的方式会将不同的 Key 分发到不同的 Redis 主节点上。

img

但是,Hash 算法有很多缺陷:

  1. 不支持动态增加节点:当业务量增加,需要增加服务器节点后,上面的计算公式变为:hash(key)%(N+1),那么,对于同一个 Key-Value,增加节点前后,对应的 Redis 节点可能是完全不同的,可能导致大量之前存储的数据失效;为了解决这个问题,需要将所有数据重新计算 Hash 值,再写入 Redis 服务器。
  2. 不支持动态减少节点,原理同上。

鉴于 Hash 算法的不足,在实际应用中一般采用“一致性哈希”算法,在增删节点的时候,可以保证尽可能多的缓存数据不失效。关于一致性哈希算法,网上文章很多,读者可自行研读。

采用客户端分片具有逻辑简单,性能高的优点,但缺点也很明显,主要有业务逻辑与数据存储逻辑耦合,可运维性差;多业务各自使用 Redis,集群资源难以管理。

基于代理的分片

为了克服客户端分片业务逻辑与数据存储逻辑耦合的不足,可以通过 Proxy 将业务逻辑和存储逻辑隔离。客户端发送请求到一个代理,代理解析客户端的数据,将请求转发至正确的节点,然后将结果回复给客户端。这种架构还有一个优点就是可以把 Proxy 当成一个中间件,在这个中间件上可以做很多事情,比如可以把集群和主从的兼容性做到几乎一致,可以做无缝扩减容、安全策略等。

基于代理的分片已经有很多成熟的方案,如开源的 Codis,阿里云的 ApsaraDB for Redis/ApsaraCache,腾讯的 CRS 等。很多大企业也在采用 Proxy+Redis-Server 的架构。

基本原理如下图所示:

img

我们来了解下代理分片的缺点。没有完美的架构,由于使用了 Proxy,带宽和 CPU 基本都要加倍,对资源的消耗会大很多。

7.2 Codis 架构

Codis 是一个分布式 Redis 解决方案,对于上层的应用来说,连接到 Codis Proxy 和连接原生的 Redis Server 没有明显的区别 (参考6.1中的代理分片模式),客户端可以像使用单机 Redis 一样使用。

架构图如下:

img

Codis 简介

从 Codis 的官方架构图可以看出,Codis 主要由四部分组成:

  • Codis Proxy(codis-proxy):是客户端连接的 Redis 代理服务,它本身实现了 Redis 协议,表现得和一个原生的 Redis 没什么区别 (就像 Twemproxy)。对于一个业务来说,可以部署多个 Codis Proxy,Codis Proxy 本身是无状态的。

  • Codis Manager(codis-config):是 Codis 的管理工具,支持添加/删除 Redis 节点,添加/删除 Proxy 节点,发起数据迁移等操作。本身还自带了一个 HTTP Server,会启动一个 Dashboard,用户可以直接在浏览器上观察 Codis 集群的运行状态。

  • Codis Redis(codis-server):是 Codis 项目维护的一个 Redis 分支,基于 2.8.13 开发,加入了对 Slot 的支持和原子的数据迁移指令。 Codis 上层的 codis-proxycodis-config 只有和这个版本的 Redis 交互才能正常运行。

  • ZooKeeper:Codis 依赖 ZooKeeper 来存放数据路由表和 codis-proxy 节点的元信息,codis-config 发起的命令都会通过 ZooKeeper 同步到各个存活的 codis-proxy

Codis 特点

Codis 出现在 Redis Cluster 之前,事实上,正是由于 Redis 官方推出 Redis Cluster 太晚,IT 巨头们又都是急性子,只好自力更生,搞大生产运动,才诞生了众多定制化或开源的分布式 Redis 方案。Codis 作为其中的代表,具有以下特点:

  • 自动平衡;

  • 使用非常简单;

  • 图形化的面板和管理工具;

  • 支持绝大多数 Redis 命令,完全兼容 Twemproxy;

  • 支持 Redis 原生客户端;

  • 安全而且透明的数据移植,可根据需要轻松添加和删除节点;

  • 提供命令行接口,支持 RESTful APIs。

7.3 类 Codis 架构:Proxy + Redis-Server

在上面曾提到,实现 Redis 分布式的基础是分片。目前,主流的分片方案有三种,即 Redis Cluster、客户端分片、代理分片。除了官方推出的 Redis Cluster,大多数 IT 公司采用的都是基于代理的分片模式,即:Proxy + Redis-Server,这与 Codis 的原理类似,因此也称为“类 Codis”架构,其架构图如下:

img

该架构有以下特点:

  • 分片算法:基于代理的分片原理,将物理节点映射到 Slot(Codis Slot 数为1024,其它方案一般为16384),对 Key-Value 进行读写操作时,采用一致性 Hash 算法或其它算法(如 Redis Cluster采用的 CRC16),计算 Key 对应的 Slot 编号,根据 Slot 编号转发到对应的物理节点;

  • 分片实例之间相互独立,每组一个 Master 实例和多个 Slave,其本质就是“1主 N 从”的单机模式;

  • 路由信息存放依赖第三方存储组件,如 ZooKeeper 或 Etcd;

  • High Availability:Redis 单机模式不支持自动故障倒换,为了保证高可用,需要类似“哨兵系统”的 HA组件来支持高可用。

8. 总结

一个有追求研发者,对开源软件绝不能停留在“知道怎么用”的层面,知其然更要知其所以然,如是,才能举一反三。基于此,本文以极为浓重的笔墨详细解读了 Redis Cluster 的原理,读完本文,相信读者可以真正理解了为什么 Redis Cluster 不适合超大规模商用场景,以及为什么 IT 巨头都更倾向于使用 Proxy+Redis-Server 架构方案。

致谢与参考文献

本文的一些图片和文字引用了一些博客和论文,尊重原创是每一个写作者应坚守的底线,在此,将本文引用过的文章一一列出,以表敬意:

  1. Gossip 算法

  2. 大厂们的 Redis 集群方案

  3. Redis 4.0、Codis、阿里云 Redis 三种 Redis 集群对比分析

  4. 不懂集群的节点通信原理,你就别想玩转 Redis!

3.6 哈希算法

#使用哈希算法有什么问题?

有的同学可能很快就想到了:哈希算法。因为对同一个关键字进行哈希计算,每次计算都是相同的值,这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。

哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于 hash(key) % 3 公式对数据进行了映射。

如果客户端要获取指定 key 的数据,通过下面的公式可以定位节点:

1
hash(key) % 3

如果经过上面这个公式计算后得到的值是 0,就说明该 key 需要去第一个节点获取。

但是有一个很致命的问题,如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据,否则会出现查询不到数据的问题。

举个例子,假设我们有一个由 A、B、C 三个节点组成分布式 KV 缓存系统,基于计算公式 hash(key) % 3 将数据进行了映射,每个节点存储了不同的数据:

img

现在有 3 个查询 key 的请求,分别查询 key-01,key-02,key-03 的数据,这三个 key 分别经过 hash() 函数计算后的值为 hash( key-01) = 6、hash( key-02) = 7、hash(key-03) = 8,然后再对这些值进行取模运算。

通过这样的哈希算法,每个 key 都可以定位到对应的节点。

img

当 3 个节点不能满足业务需求了,这时我们增加了一个节点,节点的数量从 3 变化为 4,意味取模哈希函数中基数的变化,这样会导致大部分映射关系改变,如下图:

img

比如,之前的 hash(key-01) % 3 = 0,就变成了 hash(key-01) % 4 = 2,查询 key-01 数据时,寻址到了节点 C,而 key-01 的数据是存储在节点 A 上的,不是在节点 C,所以会查询不到数据。

同样的道理,如果我们对分布式系统进行缩容,比如移除一个节点,也会因为取模哈希函数中基数的变化,可能出现查询不到数据的问题。

要解决这个问题的办法,就需要我们进行迁移数据,比如节点的数量从 3 变化为 4 时,要基于新的计算公式 hash(key) % 4 ,重新对数据和节点做映射。

假设总数据条数为 M,哈希算法在面对节点数量变化时,**最坏情况下所有数据都需要迁移,所以它的数据迁移规模是 O(M)**,这样数据的迁移成本太高了。

所以,我们应该要重新想一个新的算法,来避免分布式系统在扩容或者缩容时,发生过多的数据迁移。

#使用一致性哈希算法有什么问题?

一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。

一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值

我们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环,就像钟表一样,钟表的圆可以理解成由 60 个点组成的圆,而此处我们把这个圆想象成由 2^32 个点组成的圆,这个圆环被称为哈希环,如下图:

img

一致性哈希要进行两步哈希:

  • 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
  • 第二步:当对数据进行存储或访问时,对数据进行哈希映射;

所以,一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上

问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?

答案是,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。

举个例子,有 3 个节点经过哈希计算,映射到了如下图的位置:

img

接着,对要查询的 key-01 进行哈希计算,确定此 key-01 映射在哈希环的位置,然后从这个位置往顺时针的方向找到第一个节点,就是存储该 key-01 数据的节点。

比如,下图中的 key-01 映射的位置,往顺时针的方向找到第一个节点就是节点 A。

img

所以,当需要对指定 key 的值进行读写的时候,要通过下面 2 步进行寻址:

  • 首先,对 key 进行哈希计算,确定此 key 在环上的位置;
  • 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。

知道了一致哈希寻址的方式,我们来看看,如果增加一个节点或者减少一个节点会发生大量的数据迁移吗?

假设节点数量从 3 增加到了 4,新的节点 D 经过哈希计算后映射到了下图中的位置:

img

你可以看到,key-01、key-03 都不受影响,只有 key-02 需要被迁移节点 D。

假设节点数量从 3 减少到了 2,比如将节点 A 移除:

img

你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。

因此,在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响

上面这些图中 3 个节点映射在哈希环还是比较分散的,所以看起来请求都会「均衡」到每个节点。

但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上。

比如,下图中 3 个节点的映射位置都在哈希环的右半边:

img

这时候有一半以上的数据的寻址都会找节点 A,也就是访问请求主要集中的节点 A 上,这肯定不行的呀,说好的负载均衡呢,这种情况一点都不均衡。

另外,在这种节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,容易发生雪崩式的连锁反应。

比如,上图中如果节点 A 被移除了,当节点 A 宕机后,根据一致性哈希算法的规则,其上数据应该全部迁移到相邻的节点 B 上,这样,节点 B 的数据量、访问量都会迅速增加很多倍,一旦新增的压力超过了节点 B 的处理能力上限,就会导致节点 B 崩溃,进而形成雪崩式的连锁反应。

所以,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题

#

#如何通过虚拟节点提高均衡度?

要想解决节点能在哈希环上分配不均匀的问题,就是要有大量的节点,节点数越多,哈希环上的节点分布的就越均匀。

但问题是,实际中我们没有那么多节点。所以这个时候我们就加入虚拟节点,也就是对一个真实节点做多个副本。

具体做法是,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。

比如对每个节点分别设置 3 个虚拟节点:

  • 对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03
  • 对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03
  • 对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03

引入虚拟节点后,原本哈希环上只有 3 个节点的情况,就会变成有 9 个虚拟节点映射到哈希环上,哈希环上的节点数量多了 3 倍。

img

你可以看到,节点数量多了后,节点在哈希环上的分布就相对均匀了。这时候,如果有访问请求寻址到「A-01」这个虚拟节点,接着再通过「A-01」虚拟节点找到真实节点 A,这样请求就能访问到真实节点 A 了。

上面为了方便你理解,每个真实节点仅包含 3 个虚拟节点,这样能起到的均衡效果其实很有限。而在实际的工程中,虚拟节点的数量会大很多,比如 Nginx 的一致性哈希算法,每个权重为 1 的真实节点就含有160 个虚拟节点。

另外,虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高

比如,当某个节点被移除时,对应该节点的多个虚拟节点均会移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力。

而且,有了虚拟节点后,还可以为硬件配置更好的节点增加权重,比如对权重更高的节点增加更多的虚拟机节点即可。

因此,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景

#总结

不同的负载均衡算法适用的业务场景也不同的。

轮询这类的策略只能适用与每个节点的数据都是相同的场景,访问任意节点都能请求到数据。但是不适用分布式系统,因为分布式系统意味着数据水平切分到了不同的节点上,访问数据的时候,一定要寻址存储该数据的节点。

哈希算法虽然能建立数据和节点的映射关系,但是每次在节点数量发生变化的时候,最坏情况下所有数据都需要迁移,这样太麻烦了,所以不适用节点数量变化的场景。

为了减少迁移的数据量,就出现了一致性哈希算法。

一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。

但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,在这种情况下进行容灾与扩容时,容易出现雪崩的连锁反应。

为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点,对一个真实节点做多个副本。不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。

引入虚拟节点后,可以会提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。

3.7 Redis Cluster

前言

如何保证Redis的高并发和高可用?Redis的哨兵原理能介绍一下么?Redis Cluster如何通信?客户端怎么知道数据位于哪个节点?MOVED具体是怎么实现的?

一、Sentinel(哨兵)模式

Sentinel本质上只是一个运行在特殊模式下的Redis服务器

Sentinel(哨兵)是Redis的高可用性(high availability)解决方案:由一个或多个Sentinel实例(instance)组成的Sentinel系统(system)可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

在这里插入图片描述

Sentinel模式其实就是主从的技术方案(可以实现读写分离)的基础上,加入了Sentinel系统来监视整个主从集群的健康情况,如果发现有主服务器宕机,Sentinel系统就会对宕机服务器执行故障转移操作:

  • Sentinel系统会挑选主服务属下的其中一个从服务器,并将这个被选中的从服务器升级为新的主服务器
  • Sentinel系统会向主服务属下的所有从服务器发送新的复制指令,让它们成为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障转移操作执行完毕。
  • Sentinel系统还会继续监视已下线的服务,并在它重新上线时,将它设置为新的主服务器的从服务器。

在这里插入图片描述

启动一个Sentinel都做了哪些事情:

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

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

对于每个被Sentinel监视的主服务器来说,Sentinel会创建两个连向主服务器的异步网络连接:

  • 一个是命令连接,这个连接专门用于向主从服务器发送命令,并接收命令回复。
  • 另一个是订阅连接,这个连接专门用于订阅主从服务器的__sentinel__:hello频道。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Server  主服务器本身的信息
...
run_id:7611c59dc3a29aa6fa0609f841bb6a1019008a9c
...
# Replication 主服务器属下所有从服务器的信息
role:master
...
slave0:ip=127.0.0.1,port=11111,state=online,offset=43,lag=0
slave1:ip=127.0.0.1,port=22222,state=online,offset=43,lag=0
slave2:ip=127.0.0.1,port=33333,state=online,offset=43,lag=0
...
# Other sections
...
12345678910111213

Sentinel还会创建连接到从服务器的命令连接和订阅连接。同时也会每十秒一次的向从服务器发送INFO命令,从服务器会返回以下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Server
...
run_id:32be0699dd27b410f7c90dada3a6fab17f97899f
...
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
slave_repl_offset:11887
slave_priority:100
# Other sections
...
12345678910111213
  • 从服务器的运行ID run_id
  • 从服务器的角色role
  • 主服务器的IP地址master_host,以及主服务器的端口号master_port
  • 主从服务器的连接状态master_link_status
  • 从服务器的优先级slave_priority
  • 从服务器的复制偏移量slave_repl_offset

发送信息

PUBLISH sentinel:hello “,,,,,,,

每隔两秒钟,每个哨兵都会向自己监控主服务器和从服务器对应的__sentinel__:hello
频道里发送一个消息(包括自己的host、ip和runid还有对这个master的监控配置)。

接收信息

SUBSCRIBE sentinel:hello

对于每个与Sentinel连接的服务器,Sentinel既通过命令连接向服务器的__sentinel__:hello频道发送信息,又通过服务器的__sentinel__:hello频道接收信息

Sentinel在连接主服务器或者从服务器时,会同时创建命令连接和订阅连接,但是在连接其他Sentinel时,却只会创建命令连接,而不创建订阅连接。这是因为Sentinel需要通过接收主服务器或者从服务器发来的频道信息来发现未知的新Sentinel,所以才需要建立订阅连接,而相互已知的Sentinel只要使用命令连接来进行通信就足够了。

sdown和odown转换机制
sdown,即主观宕机,如果一个Sentinel它自己觉得master宕机了,就是主观宕机。
odown,即客观宕机,如果quorum数量的哨兵都认为一个master宕机了,则为客观宕机。

Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。

哨兵在ping一个主服务器的时候,如果超过了is-master-down-after-milliseconds指定的毫秒数之后,就是达到了sdown,就主观认为主服务器宕机了。

如果一个Sentinel在指定时间内,收到了quorum指定数量的其他Sentinel也认为那个主服务器是sdown了,那么就认为是odown了,客观认为主服务器宕机,就完成了sdown到odown的转换。

当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并由领头Sentinel对下线主服务器执行故障转移操作。

从服务器(slave)到主服务器(master)选举算法

主要通过下面几个步骤:

  1. 删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。
  2. 删除列表中所有最近五秒内没有回复过领头Sentinel的INFO命令的从服务器,这可以保证列表中剩余的从服务器都是最近成功进行过通信的。
  3. 删除所有与已下线主服务器连接断开超过down-after-milliseconds10毫秒的从服务器:down-after-milliseconds选项指定了判断主服务器下线所需的时间,而删除断开时长超过down-after-milliseconds10毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,列表中剩余的从服务器保存的数据都是比较新的。

接下来会对slave进行排序

  1. 按照slave优先级进行排序,slave priority越低,优先级就越高。
  2. 如果slave priority相同,那么看replica offset,哪个slave复制了越多的数据,offset越靠后,优先级就越高。
  3. 如果上面两个条件都相同,那么选择一个run id比较小的那个slave

哨兵集群至少要 3 个节点,来确保自己的健壮性。

如果哨兵集群 是2 个节点的问题:
2个哨兵的majority是2(3个的majority=2,4个的majority=2,5个的majority=3)
只有2个节点,其中一个宕机,那么哨兵就只有一个了,此时就无法来通过majority来进行故障转移。

redis主从 + Sentinel的架构,不是保证数据的零丢失的,它是为了保证Redis集群的高可用。

Sentinel架构的缺点是什么?

二、Cluster(集群)模式

https://redis.io/topics/cluster-tutorial/ 建议阅读官方文档

Redis集群是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。

一个Redis集群由多个节点组成。

可以这样理解一下Redis集群模式:
在这里插入图片描述
一组主从就是一个节点,把各个独立的节点连接起来就构成了一个集群。

Redis集群数据分片

Redis Cluster不使用一致的哈希,而是使用一种不同形式的分片:槽(slot)。
Redis集群中有16384个哈希槽,每个键都是属于这16384个槽中的其中一个,要计算一个键属于哪个哈希槽,只需对key的CRC16取模16384。

Redis群集中的每个节点都负责哈希槽的子集,如果有一个包含3个节点的群集,其中:
节点A包含从0到5500的哈希槽。
节点B包含从5501到11000的哈希槽。
节点C包含从11001到16383的哈希槽。

一个节点可以随意分配槽点,不是必须连续的。
为什么是16384个?

=== 2^16 占用8kb 字节,2^14 占用 2kb 字节, 压缩

传播节点的槽信息

一个节点除了保存自己负责处理的槽信息,还会将自己的slots数组通过消息发送给集群中的其他节点,来告诉其他节点自己目前负责处理哪里槽。

这个很重要记一下,后面会用到。

每个节点都保存了不同的数据,客户端查询的时候,怎么知道去哪个节点找key呢?key换节点了怎么处理的?

在集群中执行命令逻辑:

在这里插入图片描述

MOVED错误

在节点发现key所在的槽不是自己负责处理的时候,就会返回一个MOVED错误:

MOVED :

让客户端重定向( redirected)到正确的 Redis 节点。

因为节点本身会存储一份其他节点负责的槽点,所以MOVED很容易实现。

重新分片

Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。
重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。

ASK错误

在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。

当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时,怎么处理?

  1. 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
  2. 相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。

MOVED和ASK的区别

  • MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点:在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点,因为该节点就是目前负责槽i的节点。
  • 与此相反,ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求发送至ASK错误所指示的节点,但这种转向不会对客户端今后发送关于槽i的命令请求产生任何影响,客户端仍然会将关于槽i的命令请求发送至目前负责处理槽i的节点,除非ASK错误再次出现。

节点间的内部通信机制

集群中的各个节点通过发送和接收消息(message)来进行通信,采有gossip协议。

gossip协议原理:在一个有界网络中,每个节点都随机地与其他节点通信,经过一番杂乱无章的通信,最终所有节点的状态都会达成一致。每个节点可能知道所有其他节点,也可能仅知道几个邻居节点,只要这些节可以通过网络连通,最终他们的状态都是一致的,很像疫情传播。
优点在于元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续,打到所有节点上去更新,有一定的延时,降低了压力;
缺点在于元数据更新有延时可能导致集群的一些操作会有一些滞后。

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

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

主备切换原理

1.判断节点宕机

集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线(probable fail,PFAIL)。

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

2.选举新的主节点

  • 集群的配置纪元是一个自增计数器,它的初始值为0。
  • 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一。
  • 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票。
  • 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。
  • 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的主节点。
  • 每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。
  • 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张支持票时,这个从节点就会当选为新的主节点。
  • 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的主节点只会有一个。
  • 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。

故障转移

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

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

总结

Redis的集群就这些内容了。一般来说小公司使用Sentinel就够了,大公司都会使用Cluster模式。
了解完这个你应该对怎么设计一个10亿数据的读写的Redis架构有个概念。
细节的东西还是非常多的,也需要大家多花点时间去看书学习的。

第四部分《策略》

4.1 key 过期删除策略

拥有过期时间的是 key,与 value无关;

Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。

设置过期时间

先说一下对 key 设置过期时间的命令。 设置 key 过期时间的命令一共有 4 个:

  • expire <key> <n>:设置 key 在 n 秒后过期,比如 expire key 100 表示设置 key 在 100 秒后过期;
  • pexpire <key> <n>:设置 key 在 n 毫秒后过期,比如 pexpire key2 100000 表示设置 key2 在 100000 毫秒(100 秒)后过期。
  • expireat <key> <n>:设置 key 在某个时间戳(精确到秒)之后过期,比如 expireat key3 1655654400 表示 key3 在时间戳 1655654400 后过期(精确到秒);
  • pexpireat <key> <n>:设置 key 在某个时间戳(精确到毫秒)之后过期,比如 pexpireat key4 1655654400000 表示 key4 在时间戳 1655654400000 后过期(精确到毫秒)

当然,在设置字符串时,也可以同时对 key 设置过期时间,共有 3 种命令:

  • set <key> <value> ex <n> :设置键值对的时候,同时指定过期时间(精确到秒);
  • set <key> <value> px <n> :设置键值对的时候,同时指定过期时间(精确到毫秒);
  • setex <key> <n> <valule> :设置键值对的时候,同时指定过期时间(精确到秒)。

如果你想查看某个 key 剩余的存活时间,可以使用 TTL <key> 命令。

1
2
3
4
5
6
7
8
9
# 设置键值对的时候,同时指定过期时间位 60 秒
> setex key1 60 value1
OK

# 查看 key1 过期时间还剩多少
> ttl key1
(integer) 56
> ttl key1
(integer) 52

如果突然反悔,取消 key 的过期时间,则可以使用 PERSIST <key> 命令。

1
2
3
4
5
6
7
8
# 取消 key1 的过期时间
> persist key1
(integer) 1

# 使用完 persist 命令之后,
# 查下 key1 的存活时间结果是 -1,表明 key1 永不过期
> ttl key1
(integer) -1

过期字典

保存过期时间 – 快速检索

每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。

过期字典存储在 redisDb 结构中,如下:

1
2
3
4
5
typedef struct redisDb {
dict *dict; /* 数据库键空间,存放着所有的键值对 */
dict *expires; /* 键的过期时间 */
....
} redisDb;

过期字典数据结构结构如下:

  • 过期字典的 key 是一个指针,指向某个键对象;
  • 过期字典的 value 是一个 long long 类型的整数,这个整数保存了 key 的过期时间;

过期字典的数据结构如下图所示:

img

字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:

  • 如果不在,则正常读取键值;
  • 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。

过期键判断流程如下图所示:

img

三种 删除key 策略

在说 Redis 过期删除策略之前,先跟大家介绍下,常见的三种过期删除策略:

  • 定时删除;
  • 惰性删除;
  • 定期删除;

接下来,分别分析它们的优缺点。

定时删除策略是怎么样的?

定时删除策略的做法是,在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。

定时删除策略的优点

  • 可以保证过期 key 会被尽快删除,也就是内存可以被尽快地释放。因此,定时删除对内存是最友好的。

定时删除策略的缺点

  • 在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。

惰性删除策略是怎么样的?

惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。

惰性删除策略的优点

  • 因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。

惰性删除策略的缺点

  • 如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。

定期删除策略是怎么样的?

定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

定期删除策略的优点

  • 通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。

定期删除策略的缺点

  • 内存清理方面没有定时删除效果好,同时没有惰性删除使用的系统资源少。
  • 难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。

Redis:惰性 + 定期删除

前面介绍了三种过期删除策略,每一种都有优缺点,仅使用某一个策略都不能满足实际需求。

所以, Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

Redis 是怎么实现惰性删除的?

Redis 的惰性删除策略由 db.c 文件中的 expireIfNeeded 函数实现,代码如下:

1
2
3
4
5
6
7
8
9
10
int expireIfNeeded(redisDb *db, robj *key) {
// 判断 key 是否过期
if (!keyIsExpired(db,key)) return 0;
....
/* 删除过期键 */
....
// 如果 server.lazyfree_lazy_expire 为 1 表示异步删除,反之同步删除;
return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);
}

Redis 在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对其进行检查,检查 key 是否过期:

  • 如果过期,则删除该 key,至于选择异步删除,还是选择同步删除,根据 lazyfree_lazy_expire 参数配置决定(Redis 4.0版本开始提供参数),然后返回 null 客户端;
  • 如果没有过期,不做任何处理,然后返回正常的键值对给客户端;

惰性删除的流程图如下:

img

Redis 是怎么实现定期删除的?

再回忆一下,定期删除策略的做法:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

1、这个间隔检查的时间是多长呢?

在 Redis 中,默认每秒进行 10 次过期检查一次数据库,此配置可通过 Redis 的配置文件 redis.conf 进行配置,配置键为 hz 它的默认值是 hz 10。

特别强调下,每次检查数据库并不是遍历过期字典中的所有 key,而是从数据库中随机抽取一定数量的 key 进行过期检查。

2、随机抽查的数量是多少呢?

我查了下源码,定期删除的实现在 expire.c 文件下的 activeExpireCycle 函数中,其中随机抽查的数量由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 定义的,它是写死在代码中的,数值是 20。

也就是说,数据库每轮抽查时,会随机选择 20 个 key 判断是否过期。

接下来,详细说说 Redis 的定期删除的流程:

  1. 从过期字典中随机抽取 20 个 key;
  2. 检查这 20 个 key 是否过期,并删除已过期的 key;
  3. 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

可以看到,定期删除是一个循环的流程。

那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。

针对定期删除的流程,我写了个伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
do {
//已过期的数量
expired = 0
//随机抽取的数量
num = 20;
while (num--) {
//1. 从过期字典中随机抽取 1 个 key
//2. 判断该 key 是否过期,如果已过期则进行删除,同时对 expired++
}

// 超过时间限制则退出
if (timelimit_exit) return;

/* 如果本轮检查的已过期 key 的数量,超过 25%,则继续随机抽查,否则退出本轮检查 */
} while (expired > 20/4);

定期删除的流程如下:

img

4.2 内存(不足)淘汰策略

img

4.3 mysql 和redis一致性保证

说明:来自(6条消息) 如何保障 MySQL 和 Redis 的数据一致性?_redis和mysql数据一致性_Java烟雨的博客-CSDN博客

策略:都在考虑多线程并发 更新 / 查询同一个 mysql记录

之前也看了很多相关的文章,但是感觉讲的都不好,很多文章都会去讲各种策略,比如(旁路缓存)策略、(读穿 / 写穿)策略和(写回)策略等,感觉意义真的不大,然后有的文章也只讲了部分情况,也没有告诉最优解。

我直接先抛一下结论:在满足实时性的条件下,不存在两者完全保存一致的方案,只有最终一致性方案。 根据网上的众多解决方案,总结出 6 种,直接看目录:

img

不好的方案

1、先写 MySQL,再写 Redis

img

图解说明:

这是一副时序图,描述请求的先后调用顺序;

橘黄色的线是请求 A,黑色的线是请求 B;

橘黄色的文字,是 MySQL 和 Redis 最终不一致的数据;

数据是从 10 更新为 11;

后面所有的图,都是这个含义,不再赘述。

请求 A、B 都是先写 MySQL,然后再写 Redis,在高并发情况下,如果请求 A 在写 Redis 时卡了一会,请求 B 已经依次完成数据的更新,就会出现图中的问题。

这个图已经画的很清晰了,我就不用再去啰嗦了吧,不过这里有个前提,就是对于读请求,先去读 Redis,如果没有,再去读 DB,但是读请求不会再回写 Redis。 大白话说一下,就是读请求不会更新 Redis。

2、先写 Redis,再写 MySQL

img

同“先写 MySQL,再写 Redis”,看图可秒懂。

3、先删除 Redis,再写 MySQL

这幅图和上面有些不一样,前面的请求 A 和 B 都是更新请求,这里的请求 A 是更新请求,但是请求 B 是读请求,且请求 B 的读请求会回写 Redis。

img

请求 A 先删除缓存,可能因为卡顿,数据一直没有更新到 MySQL,导致两者数据不一致。

这种情况出现的概率比较大,因为请求 A 更新 MySQL 可能耗时会比较长,而请求 B 的前两步都是查询,会非常快。

好的方案

4、先删除 Redis,再写 MySQL,再删除 Redis

对于“先删除 Redis,再写 MySQL”,如果要解决最后的不一致问题,其实再对 Redis 重新删除即可,这个也是大家常说的“缓存双删”。

img

为了便于大家看图,对于蓝色的文字,“删除缓存 10”必须在“回写缓存10”后面,那如何才能保证一定是在后面呢?网上给出的第一个方案是,让请求 A 的最后一次删除,等待 500ms。

对于这种方案,看看就行,反正我是不会用,太 Low 了,风险也不可控。

那有没有更好的方案呢,我建议异步串行化删除,即删除请求入队列

img

异步删除对线上业务无影响,串行化处理保障并发情况下正确删除。

如果双删失败怎么办,网上有给 Redis 加一个缓存过期时间的方案,这个不敢苟同。个人建议整个重试机制,可以借助消息队列的重试机制,也可以自己整个表,记录重试次数,方法很多。

简单小结一下:

“缓存双删”不要用无脑的 sleep 500 ms;

通过消息队列的异步&串行,实现最后一次缓存删除;

缓存删除失败,增加重试机制。

5、先写 MySQL,再删除 Redis

img

对于上面这种情况,对于第一次查询,请求 B 查询的数据是 10,但是 MySQL 的数据是 11,只存在这一次不一致的情况,对于不是强一致性要求的业务,可以容忍。(那什么情况下不能容忍呢,比如秒杀业务、库存服务等。)

当请求 B 进行第二次查询时,因为没有命中 Redis,会重新查一次 DB,然后再回写到 Reids。

那什么情况下会出现不一致的情况呢?

img

这里需要满足 2 个条件:

缓存刚好自动失效;

请求 B 从数据库查出 10,回写缓存的耗时,比请求 A 写数据库,并且删除缓存的还长。

对于第二个条件,我们都知道更新 DB 肯定比查询耗时要长,所以出现这个情况的概率很小,同时满足上述条件的情况更小。

6、先写 MySQL,通过 Binlog,异步更新 Redis

这种方案,主要是监听 MySQL 的 Binlog,然后通过异步的方式,将数据更新到 Redis,这种方案有个前提,查询的请求,不会回写 Redis。

img

这个方案,会保证 MySQL 和 Redis 的最终一致性,但是如果中途请求 B 需要查询数据,如果缓存无数据,就直接查 DB;如果缓存有数据,查询的数据也会存在不一致的情况。

所以这个方案,是实现最终一致性的终极解决方案,但是不能保证实时性。

几种方案比较
我们对比上面讨论的 6 种方案:

  • 先写 Redis,再写 MySQL
    这种方案,我肯定不会用,万一 DB 挂了,你把数据写到缓存,DB 无数据,这个是灾难性的;
    我之前也见同学这么用过,如果写 DB 失败,对 Redis 进行逆操作,那如果逆操作失败呢,是不是还要搞个重试?

  • 先写 MySQL,再写 Redis
    对于并发量、一致性要求不高的项目,很多就是这么用的,我之前也经常这么搞,但是不建议这么做;
    当 Redis 瞬间不可用的情况,需要报警出来,然后线下处理。

  • 先删除 Redis,再写 MySQL
    这种方式,我还真没用过,直接忽略吧。

  • 先删除 Redis,再写 MySQL,再删除 Redis
    这种方式虽然可行,但是感觉好复杂,还要搞个消息队列去异步删除 Redis。

  • 先写 MySQL,再删除 Redis
    比较推荐这种方式,删除 Redis 如果失败,可以再多重试几次,否则报警出来;
    这个方案,是实时性中最好的方案,在一些高并发场景中,推荐这种。

  • 先写 MySQL,通过 Binlog,异步更新 Redis
    对于异地容灾、数据汇总等,建议会用这种方式,比如 binlog + kafka,数据的一致性也可以达到秒级;
    纯粹的高并发场景,不建议用这种方案,比如抢购、秒杀等。

个人结论:

实时一致性方案:采用“先写 MySQL,再删除 Redis”的策略,这种情况虽然也会存在两者不一致,但是需要满足的条件有点苛刻,所以是满足实时性条件下,能尽量满足一致性的最优解。

最终一致性方案:采用“先写 MySQL,通过 Binlog,异步更新 Redis”,可以通过 Binlog,结合消息队列异步更新 Redis,是最终一致性的最优解。
————————————————
版权声明:本文为CSDN博主「Java烟雨」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_70730532/article/details/125870478

第五部分《分布式锁》

(参考 分布式锁.md-黑马)

Zookeeper

Zookeeper实现分布式锁 - 知乎 (zhihu.com)

RedLock

(4条消息) Redis分布式锁之:RedLock_小小少年_的博客-CSDN博客

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class RedissonMultiLock implements RLock {
final List<RLock> locks = new ArrayList();

public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
// 初始化 waitTime --
// 四种情况:baseWaitTime, 2000ms, [leaseTime / 2, leaseTime], [baseTime, leaseTime];
// 等待时间 和 释放时间
long baseWaitTime = (long)(this.locks.size() * 1500);
long waitTime = -1L;
if (leaseTime == -1L) {
waitTime = baseWaitTime;
} else {
leaseTime = unit.toMillis(leaseTime);
if (leaseTime <= 2000L) {
waitTime = 2000L;
} else if (leaseTime <= baseWaitTime) {
waitTime = ThreadLocalRandom.current().nextLong(leaseTime / 2L, leaseTime);
} else {
waitTime = ThreadLocalRandom.current().nextLong(baseWaitTime, leaseTime);
}
}

while(!this.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS)) {
}

}
}

image-20230417142003732

Redisson

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// RedissonBaseLock
public abstract class RedissonBaseLock extends RedissonExpirable implements RLock {
private static final Logger log = LoggerFactory.getLogger(RedissonBaseLock.class);
private static final ConcurrentMap<String, RedissonBaseLock.ExpirationEntry> EXPIRATION_RENEWAL_MAP = new ConcurrentHashMap();
protected long internalLockLeaseTime;
final String id;
final String entryName;
final CommandAsyncExecutor commandExecutor;
private static final RedisCommand<Integer> HGET = new RedisCommand("HGET", new MapValueDecoder(), new IntegerReplayConvertor(0));
}

// RedissonLock
public class RedissonLock extends RedissonBaseLock {
protected long internalLockLeaseTime;
// 发布订阅
protected final LockPubSub pubSub;
//
final CommandAsyncExecutor commandExecutor;
}

image-20230417141831965

https://blog.csdn.net/CPLASF_/article/details/121690952

入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 这里是尝试加锁的代码
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
// 1.如果指定了超时时间,就进入下面这个分支执行加锁的逻辑
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
// 2.如果没有指定超时时间,会进入到这里,默认加锁30S
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}

// lock acquired
// 3.在加锁成功之后,会开启一个线程,在第10S的时候,进行锁续期
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}

加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 这里就是真正加锁的lua脚本了
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);

return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

这段lua脚本大致的思想是这样的:

先判断key是否存在,如果key不存在,就加锁,并且设置过期时间
如果key已经存在,并且当前加锁的key是重入了,那就将key对应的加锁次数加1
如果key已存在,并且当前加锁的key和线程和已加锁的不一样,无法重入,那就返回当前key的过期时间

锁续期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}

// 锁续期的核心代码,就是下面这个run方法
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// 上面应该都是一些判断,最终会调用下面这个代码,这里的lua脚本就是续期的
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}

// 如果续期成功,会循环调用
if (res) {
// reschedule itself
renewExpiration();
}
});
}
// internalLockLeaseTime 这个参数是加锁的时间,默认是30S,这里的意思是,会延迟 30 / 3的时间去执行run方法
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

ee.setTimeout(task);
}

第六部分《Redis 服务端使用-实战》

redis.conf

redis.conf 是 Redis 的配置文件,用于配置 Redis 服务器的各种参数和选项。根据模块功能,redis.conf 可以配置以下内容:

  1. 通用配置:

    • daemonize:是否以守护进程方式运行 Redis。
    • port:Redis 服务器监听的端口号。
    • bind:Redis 服务器绑定的 IP 地址。
    • timeout:客户端连接超时时间。
    • logfile:日志文件路径。
    • databases:数据库数量。
    • save:持久化配置,用于设置数据自动保存的条件。
    • rdbcompression:是否开启 RDB 文件压缩。
    • maxclients:最大客户端连接数。
    • maxmemory:最大内存限制。
    • appendonly:是否开启 AOF 持久化。
    • appendfilename:AOF 文件名。
    • requirepass:设置连接密码。
  2. 快照持久化配置:

    • save:设置数据自动保存的条件。
    • stop-writes-on-bgsave-error:在后台保存出错时是否停止写入操作。
  3. AOF 持久化配置:

    • appendfsync:AOF 文件同步策略。
    • auto-aof-rewrite-percentage:自动触发 AOF 重写的条件。
    • auto-aof-rewrite-min-size:自动触发 AOF 重写的条件。
  4. 主从复制配置:

    • slaveof:设置 Redis 服务器的主服务器。
    • masterauth:主服务器连接密码。
    • slave-serve-stale-data:是否在同步过程中提供旧数据。
  5. 安全配置:

    • requirepass:设置连接密码。
    • rename-command:重命名 Redis 命令。
  6. 客户端配置:

    • maxclients:最大客户端连接数。
    • maxmemory:最大内存限制。
  7. 集群配置:

    • cluster-enabled:是否开启集群模式。
    • cluster-config-file:集群配置文件路径。
    • cluster-node-timeout:节点超时时间。

以上是 redis.conf 配置文件按照模块功能划分的一些常见配置选项,具体的配置项还有很多,可以根据实际需求进行配置。如果需要了解更详细的配置信息,可以查阅 Redis 官方文档或者搜索相关资料。

除了上述提到的模块功能,redis.conf 还可以配置以下模块:

  1. 客户端输出缓冲配置:

    • client-output-buffer-limit:客户端输出缓冲区限制。
  2. 网络配置:

    • tcp-backlog:TCP 连接的 backlog 队列长度。
    • tcp-keepalive:是否开启 TCP keepalive 选项。
    • tcp-keepalive-interval:TCP keepalive 检测间隔。
    • tcp-keepalive-count:TCP keepalive 失败次数。
  3. 日志配置:

    • syslog-enabled:是否将日志输出到 syslog。
    • syslog-ident:syslog 标识符。
    • syslog-facility:syslog 设备。
  4. 限制配置:

    • maxmemory-policy:内存达到上限时的策略。
    • maxmemory-samples:内存淘汰策略采样数。
  5. 慢查询日志配置:

    • slowlog-log-slower-than:慢查询日志记录的阈值。
    • slowlog-max-len:慢查询日志的最大长度。
  6. 监控配置:

    • notify-keyspace-events:订阅键空间通知的事件类型。
  7. Lua 脚本配置:

    • lua-time-limit:Lua 脚本执行时间限制。
  8. 安全配置:

    • aclfile:访问控制列表文件路径。

这些是 redis.conf 配置文件中的一些常见模块功能,具体的配置选项可以根据实际需求进行设置。如果需要了解更详细的配置信息,可以查阅 Redis 官方文档或者搜索相关资料。

最后部分《业务场景》

(13条消息) Redis 16 个常见使用场景_redis使用场景_Young丶的博客-CSDN博客

11、点赞、签到、打卡

用 like:t1001 来维护 t1001 这条微博的所有点赞用户

点赞了这条微博:sadd like:t1001 u3001
取消点赞:srem like:t1001 u3001
是否点赞:sismember like:t1001 u3001
点赞的所有用户:smembers like:t1001
点赞数:scard like:t1001

16 .显示最新的项目列表

比如说,我们的一个Web应用想要列出用户贴出的最新20条评论。在最新的评论边上我们有一个“显示全部”的链接,点击后就可以获得更多的评论。

每次新评论发表时,我们会将它的ID添加到一个Redis列表。可以限定列表的长度为5000

LPUSH latest.comments

在Redis中我们的最新ID使用了常驻缓存,这是一直更新的。但是我们做了限制不能超过5000个ID,因此我们的获取ID函数会一直询问Redis。只有在超出了这个范围的时候,才需要去访问数据库

(4条消息) 如何保障 MySQL 和 Redis 的数据一致性?_redis和mysql数据一致性_Java烟雨的博客-CSDN博客

17.HyperLogLog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
pfadd 添加
影响基数估值则返回1否则返回0.若key不存在则创建
时间复杂度O(1)
127.0.0.1:6379> pfadd m1 1 2 3 4 1 2 3 2 2 2 2
(integer) 1
1
2
pfcount 获得基数值
得到基数值,白话就叫做去重值(11223)的插入pfcount得到的是3
可一次统计多个key
时间复杂度为O(N),N为key的个数
返回值是一个带有 0.81% 标准错误(standard error)的近似值.
127.0.0.1:6379> pfadd m1 1 2 3 4 1 2 3 2 2 2 2
(integer) 1
127.0.0.1:6379> pfcount m1
(integer) 4
1
2
3
4
pfmerge 合并多个key
取多个key的并集
命令只会返回 OK.
时间复杂度为O(N)
127.0.0.1:6379> pfadd m1 1 2 3 4 1 2 3 2 2 2 2
(integer) 1
127.0.0.1:6379> pfcount m1
(integer) 4
127.0.0.1:6379> pfadd m2 3 3 3 4 4 4 5 5 5 6 6 6 1
(integer) 1
127.0.0.1:6379> pfcount m2
(integer) 5
127.0.0.1:6379> pfmerge mergeDes m1 m2
OK
127.0.0.1:6379> pfcount mergeDes
(integer) 6

应用场景
说明:

基数不大,数据量不大就用不上,会有点大材小用浪费空间
有局限性,就是只能统计基数数量,而没办法去知道具体的内容是什么
和bitmap相比,属于两种特定统计情况,简单来说,HyperLogLog 去重比 bitmap 方便很多
一般可以bitmap和hyperloglog配合使用,bitmap标识哪些用户活跃,hyperloglog计数
一般使用:

统计注册 IP 数
统计每日访问 IP 数
统计页面实时 UV 数
统计在线用户数
统计用户每天搜索不同词条的个数


Redis概述
http://example.com/2023/06/01/分布式组件+常见组件/Redis概述/
作者
where
发布于
2023年6月1日
许可协议