Redis-链表

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活的调整链表的长度。

作为一种常用数据结构,链表内置在很多高级的编程语言里面,因为Redis使用的c语言并没有内置这种数据结构,所以Redis构建了自己的链表实现。

链表在Redis中的应用非常广泛,比如列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串的时,Redis就会使用链表作为列表键的实现。

举个例子,以下展示的integers列表键包含了1-1024共一千零二十四个整数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
redis> LLEN integers
(integer) 1024
redis> LRANGE integers 0 10
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
6) "6"
7) "7"
8) "8"
9) "9"
10) "10"
11) "11"

integers列表键的底层实现就是一个链表,链表中的每个节点都保存了一个整数值。
除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还是要链表保存多个客户端信息的状态信息,以及使用链表来构建客户端输出缓冲区(output buffer)

链表和链表节点的实现

每个链接节点使用一个adlist.h/listNode结构来表示:

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

多个listNode可以通过prevnext指针组成双端列表,如下图:

虽然仅仅使用多个listNode结构就可以组成链表,但使用adlist.h/list来持有链表的话,操作起来会更方便:

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

list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dupfreematch成员则是用于实现多态链表所需要的类型特定函数:

  • dup函数用于复制链表节点所保存的值;
  • free函数用于释放链表节点所保存的值;
  • match函数则用于对比链表节点所保存的值和另一个输入值是否相等;

下图是由一个list结构和三个listNode结构组成的链表。

总结

Redis的链表实现的特性可以总结如下:

  • 双端:链表节点带有prevnext指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
  • 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的的复杂度为O(1)。
  • 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dupfreematch三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

Redis-简单动态字符串SDS

Redis-简单动态字符串SDS

好久没写博客了,今天水一篇文章。

Redis没有使用c语言传统的字符串去表示。而是构建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将sds用作redis的默认字符串表示。

举个例子set msg "hello world"

  • 键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串”msg“的sds
  • 键值对的值也是一个字符串对象,对象的底层实现是一个保存着字符串“hello world”的sds

又比如rpush fruits "apple" "banana"

  • 键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串”msg“的sds
  • 键值对的值是一个列表对象,列表对象包含了两个字符串对象,一个sds保存着apple,另一个保存着banana

SDS的定义

每个sds.h/sdshdr结构表示一个SDS值:

1
2
3
4
5
6
7
8
9
10
struct sdshdr {
// 记录buf数组中已使用字节数量 等于sds所保存字符串的长度
int len;

// 记录buf数组中未使用的字节数量
int free;

// 字节数组 用于保存字符串
char buf[];
}

  • free属性值为0,表示这个sds没有分配任何使用空间。
  • len属性值为5,表示这个sds保存了一个五字节长的字符串。
  • buf属性是一个char类型的数组,数组的前5个字节分别保存了为Redis五个字符,而最后一个字节则保存了空字符\0

sds遵循c语言字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在sdslen属性里面。并且为空字符分配额外的1字节空间及添加空字符到字符串末尾等操作都是有sds函数自动完成,所以这个空字符对于sds的使用者是完全透明的。

SDS与C字符串的区别

c语言字符串使用长度为N+1的字符数组表示长度为N的字符串,并且字符数组的最后一个元素为空字符\0

这种简单的字符串不能满足redis对字符串在安全性、效率性以及功能方面的要求。

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

c字符串并不记录自身的长度信息,所以获取一个c字符串的长度,程序需要遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,时间复杂度为O(N)

sdslen属性记录了sds本身的长度,所以获取一个sds的长度复杂度为O(1)

设置和更新sds的长度工作是由sdsapi在执行时自动完成的,使用sds无须进行任何手动修改长度的工作。

通过使用sds而不是c字符串,redis将获取字符串长度所需的时间复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为redis的性能瓶颈。

杜绝缓冲区溢出

c字符串不记录自身长度将会带来另一个问题:容易造成缓冲区溢出(buffer overflow)

strcat函数可以将src字符串的内容拼接到dest字符串末尾:

1
char *strcat(char *dest, const char *src);

因为c字符串不记录自身的长度,所以stract假定用户在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立,就会产生缓冲区溢出。

举个例子,假设程序有两个在内存中紧邻着的c字符串s1s2,其中s1保存了字符串redis,而s2则保存了字符串MongoDb。如图:

如果一个程序决定通过执行:

1
strcat(s1, " Cluster");

s1的内容修改为Redis Cluster,但粗心的却忘记了在执行strcat之前为s1分配足够的空间,那么函数执行之后,s1的数据将溢出到s2所在的空间中,导致s2保存的内容被意外的修改。如图:

c字符串不同,sds的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当sdsapi需要对sds进行修改时,api会先检查sds的空间是否满足修改所需的要求,如果不满足,会自动将sds的空间扩展至执行修改所需要的大小,然后才执行实际的修改操作。所以使用sds不需要手动修改sds的空间大小,也不会出现缓冲区溢出问题。

举个例子:

1
sdscat(s, " Cluster")

sdscat将在执行拼接操作之前检查s的长度是否足够,不够将扩展空间,才去执行拼接操作,拼接完成后的sds如图:

sdscat不仅为这个sds进行拼接操作,还分配了13字节的未使用空间,并且拼接之后的字符串正好也是13字节,这不是bug,而是与sds的分配空间策略有关,下面会说明。

减少修改字符串时带来的内存重分配次数

因为c字符串的长度和底层数组的长度直接存在着关联关系,所以c语言字符串每次增长或者缩短都要进行一次内存重新分配操作;

  • 如果程序执行增长字符串操作,执行操作前,程序需要先通过内存重新分配来扩展底层空间大小,忘记这步操作将会产生缓冲区溢出。
  • 如果程序执行缩短字符串操作,执行操作后,程序需要通过内存重新分配来释放字符串不使用的空间,忘记这步操作会产生内存泄露。

对于redis,经常被用于速度要求严苛、数据被频繁修改的场景,如果每次修改字符串长度都要执行一次内存分配,光是执行内存重新分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁发生,可能会对性能造成影响。

为了避免c字符串的这种缺陷,sds通过未使用空间解决了字符串长度和底层数组长度之间的关联。通过未使用空间,sds实现了空间预分配和惰性空间释放两种优化策略。

空间预分配

空间预分配用于优化sds的字符串增长操作:当sdsapi对一个sds进行修改,并且需要对sds进行空间扩展的时候,程序不仅会为sds分配修改所必须要的空间,还会为sds分配额外的未使用空间。

其中,额外分配未使用空间数量由以下公式决定:

  • 如果对sds进行修改后,sds的长度将小于1mb,那么程序会分配和len属性同样大小的未使用空间,这时sdslen属性和free的值相同。举个例子,进行修改后len10,那么free也为10sdsbuf数组的实际长度为10 + 10 + 1 = 21
  • 如果对sds进行修改后,sds的长度将大于1mb,那么程序会分配1mb的使用空间。举个例子,进行修改后len10mb,那么free1mbsdsbuf数组的实际长度为10mb + 1mb + 1byte

懒惰空间释放

懒惰空间释放用于优化sds的字符串缩短操作:当sdsapi需要缩短sds保存的字符串时,程序并不会立即使用内存重分配来回收缩短后多出来的自己,而是使用free属性将这些字节的数量记录起来,并等待将来使用。

举个例子,sdstrim函数接受一个sds和一个c字符串作为参数,从sds左右两端分别移出所以在c字符串中出现的字符。

如图:

执行:

1
sdstrim(s, "XY"); // 移除 sds 字符串中所有 ‘X’和‘Y’

会将sds修改成如下图:

注意执行sdstrim之后的sds并没有释放出来多余的8字节空间,而是将这8字节空间作为未使用空间保留在了sds里面,如果将来要sds进行增长操作,这些未使用空间就会用上。

举个例子,现在对s执行:

1
sdscat(s, " Redis");

那么完成这次sdscat操作将不需要执行内存重新分配,因为sds预留的8字节空间足以拼接6个字节的 Redis,通过惰性空间释放策略,sds避免了缩短字符串所需的内存重分配操作,并为将来可能有的增长操作提供了优化,如图:

与此同时,sds也提供了相应的api,让我们可以在有需要时,真正的释放sds的未使用空间,所以不必担心惰性空间释放策略会造成内存浪费。

二进制安全

c字符串中的字符必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符串,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得c字符串只能保存文本数据,不能保存图片、音频这样的二进制数据。

举个例子,如果有一种使用空字符串分割多个单词的特殊数据格式,那么这种格式就不能使用c字符串来保存,因为c字符串所用的函数之后识别出其中的Redis,而忽略之后的Cluster。如图:

sdsbuf属性被成为字节数组的原因——redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。

例如,使用sds来保存之前提到的数据格式就没有问题,因为sds使用len属性的值而不是空字符来判断字符串是否结束,如图:

兼容部分c字符串函数

虽然sdsapi都是二进制安全,但它们一样遵循c字符串以空字符串结尾的惯例:这些api总会将sds保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个字符串,这是为了让那些保存文本数据的sds可以重用一部分<string.h>库定义的函数。

举个例子,如果有一个保存文本数据的sds,如上图,那么我们就可以重用<string.h>/strasecmp函数,使用它来对比sds保存的字符串另一个c字符串:

1
strcasecmp(sds->buf, "hello world");

遵循c字符串以空字符结尾的惯例,sds可以在有需要时重用<string.h>函数库,避免不必要的代码重复。

总结

C字符串 SDS
获取字符串长度的复杂度为O(N) 获取字符串长度的复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出 API是安全的,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存重分配 修改字符串长度N次最多需要执行N次内存重分配
只能保存文本数据 可以保存文本数据或者二进制数据
可以使用<string.h>库中的函数 可以使用一部分<string.h>库中的函数

如何搭建redis-cluster

​ 假设在一台主从机器上配置了20G内存,但是业务需求是需要50G内存的时候,主从结构+哨兵可以实现高可用故障切换+冗余备份,但是不能解决数据容量的问题,用哨兵,每个redis实例存储的数据也都是完整的数据,浪费内存且有木桶效应。

​ 为了最大化利用内存,可以采用cluster,就是分布式存储。即每台redis存储不同的内容。

Redis分布式方案一般有两种:

  • 客户端分区方案:优点是分区逻辑可控,缺点是需要自己处理数据路由,实现高可用、故障转移等问题。比如在redis2.8之前通常的做法是获取某个keyhashcode,然后取余分布到不同节点,不过这种做法无法很好的支持动态伸缩性需求,一旦节点的增或者删操作,都会导致key无法在redis中命中。
  • 代理方案:优点是简化客户端分布式逻辑和升级维护便利,缺点是加重架构部署复杂度和性能损耗。如twemproxycodis

redis官方提供了专有的集群方案:Redis Cluster,它非常优雅的解决了Redis集群方面的问题,部署方便简单。

Redis Cluster

Redis ClusterRedis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。

​ 在Redis Cluster,它们任何两个节点之间都是相互连通的。客户端可以与任何一个节点相连接,然后就可以访问集群中的任何一个节点,对其进行存取和其它操作。

Redis Cluster提供的好处:

  • 将数据自动切分到多个节点的能力
  • 当集群中的一部分节点失效或者无法进行通讯时,仍然可以继续处理命令请求的能力,拥有自动故障转移的能力。

Redis Clusterreplication + sentinel 如何选择:

如果数据量很少,主要是承载高并发高性能的场景,比如你的缓存一般就几个G,单机就够了。

  • Replication:一个master,多个slave,要几个slave跟你的要求的读吞吐量有关系,结合sentinel集群,去保证redis主从架构的高可用就行了。
  • Redis Cluster:主要是针对海量数据+高并发+高可用的场景,海量数据,如果数据量很大,建议用Redis Cluster

数据分布理论:

分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上,每个节点负责整体数据的一个子集。

常见的分区规则有哈希分区和顺序分区两种:

  • 顺序分布:把一整块数据分散到很多机器中,一般都是平均分配的。
  • 哈希分区:通过hash的函数,取余产生的数。保证这串数字充分的打散,均匀的分配到各台机器上。

哈希分布和顺序分布只是场景上的适用。哈希分布不能顺序访问,比如你想访问1~100,哈希分布只能遍历全部数据,同时哈希分布因为做了hash后导致与业务数据无关了。

分区方式 描述 代表产品
哈希分区 离散度好
数据分布业务无关
无法顺序访问
RedisCluster
Cassanda
Dynamo
顺序分区 离散度易倾斜
数据分布业务相关
可顺序访问
Bigtable
Hbase
Hypertable

数据倾斜与数据迁移跟节点伸缩:

顺序分布是会导致数据倾斜的,主要是访问的倾斜。每次点击会重点访问某台机器,这就导致最后数据都到这台机器上了,这就是顺序分布最大的缺点。但哈希分布的时候,假如要扩容机器的时候,称之为“节点伸缩”,这个时候,因为是哈希算法,会导致数据迁移。

哈希分区方式:

  • 节点取余分区:

    • 使用特点的数据(包括redis的键或用户ID),再根据节点数量N,使用公式:hash(key)%N计算出一个0~(N-1)值,来决定数据映射到哪一个节点上。即哈希值对节点总数取余。
    • 缺点:当节点数量N变化时(扩容或者收缩),数据和节点之间的映射关系需要重新计算,这样的话,按照新的规则映射,要么之前存储的数据找不到,要么之前数据被重新映射到新的节点(导致以前存储的数据发生数据迁移)。
    • 实践:常用于数据库的分库分表规则,一般采用预分区的方式,提前根据数量规划好分区数,比如划分为512或1024张表,保证可支撑未来一段时间的数据量,再根据负载情况将表迁移到其它数据库中。
  • 一致性哈希:

    • 一致性哈希分区(Distributed Hash Table)实现思路是为系统中每个节点分配一个token,范围一般在0~232,这些token构成一个哈希环。数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点。

      一致性哈希

      上图就是一个一致性hash的原理解析。

      假设有n1~n4这四台机器,我们对每一台机器分配一个唯一token,每次有数据(黄色代表数据),一致性哈希算法规则每次都顺时针漂移数据,也就是图中黄色的数据都指向n3。

      这个时候我们需要增加一个节点n5,在n2和n3之间,数据还是会发生漂移(会偏移到大于等于的节点),但是这个时候你是否注意到,其实只有n2~n3这部分的数据被漂移,其它的数据都是不会变的,这种方式相比节点取余最大的好处在于加入和删除节点只影响哈希环中相邻的节点,对其它节点无影响。

    • 缺点:每个节点的负载不相同,因为每个节点的hash是根据key计算出来的,换句话说就是假设key足够多,被hash算法打散得非常均匀,但是节点过少,导致每个节点处理的key个数不太一样,甚至相差很大,这就导致某些节点压力很大

    • 实践:加减节点会造成哈希环中部分数据无法命中,需要手动处理或者忽略这部分数据,因此一致性哈希常用于缓存场景。

虚拟槽分区:

虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。这个范围一般远远大于节点数,比如Redis Cluster槽范围是0~16383(也就是说有16383个槽)。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展。每个节点会负责一定数量的槽。

虚拟槽分区

如上图所示,当前集群有5个节点,每个节点平均大约负责3276个槽。由于采用高质量的哈希算法,每个槽所映射的数据通常比较均匀,将数据平均划分到5个节点进行数据分区。Redis Cluster就是采用虚拟槽分区,每当key访问过来,Redis Cluster会计算哈希值是否在这个区间里。它们彼此都知道对应的槽在哪台机器上,这样就能做到平均分配了。

集群限制:批量key操作。

Docker-compose搭建Redis Cluster

因为没有多台机器去部署redis实例,所以这里采用docker来搭建,在生产环境中肯定是多台机器去部署的。

容器名称 ip 端口
redis-cluster1 192.168.3.101 6380->6379
16380->16379
redis-cluster2 192.168.3.102 6381->6379
16381->16379
redis-cluster3 192.168.3.103 6382->6379
16382->16379
redis-cluster4 192.168.3.104 6383->6379
16383->16379
redis-cluster5 192.168.3.105 6384->6379
16384->16379
Redis-cluster6 192.168.3.106 6385->6379
16385->16379

redis-cluster文件目录

如上图把redis.conf复制进去,自行去下载,然后分别加入以下代码:

1
2
3
4
5
6
7
bind 0.0.0.0
cluster-enabled yes
cluster-config-file "/redis/conf/nodes.conf"
cluster-node-timeout 5000
protected-mode no
port 6379
daemonize no

docker-compose.yml

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
version: "3.6"
services:
redis-cluster1:
image: redis
container_name: redis-cluster1
ports:
- "6380:6379"
- "16380:16379"
volumes:
- /Users/gaobinzhan/Documents/Redis/cluster/cluster1:/redis
command: redis-server /redis/conf/redis.conf
networks:
redis-test:
ipv4_address: 192.168.3.101
redis-cluster2:
image: redis
container_name: redis-cluster2
ports:
- "6381:6379"
- "16381:16379"
volumes:
- /Users/gaobinzhan/Documents/Redis/cluster/cluster2:/redis
command: redis-server /redis/conf/redis.conf
networks:
redis-test:
ipv4_address: 192.168.3.102
redis-cluster3:
image: redis
container_name: redis-cluster3
ports:
- "6382:6379"
- "16382:16379"
volumes:
- /Users/gaobinzhan/Documents/Redis/cluster/cluster3:/redis
command: redis-server /redis/conf/redis.conf
networks:
redis-test:
ipv4_address: 192.168.3.103
redis-cluster4:
image: redis
container_name: redis-cluster4
ports:
- "6383:6379"
- "16383:16379"
volumes:
- /Users/gaobinzhan/Documents/Redis/cluster/cluster4:/redis
command: redis-server /redis/conf/redis.conf
networks:
redis-test:
ipv4_address: 192.168.3.104
redis-cluster5:
image: redis
container_name: redis-cluster5
ports:
- "6384:6379"
- "16384:16379"
volumes:
- /Users/gaobinzhan/Documents/Redis/cluster/cluster5:/redis
command: redis-server /redis/conf/redis.conf
networks:
redis-test:
ipv4_address: 192.168.3.105
redis-cluster6:
image: redis
container_name: redis-cluster6
ports:
- "6385:6379"
- "16385:16379"
volumes:
- /Users/gaobinzhan/Documents/Redis/cluster/cluster6:/redis
command: redis-server /redis/conf/redis.conf
networks:
redis-test:
ipv4_address: 192.168.3.106
networks:
redis-test:
driver: bridge
ipam:
config:
- subnet: "192.168.3.0/24"

然后进行docker-compose up

redis-cluster

此刻每个目录下面多了nodes.conf 现在文件内容只是单单保存了redis实例自身的节点数据。

也可以随便连接一台redis,查看集群状态:

1
2
3
4
gaobinzhan-MBP:~ gaobinzhan$ redis-cli -p 6380
127.0.0.1:6380> info Cluster
# Cluster
cluster_enabled:1

此刻集群只是开启状态,往里面写入数据会报错:

1
2
127.0.0.1:6380> set 1 2
(error) CLUSTERDOWN Hash slot not served

是因为没有分配槽。

redis之前的版本,需要手动分配槽,非常不方便。现在的版本只需要简单执行下命令就可以了。

随便进入一个容器当中 docker exec -it redis-cluster1 sh

然后执行redis-cli --cluster create 192.168.3.101:6379 192.168.3.102:6379 192.168.3.103:6379 192.168.3.104:6379 192.168.3.105:6379 192.168.3.106:6379 --cluster-replicas 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
gaobinzhan-MBP:~ gaobinzhan$ docker exec -it redis-cluster1 sh
# redis-cli --cluster create 192.168.3.101:6379 192.168.3.102:6379 192.168.3.103:6379 192.168.3.104:6379 192.168.3.105:6379 192.168.3.106:6379 --cluster-replicas 1
>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 192.168.3.105:6379 to 192.168.3.101:6379
Adding replica 192.168.3.106:6379 to 192.168.3.102:6379
Adding replica 192.168.3.104:6379 to 192.168.3.103:6379
M: 2a373fe66cde81d1530584bb86e49694b000d0c2 192.168.3.101:6379
slots:[0-5460] (5461 slots) master
M: 8de35d3b2439d05de839a2539f68e4833b90679d 192.168.3.102:6379
slots:[5461-10922] (5462 slots) master
M: caca339b5511d3a14e381d5ffd9434458c7368bd 192.168.3.103:6379
slots:[10923-16383] (5461 slots) master
S: 97eccf5edf7fb12d4a2e01b16dbf2d9c561896b3 192.168.3.104:6379
replicates caca339b5511d3a14e381d5ffd9434458c7368bd
S: 64512db80b1a159ca823a25a7e9893154efd555c 192.168.3.105:6379
replicates 2a373fe66cde81d1530584bb86e49694b000d0c2
S: 1722206f30f3b7c65706d30f4a1ee3b6e0cbca7c 192.168.3.106:6379
replicates 8de35d3b2439d05de839a2539f68e4833b90679d
Can I set the above configuration? (type 'yes' to accept):

此刻会提示你,是否接受此配置,输入yes即可。

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
Can I set the above configuration? (type 'yes' to accept): yes
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join
...
>>> Performing Cluster Check (using node 192.168.3.101:6379)
M: 2a373fe66cde81d1530584bb86e49694b000d0c2 192.168.3.101:6379
slots:[0-5460] (5461 slots) master
1 additional replica(s)
S: 64512db80b1a159ca823a25a7e9893154efd555c 192.168.3.105:6379
slots: (0 slots) slave
replicates 2a373fe66cde81d1530584bb86e49694b000d0c2
S: 1722206f30f3b7c65706d30f4a1ee3b6e0cbca7c 192.168.3.106:6379
slots: (0 slots) slave
replicates 8de35d3b2439d05de839a2539f68e4833b90679d
M: 8de35d3b2439d05de839a2539f68e4833b90679d 192.168.3.102:6379
slots:[5461-10922] (5462 slots) master
1 additional replica(s)
S: 97eccf5edf7fb12d4a2e01b16dbf2d9c561896b3 192.168.3.104:6379
slots: (0 slots) slave
replicates caca339b5511d3a14e381d5ffd9434458c7368bd
M: caca339b5511d3a14e381d5ffd9434458c7368bd 192.168.3.103:6379
slots:[10923-16383] (5461 slots) master
1 additional replica(s)
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.

此时redis-cluster已经搭建好了,nodes.conf文件也发生了变化,执行cluster info

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
127.0.0.1:6380> cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_ping_sent:234
cluster_stats_messages_pong_sent:242
cluster_stats_messages_sent:476
cluster_stats_messages_ping_received:237
cluster_stats_messages_pong_received:234
cluster_stats_messages_meet_received:5
cluster_stats_messages_received:476

状态是ok的,来试着写写数据。进入redis实例docker exec -it redis-cluster1 sh

1
2
3
4
gaobinzhan-MBP:~ gaobinzhan$ docker exec -it redis-cluster1 sh
# redis-cli
127.0.0.1:6379> set 1 2
(error) MOVED 9842 192.168.3.102:6379

报错了,因为槽的问题,这个数据需要写入到102那台实例当中,这时可以用集群方式启动redis-cli -c

1
2
3
4
5
6
# redis-cli -c
127.0.0.1:6379> set 1 2
-> Redirected to slot [9842] located at 192.168.3.102:6379
OK
192.168.3.102:6379> get 1
"2"

会发现提示数据移动到102节点了,因为是集群方式所以可以获取的,切换到102节点:

1
2
3
# redis-cli -c -h 192.168.3.102
192.168.3.102:6379> get 1
"2"

也可以正常的获取。

往期方式搭建集群

  • 准备节点
  • 节点握手
  • 分配槽

准备节点

Redis集群一般由多个节点组成,节点数量至少为6个才能保证组成完整高可用的集群,前面的主从复制跟哨兵共同构成了高可用。每个节点需要开启配置 cluster-enabled yes ,让Redis 运行在集群模式下。

  • 性能:这是Redis赖以生存的看家本领,增加集群功能后当然不能对性能产生太大影响,所以Redis采取了P2P而非Proxy方式、异步复制、客户端重定向等设计,而牺牲了部分的一致性、使用性。
  • 水平扩展:集群的最重要能力当然是扩展,文档中称可以线性扩展到1000结点。 可用性:在Cluster推出之前,可用性要靠Sentinel保证。有了集群之后也自动具有了Sentinel的监控和自动Failover能力

集群的相关配置:

1
2
3
4
5
6
7
8
#节点端口
port 6379
#开启集群模式
cluster-enabled yes
#节点超时时间,单位毫秒
cluster-node-timeout 15000
#集群内部配置文件
cluster-config-file "nodes-6379.conf"

其他配置和单机模式一致即可,配置文件命名规则redis-{port}.conf ,准备好配置后启动所有节点,第一次启动时如果没有集群配置文件,它会自动创建一份,文件名称采用 cluster-config-file 参数项控制,建议采用node-{port}.conf格式定义,也就是说会有两份配置文件。

当集群内节点信息发生变化,如添加节点、节点下线、故障转移等。节点会自动保存集群状态到配置文件中。需要注意的是, Redis自动维护集群配置文件,不要手动修改,防止节点重启时产生集群信息错乱。

redis-cluster启动

然后就跟上面一样准备准备节点就行了。

节点握手

节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信,达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户端发起命令:cluster meet{ip}{port}

通过命令cluster meet 127.0.0.1 6381让节点6380和6381节点进行握手通信。cluster meet命令是一个异步命令,执行之后立刻返回。内部发起与目标节点进行握手通信 。

  • 节点6380本地创建6381节点信息对象,并发送meet消息。
  • 节点6381接受到meet消息后,保存6380节点信息并回复pong消息。
  • 之后节点6380和6381彼此定期通过ping/pong消息进行正常的节点通信。

通过cluster nodes命令确认6个节点都彼此感知并组成集群。

注意:

  • 每个Redis Cluster节点会占用两个TCP端口,一个监听客户端的请求,默认是6379,另外一个在前一个端口加上10000, 比如16379,来监听数据的请求,节点和节点之间会监听第二个端口,用一套二进制协议来通信。节点之间会通过套协议来进行失败检测,配置更新,failover认证等等。为了保证节点之间正常的访问,需要注意防火墙的配置。
  • 节点建立握手之后集群还不能正常工作,这时集群处于下线状态,所有的数据读写都被禁止。
  • 设置从节点作为一个完整的集群,需要主从节点,保证当它出现故障时可以自动进行故障转移。集群模式下,Reids 节点角色分为主节点和从节点。
  • 首次启动的节点和被分配槽的节点都是主节点,从节点负责复制主节点槽信息和相关的数据。
  • 使用 cluster replicate {nodeId}命令让一个节点成为从节点。其中命令执行必须在对应的从节点上执行,将当前节点设置为node_id指定的节点的从节点。

分配槽

Redis 集群把所有的数据映射到16384个槽中。每个key会映射为一个固定的槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。通过cluster addslots命令为节点分配槽利用bash特性批量设置槽(slots),命令如下:

redis-cli -h 192.168.3.101 cluster addslots {0..5461}

redis-cli -h 192.168.3.102 cluster addslots {5462..10922}

redis-cli -h 192.168.3.103 cluster addslots {10923..16383}

然后就可以进行操作了。

集群的命令:

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
CLUSTER nodes: 列出集群当前已知的所有节点(node)的相关信息。 

CLUSTER meet <ip> <port>: 将ip和port所指定的节点添加到集群当中。

CLUSTER addslots <slot> [slot ...]: 将一个或多个槽(slot)指派(assign)给当前节点。

CLUSTER delslots <slot> [slot ...]: 移除一个或多个槽对当前节点的指派。

CLUSTER slots: 列出槽位、节点信息。

CLUSTER slaves <node_id>: 列出指定节点下面的从节点信息。

CLUSTER replicate <node_id>: 将当前节点设置为指定节点的从节点。

CLUSTER saveconfig: 手动执行命令保存保存集群的配置文件,集群默认在配置修改的时候会自动保存配置文件。

CLUSTER keyslot <key>: 列出key被放置在哪个槽上。

CLUSTER flushslots: 移除指派给当前节点的所有槽,让当前节点变成一个没有指派任何槽的节点。

CLUSTER countkeysinslot <slot>: 返回槽目前包含的键值对数量。

CLUSTER getkeysinslot <slot> <count>: 返回count个槽中的键。

CLUSTER setslot <slot> node <node_id> 将槽指派给指定的节点,如果槽已经指派给另一个节点,那么先让另一个节点删除该槽,然后再进行指派。

CLUSTER setslot <slot> migrating <node_id> 将本节点的槽迁移到指定的节点中。

CLUSTER setslot <slot> importing <node_id> 从 node_id 指定的节点中导入槽 slot 到本节点。

CLUSTER setslot <slot> stable 取消对槽 slot 的导入(import)或者迁移(migrate)。

CLUSTER failover: 手动进行故障转移。

CLUSTER forget <node_id>: 从集群中移除指定的节点,这样就无法完成握手,过期时为60s,60s后两节点又会继续完成握手。

CLUSTER reset [HARD|SOFT]: 重置集群信息,soft是清空其他节点的信息,但不修改自己的id,hard还会修改自己的id,不传该参数则使用soft方式。

CLUSTER count-failure-reports <node_id>: 列出某个节点的故障报告的长度。

CLUSTER SET-CONFIG-EPOCH: 设置节点epoch,只有在节点加入集群前才能设置。

注意:在apline系统中不支持{1..10}操作

以上理论知识内容为网络整理。。。。

redis哨兵故障转移及实现

在上篇文章中docker-compose搭建redis-sentinel(成功的搭建了1主2从3哨兵。

Sentinel命令

sentinel是一个特殊的redis节点,它有自己专属的api

  • sentinel masters 显示被监控的所有master以及它们的状态。
  • sentinel master <master name> 显示指定master的信息和状态。
  • sentinel slaves <master name> 显示指定master的所有slave及它们的状态。
  • sentinel sentinels <master name> 显示指定mastersentinel节点集合(不包含当前节点)。
  • sentinel get-master-addr-by-name <master name> 返回指定masteripport,如果正在进行failover或者failover已经完成,将会显示被提升为masterslaveipport
  • sentinel failover <master name> 强制sentinel执行failover,并且不需要得到其它sentinel的同意。但是failover后会将最新的配置发送给其它sentinel

sentinel masters

展示所有被监控的主节点状态及相关信息:

1
2
3
4
5
6
7
8
127.0.0.1:26380> sentinel masters
1) 1) "name"
2) "mymaster"
3) "ip"
4) "192.168.3.2"
5) "port"
6) "6379"
…………………………………………………………

sentinel master <master name>

展示指定**<master name>**状态以及相关的信息:

1
2
3
4
5
6
7
8
127.0.0.1:26380> sentinel master mymaster
1) "name"
2) "mymaster"
3) "ip"
4) "192.168.3.2"
5) "port"
6) "6379"
………………………………

sentinel slaves <master name>

展示指定 **<master name>**的从节点状态以及相关的统计信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
127.0.0.1:26380> sentinel slaves mymaster
1) 1) "name"
2) "192.168.3.4:6379"
3) "ip"
4) "192.168.3.4"
5) "port"
6) "6379"
…………………………………………
2) 1) "name"
2) "192.168.3.3:6379"
3) "ip"
4) "192.168.3.3"
5) "port"
6) "6379"
…………………………………………

sentinel sentinels <master name>

展示指定 <master name>sentinel节点集合(不包含当前sentinel节点):

1
2
3
4
5
6
7
8
127.0.0.1:26380> sentinel sentinels mymaster
1) 1) "name"
2) "570de1d8085ec8bd7974431c01c589847c857edf"
3) "ip"
4) "192.168.3.13"
5) "port"
6) "26379"
………………………………………………

sentinel get-master-addr-by-name <master name>

获取主节点信息:

1
2
3
127.0.0.1:26380> sentinel get-master-addr-by-name mymaster
1) "192.168.3.2"
2) "6379"

sentinel failover <master name>

对**<master name>**进行强制故障转移:

1
2
3
4
5
6
7
8
9
10
127.0.0.1:26380> sentinel failover mymaster
OK
127.0.0.1:26380> info sentinel
# Sentinel
sentinel_masters:1
sentinel_tilt:0
sentinel_running_scripts:0
sentinel_scripts_queue_length:0
sentinel_simulate_failure_flags:0
master0:name=mymaster,status=ok,address=192.168.3.3:6379,slaves=2,sentinels=3

修改配置:

  • 添加新的监听:sentinel monitor test 127.0.0.1 6379 2
  • 放弃对某个master监听:sentinel REMOVE test
  • 设置配置选项:sentinel set failover-timeout mymaster 180000

Master可能会因为某些情况宕机了,如果客户端是固定一个地址去访问,肯定是不合理的,所以客户端请求是请求哨兵,从哨兵获取主机地址的信息,或者是从机的信息。可以实现一个例子:

  • 随机选择一个哨兵连接,获取主机及从机信息。
  • 模拟客户端定时访问,实现简单轮询效果,轮询从节点。
  • 连接失败重试访问

Sentinel故障转移

执行docker-composer up之后sentinel.conf发生了变化,每个配置文件变化如下:

sentinel\conf\sentinel.conf

1
2
3
4
5
6
user default on nopass ~* [email protected]
sentinel known-replica mymaster 192.168.3.3 6379
sentinel known-replica mymaster 192.168.3.4 6379
sentinel known-sentinel mymaster 192.168.3.12 26379 497f733919cb5d41651b4a2b5648c4adffae0a73
sentinel known-sentinel mymaster 192.168.3.13 26379 0d0ee41bcb5d765e9ff78ed59de66be049a23a82
sentinel current-epoch 0

sentine2\conf\sentinel.conf

1
2
3
4
5
6
user default on nopass ~* [email protected]
sentinel known-replica mymaster 192.168.3.3 6379
sentinel known-replica mymaster 192.168.3.4 6379
sentinel known-sentinel mymaster 192.168.3.13 26379 0d0ee41bcb5d765e9ff78ed59de66be049a23a82
sentinel known-sentinel mymaster 192.168.3.11 26379 f5f2a73dc0e60514e4f28c6f40517f48fa409eed
sentinel current-epoch 0

sentine3\conf\sentinel.conf

1
2
3
4
5
6
user default on nopass ~* [email protected]
sentinel known-replica mymaster 192.168.3.3 6379
sentinel known-replica mymaster 192.168.3.4 6379
sentinel known-sentinel mymaster 192.168.3.12 26379 497f733919cb5d41651b4a2b5648c4adffae0a73
sentinel known-sentinel mymaster 192.168.3.11 26379 f5f2a73dc0e60514e4f28c6f40517f48fa409eed
sentinel current-epoch 0

从变化中可以看出每台Sentinel分别记录了slave的节点信息和其它Sentinel节点信息。

在宿主机中随便进入一台Sentinel

1
2
3
4
5
6
7
127.0.0.1:26380> sentinel masters
1) 1) "name"
2) "mymaster"
3) "ip"
4) "192.168.3.2"
5) "port"
6) "6379"

可以观察到监听的所有master,将192.168.3.2这台master进行宕机

docker stop redis-master

宕机完之后等待Sentinel检测周期过了之后再对sentinel.confredis.conf进行观察。

3台Sentinelsentinel monitor mymaster 192.168.3.2 6379 2变成了sentinel monitor mymaster 192.168.3.4 6379 2

其次master对应的slave节点信息也进行更改。

192.168.3.3redis.confreplicaof 192.168.3.2 6379也变成了replicaof 192.168.3.4 6379

192.168.3.2redis.confreplicaof 192.168.3.2 6379这行配置被删除掉了。

再次启动192.168.3.2redis节点,而这台节点的redis.conf中增加了一行replicaof 192.168.3.4 6379

其实就是将我们的操作自动化了。

Sentinel实现原理

Sentinel的实现原理,主要分为三个步骤:

  • 检测问题:三个定时任务,这三个内部的执行任务可以保证出现问题马上让Sentinel知道。
  • 发现问题:主观下线和客观下线,当有一台Sentinel机器发现问题时,它就会对它主观下线。但是当多个Sentinel都发现问题的时候,才会出现客观下线。
  • 找到解决问题的Sentinel:进行领导者选举,如何在Sentinel内部多台节点做领导者选择。
  • 解决问题:就是要进行故障转移。

三个定时任务

  • 每10s每个SentinelMasterSlave执行一次Info Replication

    Redis Sentinel可以对Redis节点做失败判断和故障转移,来Info Replication发现Slave节点,来确定主从关系。

  • 每2s每个Sentinel通过Master节点的channel交换信息(pub/sub)。

    类似于发布订阅,Sentinel会对主从关系进行判断,通过__sentinel__:hello频道交互。了解主从关系可以帮助更好的自动化操作Redis。然后Sentinel会告知系统消息给其它Sentinel节点,最终达到共识,同时Sentinel节点能够互相感知到对方。

  • 每1s每个Sentinel对其它SentinelRedis执行ping

    对每个节点和其它Sentinel进行心跳检测,它是失败判断的依据。

主观下线和客观下线

回顾上一篇文章中Sentinel的配置。

1
2
sentinel monitor mymaster 192.168.3.2 6379 2
sentinel down-after-millseconds mymaster 30000

主观下线:每个Sentinel节点对Redis失败的“偏见”。之所以是偏见,只是因为某一台机器30s内没有得到回复。

客观下线:这个时候需要所以Sentinel节点都发现它30s内无回复,才会达到共识。

领导者选举方式

  • 每个做主观下线的Sentinel节点,会像其它的Sentinel节点发送命令,要求将它设置成为领导者。
  • 收到命令的Sentinel节点,如果没有同意通过其它节点发送的命令,那么就会同意请求,否则就会拒绝。
  • 如果Sentinel节点发现自己的票数超过半数,同时也超过了sentinel monitor mymaster 192.168.3.2 6379 2超过2个的时候,就会成为领导者。
  • 进行故障转移操作。

如何选择“合适”的Slave节点

Redis内部其实是有一个优先级配置的,在配置文件中replica-priority,这个参数是slave节点的优先级配置,如果存在则返回,如果不存在则继续。当上面这个优先级不满足的时候,Redis还会选择复制偏移量最大的Slave节点,如果存在则返回,如果不存在则继续。之所以选择偏移量最大,这是因为偏移量越小,和Master的数据越不接近,现在Master挂掉了,说明这个偏移量小的机器数据可能存在问题,这就是为什么选择偏移量最大的Slave的原因。如果发现偏移量都一样,这个时候 Redis 会默认选择 runid 最小的节点。

生产环境部署技巧:

  • Sentinel节点不应该部署在一台物理机器上。

    这里特意强调物理机是因为一台物理机做成了若干虚拟机或者现今比较流行的容器,它们虽然有不同的IP地址,但实际上它们都是同一台物理机,同一台物理机意味着如果这台机器有什么硬件故障,所有的虚拟机都会受到影响,为了实现Sentinel节点集合真正的高可用,请勿将Sentinel节点部署在同一台物理机器上。

  • 部署至少三个且奇数个的Sentinel节点。通过增加Sentinel节点的个数提高对于故障判定的准确性,因为领导者选举需要至少一半加1个节点。

Sentinel常见问题

哨兵集群在发现master node挂掉后会进行故障转移,也就是启动其中一个slave nodemaster node。在这过程中,可能会导致数据丢失的情况。

  • 异步复制导致数据丢失

    因为master->slave的复制是异步,所以有可能部分还没来得及复制到slave就宕机了,此时这些部分数据就丢失了。

  • 集群脑裂导致数据丢失

    脑裂,也就是说。某个master所在机器突然脱离了正常的网络,跟其它slave机器不能连接,但是实际上master还运行着。

造成的问题:

​ 此时哨兵可能就会认为master宕机了,然后开始选举,将其它slave切换成master。这时候集群里就会有2个master,也就是所谓的脑裂。此时虽然某个slave被切换成master,但是可能client还没来得及切换成新的master,还继续写向旧的master的数据可能就丢失了。因此旧master再次被恢复的时候,会被作为一个slave挂到新的master上去,自己的数据会被清空,重新从新的master复制数据。

怎么解决:

1
2
min-slaves-to-write 1
min-slaves-max-lag 10

要求至少有一个slave,数据复制和同步的延迟不能超过10s。

如果说一旦所有的slave,数据复制和同步的延迟都超过了10s,这个时候,master就不会再接收任何请求了。

上面两个配置可以减少异步复制和脑裂导致的数据丢失。

异步复制导致的数据丢失:

​ 在异步复制的过程当中,通过min-slaves-max-lag这个配置,就可以确保的说,一旦slave复制数据和ack延迟时间太长,就认为可能master宕机后损失的数据太多了,那么就拒绝写请求,这样就可以把master宕机时由于部分数据未同步到slave导致的数据丢失降低到可控范围内。

集群脑裂导致的数据丢失:

​ 集群脑裂因为client还没来得及切换成新的master,还继续写向旧的master的数据可能就丢失了通过min-slaves-to-write确保必须是有多少个从节点连接,并且延迟时间小于min-slaves-max-lag多少秒。

客户端需要怎么做:

​ 对于client来讲,就需要做些处理,比如先将数据缓存到内存当中,然后过一段时间处理,或者连接失败,接收到错误切换新的master处理。

docker-compose搭建redis-sentinel

主从复制的问题

​ 对于上篇文章redis持久化rdb及aof中,redis服务器重启时的数据恢复,在新版本中是不符合我画的那个流程图的。

redis启动的时候会去判断是否开启aof,如果开启了,不存在aof文件的话,会去判断是否存在rdb,但在新的版本中,如果开启aof,不存在aof文件的时候,redis会主动创建aof文件并且加载aof,这就会导致数据丢失。解决方案如下:

  • 关闭aof
  • 启动redis去加载rdb文件
  • 动态开启aof最终达到数据一致性

​ 当主机master宕机以后,需要人工解决切换,比如使用slaveof no one。实际上主从复制并没有实现高可用。

高可用侧重备份机器,利用集群中系统的冗余,当系统中某台机器发生损坏的时候,其它后备的机器可以迅速的接替它来启动服务。

redis主从复制问题

如何解决:

如果我们有一个监控程序能够监控各个机器的状态并及时调整,手动操作变为自动操作,Sentinel的出现就是为了解决这个问题。

哨兵机制的原理

Reids Sentinel一个分布式架构,其中包含若干个Sentinel节点和Redis数据节点,每个Sentinel节点会对数据节点和其余Sentinel节点进行监控,当它发现节点不可达的时候,会对节点做下线标识。

​ 如果被标识的是主节点,它还会和其它Sentinel节点进行协商,当大多数Sentinel节点都认为主节点不可达时,它们会选举出一个Sentinel节点来完成自动故障转移的工作,同时会将这个变化实时通知给Redis应用方。整个过程完全是自动的,不需要人工来介入,所以这套方案很有效地解决了Redis高可用的问题。

redis哨兵

基本的故障转移流程:

  • 主节点出现故障,此时两个从节点与主节点失去连接,主从复制失败。
  • 每个Sentinel节点通过定期监控发现主节点出现了故障
  • 多个Sentinel节点对主节点的故障达成一致会选举出其中一个节点作为领导者负责故障转移。
  • Sentinel领导者节点执行了故障转移,整个过程基本是跟我们手动调整一致的,只不过是自动化完成的。
  • 故障转移后整个Redis Sentinel的结构,重新选举了新的主节点。

Redis Sentinel具有的功能:

  • 监控Sentinel节点会定期检查Redis数据节点、其余Sentinel节点是否可达。
  • 通知Sentinel节点会将故障转移的结果通知给应用方。
  • 主节点故障转移:实现从节点晋升为主节点并维护后续正确的主从关系。
  • 配置提供者:在Redis Sentinel结构中,客户端在初始化的时候连接的是Sentinel节点集合,从中获取主节点信息。

同时Redis Sentinel包含了若干个Sentinel节点,这样做也带了两个好处:

  • 对于节点的故障判断是由多个Sentinel节点共同完成,这样可以有效地防止误判。
  • Sentinel节点集合是由若干个Sentinel节点组成的,这样即使个别Sentinel节点不可用,整个Sentinel节点集合依然是健壮的。

但是Sentinel节点本身就是独立的Reids节点,只不过它们有一些特殊,不存储数据,只支持部分命令。

docker-compose 实现 redis-sentinel

容器名称 容器IP 映射端口号 服务运行模式
Redis-master 192.168.3.2 6380->6379 Master
Redis-slave1 192.168.3.3 6381->6379 Slave
Redis-slave2 192.168.3.4 6382->6379 Slave
Redis-sentinel1 192.168.3.11 26380->26379 Sentinel
Redis-sentinel2 192.168.3.12 26381->26379 Sentinel
Redis-sentinel3 192.168.3.13 26382->26379 Sentinel

在这里我用的镜像是redis官方的6.0.5。去网上把配置文件下载下来(redis.confsentinel.conf)

然后开始进行:

redis文件目录

创建目录,并且把配置文件拷贝进去。

sentinel目录下的所有配置文件进行简单的修改:

搜索sentinel monitor 改为 sentinel monitor mymaster 192.168.3.2 6379 2

server目录下进行修改:

bind 127.0.0.1改为bind 0.0.0.0

replicaof <masterip> <masterport>改为replicaof 192.168.3.2 6379(除master目录)

创建docker-compose.yml

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
version: "3.6"
services:
redis-master:
image: redis
container_name: "redis-master"
ports:
- "6380:6379"
volumes:
- /Users/gaobinzhan/Documents/Redis/server/master:/redis
command: redis-server /redis/conf/redis.conf
networks:
redis-test:
ipv4_address: 192.168.3.2
redis-slave1:
image: redis
container_name: "redis-slave1"
ports:
- "6381:6379"
volumes:
- /Users/gaobinzhan/Documents/Redis/server/slave1:/redis
command: redis-server /redis/conf/redis.conf
networks:
redis-test:
ipv4_address: 192.168.3.3
redis-slave2:
image: redis
container_name: "redis-slave2"
ports:
- "6382:6379"
volumes:
- /Users/gaobinzhan/Documents/Redis/server/slave2:/redis
command: redis-server /redis/conf/redis.conf
networks:
redis-test:
ipv4_address: 192.168.3.4
redis-sentinel1:
image: redis
container_name: "redis-sentinel1"
ports:
- "26380:26379"
volumes:
- /Users/gaobinzhan/Documents/Redis/sentinel/sentinel1:/redis
command: redis-sentinel /redis/conf/sentinel.conf
networks:
redis-test:
ipv4_address: 192.168.3.11
redis-sentinel2:
image: redis
container_name: "redis-sentinel2"
ports:
- "26381:26379"
volumes:
- /Users/gaobinzhan/Documents/Redis/sentinel/sentinel2:/redis
command: redis-sentinel /redis/conf/sentinel.conf
networks:
redis-test:
ipv4_address: 192.168.3.12
redis-sentinel3:
image: redis
container_name: "redis-sentinel3"
ports:
- "26382:26379"
volumes:
- /Users/gaobinzhan/Documents/Redis/sentinel/sentinel3:/redis
command: redis-sentinel /redis/conf/sentinel.conf
networks:
redis-test:
ipv4_address: 192.168.3.13
networks:
redis-test:
driver: bridge
ipam:
config:
- subnet: "192.168.3.0/24"

进行docker-compose up,执行完毕后:

redis-cli工具运行redis-cli -p 26380输入info

image-20200620005154269

出现以上信息即搭建成功。。

Sentinel的核心配置:

sentinel monitor mymaster 192.168.3.2 6379 2

监控的主节点的名字、IP 和端口,最后一个2的意思是有几台 Sentinel发现有问题,就会发生故障转移,例如 配置为2,代表至少有2个 Sentinel 节点认为主节点不可达,那么这个不可达的判定才是客观的。对于设置的越小,那么达到下线的条件越宽松,反之越严格。一般建议将其设置为 Sentinel 节点的一半加1。最后的参数不可大于Sentinel节点数。

sentinel down-after-millseconds mymaster 30000

这个是超时的时间(单位为毫米)。打个比方,当你去ping一个机器的时候,多长时间后仍ping不通,那么就认为它是有问题。

sentinel parallel-syncs my master 1

Sentinel节点集合对主节点故障判断达成一致时,Sentinel领导者节点会被做故障转移操作,选出新的主节点,原来的从节点会向新的主节点发起复制操作,paraller-syncs就是用来限制在一次故障转移之后,每次向新的主节点发起复制操作的从节点个数,指出Sentinel属于并发还是串行。1代表每次只能复制一个,可以减轻Master的压力。

sentinel auth-pass <master-name> <password>

如果 Sentinel 监控的主节点配置了密码,sentinel auth-pass 配置通过添加主节点的密码,防止 Sentinel 节点对主节点无法监控。

sentinel failover-timeout mymaster 180000

表示故障转移的时间。

php反射实现Ioc-Di及注解

PHP5之后提供了完整的反射API,添加了对类、接口、函数、方法和扩展进行反向工程的能力。此外,反射API提供了方法来取出函数、类和方法的文档注释。

Ioc/Di大家应该都不陌生,但是对小白来说呢听起来就挺高大上的,下面就用代码来实现:

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
<?php
/**
* @author gaobinzhan <gaobinzhan@gmail.com>
*/

class Foo
{
public function getClassName()
{
return 'this is Foo';
}
}

class Bar
{
public function getClassName()
{
return 'this is Bar';
}
}

class Test
{
public function __construct(Foo $foo, Bar $bar)
{
var_dump($foo->getClassName());
var_dump($bar->getClassName());
}

public function index(Foo $foo)
{
var_dump($foo->getClassName());
}
}

// 反射Test
$reflect = new ReflectionClass(Test::class);
// 获取是否有构造函数
$constructor = $reflect->getConstructor();
if ($constructor) {
// 如果存在构造 获取参数
$constructorParams = $constructor->getParameters();
// 初始化注入的参数
$args = [];
// 循环去判断参数
foreach ($constructorParams as $param) {
// 如果为class 就进行实例化
if ($param->getClass()) {
$args[] = $param->getClass()->newInstance();
} else {
$args[] = $param->getName();
}
}
// 实例化注入参数
$class = $reflect->newInstanceArgs($args);
} else {
$class = $reflect->newInstance();
}


// 假设我们要调用index方法 在此之前自己判断下方法是否存在 我省略了
$reflectMethod = new ReflectionMethod($class, 'index');
// 判断方法修饰符是否为public
if ($reflectMethod->isPublic()) {
// 以下代码同等上面
$args = [];
$methodParams = $reflectMethod->getParameters();
foreach ($methodParams as $param) {
if ($param->getClass()) {
$args[] = $param->getClass()->newInstance();
} else {
$args[] = $param->getName();
}
}
$reflectMethod->invokeArgs($class, $args);
}

以上就简单的实现了依赖注入,下面我们接着封装一下。

Ioc.php

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
<?php
/**
* @author gaobinzhan <gaobinzhan@gmail.com>
*/

class Ioc
{
public static function getInstance($className)
{
$args = self::getMethodParams($className);
return (new ReflectionClass($className))->newInstanceArgs($args);
}

public static function make($className, $methodName, $params = []) {

// 获取类的实例
$instance = self::getInstance($className);

// 获取该方法所需要依赖注入的参数
$args = self::getMethodParams($className, $methodName);

return $instance->{$methodName}(...array_merge($args, $params));
}

protected static function getMethodParams($className, $methodsName = '__construct')
{

// 通过反射获得该类
$class = new ReflectionClass($className);
$args = []; // 记录注入的参数

// 判断该类是否有构造函数
if ($class->hasMethod($methodsName)) {
// 构造函数存在 进行获取
$construct = $class->getMethod($methodsName);

// 获取构造函数的参数
$params = $construct->getParameters();

// 构造函数无参数 直接返回
if (!$params) return $args;

// 判断参数类型
foreach ($params as $param) {

// 假设参数为类
if ($paramClass = $param->getClass()) {

// 获得参数类型名称
$paramClassName = $paramClass->getName();
// 如果注入的这个参数也是个类 就要继续判断是否存在构造函数
$methodArgs = self::getMethodParams($paramClassName);

// 存入数组中
$args[] = (new ReflectionClass($paramClass->getName()))->newInstanceArgs($methodArgs);
}
}
}
// 返回参数
return $args;
}
}

以上代码实现了构造函数的依赖注入及方法的依赖注入,下面进行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
class Bar
{
public function getClassName()
{
return 'this is Bar';
}
}

class Test
{
public function __construct(Foo $foo, Bar $bar)
{
var_dump($foo->getClassName());
var_dump($bar->getClassName());
}

public function index(Foo $foo)
{
var_dump($foo->getClassName());
}
}
Ioc::getInstance(Test::class);
Ioc::make(Test::class,'index');

以上呢,就简单的通过php的反射机制实现了依赖注入。

继基于swoole的微服务框架出现,注解呢就开始慢慢出现在我们的视角里。据说php8也加入了注解支持:

1
2
3
4
5
6
7
8
9
10
use \Support\Attributes\ListensTo;

class ProductSubscriber
{
<<ListensTo(ProductCreated::class)>>
public function onProductCreated(ProductCreated $event) { /* … */ }

<<ListensTo(ProductDeleted::class)>>
public function onProductDeleted(ProductDeleted $event) { /* … */ }
}

就类似这样的,哈哈哈。

而我们现在的注解则是通过反射拿到注释去做到的解析。

接下来我们去用别人写好的组件去实现annotations

编写我们的composer.json

1
2
3
4
5
6
7
8
9
10
11
{
"require": {
"doctrine/annotations": "^1.8"
},
"autoload": {
"psr-4": {
"app\\": "app/",
"library\\": "library/"
}
}
}

接下来要执行啥???这个你要是再不会,真的我劝你回家种地吧!!哈哈哈 闹着玩呢!

composer install

然后我们接下来去创建目录:

1
2
3
4
5
6
7
8
9
10
11
12
- app // app目录
- Http
- HomeController.php
- library // 核心注解库
- annotation
- Mapping
- Controller.php
- RequestMapping.php
- Parser
- vendor
- composer.json
- index.php // 测试文件

php-annotation目录

害 图片有点大。。。。。咋整。。。算了,就这样吧!!!

温馨提示:在phpstrom里面,安装插件PHP Annotation写代码会更友好啊!!

创建library\Mapping\Controller.php

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
<?php
/**
* @author gaobinzhan <gaobinzhan@gmail.com>
*/


namespace library\annotation\Mapping;


use Doctrine\Common\Annotations\Annotation\Attribute;
use Doctrine\Common\Annotations\Annotation\Attributes;
use Doctrine\Common\Annotations\Annotation\Required;
use Doctrine\Common\Annotations\Annotation\Target;

/**
* Class Controller
* @package library\annotation\Mapping
* @Attributes({
* @Attribute("prefix", type="string"),
* })
* @Annotation
* @Target("CLASS")
*/
final class Controller
{
/**
* @Required()
* @var string
*/
private $prefix = '';
public function __construct(array $value)
{
if (isset($value['value'])) $this->prefix = $value['value'];
if (isset($value['prefix'])) $this->prefix = $value['prefix'];
}

public function getPrefix(){
return $this->prefix;
}
}

@Annotation表示这是个注解类,让IDE提示更加友好!

@Target表示这个注解类只能被类使用!

@Required表示这个属性是必须填写的!

@Attributes表示这个注解类有多少个属性!

创建library\Mapping\RequestMapping.php

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
<?php
/**
* @author gaobinzhan <gaobinzhan@gmail.com>
*/


namespace library\annotation\Mapping;


use Doctrine\Common\Annotations\Annotation\Required;
use Doctrine\Common\Annotations\Annotation\Target;

/**
* Class RequestMapping
* @package library\annotation\Mapping
* @Annotation
* @Attributes({
* @Attribute("route", type="string"),
* })
* @Target("METHOD")
*/
final class RequestMapping
{
/**
* @Required()
*/
private $route;
public function __construct(array $value)
{
if (isset($value['value'])) $this->route = $value['value'];
if (isset($value['route'])) $this->route = $value['route'];
}

public function getRoute(){
return $this->route;
}
}

这里的代码就不用再重复解释了吧!我偏不解释了!哈哈哈

创建app\Http\HomeController.php

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
<?php
/**
* @author gaobinzhan <gaobinzhan@gmail.com>
*/


namespace app\Http;


use library\annotation\Mapping\Controller;
use library\annotation\Mapping\RequestMapping;

/**
* Class HomeController
* @package app\Http
* @Controller(prefix="/home")
*/
class HomeController
{
/**
* @RequestMapping(route="/test")
*/
public function test(){
echo 111;
}
}

这里代码没啥解释的。。。

哈哈,下面来测试我们的结果!

index.php

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
<?php
/**
* @author gaobinzhan <gaobinzhan@gmail.com>
*/

$loader = require './vendor/autoload.php';

// 获取一个反射类
$refClass = new \ReflectionClass('\app\Http\HomeController');

// 注册load
\Doctrine\Common\Annotations\AnnotationRegistry::registerLoader([$loader, 'loadClass']);

// new 我们的注解读取器
$reader = new \Doctrine\Common\Annotations\AnnotationReader();

// 获取该类上的所有注解
$classAnnotations = $reader->getClassAnnotations($refClass);

// 这是个循环 说明$classAnnotations是个数组
foreach ($classAnnotations as $annotation){
// 因为我们定义了Controller注解类 要判断好啊
if ($annotation instanceof \library\annotation\Mapping\Controller){
// 获取我们的 prefix 这地方能看懂吧。。。
echo $routePrefix = $annotation->getPrefix().PHP_EOL;
// 获取类中所有方法
$refMethods = $refClass->getMethods();
}

// 进行循环
foreach ($refMethods as $method){
// 获取方法上的所有注解
$methodAnnotations = $reader->getMethodAnnotations($method);
// 循环
foreach ($methodAnnotations as $methodAnnotation){
if ($methodAnnotation instanceof \library\annotation\Mapping\RequestMapping){
// 输出我们的route
echo $methodAnnotation->getRoute().PHP_EOL;
}
}
}
}

执行结果:

1
2
/home
/test

之前我们不是建立了个Parser的目录嘛,可以在里面创建对应的解析类,然后去解析,把它们封装一下子!!

redis持久化rdb及aof

介绍

​ 持久化的功能:Redis是内存数据库,数据都是存储在内存中,为了避免进程退出导致数据的永久丢失,需要定期将Redis中的数据以某种形式(数据或命令)从内存保存到硬盘中。当下次Redis重启时,利用持久化文件实现数据恢复。除此之外,为了进行灾难备份,可以将持久化文件拷贝到一个远程位置。Redis持久化分为RDBAOF,前者将当前数据保存到硬盘,后者则是将每次执行的写命令保存的硬盘。

RDB持久化

RDB是一种快照存储持久方式,具体就是将Redis某一时刻的内存数据保存到硬盘的文件当中,默认保存的文件名为dump.rdb,而在Redis服务器启动时,会重新加载dump.rdb文件的数据到内存当中恢复数据。触发RDB持久化过程分为手动触发和自动触发。

触发机制

手动触发分别对应savebgsave命令:

save命令:阻塞当前Redis服务器,直到RDB过程完成为止,对于内存比较大的实例会造成长时间阻塞,线上环境不建议使用。

bgsave命令:Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。

显示bgsave命令是针对save阻塞问题做的优化。因此Redis内部所有的涉及RDB的操作都采用bgsave的方式。

除了执行命令手动触发之外,Redis内部还存在自动触发RDB的持久化机制,例如以下场景:

  • 使用save相关配置,如save m n。表示m秒内数据集存在n次修改时,自动触发bgsave
  • 如果从节点执行全量复制操作,主节点自动执行bgsave生成RDB文件并发送给从节点。
  • 执行debug reload命令重载Redis时,也会自动触发save操作。
  • 默认情况下执行shutdown命令时,如果没有开启AOF持久化功能则自动执行bgsave

执行流程

bgsave是主流的触发RDB持久化方式:

redis-bgsave命令

  • 执行bgsave命令,Redis父进程判断当前是否存在正在执行的子进程,如RDB/AOF子进程,如果存在bgsave命令直接返回。
  • 父进程执行fork操作创建子进程,fork操作过程中父进程会阻塞,通过info stats命令查看latest_fork_usec选项,可以获得最近一个fork操作的耗时,单位为微妙。
  • 父进程fork完成后,bgsave命令返回Background saving started信息并不再阻塞父进程,可以继续响应其它命令。
  • 子进程创建RDB文件,根据父进程内存生成临时快照文件,完成后原有文件进行原子替换。执行lastsave命令可以获取最后一次生成RDB的时间,对应info统计的rdb_last_save_time选项。
  • 进程发送信号给父进程表示完成,父进程更新统计信息,具体见info Persistence下的rdb_*相关选项。

服务器配置自动触发

​ 除了通过客户端发送命令外,还有一种方式,就是在Redis配置文件中的save指定到达触发RDB持久化的条件,比如【多少秒内至少达到多少写操作】就开启RDB数据同步。

例如我们可以在配置文件redis.conf指定如下的选项:

1
2
3
4
5
6
# 900s内至少达到1条写命令
save 900 1
# 300s内至少达到10条写命令
save 300 10
# 60s内至少达到1000条写命令
save 60 1000

这种通过服务器配置文件触发RDB的方式,与bgsave命令类似,达到触发条件时,会fork一个子进程进行数据同步,不过最好不要通过这种方式来触发RDB持久化,因为设置触发的时间太短,则容易频繁写入rdb文件,影响服务器性能,时间设置太长会造成数据丢失。

RDB文件的处理

保存:

  • RDB文件保存在dir配置指定的目录下,文件名通过dbfilename配置指定。可通过执行config set dir {newDir}config set dbfilename {newFileName}运行期动态执行,当下次运行时RDB文件会保存到新目录。

压缩:

  • Redis默认采用LZF算法对生存的RDB文件做压缩处理,压缩后的文件远远小于内存大小,默认开启,可以通过参数config set rdbcompression {yes|no}动态修改。
  • 虽然压缩RDB会消耗CPU,但可大幅度降低文件的体积,方便保存到硬盘或通过网络发送给从节点,因此线上建议开启。

RDB方式的优缺点

优点:

  • RDB是一个非常紧凑的文件,它保存了Redis在某个时间点上的数据集。这种文件非常适合用于备份;比如说,你可以在最近的24小时内,每小时备份一次RDB文件,并且在每个月的每一天,也备份一个RDB文件。这样的话,即使遇上问题,也可以随时将数据集还原到不同的版本。
  • RDB可以最大化Redis的性能;父进程在保存RDB文件时唯一要做的就是fork出一个子进程,然后这个子进程就会处理接下来的所有保存工作,父进程无需执行任何磁盘I/O操作。
  • RDB在恢复大数据集时的速度要比AOF的恢复速度快。

缺点:

  • RDB方式数据没办法做到实时持久化/秒级持久化。如果服务器宕机的话,采用RDB的方式会造成某个时段内数据的丢失,比如我们设置10分钟同步一次或者5分钟达到1000次写入就同步一次,那么如果还没达到触发条件服务器就死机了,那么这个时间段的数据会丢失。
  • 使用bgsave命令在fork子进程时,如果数据量太大,fork的过程也会发生阻塞,另外,fork子进程会消耗内存。针对RDB不适合做实时持久化的问题,Redis提供了AOF持久化方式来解决。

AOF持久化

AOF(append only file)持久化;与RDB存储某个时刻的快照不同,AOF持久化方式会记录客户端对服务器的每一次写操作命令到日志当中,并将这些操作以Redis协议追加保存到以后缀为aof文件末尾。

使用AOF

​ 开启AOF功能需要设置配置;appendonly yes,默认不开启。AOF文件名通过appendfilename配置设置,默认文件名是appendonly.aof。保存路径同RDB持久化方式一致,通过dir配置指定。

持久化配置

1
2
3
4
appendonly yes #启用aof持久化方式
appendfsync always #每次收到命令就立即强制写入磁盘,最慢的大概只有几百的TPS,但是保证完全的持久化,不推荐使用
appendfsync everysec #每秒钟强制写入磁盘一次,在性能和持久化方面做了很好的折中,推荐
appendfsync no #完全依赖os,性能最好,持久化没保证,Redis不会主动调用fsync去将AOF日志内容同步到磁盘,所以这一切完全依赖于操作系统的调试了。对于大多数Linux操作系统,是每30s进行一次fsync,将缓冲区中的数据写的磁盘上。

执行流程

redis-aof

  • 所以的写入命令会追加到aof_buf(缓冲区)中。
  • AOF缓冲区根据对应的策略向硬盘做同步操作。
  • 随着AOF文件越来越大,需要定期对AOF文件进行重写,达到压缩的目的。
  • Redis服务器重启时,可以加载AOF文件进行数据恢复。

在同步期间可能会发生阻塞问题

redis-aof追加阻塞

  • 若果AOF文件fsync同步时间大于2sRedis主进程就会阻塞。
  • 若果AOF文件fsync同步时间小于2sRedis主进程就会返回。

其实这样做是为了保证文件安全性的一种策略。

AOF追加阻塞会产生的问题:

  • fsync大于2s时候,会阻塞redis主进程,我们都知道redis主进程是用来执行redis命令的,是不能阻塞的。
  • 虽然每秒everysec刷盘策略,但是实际上不是丢失1s数据,实际有可能丢失2s数据。

重写机制

  • AOF将客户端的每一个写操作都追加到aof文件末尾,随着命令不断写入AOF,文件会越来越大,为了解决这个问题,Redis引入AOF重写机制压缩文件体积。

  • AOF文件重写是吧Redis进程内的数据转化为写命令同步到新AOF文件的过程

    比如:多条命令可以合并为一个,lpush list a、lpush list b可以转化为lpush list a b

  • AOF重写降低了文件占用空间,除此之外,另一个目的是:更小的AOF文件可以更快地被加载。

触发机制:

AOF重写过程可以手动触发和自动触发:

  • 手动触发:直接调用bgrewriteaof命令。
  • 自动触发:根据auto-aof-rewrite-min-sizeauto-aof-rewrite-percentage参数确定自动触发。
    • auto-aof-rewrite-min-size表示运行AOF重写时文件最小体积,默认为64MB
    • auto-aof-rewrite-percentage代表当前AOF文件空间(aof_current_size)和上一次重写后AOF文件空间(aof_base_size)的比值。
  • 示例:
    • auto-aof-rewrite-percentage:100
    • auto-aof-rewrite-min-size:64mb
  • 默认配置时当AOF文件大小是上次rewrite后大小的一倍且文件大于64mb时触发。

当触发AOF重写时,内部流程:

执行AOF重写请求。如果当前进程正在执行AOF重写,请求不执行并返回如下响应:ERR Background append only file rewriting already in progress

  • 父进程执行fork创建子进程,开销等同于bgsave过程。
    • 主进程fork操作完成后,继续响应其它命令。所以修改命令依然写入AOF缓冲区并根据appendfsync策略同步到硬盘,保证原有AOF机制正确性。
    • 由于fork操作运用写时复制技术,子进程只能共享fork操作时的内存数据。由于父进程依然响应命令,Redis使用AOF重写缓冲区保存这部分新数据,防止新AOF文件生成期间丢失这部分数据。
  • 子进程根据内存快照,按照命令合并规则写入新的AOF文件,每次批量写入硬盘数据量由配置aof-rewrite-incremental-fsync控制,默认为32MB,防止单词刷盘数据过多造成硬盘阻塞。
    • AOF文件写入完成后,子进程发送信号给父进程,父进程更新统计信息,具体见info persistence的**aof_***相关统计。
    • 父进程把AOF重写缓冲区的数据写入到新的AOF文件。
    • 使用新AOF文件替换老文件,完成AOF重写。

注意事项

​ 在写入AOF日志文件时,如果Redis服务器宕机,则aof日志文件会出现格式错误,在重启Redis服务器时,Redis服务器会拒绝载入这个aof文件,可以通过命令修复aof并恢复数据。

redid-check-aof -fix appendonly.aof

AOF的优缺点

优点:

  • AOF可以设置完全不同步、每秒同步、每次操作同步,默认时每秒同步。因为AOF时操作指令的追加,所以可以频繁的大量的同步。
  • AOF文件是一个值追加日志的文件,即使服务宕机为写入完整的命令,也可以通过redis-check-aof工具修复这些问题。
  • 如果AOF文件过大,Redis会在后台自动地重写AOF文件。重写后会使AOF文件压缩到最小所需的指令集。
  • AOF文件是有序保存数据看的所有写入操作,易读,易分析。即使如果不小心误操作数据看,也很容易找出业务错误指令,恢复到某个数据节点。例如不小FLUSHALL,可以非常容易恢复到执行命令之前。

缺点:

  • 相同数据量下,AOF的文件通常体积会比RDB大。因为AOF是存指令的,而RDB是所有指令的结果快照。但AOF在日志重写后会压缩一些空间。
  • 在大量写入和载入的时候,AOF的效率会比RDB低,因为大量写入,AOF会执行更多的保存命令,载入的时候也需要大量的重执行命令来得到最后的结果。RDB对此更有优势。

AOF常用配置

appendonly no:是否开启AOF

appendfilename "appendonly.aof"AOF文件名

dir ./RDB文件和AOF文件所在目录

appendfsync everysec:fsync持久化策略

no-appendfsync-on-rewrite noAOF重写期间是否禁止fsync;如果开启该选项,可以减轻文件重写时CPU和硬盘的负载(尤其是硬盘),但是可能会丢失AOF重写期间的数据;需要在负载和安全性之间进行平衡

auto-aof-rewrite-percentage 100:文件重写触发条件之一

auto-aof-rewrite-min-size 64mb:文件重写触发提交之一

aof-load-truncated yes:如果AOF文件结尾损坏,Redis启动时是否仍载入AOF文件

重启加载的选择

AOFRDB文件都可以用于服务器重启时的数据恢复。

redis重写加载

持久化的选择

​ 在实际生产环境中,根据数据量、应用对数据的安全要求、预算限制等不同情况,会有各种各样的持久化策略;如完全不使用任何持久化、使用RDBAOF的一种,或同时开启RDB和AOF持久化等

​ 此外,持久化的选择必须与Redis的主从策略一起考虑,因为主从复制与持久化同样具有数据备份的功能,而且主机master和从机slave可以独立的选择持久化方案。

面分场景来讨论持久化策略的选择,下面的讨论也只是作为参考,实际方案可能更复杂更具多样性。

  • 如果Redis中的数据完全丢弃也没有关系(如Redis完全用作DB层数据的cache),那么无论是单机,还是主从架构,都可以不进行任何持久化。
  • 在单机环境下(对于个人开发者,这种情况可能比较常见),如果可以接受十几分钟或更多的数据丢失,选择RDBRedis的性能更加有利;如果只能接受秒级别的数据丢失,应该选择AOF
  • 但在多数情况下,我们都会配置主从环境,slave的存在既可以实现数据的热备,也可以进行读写分离分担Redis读请求,以及在master宕掉后继续提供服务。在这种情况下的做法是:
    • master:完全关闭持久化(包括RDBAOF),这样可以让master的性能达到最好;
    • slave:关闭RDB,开启AOF(如果对数据安全要求不高,开启RDB关闭AOF也可以),并定时对持久化文件进行备份(如备份到其他文件夹,并标记好备份的时间);然后关闭AOF的自动重写,然后添加定时任务,在每天Redis闲时(如凌晨12点)调用bgrewriteaof
    • 这里需要解释一下,为什么开启了主从复制,可以实现数据的热备份,还需要设置持久化呢?因为在一些特殊情况下,主从复制仍然不足以保证数据的安全,例如:
      • masterslave进程同时停止:考虑这样一种场景,如果masterslave在同一个机房,则一次停电事故就可能导致masterslave机器同时关机,Redis进程停止;如果没有持久化,则面临的是数据的完全丢失。
      • master误重启:考虑这样一种场景,master服务因为故障宕掉了,如果系统中有自动拉起机制(即检测到服务停止后重启该服务)将master自动重启,由于没有持久化文件,那么master重启后数据是空的,slave同步数据也变成了空的;如果masterslave都没有持久化,同样会面临数据的完全丢失。需要注意的是,即便是使用了哨兵进行自动的主从切换,也有可能在哨兵轮询到master之前,便被自动拉起机制重启了。因此,应尽量避免“自动拉起机制”和“不做持久化”同时出现。
  • 异地灾备:上述讨论的几种持久化策略,针对的都是一般的系统故障,如进程异常退出、宕机、断电等,这些故障不会损坏硬盘。但是对于一些可能导致硬盘损坏的灾难情况,如火灾地震,就需要进行异地灾备。
    • 例如对于单机的情形,可以定时将RDB文件或重写后的AOF文件,通过scp拷贝到远程机器,如阿里云;对于主从的情形,可以定时在master上执行bgsave,然后将RDB文件拷贝到远程机器,或者在slave上执行bgrewriteaof重写AOF文件后,将AOF文件拷贝到远程机器上。
    • 一般来说,由于RDB文件文件小、恢复快,因此灾难恢复常用RDB文件;异地备份的频率根据数据安全性的需要及其它条件来确定,但最好不要低于一 天一次。

持久化配置方案

  • 企业级的持久化的配置策略

    • save 60 10000:如果你希望尽可能确保说,RDB最多丢1分钟的数据,那么尽量就是每隔1分钟都生成一个快照,低峰期,数据量很少,也没必要 10000->生成RDB,1000->RDB,这个根据你自己的应用和业务的数据量,自己去决定
    • AOF一定要打开。
    • auto-aof-rewrite-percentage 100: 就是当前AOF大小膨胀到超过上次**100%**,上次的两倍
    • auto-aof-rewrite-min-size 64mb: 根据你的数据量来定,16mb32mb
  • 数据备份方案 RDB非常适合做冷备,每次生成之后,就不会再有修改了

    • crontab定时调度脚本去做数据备份
    • 每小时都copy一份rdb的备份,到一个目录中去,仅仅保留最近48小时的备份
    • 每天都保留一份当日的rdb的备份,到一个目录中去,仅仅保留最近1个月的备份
    • 每次copy备份的时候,都把太旧的备份给删了
    • 每天晚上将当前服务器上所有的数据备份,发送一份到远程的云服务上去【crontab】

redis之主从问题处理

主从复制的常用相关配置

  • Slaveof

    slaveof <masterip> <masterport>

    slave实例需要配置该项,指向master的(ip,port)

  • masterauth

    masterauth <master-password>

    如果master实例启用了密码保护,则该配置项需要填master的启动密码;

    如果未启用,需要将该配置项注视掉。

  • slave-serve-stale-data

    指定slavemaster连接中断时的动作。默认为yes,表明slave会继续应答来自client的请求,但这些数据可能已经过期(因为连接中断导致无法从master同步)。若配置为no,则slave除正常应答“INFO”和“SLAVEOF”命令外,其余来自客户端的请求命令均会得到“SYNC with master in progress“的应答,直到该slavemaster连接重建成功或该slave被提升为master

  • slave-read-only

    指定slave是否只读,默认为yes。若配置为no,表示slave是可写的,但写的内容在主从同步完成后会被删除掉。

  • repl-disable-tcp-nodelay

    指定向slave同步数据时,是否禁用socketNO_DELAY选项。若配置为yes,则禁用NO_DELAY,则TCP协议栈会合并小包统一发送,这样可以减少主从节点间的包数量并节省宽带,但会增加数据同步到slave的时间。若配置为no,表明启用NO_DELAY,则TCP协议栈不会延迟小包的发送时间,这样数据同步的延时会减少,但需要更大的宽带。

  • slave-priority

    指定slave的优先级。在不只1个slave存在的部署环境下,当master宕机时,Redis Sentinel 会将priority值最小的slave提升为master。需要注意的是,若该配置项为0,则对应的slave永远不会被Redis Sentinel 自动提升为master

读写分离

复制数据延迟

Redis复制数据的延迟由于异步复制特性是无法避免的,延迟取决于网络宽带和命令阻塞情况,比如刚在主节点写入数据后立刻在从节点上读取可能获取不到。需要业务场景允许短时间内的数据延迟。对于无法容忍大量延迟场景,可以编写外部监控程序监听主从节点的复制偏移量,当延迟较大时触发报警或通知客户端避免读取延迟过高的从节点。

具体实现逻辑:

  • 监控程序定期检查主从节点的偏移量,主节点偏移量在info replicationmaster_repl_offset指标记录,从节点偏移量可以查询主节点的slave0字段的offset指标,它们的差值就是主从节点延迟的字节量。
  • 对于无法容忍大量延迟场景,可以编写外部监控程序监听主从节点的复制偏移量,当延迟较大时触发报警或者通知客户端避免读取延迟过高的从节点,同时从节点的slave-serve-stable-data参数也与此有关,它控制这种情况下从节点的表现,当从库同主机失去连接或者复制正在进行,从机库有两种运行方式。

读取过期数据

当主节点存储大量设置超时的数据时,redis内部需要维护过期数据删除策略,删除策略主要有两种:

  • 惰性删除

    主节点每次处理读取命令时,都会检查健是否超时,如果超时则执行·del命令删除键对象,之后del命令也会异步发给从节点。因为保持复制的一致性,从节点自身永远不会主动删除超时数据。

  • 定时删除

    Redis主节点在内部定时任务会循环采样一定数据量的键,当发现采用的键过期时会执行del命令,之后再同步给从节点。

从节点故障问题

对于从节点的故障问题,需要在客户端维护一个可用从节点可用列表,当从节点故障时,立刻切换到其他从节点或主节点,redis Cluster可以解决这个问题。

配置不一致

主节点和从节点不同,经常导致主节点和从节点的配置不同,并带来问题。

主从配置不一致是一个容易忽视的问题。对于有些配置主从之间是可以不一致,比如:主节点关闭AOF,从节点开启AOF。但对于内存相关的配置必须要一致,比如maxmemory,hash-max-ziplist-entries等参数。

数据丢失:主机和从机有时候发生配置不一致的情况,例如maxmemory不一致。假如主机配置maxmemory为8G,从机设置为4G,这个时候是可以用的,而且不会报错。但如果要做高可用,让从节点变成主节点的时候,就会发现数据已经丢失,而且无法挽回。

规避全量复制

全量复制指的是当slave断开并重启后,runid产生变化而导致需要在master主机里拷贝全部数据。这种拷贝全部数据的过程非常耗资源。

全量复制是不可避免的,例如第一次的全量复制就不可避免,这时我们需要选择小主节点,且maxmemory值不要过大,这样就会比较快。同时选择在低峰值的时候做全量复制。

造成全量复制的原因:

  • 主从机的运行runid不匹配。解释一下,主节点如果重启,runid将会发生变化。如果从节点监控到runid不是同一个,它就会认为你的节点不安全。当发生故障转移的时候,如果主节点发生故障,那么从节点就会变成主节点(哨兵和集群)。
  • 复制缓冲区空间不足,比如默认值为1M,可以部分复制,但如果缓冲区不够大的话,首先需要网络中断,部分复制将无法满足。其次需要增大复制缓冲区配置repl-backlog-size,对网络的缓冲增强。

怎么解决:

  • 在一些场景下,可能希望对主节点进行重启,例如主节点内存碎片率过高,或者希望调整一些只能在启动时调整的参数。如果使用普通的手段重启主节点,会使得runid发生变化,可能导致不必要的全量复制。
  • 为了解决这个问题,Redis提供了debug reload的重启方式:重启后,主节点的runidoffset都不受影响,避免了全量复制。

规避复制风暴

复制风暴是指大量从节点对同一主节点或者对同一台机器的多个主节点短时间内发起全量复制的过程。复制风暴对发起复制的主节点或者机器造成大量开销,导致 CPU、内存、带宽消耗。因此我们应该分析出复制风暴发生的场景,提前采用合理的方式规避。规避方式有如下几个。

单节点复制风暴

当一个主机下面挂了很多个 slave从机的时候,主机 master 挂了,这时 master 主机重启后,因为 runid 发生了变化,所有的 slave 从机都要做一次全量复制。这将引起单节点和单机器的复制风暴,开销会非常大。

解决:

  • 可以采用树状结构降低多个从节点对主节点的消耗。
  • 从节点采用树状树非常有用,网络开销交给位于中间层的从节点,而不必消耗顶层的主节点。但是这种树状结构也带来了运维的复杂性,增加了手动和自动 处理故障转移的难度。

单机器复制风暴

由于 Redis 的单线程架构,通常单台机器会部署多个 Redis 实例。当一台机器(machine)上同时部署多个主节点(master)时,如果每个 master 主机只有一台 slave 从机,那么当机器宕机以后,会产生大量全量复制。这种情况是非常危险的情况,带宽马上会被占用,会导致不可用。

解决:

  • 应该把主节点尽量分散在多台机器上,避免在单台机器上部署过多的主节点。
  • 当主节点所在机器故障后提供故障转移机制,避免机器恢复后进行密集的全量复制。

补充

###########从库##############

#设置该数据库为其他数据库的从数据库

slaveof <masterip> <masterport>

#主从复制中,设置连接master服务器的密码(前提master启用了认证)

masterauth <master-password>

# 当从库同主库失去连接或者复制正在进行,从库有两种运行方式:

# 1) 如果slave-serve-stale-data设置为yes(默认设置),从库会继续相应客户端的请求

# 2) 如果slave-serve-stale-data设置为no,除了INFOSLAVOF命令之外的任何请求都会返回一个错误”SYNC with master in progress

slave-serve-stale-data yes

#当主库发生宕机时候,哨兵会选择优先级最高的一个称为主库,从库优先级配置默认100,数值越小优先级越高

slave-priority 100

#从节点是否只读;默认yes只读,为了保持数据一致性,应保持默认。

slave-read-only yes

########主库配置##############

#在slavemaster同步后(发送psync/sync),后续的同步是否设置成TCP_NODELAY假如设置成yes,则redis会合并小的TCP包从而节省带宽,但会增加同步延迟(40ms),造成masterslave数据不一致假如设置成no,则redis master会立即发送同步数据,没有延迟。

#前者关注性能,后者关注一致性

repl-disable-tcp-nodelay no

#从库会按照一个时间间隔向主库发送PING命令来判断主服务器是否在线,默认是10秒

repl-ping-slave-period 10

#复制积压缓冲区大小设置

repl-backlog-size 1mb

#master没有slave一段时间会释放复制缓冲区的内存,repl-backlog-ttl用来设置该时间长度。单位为秒。

repl-backlog-ttl 3600

#redis提供了可以让master停止写入的方式,如果配置了min-slaves-to-write,健康的slave的个数小于Nmater就禁止写入。master最少得有多少个健康的slave存活才能执行写命令。这个配置虽然不能保证Nslave都一定能接收到master的写操作,但是能避免没有足够健康的slave的时候,master不能写入来避免数据丢失。设置为0是关闭该功能。

min-slaves-to-write 3

min-slaves-max-lag 10

redis主从之全量复制及增量复制

主从注意事项

对于主从复制的好处,在上篇文章我也写了,下面说一下注意事项。

注意事项

  • 安全

    对于数据比较重要的节点,主节点会通过设置requirepass参数进行密码验证,这时候所有的客户端访问必须使用auth命令进行验证。从节点与主节点的复制链接是通过一个特殊标识的客户端来完成。因此需要配置从节点的masterauth参数与主节点密码保持一致,这样从节点才可以正确地链接到主节点并发起复制流程。

  • 从节点只读

    默认情况下slave-read-only=yes配置为只读,由于复制只能从主节点到从节点,对于从节点的任何修改主节点都无法感知,修改从节点会造成主从数据不一致。因此没必要就不要动这个配置。

  • 网络延迟问题

    主从节点一般部署在不同机器上,复制时的网络延迟就成为需要考虑的问题,redis为我们提供了repl-disable-tcp-nodelay参数用于控制是否关闭 tcp nodelay,默认是关闭的,说明如下:

    关闭时,主节点产生的命令数据无论大小都会及时地发送给从节点,这样主从之间延迟将会变小,但增加了网络宽带的消耗。适用于主从之间的网络环境较好的场景。

    开启时,主节点会合并较小的TCP数据包从而节省宽带。默认发送时间间隔取决于Linux的内核,一般默认为40ms。这种配置节省了宽带但增大主从之间的延迟。适用于主从网络环境复杂或宽带紧张的场景。

部署主从节点时需要考虑网络延迟、宽带使用率、防灾级别等因素,如要求低延迟时,建议同机房部署并关闭repl-disable-tcp-nodelay,如考虑容灾性,可以跨机房部署并开启repl-disable-tcp-nodelay

拓扑图

一主一从

1
2
3
4
5

graph TD

A[Redis-master] --> B[Redis-slave]

一主多从

1
2
3
4
5
graph TD

A[Redis-master] --> B[Redis-slave]
A[Redis-master] --> C[Redis-slave]
A[Redis-master] --> D[Redis-slave]

树状主从

1
2
3
4
5
6
graph TD

A[Redis-master] --> B[Redis-slave]
A[Redis-master] --> C[Redis-slave]
B[Redis-slave] --> D[Redis-slave]
B[Redis-slave] --> E[Redis-slave]

原理

1
2
3
4
5
6
7
8
9
10
graph TD

A[slaveof] -->|127.0.0.1:6379| B[slave]
B[slave] --> D[保存主节点信息]
D[保存主节点信息] --> E[主从建立socket连接]
E[主从建立socket连接] --> F[发送ping命令]
F[发送ping命令] --> G[权限验证]
G[权限验证] --> H[同步数据集]
H[同步数据集] --> I[命令持续复制]
I[命令持续复制] --> J[master]

从上图可以看出来大致分为6个过程:

  • 执行slaveof后从节点保存主节点的地址信息便返回,这时候复制流程还没开始。
  • 从节点内部通过每秒运行的定时任务维护复制相关逻辑,当定时任务发现存在新的主节点后,会尝试与该节点建立网络连接,从节点会建立一个socket套接字。
  • 发送ping命令,检测主从之间网络套接字是否可用,检测主节点是否可用接受处理命令。如果发送 ping 命令后,从节点没有收到主节点的 pong 回复或者超时,比如网络超时或者主节点正在阻塞无法响应命令,从节点会断开复制连接,下次定时任务会发起重连。
  • 如果主节点配置了requirepass参数,则需要密码认证,从节点必须配置masterauth参数保证与主节点相同的密码才能通过验证。
  • 主从复制连接正常通信后,对于首次建立复制的场景,主节点会把持有的数据全部发送给从节点,这部分操作是耗时最长的步骤。
  • 当主节点把当前的数据同步给从节点后,便完成了复制的建立流程。接下来主节点会持续地把写命令发送给从节点,保证主从数据一致性。

主从同步的过程中,从节点会把原来的数据清空。

数据同步

同步方式:

  • 全量复制

    用于初次复制或其它无法进行部分复制的情况,将主节点中的所有数据都发送给从节点。当数据量过大的时候,会造成很大的网络开销。

  • 部分复制

    用于处理在主从复制中因网络闪退等原因造成数据丢失场景,当从节点再次连上主节点,如果条件允许,主节点会补发丢失数据给从节点,因为补发的数据远远小于全量数据,可以有效避免全量复制的过高开销。但需要注意,如果网络中断时间过长,造成主节点没有能够完整地保存中断期间执行的写命令,则无法进行部分复制,仍使用全量复制 。

复制偏移量:

  • 参与复制的主从节点都会维护自身复制偏移量,主节点在处理完写入命令操作后,会把命令的字节长度做累加记录,统计信息在info replication中的master_repl_offset指标中。

  • 从节点每秒钟上报自身的复制偏移量给主节点,因此主节点也会保存从节点的复制偏移量slave0:ip=192.168.1.3,port=6379,state=online,offset=116424,lag=0

  • 从节点在接收到主节点发送的命令后,也会累加记录自身的偏移量。统计信息在info replication中的slave_repl_offset中。

复制积压缓冲区:

  • 复制积压缓冲区是保存在主节点上的一个固定长度的队列,默认大小为1MB,当主节点有连接的从节点时被创建,这时主节点响应写命令时,不但会把命令发给从节点,还会写入复制积压缓冲区。
  • 在命令传播阶段,主节点除了将写命令发送给从节点,还会发送一份给复制积压缓冲区,作为写命令的备份;除了存储写命令,复制积压缓冲区中还存储了其中 的每个字节对应的复制偏移量(offset) 。由于复制积压缓冲区定长且先进先出,所以它保存的是主节点最近执行的写命令;时间较早的写命令会被挤出缓冲区。

主节点运行ID:

  • 每个redis节点启动后都会动态分配一个40位的十六进制字符串为运行ID。运行ID的主要作用是来唯一识别redis节点,比如从节点保存主节点的运行ID识别自已正在复制是哪个主节点。如果只使用ip+port的方式识别主节点,那么主节点重启变更了整体数据集(如替换RDB/AOF文件),从节点再基于偏移量复制数据将是不安全的,因此当运行ID变化后从节点将做全量复制。可以在info server命令查看当前节点的运行ID。
  • 需要注意的是redis关闭再启动,运行的id会随之变化。

Psync命令:

  • 从节点使用psync命令完成部分复制和全量复制功能psync runid offset
  • 流程说明:
    • 从节点(slave)发送psync命令给主节点,参数runid是当前从节点保存的主节点运行id,如果没有则默认值为 ?, 参数offset是当前从节点保存的复制偏移量,如果是第一次参与复制则默认值为-1。
    • 主节点根据pysnc参数和自身数据情况决定响应结果:
      • 如果回复+FULLRESYNC {runid} {offset},那么从节点将触发全量复制流程。
      • 如果回复+CONTINUE,从节点将触发部分复制流程。
      • 如果回复-ERR,说明主节点版本低于Redis2.8。

全量复制流程:

  • 发送psync命令进行数据同步,由于是第一次进行复制,从节点没有复制偏移量和主节点的运行id,所以发送psync ? -1
  • 主节点根据psync ? -1解析出当前为全量复制,回复+FULLRESYNC响应(主机会向从机发送 runid 和 offset,因为 slave 并没有对应的 offset,所以是全量复制)
  • 从节点接收主节点的响应数据保存运行ID和偏移量offset(从机 slave 会保存 主机master 的基本信息 save masterInfo)
  • 主节点收到全量复制的命令后,执行bgsave(异步执行),在后台生成RDB文件(快照),并使用一个缓冲区(称为复制缓冲区)记录从现在开始执行的所有写命令
  • 主节点发送RDB文件给从节点,从节点把接收到的RDB文件保存在本地并直接作为从节点的数据文件,接收完RDB后从节点打印相关日志,可以在日志中查看主节点发送的数据量(主机send RDB 发送 RDB 文件给从机)
    • 注意!对于数据量较大的主节点,比如生成的RDB文件超过6GB以上时要格外小心。传输文件这一步操作非常耗时,速度取决于主从节点之间网络带宽。
    • 通过细致分析Full resync和MASTER <-> SLAVE这两行日志的时间差,可以算出RDB文件从创建到传输完毕消耗的总时间。如果总时间超过repl-timeout所配置的值 (默认60秒),从节点将放弃接受RDB文件并清理已经下载的临时文件,导致全量复制失败;针对数据量较大的节点,建议调大repl-timeout参数防止出现全量同步数据超时;
    • 例如对于千兆网卡的机器,网卡带宽理论峰值大约每秒传输100MB,在不考虑其他进程消耗带宽的情况下,6GB的RDB文件至少需要60秒传输时间,默认配置下,极易出现主从数同步超时。
  • 对于从节点开始接收RDB快照到接收完成期间,主节点仍然响应读写命令,因此主节点会把这期间写命令数据保存在复制客户端缓冲区内,当从节点加载完RDB文件后,主节点再把缓冲区内的数据发送给从节点,保证主从之间数据致性。(发送缓冲区数据)
  • 从节点接收完主节点传送来的全部数据后会清空自身旧数据(刷新旧的数据,从节点在载入主节点的数据之前要先将老数据清除)
  • 从节点清空数据后开始加载RDB文件,对于较大的RDB文件,这一步操作依然比较消耗时间,可以通过计算日志之间的实际差来判断加载RDB的总消耗时间(加载 RDB 文件将数据库状态更新至主节点执行bgsave时的数据库状态和缓冲区数据的加载。)
  • 从节点成功加载完RDB后,如果当前节点开启了AOF持久化的功能,它会立刻做bgrewriteeaof的操作,为了保证全量复制后AOF持久化文件立刻可用。 通过分析全量复制的所有流程,全量复制是一个非常耗时费力的操作。他的实际开销主要包括:
    • 主节点bgsave时间
    • RDB文件网络传输时间
    • 从节点清空数据时间
    • 从节点加载RDB的时间
    • 可能的AOF重写时间

部分复制流程:

  • 部分复制是 Redis 2.8 以后出现的,之所以要加入部分复制,是因为全量复制会产生很多问题,比如像上面的时间开销大、无法隔离等问题, Redis 希望能够在主节点出现抖动(相当于断开连接)的时候,可以有一些机制将复制的损失降低到最低

  • 当主从节点之间网络出现中断时,如果超过repl-timeout时间,主节点会认为从节点出问题了并断开复制链接(如果网络抖动(连接断开 connection lost))。

  • 主从连接中断期间主节点依然响应命令,但因复制链接中断命令无法发送给从节点不过主节点内部存在的复制积压缓存去,依然可以保存一段时间的写命令数据,默认最大缓存1MB(主机master 还是会写 replbackbuffer(复制缓冲区))

  • 当主从节点网络恢复后,从节点会再次连上主节点。(从机slave会继续尝试连接主机)

  • 当主从连接恢复后,由于从节点之前保存了自身已复制的偏移量和主节点的运行id。因此会把他们当作psync参数发送给主节点,要求进行部分复制操作。(从机 slave 会把自己当前 runid 和偏移量传输给主机 master,并且执行 pysnc 命令同步)

  • 主节点接到psync命令后首先核对参数的runid,如果 master 发现你的偏移量是在缓冲区的范围内,根据参数offset在缓冲区查找复制内内,如果在偏移量之后的数据存在缓存区中,则对从节点发送continue表示可以进行部分复制

  • 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态。(同步了 offset 的部分数据,所以部分复制的基础就是偏移量 offset)

心跳:

主节点在建立成功后会维护这长连接彼此发送心跳检测

  • 主从节点彼此都有心跳检测机制,各自模拟成对方的客户端进行通信,通过client list命令查看复制相关客户端信息,主节点的连接状态为flags=M,从节点连接状态 flags=S。
  • 主节点默认每隔10秒对从节点发送ping命令,判断从节点的存活性和连接状态。可通过参数repl-ping-slave-period控制发送频率。
  • 从节点在主线程中每隔1秒发送replconf ack {offset} 命令,给主节点上报自身当前的复制偏移量。

缓冲区大小调节:

  • 由于缓冲区长度固定且有限,因此可以备份的写命令也有限,当主从节点offset的差距过大超过缓冲区长度时,将无法执行部分复制,只能执行全量复制。
  • 反过来说,为了提高网络中断时部分复制执行的概率,可以根据需要增大复制积压缓冲区的大小(通过配置repl-backlog-size)来设置;
  • 例如 如果网络中断的平均时间是 60s,而主节点平均每秒产生的写命令(特定协议格式)所占的字节数为100KB,则复制积压缓冲区的平均需求为6MB,保险起见, 可以设置为12MB,来保证绝大多数断线情况都可以使用部分复制。

Go常见架构模式的实现

实现pipe-filter framework

Pipe-Filter 模式:

  • ⾮常适合与数据处理及数据分析系统
  • Filter封装数据处理的功能
  • Pipe⽤于连接Filter传递数据或者在异步处理过程中缓冲数据流
  • 进程内同步调⽤时,pipe演变为数据在⽅法调⽤间传递
  • 松耦合:Filter只跟数据(格式)耦合

Filter和组合模式:

示例:

简单示例代码:

filter.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Package pipefilter is to define the interfaces and the structures for pipe-filter style implementation
package pipefilter

// Request is the input of the filter
type Request interface{}

// Response is the output of the filter
type Response interface{}

// Filter interface is the definition of the data processing components
// Pipe-Filter structure
type Filter interface {
Process(data Request) (Response, error)
}

split_filter.go

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
package pipefilter

import (
"errors"
"strings"
)

var SplitFilterWrongFormatError = errors.New("input data should be string")

type SplitFilter struct {
delimiter string
}

func NewSplitFilter(delimiter string) *SplitFilter {
return &SplitFilter{delimiter}
}

func (sf *SplitFilter) Process(data Request) (Response, error) {
str, ok := data.(string) //检查数据格式/类型,是否可以处理
if !ok {
return nil, SplitFilterWrongFormatError
}
parts := strings.Split(str, sf.delimiter)
return parts, nil
}

split_filter_test.go

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
package pipefilter

import (
"reflect"
"testing"
)

func TestStringSplit(t *testing.T) {
sf := NewSplitFilter(",")
resp, err := sf.Process("1,2,3")
if err != nil {
t.Fatal(err)
}
parts, ok := resp.([]string)
if !ok {
t.Fatalf("Repsonse type is %T, but the expected type is string", parts)
}
if !reflect.DeepEqual(parts, []string{"1", "2", "3"}) {
t.Errorf("Expected value is {\"1\",\"2\",\"3\"}, but actual is %v", parts)
}
}

func TestWrongInput(t *testing.T) {
sf := NewSplitFilter(",")
_, err := sf.Process(123)
if err == nil {
t.Fatal("An error is expected.")
}
}

实现micro-kernel framework

  • 特点

    • 易于扩展
    • 错误隔离
    • 保持架构⼀致性
  • 要点

  • 内核包含公共流程或通⽤逻辑

    • 将可变或可扩展部分规划为扩展点
  • 抽象扩展点⾏为,定义接⼝

    • 利⽤插件进⾏扩展

示例:

简单示例代码:

agent.go

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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
package microkernel

import (
"context"
"errors"
"fmt"
"strings"
"sync"
)

const (
Waiting = iota
Running
)

var WrongStateError = errors.New("can not take the operation in the current state")

type CollectorsError struct {
CollectorErrors []error
}

func (ce CollectorsError) Error() string {
var strs []string
for _, err := range ce.CollectorErrors {
strs = append(strs, err.Error())
}
return strings.Join(strs, ";")
}

type Event struct {
Source string
Content string
}

type EventReceiver interface {
OnEvent(evt Event)
}

type Collector interface {
Init(evtReceiver EventReceiver) error
Start(agtCtx context.Context) error
Stop() error
Destory() error
}

type Agent struct {
collectors map[string]Collector
evtBuf chan Event
cancel context.CancelFunc
ctx context.Context
state int
}

func (agt *Agent) EventProcessGroutine() {
var evtSeg [10]Event
for {
for i := 0; i < 10; i++ {
select {
case evtSeg[i] = <-agt.evtBuf:
case <-agt.ctx.Done():
return
}
}
fmt.Println(evtSeg)
}

}

func NewAgent(sizeEvtBuf int) *Agent {
agt := Agent{
collectors: map[string]Collector{},
evtBuf: make(chan Event, sizeEvtBuf),
state: Waiting,
}

return &agt
}

func (agt *Agent) RegisterCollector(name string, collector Collector) error {
if agt.state != Waiting {
return WrongStateError
}
agt.collectors[name] = collector
return collector.Init(agt)
}

func (agt *Agent) startCollectors() error {
var err error
var errs CollectorsError
var mutex sync.Mutex

for name, collector := range agt.collectors {
go func(name string, collector Collector, ctx context.Context) {
defer func() {
mutex.Unlock()
}()
err = collector.Start(ctx)
mutex.Lock()
if err != nil {
errs.CollectorErrors = append(errs.CollectorErrors,
errors.New(name+":"+err.Error()))
}
}(name, collector, agt.ctx)
}
if len(errs.CollectorErrors) == 0 {
return nil
}
return errs
}

func (agt *Agent) stopCollectors() error {
var err error
var errs CollectorsError
for name, collector := range agt.collectors {
if err = collector.Stop(); err != nil {
errs.CollectorErrors = append(errs.CollectorErrors,
errors.New(name+":"+err.Error()))
}
}
if len(errs.CollectorErrors) == 0 {
return nil
}

return errs
}

func (agt *Agent) destoryCollectors() error {
var err error
var errs CollectorsError
for name, collector := range agt.collectors {
if err = collector.Destory(); err != nil {
errs.CollectorErrors = append(errs.CollectorErrors,
errors.New(name+":"+err.Error()))
}
}
if len(errs.CollectorErrors) == 0 {
return nil
}
return errs
}

func (agt *Agent) Start() error {
if agt.state != Waiting {
return WrongStateError
}
agt.state = Running
agt.ctx, agt.cancel = context.WithCancel(context.Background())
go agt.EventProcessGroutine()
return agt.startCollectors()
}

func (agt *Agent) Stop() error {
if agt.state != Running {
return WrongStateError
}
agt.state = Waiting
agt.cancel()
return agt.stopCollectors()
}

func (agt *Agent) Destory() error {
if agt.state != Waiting {
return WrongStateError
}
return agt.destoryCollectors()
}

func (agt *Agent) OnEvent(evt Event) {
agt.evtBuf <- evt
}

agent_test.go

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
package microkernel

import (
"context"
"errors"
"fmt"
"testing"
"time"
)

type DemoCollector struct {
evtReceiver EventReceiver
agtCtx context.Context
stopChan chan struct{}
name string
content string
}

func NewCollect(name string, content string) *DemoCollector {
return &DemoCollector{
stopChan: make(chan struct{}),
name: name,
content: content,
}
}

func (c *DemoCollector) Init(evtReceiver EventReceiver) error {
fmt.Println("initialize collector", c.name)
c.evtReceiver = evtReceiver
return nil
}

func (c *DemoCollector) Start(agtCtx context.Context) error {
fmt.Println("start collector", c.name)
for {
select {
case <-agtCtx.Done():
c.stopChan <- struct{}{}
break
default:
time.Sleep(time.Millisecond * 50)
c.evtReceiver.OnEvent(Event{c.name, c.content})
}
}
}

func (c *DemoCollector) Stop() error {
fmt.Println("stop collector", c.name)
select {
case <-c.stopChan:
return nil
case <-time.After(time.Second * 1):
return errors.New("failed to stop for timeout")
}
}

func (c *DemoCollector) Destory() error {
fmt.Println(c.name, "released resources.")
return nil
}

func TestAgent(t *testing.T) {
agt := NewAgent(100)
c1 := NewCollect("c1", "1")
c2 := NewCollect("c2", "2")
agt.RegisterCollector("c1", c1)
agt.RegisterCollector("c2", c2)
if err := agt.Start(); err != nil {
fmt.Printf("start error %v\n", err)
}
fmt.Println(agt.Start())
time.Sleep(time.Second * 1)
agt.Stop()
agt.Destory()
}