⭐Redis分布式——主从复制、Sentinel、集群彻底吃透⭐(看完这篇万字长文,你的Redis水平将会上升一个层次)

4,140 阅读40分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

一、主从复制

1、简介

主从复制是Redis分布式的基石,也是Redis高可用的保障。在Redis中,被复制的服务器称为主服务器(Master),对主服务器进行复制的服务器称为从服务器(Slave)。

主-从.png
主从复制的配置非常简单,有三种方式(其中IP-主服务器IP地址/PORT-主服务器Redis服务端口):

  1. 配置文件——redis.conf文件中,配置slaveof ip port
  2. 命令——进入Redis客户端执行slaveof ip port
  3. 启动参数—— ./redis-server --slaveof ip port

2、主从复制的演进

Redis的主从复制机制,并不是一开始就像6.x版本一样完善,而是一个版本一个版本迭代而来的。它大体上经过三个版本的迭代:

  • 2.8以前
  • 2.8~4.0
  • 4.0以后

随着版本的增长,Redis主从复制机制逐渐完善;但是他们的本质都是围绕同步(sync)和命令传播(command propagate)两个操作展开:

  • 同步(sync):指的是将从服务器的数据状态更新至主服务器当前的数据状态,主要发生在初始化或后续的全量同步。
  • 命令传播(command propagate):当主服务器的数据状态被修改(写/删除等),主从之间的数据状态不一致时,主服务将发生数据改变的命令传播给从服务器,让主从服务器之间的状态重回一致。

2.1 版本2.8以前

2.1.1 同步

2.8以前的版本,从服务器对主服务器的同步需要从服务器向主服务器发生sync命令来完成:\

2.8版本.png

  1. 从服务器接收到客户端发送的slaveof ip prot命令,从服务器根据ip:port向主服务器创建套接字连接
  2. 套接字成功连接到主服务器后,从服务器会为这个套接字连接关联一个专门用于处理复制工作的文件事件处理器,处理后续的主服务器发送的RDB文件和传播的命令
  3. 开始进行复制,从服务器向主服务器发送sync命令
  4. 主服务器接收到sync命令后,执行bgsave命令,主服务器主进程fork的子进程会生成一个RDB文件,同时将RDB快照产生后的所有写操作记录在缓冲区中
  5. bgsave命令执行完成后,主服务器将生成的RDB文件发送给从服务器,从服务器接收到RDB文件后,首先会清除本身的全部数据,然后载入RDB文件,将自己的数据状态更新成主服务器的RDB文件的数据状态
  6. 主服务器将缓冲区的写命令发送给从服务器,从服务器接收命令,并执行。
  7. 主从复制同步步骤完成

2.1.2 命令传播

当同步工作完成之后,主从之间需要通过命令传播来维持数据状态的一致性。
如下图,当前主从服务器之间完成同步工作之后,主服务接收客户端的DEL K6指令后删除了K6,此时从服务器仍然存在K6,主从数据状态并不一致。为了维持主从服务器状态一致,主服务器会将导致自己数据状态发生改变的命令传播到从服务器执行,当从服务器也执行了相同的命令之后,主从服务器之间的数据状态将会保持一致。\

2.8主从同步+命令传播.png

2.1.3 缺陷

从上面看不出2.8以前版本的主从复制有什么缺陷,这是因为我们还没有考虑网络波动的情况。了解分布式的兄弟们肯定听说过CAP理论,CAP理论是分布式存储系统的基石,在CAP理论中P(partition网络分区)必然存在,Redis主从复制也不例外。当主从服务器之间出现网络故障,导致一段时间内从服务器与主服务器之间无法通信,当从服务器重新连接上主服务器时,如果主服务器在这段时间内数据状态发生了改变,那么主从服务器之间将出现数据状态不一致。
在Redis 2.8以前的主从复制版本中,解决这种数据状态不一致的方式是通过重新发送sync命令来实现。虽然sync能保证主从服务器数据状态一致,但是很明显sync是一个非常消耗资源的操作。

sync命令执行,主从服务器需要占用的资源:

  • 主服务器执行BGSAVE生成RDB文件,会占用大量CPU、磁盘I/O和内存资源
  • 主服务器将生成的RDB文件发送给从服务器,会占用大量网络带宽,
  • 从服务器接收RDB文件并载入,会导致从服务器阻塞,无法提供服务

从上面三点可以看出,sync命令不仅会导致主服务器的响应能力下降,也会导致从服务器在此期间拒绝对外提供服务。

2.2 版本2.8-4.0

2.2.1 改进点

针对2.8以前的版本,Redis在2.8之后对从服务器重连后的数据状态同步进行了改进。改进的方向是减少全量同步(full resynchronizaztion)的发生,尽可能使用增量同步(partial resynchronization)。在2.8版本之后使用psync命令代替了sync命令来执行同步操作,psync命令同时具备全量同步和增量同步的功能:

  • 全量同步与上一版本(sync)一致
  • 增量同步中对于断线重连后的复制,会根据情况采取不同措施;如果条件允许,仍然只发送从服务缺失的部分数据。

2.2.2 psync如何实现

Redis为了实现从服务器断线重连后的增量同步,增加了三个辅助参数:

  • 复制偏移量(replication offset)
  • 积压缓冲区(replication backlog)
  • 服务器运行id(run id)
2.2.2.1 复制偏移量

在主服务器和从服务器内都会维护一个复制偏移量

  • 主服务器向从服务发送数据,传播N个字节的数据,主服务的复制偏移量增加N
  • 从服务器接收主服务器发送的数据,接收N个字节的数据,从服务器的复制偏移量增加N

正常同步的情况如下:\

偏移量.png


通过对比主从服务器之间的复制偏移量是否相等,能够得知主从服务器之间的数据状态是否保持一致。
假设此时A/B正常传播,C从服务器断线,那么将出现如下情况:\

偏移量+断线.png
很明显有了复制偏移量之后,从服务器C断线重连后,主服务器只需要发送从服务器缺少的100字节数据即可。但是主服务器又是如何知道从服务器缺少的是那些数据呢?

2.2.2.2 复制积压缓冲区

复制积压缓冲区是一个固定长度的队列,默认为1MB大小。当主服务器数据状态发生改变,主服务器将数据同步给从服务器的同时会另存一份到复制积压缓冲区中。\

复制积压缓冲区.png
复制积压缓冲区为了能和偏移量进行匹配,它不仅存储了数据内容,还记录了每个字节对应的偏移量:\

复制积压缓冲区+字节值+偏移量.png
当从服务器断线重连后,从服务器通过psync命令将自己的复制偏移量(offset)发送给主服务器,主服务器便可通过这个偏移量来判断进行增量传播还是全量同步。

  • 如果偏移量offset+1的数据仍然在复制积压缓冲区中,那么进行增量同步操作
  • 反之进行全量同步操作,与sync一致

Redis的复制积压缓冲区的大小默认为1MB,如果需要自定义应该如何设置呢?
很明显,我们希望能尽可能的使用增量同步,但是又不希望缓冲区占用过多的内存空间。那么我们可以通过预估Redis从服务断线后重连的时间T,Redis主服务器每秒接收的写命令的内存大小M,来设置复制积压缓冲区的大小S。

S = 2 * M * T

注意这里扩大2倍是为了留有一定的余地,保证绝大部分的断线重连都能采用增量同步。

2.2.2.3 服务器运行 ID

看到这里是不是再想上面已经可以实现断线重连的增量同步了,还要运行ID干嘛?其实还有一种情况没考虑,就是当主服务器宕机后,某台从服务器被选举成为新的主服务器,这种情况我们就通过比较运行ID来区分。

  • 运行ID(run id)是服务器启动时自动生成的40个随机的十六进制字符串,主服务和从服务器均会生成运行ID
  • 当从服务器首次同步主服务器的数据时,主服务器会发送自己的运行ID给从服务器,从服务器会保存在RDB文件中
  • 当从服务器断线重连后,从服务器会向主服务器发送之前保存的主服务器运行ID,如果服务器运行ID匹配,则证明主服务器未发生更改,可以尝试进行增量同步
  • 如果服务器运行ID不匹配,则进行全量同步

2.2.3 完整的psync

完整的psync过程非常的复杂,在2.8-4.0的主从复制版本中已经做到了非常完善。psync命令发送的参数如下:

psync

当从服务器没有复制过任何主服务器(并不是主从第一次复制,因为主服务器可能会变化,而是从服务器第一次全量同步),从服务器将会发送:

psync ? -1

psync.png 一起完整的psync流程如下图:\

一次完整的psync.png

一次完整的psync.png

  1. 从服务器接收到SLAVEOF 127.0.0.1 6379命令
  2. 从服务器返回OK给命令发起方(这里是异步操作,先返回OK,再保存地址和端口信息)
  3. 从服务器将IP地址和端口信息保存到Master Host和Master Port中
  4. 从服务器根据Master Host和Master Port主动向主服务器发起套接字连接,同时从服务将会未这个套接字连接关联一个专门用于文件复制工作的文件事件处理器,用于后续的RDB文件复制等工作
  5. 主服务器接收到从服务器的套接字连接请求,为该请求创建对应的套接字连接之后,并将从服务器看着一个客户端(在主从复制中,主服务器和从服务器之间其实互为客户端和服务端)
  6. 套接字连接建立完成,从服务器主动向主服务发送PING命令,如果在指定的超时时间内主服务器返回PONG,则证明套接字连接可用,否则断开重连
  7. 如果主服务器设置了密码(masterauth),那么从服务器向主服务器发送AUTH masterauth命令,进行身份验证。注意,如果从服务器发送了密码,主服务并未设置密码,此时主服务会发送no password is set错误;如果主服务器需要密码,而从服务器未发送密码,此时主服务器会发送NOAUTH错误;如果密码不匹配,主服务器会发送invalid password错误。
  8. 从服务器向主服务器发送REPLCONF listening-port xxxx(xxxx表示从服务器的端口)。主服务器接收到该命令后会将数据保存起来,当客户端使用INFO replication查询主从信息时能够返回数据
  9. 从服务器发送psync命令,此步骤请查看上图psync的两种情况
  10. 主服务器与从服务器之间互为客户端,进行数据的请求/响应
  11. 主服务器与从服务器之间通过心跳包机制,判断连接是否断开。从服务器每个1秒向主服务器发送命令,REPLCONF ACL offset(从服务器的复制偏移量),该机制可以保证主从之间数据的正确同步,如果偏移量不相等,主服务器将会采取增量/全量同步措施来保证主从之间数据状态一致(增量/全量的选择取决于,offset+1的数据是否仍在复制积压缓冲区中)

2.3 版本4.0

Redis 2.8-4.0版本仍然有一些改进的空间,当主服务器切换时,是否也能进行增量同步呢?因此Redis 4.0版本针对这个问题做了优化处理,psync升级为psync2.0。
psync2.0 抛弃了服务器运行ID,采用了replid和replid2来代替,其中replid存储的是当前主服务器的运行ID,replid2保存的是上一个主服务器运行ID。

  • 复制偏移量(replication offset)
  • 积压缓冲区(replication backlog)
  • 主服务器运行id(replid)
  • 上个主服务器运行id(replid2)

通过replid和replid2我们可以解决主服务器切换时,增量同步的问题:

  • 如果replid等于当前主服务器的运行id,那么判断同步方式增量/全量同步
  • 如果replid不相等,则判断replid2是否相等(是否同属于上一个主服务器的从服务器),如果相等,仍然可以选择增量/全量同步,如果不相等则只能进行全量同步。

二、Sentinel

1、简介

主从复制奠定了Redis分布式的基础,但是普通的主从复制并不能达到高可用的状态。在普通的主从复制模式下,如果主服务器宕机,就只能通过运维人员手动切换主服务器,很显然这种方案并不可取。
针对上述情况,Redis官方推出了可抵抗节点故障的高可用方案——Redis Sentinel(哨兵)。Redis Sentinel(哨兵):由一个或多个Sentinel实例组成的Sentinel系统,它可以监视任意多个主从服务器,当监视的主服务器宕机时,自动下线主服务器,并且择优选取从服务器升级为新的主服务器。

如下示例:当旧Master下线时长超过用户设定的下线时长上限,Sentinel系统就会对旧Master执行故障转移操作,故障转移操作包含三个步骤:

  1. 在Slave中选择数据最新的作为新的Master
  2. 向其他Slave发送新的复制指令,让其他从服务器成为新的Master的Slave
  3. 继续监视旧Master,如果其上线则将旧Master设置为新Master的Slave

sentinel监视主服务器下线.png


本文基于如下资源清单进行开展:

IP地址节点角色端口
192.168.211.104Redis Master/ Sentinel6379/26379
192.168.211.105Redis Slave/ Sentinel6379/26379
192.168.211.106Redis Slave/ Sentinel6379/26379

2、Sentinel初始化与网络连接

Sentinel并没有什么特别神奇的地方,它就是一个更加简单的Redis服务器,在Sentinel启动的时候它会加载不同的命令表和配置文件,因此从本质上来讲Sentinel就是一个拥有较少命令和部分特殊功能的Redis服务。当一个Sentinel启动时它需要经历如下步骤:

  1. 初始化Sentinel服务器
  2. 替换普通Redis代码为Sentinel的专用代码
  3. 初始化Sentinel状态
  4. 根据用户给定的Sentinel配置文件,初始化Sentinel监视的主服务器列表
  5. 创建连接主服务器的网络连接
  6. 根据主服务获取从服务器信息,创建连接从服务器的网络连接
  7. 根据发布/订阅获取Sentinel信息,创建Sentinel之间的网络连接

2.1 初始化Sentinel服务器

Sentinel本质上就是一个Redis服务器,因此启动Sentinel需要启动一个Redis服务器,但是Sentinel并不需要读取RDB/AOF文件来还原数据状态。

2.2 替换普通Redis代码为Sentinel的专用代码

Sentinel用于较少的Redis命令,大部分命令在Sentinel客户端都不支持,并且Sentinel拥有一些特殊的功能,这些需要Sentinel在启动时将Redis服务器使用的代码替换为Sentinel的专用代码。在此期间Sentinel会载入与普通Redis服务器不同的命令表。
Sentinel不支持SET、DBSIZE等命令;保留支持PING、PSUBSCRIBE、SUBSCRIBE、UNSUBSCRIBE、INFO等指令;这些指令在Sentinel工作中提供了保障。

2.3 初始化Sentinel状态

装载Sentinel的特有代码之后,Sentinel会初始化sentinelState结构,该结构用于存储Sentinel相关的状态信息,其中最重要的就是masters字典。

 1struct sentinelState {
 2
 3    //当前纪元,故障转移使用
 4    uint64_t current_epoch; 
 5
 6    // Sentinel监视的主服务器信息 
 7    // key -> 主服务器名称 
 8    // value -> 指向sentinelRedisInstance指针
 9    dict *masters; 
10    // ...
11} sentinel;

2.4 初始化Sentinel监视的主服务器列表

Sentinel监视的主服务器列表保存在sentinelState的masters字典中,当sentinelState创建之后,开始对Sentinel监视的主服务器列表进行初始化。

  • masters的key是主服务的名字
  • masters的value是一个指向sentinelRedisInstance指针

主服务器的名字由我们sentinel.conf配置文件指定,如下主服务器名字为redis-master(我这里是一主二从的配置):

1daemonize yes
2port 26379
3protected-mode no
4dir "/usr/local/soft/redis-6.2.4/sentinel-tmp"
5sentinel monitor redis-master 192.168.211.104 6379 2
6sentinel down-after-milliseconds redis-master 30000
7sentinel failover-timeout redis-master 180000
8sentinel parallel-syncs redis-master 1

sentinelRedisInstance实例保存了Redis服务器的信息(主服务器、从服务器、Sentinel信息都保存在这个实例中)。

 1typedef struct sentinelRedisInstance {
 2
 3    // 标识值,标识当前实例的类型和状态。如SRI_MASTER、SRI_SLVAE、SRI_SENTINEL
 4    int flags;
 5
 6    // 实例名称 主服务器为用户配置实例名称、从服务器和Sentinel为ip:port
 7    char *name;
 8
 9    // 服务器运行ID
10    char *runid;
11
12    //配置纪元,故障转移使用
13    uint64_t config_epoch; 
14
15    // 实例地址
16    sentinelAddr *addr;
17
18    // 实例判断为主观下线的时长 sentinel down-after-milliseconds redis-master 30000
19    mstime_t down_after_period; 
20
21    // 实例判断为客观下线所需支持的投票数 sentinel monitor redis-master 192.168.211.104 6379 2
22    int quorum;
23
24    // 执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量 sentinel parallel-syncs redis-master 1
25    int parallel-syncs;
26
27    // 刷新故障迁移状态的最大时限 sentinel failover-timeout redis-master 180000
28    mstime_t failover_timeout;
29
30    // ...
31} sentinelRedisInstance;

根据上面的一主二从配置将会得到如下实例结构:\

初始实例结构.png

2.5 创建连接主服务器的网络连接

当实例结构初始化完成之后,Sentinel将会开始创建连接Master的网络连接,这一步Sentinel将成为Master的客户端。
Sentinel和Master之间会创建一个命令连接和一个订阅连接:

  • 命令连接用于获取主从信息
  • 订阅连接用于Sentinel之间进行信息广播,每个Sentinel和自己监视的主从服务器之间会订阅sentinel:hello频道(注意Sentinel之间不会创建订阅连接,它们通过订阅sentinel:hello频道来获取其他Sentinel的初始信息)

命令连接和订阅连接.png
Sentinel在创建命令连接完成之后,每隔10秒钟向Master发送一次INFO指令,通过Master的回复信息可以获得两方面的知识:

  • Master本身的信息
  • Master下的Slave信息

主从信息.png

2.6 创建连接从服务器的网络连接

根据主服务获取从服务器信息,Sentinel可以创建到Slave的网络连接,Sentinel和Slave之间也会创建命令连接和订阅连接。\

Slave命令连接和订阅连接.png
当Sentinel和Slave之间创建网络连接之后,Sentinel成为了Slave的客户端,Sentinel也会每隔10秒钟通过INFO指令请求Slave获取服务器信息。
到这一步Sentinel获取到了Master和Slave的相关服务器数据。这其中比较重要的信息如下:

  • 服务器ip和port
  • 服务器运行id run id
  • 服务器角色role
  • 服务器连接状态mater_link_status
  • Slave复制偏移量slave_repl_offset(故障转移中选举新的Master需要使用)
  • Slave优先级slave_priority

此时实例结构信息如下所示:\

从服务器信息.png

2.7 创建Sentinel之间的网络连接

此时是不是还有疑问,Sentinel之间是怎么互相发现对方并且相互通信的,这个就和上面Sentinel与自己监视的主从之间订阅sentinel:hello频道有关了。
Sentinel会与自己监视的所有Master和Slave之间订阅sentinel:hello频道,并且Sentinel每隔2秒钟向sentinel:hello频道发送一条消息,消息内容如下:

PUBLISH sentinel:hello ",,,,,,,"

其中s代码Sentinel,m代表Master;ip表示IP地址,port表示端口、runid表示运行id、epoch表示配置纪元。

多个Sentinel在配置文件中会配置相同的主服务器ip和端口信息,因此多个Sentinel均会订阅sentinel:hello频道,通过频道接收到的信息就可获取到其他Sentinel的ip和port,其中有如下两点需要注意:

  • 如果获取到的runid与Sentinel自己的runid相同,说明消息是自己发布的,直接丢弃
  • 如果不相同,则说明接收到的消息是其他Sentinel发布的,此时需要根据ip和port去更新或新增Sentinel实例数据

Sentinel之间不会创建订阅连接,它们只会创建命令连接:\

sentinel之间的命令连接.png
此时实例结构信息如下所示:\

sentinel服务器信息.png

3、Sentinel工作

Sentinel最主要的工作就是监视Redis服务器,当Master实例超出预设的时限后切换新的Master实例。这其中有很多细节工作,大致分为检测Master是否主观下线、检测Master是否客观下线、选举领头Sentinel、故障转移四个步骤。

3.1 检测Master是否主观下线

Sentinel每隔1秒钟,向sentinelRedisInstance实例中的所有Master、Slave、Sentinel发送PING命令,通过其他服务器的回复来判断其是否仍然在线。​

1sentinel down-after-milliseconds redis-master 30000

在Sentinel的配置文件中,当Sentinel PING的实例在连续down-after-milliseconds配置的时间内返回无效命令,则当前Sentinel认为其主观下线。Sentinel的配置文件中配置的down-after-milliseconds将会对其sentinelRedisInstance实例中的所有Master、Slave、Sentinel都适应。

无效指令指的是+PONG、-LOADING、-MASTERDOWN之外的其他指令,包括无响应

如果当前Sentinel检测到Master处于主观下线状态,那么它将会修改其sentinelRedisInstance的flags为SRI_S_DOWN\

主观下线状态修改.png

3.2 检测Master是否客观下线

当前Sentinel认为其下线只能处于主观下线状态,要想判断当前Master是否客观下线,还需要询问其他Sentinel,并且所有认为Master主观下线或者客观下线的总和需要达到quorum配置的值,当前Sentinel才会将Master标志为客观下线。\

客观下线状态修改.png 当前Sentinel向sentinelRedisInstance实例中的其他Sentinel发送如下命令:

1SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>
  • ip:被判断为主观下线的Master的IP地址
  • port:被判断为主观下线的Master的端口
  • current_epoch:当前sentinel的配置纪元
  • runid:当前sentinel的运行id,runid

current_epoch和runid均用于Sentinel的选举,Master下线之后,需要选举一个领头Sentinel来选举一个新的Master,current_epoch和runid在其中发挥着重要作用,这个后续讲解。

接收到命令的Sentinel,会根据命令中的参数检查主服务器是否下线,检查完成后会返回如下三个参数:

  • down_state:检查结果1代表已下线、0代表未下线
  • leader_runid:返回*代表判断是否下线,返回runid代表选举领头Sentinel
  • leader_epoch:当leader_runid返回runid时,配置纪元会有值,否则一直返回0
  1. 当Sentinel检测到Master处于主观下线时,询问其他Sentinel时会发送current_epoch和runid,此时current_epoch=0,runid=*
  2. 接收到命令的Sentinel返回其判断Master是否下线时down_state = 1/0,leader_runid = *,leader_epoch=0

询问其他sentinel.png

3.3 选举领头Sentinel

down_state返回1,证明接收is-master-down-by-addr命令的Sentinel认为该Master也主观下线了,如果down_state返回1的数量(包括本身)大于等于quorum(配置文件中配置的值),那么Master正式被当前Sentinel标记为客观下线。
此时,Sentinel会再次发送如下指令:

1SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>

此时的runid将不再是0,而是Sentinel自己的运行id(runid)的值,表示当前Sentinel希望接收到is-master-down-by-addr命令的其他Sentinel将其设置为领头Sentinel。这个设置是先到先得的,Sentinel先接收到谁的设置请求,就将谁设置为领头Sentinel。
发送命令的Sentinel会根据其他Sentinel回复的结果来判断自己是否被该Sentinel设置为领头Sentinel,如果Sentinel被其他Sentinel设置为领头Sentinel的数量超过半数Sentinel(这个数量在sentinelRedisInstance的sentinel字典中可以获取),那么Sentinel会认为自己已经成为领头Sentinel,并开始后续故障转移工作(由于需要半数,且每个Sentinel只会设置一个领头Sentinel,那么只会出现一个领头Sentinel,如果没有一个达到领头Sentinel的要求,Sentinel将会重新选举直到领头Sentinel产生为止)。

3.4 故障转移

故障转移将会交给领头sentinel全权负责,领头sentinel需要做如下事情:

  1. 从原先master的slave中,选择最佳的slave作为新的master
  2. 让其他slave成为新的master的slave
  3. 继续监听旧master,如果其上线,则将其设置为新的master的slave

这其中最难的一步是如果选择最佳的新Master,领头Sentinel会做如下清洗和排序工作:

  1. 判断slave是否有下线的,如果有从slave列表中移除
  2. 删除5秒内未响应sentinel的INFO命令的slave
  3. 删除与下线主服务器断线时间超过down_after_milliseconds * 10 的所有从服务器
  4. 根据slave优先级slave_priority,选择优先级最高的slave作为新master
  5. 如果优先级相同,根据slave复制偏移量slave_repl_offset,选择偏移量最大的slave作为新master
  6. 如果偏移量相同,根据slave服务器运行id run id排序,选择run id最小的slave作为新master

新的Master产生后,领头sentinel会向已下线主服务器的其他从服务器(不包括新Master)发送SLAVEOF ip port命令,使其成为新master的slave。

到这里Sentinel的的工作流程就算是结束了,如果新master下线,则循环流程即可!

三、集群

1、简介

Redis集群是Redis提供的分布式数据库方案,集群通过分片(sharding)进行数据共享,Redis集群主要实现了以下目标:

  • 在1000个节点的时候仍能表现得很好并且可扩展性是线性的。
  • 没有合并操作(多个节点不存在相同的键),这样在 Redis 的数据模型中最典型的大数据值中也能有很好的表现。
  • 写入安全,那些与大多数节点相连的客户端所做的写入操作,系统尝试全部都保存下来。但是Redis无法保证数据完全不丢失,异步同步的主从复制无论如何都会存在数据丢失的情况。
  • 可用性,主节点不可用,从节点能替换主节点工作。

关于Redis集群的学习,如果没有任何经验的弟兄们建议先看下这三篇文章(中文系列):
Redis集群教程

redis.cn/topics/clus…

Redis集群规范

redis.cn/topics/clus…

Redis3主3从伪集群部署

blog.csdn.net/qq_41125219…

下文内容依赖下图三主三从结构开展:\

8624ecbdf7f691d78ef48bc5afdb6d18.png
资源清单:

节点IP槽(slot)范围
Master[0]192.168.211.107:6319Slots 0 - 5460
Master[1]192.168.211.107:6329Slots 5461 - 10922
Master[2]192.168.211.107:6339Slots 10923 - 16383
Slave[0]192.168.211.107:6369
Slave[1]192.168.211.107:6349
Slave[2]192.168.211.107:6359

Redis集群.png

2、集群内部

Redis 集群没有使用一致性hash, 而是引入了 哈希槽的概念。Redis 集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽,这种结构很容易添加或者删除节点。集群的每个节点负责一部分hash槽,比如上面资源清单的集群有3个节点,其槽分配如下所示:

  • 节点 Master[0] 包含 0 到 5460 号哈希槽
  • 节点 Master[1] 包含5461 到 10922 号哈希槽
  • 节点 Master[2] 包含10923到 16383 号哈希槽

深入学习Redis集群之前,需要了解集群中Redis实例的内部结构。当某个Redis服务节点通过cluster_enabled配置为yes开启集群模式之后,Redis服务节点不仅会继续使用单机模式下的服务器组件,还会增加custerState、clusterNode、custerLink等结构用于存储集群模式下的特殊数据。

如下三个数据承载对象一定要认真看,尤其是结构中的注释,看完之后集群大体上怎么工作的,心里就有数了,嘿嘿嘿;

2.1 clsuterNode

clsuterNode用于存储节点信息,比如节点的名字、IP地址、端口信息和配置纪元等等,以下代码列出部分非常重要的属性:

 1typedef struct clsuterNode {
 2
 3    // 创建时间
 4    mstime_t ctime;
 5
 6    // 节点名字,由40位随机16进制的字符组成(与sentinel中讲的服务器运行id相同)
 7    char name[REDIS_CLUSTER_NAMELEN];
 8
 9    // 节点标识,可以标识节点的角色和状态
10    // 角色 -> 主节点或从节点 例如:REDIS_NODE_MASTER(主节点) REDIS_NODE_SLAVE(从节点)
11    // 状态 -> 在线或下线 例如:REDIS_NODE_PFAIL(疑似下线) REDIS_NODE_FAIL(下线) 
12    int flags;
13
14    // 节点配置纪元,用于故障转移,与sentinel中用法类似
15    // clusterState中的代表集群的配置纪元
16    unit64_t configEpoch;
17
18    // 节点IP地址
19    char ip[REDIS_IP_STR_LEN];
20
21    // 节点端口
22    int port;
23
24    // 连接节点的信息
25    clusterLink *link;
26
27    // 一个2048字节的二进制位数组
28    // 位数组索引值可能为0或1
29    // 数组索引i位置值为0,代表节点不负责处理槽i
30    // 数组索引i位置值为1,代表节点负责处理槽i
31    unsigned char slots[16384/8];
32
33    // 记录当前节点处理槽的数量总和
34    int numslots;
35
36    // 如果当前节点是从节点
37    // 指向当前从节点的主节点
38    struct clusterNode *slaveof;
39
40    // 如果当前节点是主节点
41    // 正在复制当前主节点的从节点数量
42    int numslaves;
43
44    // 数组——记录正在复制当前主节点的所有从节点
45    struct clusterNode **slaves;
46
47} clsuterNode;

上述代码中可能不太好理解的是slots[16384/8],其实可以简单的理解为一个16384大小的数组,数组索引下标处如果为1表示当前槽属于当前clusterNode处理,如果为0表示不属于当前clusterNode处理。clusterNode能够通过slots来识别,当前节点处理负责处理哪些槽。
初始clsuterNode或者未分配槽的集群中的clsuterNode的slots如下所示:
初始slots[16384_8].png
假设集群如上面我给出的资源清单,此时代表Master[0]的clusterNode的slots如下所示:\

Master[0]的clusterNode的slots.png

2.2 clusterLink

clusterLink是clsuterNode中的一个属性,用于存储连接节点所需的相关信息,比如套接字描述符、输入输出缓冲区等待,以下代码列出部分非常重要的属性:

 1typedef struct clusterState {
 2
 3    // 连接创建时间
 4    mstime_t ctime;
 5
 6    // TCP 套接字描述符
 7    int fd;
 8
 9    // 输出缓冲区,需要发送给其他节点的消息缓存在这里
10    sds sndbuf;
11
12    // 输入缓冲区,接收打其他节点的消息缓存在这里
13    sds rcvbuf;
14
15    // 与当前clsuterNode节点代表的节点建立连接的其他节点保存在这里
16    struct clusterNode *node;
17} clusterState;

2.3 custerState

每个节点都会有一个custerState结构,这个结构中存储了当前集群的全部数据,比如集群状态、集群中的所有节点信息(主节点、从节点)等等,以下代码列出部分非常重要的属性:

 1typedef struct clusterState {
 2
 3    // 当前节点指针,指向一个clusterNode
 4    clusterNode *myself;
 5
 6    // 集群当前配置纪元,用于故障转移,与sentinel中用法类似
 7    unit64_t currentEpoch;
 8
 9    // 集群状态 在线/下线
10    int state;
11
12    // 集群中处理着槽的节点数量总和
13    int size;
14
15    // 集群节点字典,所有clusterNode包括自己
16    dict *node;
17
18    // 集群中所有槽的指派信息
19    clsuterNode *slots[16384];
20
21    // 用于槽的重新分配——记录当前节点正在从其他节点导入的槽
22    clusterNode *importing_slots_from[16384];
23
24    // 用于槽的重新分配——记录当前节点正在迁移至其他节点的槽
25    clusterNode *migrating_slots_to[16384];
26
27    // ...
28
29} clusterState;

在custerState有三个结构需要认真了解的,第一个是slots数组,clusterState中的slots数组与clsuterNode中的slots数组是不一样的,在clusterNode中slots数组记录的是当前clusterNode所负责的槽,而clusterState中的slots数组记录的是整个集群的每个槽由哪个clsuterNode负责,因此集群正常工作的时候clusterState的slots数组每个索引指向负责该槽的clusterNode,集群槽未分配之前指向null。

如图展示资源清单中的集群clusterState中的slots数组与clsuterNode中的slots数组:\

clusterState中Slots数组.png
Redis集群中使用两个slots数组的原因是出于性能的考虑:

  • 当我们需要获取整个集群中clusterNode分别负责什么槽时,只需要查询clusterState中的slots数组即可。如果没有clusterState的slots数组,则需要遍历所有的clusterNode结构,这样显然要慢一些
  • 此外clusterNode中的slots数组也有存在的必要,因为集群中任意一个节点之间需要知道彼此负责的槽,此时节点之间只需要互相传输clusterNode中的slots数组结构就行。

第二个需要认真了解的结构是node字典,该结构虽然简单,但是node字典中存储了所有的clusterNode,这也是Redis集群中的单个节点获取其他主节点、从节点信息的主要位置,因此我们也需要注意一下。
第三个需要认真了解的结构是importing_slots_from[16384]数组和migrating_slots_to[16384],这两个数组在集群重新分片时需要使用,需要重点了解,后面再说吧,这里说的话顺序不太对。

3、集群工作

3.1 槽(slot)如何指派?

Redis集群一共16384个槽,如上资源清单我们在三主三从的集群中,每个主节点负责自己相应的槽,而在上面的三主三从部署的过程中并未看到我指定槽给对应的主节点,这是因为Redis集群自己内部给我们划分了槽,但是如果我们想自己指派槽该如何整呢?
我们可以向节点发送如下命令,将一个或多个槽指派给当前节点负责:

CLUSTER ADDSLOTS

比如我们想把0和1槽指派给Master[0],我们只需要想Master[0]节点发送如下命令即可:

CLUSTER ADDSLOTS 0 1

当节点被指派了槽后,会将clusterNode的slots数组更新,节点会将自己负责处理的槽也就是slots数组通过消息发送给集群中的其他节点,其他节点在接收当消息后会更新对应clusterNode的slots数组以及clusterState的solts数组。

3.2 ADDSLOTS 在Redis集群内部是如何实现的呢?

这个其实也比较简单,当我们向Redis集群中的某个节点发送CLUSTER ADDSLOTS命令时,当前节点首先会通过clusterState中的slots数组来确认指派给当前节点的槽是否没有指派给其他节点,如果已经指派了,那么会直接抛出异常,返回错误给指派的客户端。如果指派给当前节点的所有槽都未指派给其他节点,那么当前节点会将这些槽指派给自己。
指派主要有三个步骤:

  1. 更新clusterState的slots数组,将指定槽slots[i]指向当前clusterNode
  2. 更新clusterNode的slots数组,将指定槽slots[i]处的值更新为1
  3. 向集群中的其他节点发送消息,将clusterNode的slots数组发送给其他节点,其他节点接收到消息后也更新对应的clusterState的slots数组和clusterNode的slots数组

3.3 集群这么多节点,客户端怎么知道请求哪个节点?

在了解这个问题之前先要知道一个点,Redis集群是怎么计算当前这个键属于哪个槽的呢?根据官网的介绍,Redis其实并未使用一致性hash算法,而是将每个请求的key通过CRC16校验后对16384取模来决定放置到哪个槽中。

HASH_SLOT = CRC16(key) mod 16384

此时,当客户端连接向某个节点发送请求时,当前接收到命令的节点首先会通过算法计算出当前key所属的槽i,计算完后当前节点会判断clusterState的槽i是否由自己负责,如果恰好由自己负责那么当前节点就会之间响应客户端的请求,如果不由当前节点负责,则会经历如下步骤:

  1. 节点向客户端返回MOVED重定向错误,MOVED重定向错误中会将计算好的正确处理该key的clusterNode的ip和port返回给客户端
  2. 客户端接收到节点返回的MOVED重定向错误时,会根据ip和port将命令转发给正确的节点,整个处理过程对程序员来说透明,由Redis集群的服务端和客户端共同负责完成。

3.4 如果我想将已经分配给A节点的槽重新分配给B节点,怎么整?

这个问题其实涵括了很多问题,比如移除Redis集群中的某些节点,增加节点等都可以概括为把哈希槽从一个节点移动到另外一个节点。并且Redis集群非常牛逼的一点也在这里,它支持在线(不停机)的分配,也就是官方说集群在线重配置(live reconfiguration )。

在将实现之前先来看下CLUSTER的指令,指令会了操作就会了:

  • CLUSTER ADDSLOTS slot1 [slot2] … [slotN]
  • CLUSTER DELSLOTS slot1 [slot2] … [slotN]
  • CLUSTER SETSLOT slot NODE node
  • CLUSTER SETSLOT slot MIGRATING node
  • CLUSTER SETSLOT slot IMPORTING node

CLUSTER 用于槽分配的指令主要有如上这些,ADDSLOTS 和DELSLOTS主要用于槽的快速指派和快速删除,通常我们在集群刚刚建立的时候进行快速分配的时候才使用。CLUSTER SETSLOT slot NODE node也用于直接给指定的节点指派槽。如果集群已经建立我们通常使用最后两个来重分配,其代表的含义如下所示:

  • 当一个槽被设置为 MIGRATING,原来持有该哈希槽的节点仍会接受所有跟这个哈希槽有关的请求,但只有当查询的键还存在原节点时,原节点会处理该请求,否则这个查询会通过一个 -ASK 重定向(-ASK redirection)转发到迁移的目标节点。
  • 当一个槽被设置为 IMPORTING,只有在接受到 ASKING 命令之后节点才会接受所有查询这个哈希槽的请求。如果客户端一直没有发送 ASKING 命令,那么查询都会通过 -MOVED 重定向错误转发到真正处理这个哈希槽的节点那里。

上面这两句话是不是感觉不太看的懂,这是官方的描述,不太懂的话我来给你通俗的描述,整个流程大致如下步骤:

  1. redis-trib(集群管理软件redis-trib会负责Redis集群的槽分配工作),向目标节点(槽导入节点)发送CLUSTER SETSLOT slot IMPORTING node命令,目标节点会做好从源节点(槽导出节点)导入槽的准备工作。
  2. redis-trib随即向源节点发送CLUSTER SETSLOT slot MIGRATING node命令,源节点会做好槽导出准备工作
  3. redis-trib随即向源节点发送CLUSTER GETKEYSINSLOT slot count命令,源节点接收命令后会返回属于槽slot的键,最多返回count个键
  4. redis-trib会根据源节点返回的键向源节点依次发送MIGRATE ip port key 0 timeout命令,如果key在源节点中,将会迁移至目标节点。
  5. 迁移完成之后,redis-trib会向集群中的某个节点发送CLUSTER SETSLOT slot NODE node命令,节点接收到命令后会更新clusterNode和clusterState结构,然后节点通过消息传播槽的指派信息,至此集群槽迁移工作完成,且集群中的其他节点也更新了新的槽分配信息。

3.5 如果客户端访问的key所属的槽正在迁移怎么办?

优秀的你总会想到这种并发情况,牛皮呀!大佬们!\

u=79087421,2199932123&fm=26&fmt=auto.webp
这个问题官方也考虑了,还记得我们在聊clusterState结构的时候么?importing_slots_from和migrating_slots_to就是用来处理这个问题的。

 1typedef struct clusterState {
 2
 3    // ...
 4
 5    // 用于槽的重新分配——记录当前节点正在从其他节点导入的槽
 6    clusterNode *importing_slots_from[16384];
 7
 8    // 用于槽的重新分配——记录当前节点正在迁移至其他节点的槽
 9    clusterNode *migrating_slots_to[16384];
10
11    // ...
12
13} clusterState;
  • 当节点正在导出某个槽,则会在clusterState中的migrating_slots_to数组对应的下标处设置其指向对应的clusterNode,这个clusterNode会指向导入的节点。
  • 当节点正在导入某个槽,则会在clusterState中的importing_slots_from数组对应的下标处设置其指向对应的clusterNode,这个clusterNode会指向导出的节点。

有了上述两个相互数组,就能判断当前槽是否在迁移了,而且从哪里迁移来,要迁移到哪里去?搞笑不就是这么简单……

此时,回到问题中,如果客户端请求的key刚好属于正在迁移的槽。那么接收到命令的节点首先会尝试在自己的数据库中查找键key,如果这个槽还没迁移完成,且当前key刚好也还没迁移完成,那就直接响应客户端的请求就行。如果该key已经不在了,此时节点会去查询migrating_slots_to数组对应的索引槽,如果索引处的值不为null,而是指向了某个clusterNode结构,那说明这个key已经被迁移到这个clusterNode了。这个时候节点不会继续在处理指令,而是返回ASKING命令,这个命令也会携带导入槽clusterNode对应的ip和port。客户端在接收到ASKING命令之后就需要将请求转向正确的节点了,不过这里有一点需要注意的地方 (因此我放个表情包在这里,方便读者注意)。\

u=1042078930,3579951952&fm=26&fmt=auto.webp
前面说了,当节点发现当前槽不属于自己处理时会返回MOVED指令,那么在迁移中的槽时怎么处理的呢?这个Redis集群是这个玩的。
节点发现槽正在迁移则向客户端返回ASKING命令,客户端会接收到ASKING命令,其中包含了槽迁入的clusterNode的节点ip和port。那么客户端首先会向迁入的clusterNode发送一条ASKING命令,这个命令必须要发目的是告诉当前节点,你要破例处理这次请求,因为这个槽已经迁移到你这里了,你不能直接拒绝我(因此如果Redis未接收到ASKING命令,会直接查询节点的clusterState,而正在迁移中的槽还没有更新到clusterState中,那么只能直接返回MOVED,这样不就会一直循环很多次……),接收到ASKING命令的节点会强制执行一次这个请求(只执行一次,下次再来需要重新提前发送ASKING命令)。

4、集群故障

Redis集群故障比较简单,这个和sentinel中主节点宕机或者在指定最长时间内未响应,重新在从节点中选举新的主节点的方式其实差不多。当然前提是Redis集群中的每个主节点,我们提前设置了从节点,要不就嘿嘿嘿……没戏。其大致步骤如下:

  1. 正常工作的集群,每个节点之间会定期向其他节点发送PING命令,如果接收命令的节点未在规定时间内返回PONG消息 ,当前节点会将接收命令的节点的clusterNode的flags设置为REDIS_NODE_PFAIL,PFAIL并不是下线,而是疑似下线。
  2. 集群节点会通过发送消息的方式来告知其他节点,集群中各个节点的状态信息
  3. 如果集群中半数以上负责处理槽的主节点都将某个主节点设置为疑似下线,那么这个节点将会被标记位下线状态,节点会将接收命令的节点的clusterNode的flags设置为REDIS_NODE_FAIL,FAIL表示已下线
  4. 集群节点通过发送消息的方式来告知其他节点,集群中各个节点的状态信息,此时下线节点的从节点在发现自己的主节点已经被标记为下线状态了,那么是时候挺身而出了
  5. 下线主节点的从节点,会选举出一个从节点作为最新的主节点,执行被选中的节点指向SLAVEOF no one成为新的主节点
  6. 新的主节点会撤销掉原主节点的槽指派,并将这些槽指派修改为自己,也就是修改clusterNode结构和clusterState结构
  7. 新的主节点向集群广播一条PONG指令,其他节点将会知道有新的主节点产生,并更新clusterNode结构和clusterState结构
  8. 新的主节点如果会向原主节点剩余的从节点发送新的SLAVEOF指令,使其成为自己的从节点
  9. 最后新的主节点将会负责原主节点的槽的响应工作

这里我写得非常模糊,如果需要细致挖掘的一定要看这篇文章:

redis.cn/topics/clus…

或者可以看下黄健宏老师的《Redis设计与实现》这本书写得挺好,我也参考了很多内容。