redis设计与实现读书笔记-多机数据库的实现

前言

经过前两篇读书笔记的整理对redis设计与实现这本书梳理了下,当然我的梳理稍显粗糙,因为很多内容在书上介绍的比较清楚,而这本书就在我手头上,我在笔记中就不再赘述,有资源的最好读原书,看一本好书的时候最直观的感受就是这本书看的很顺畅,津津有味,对很多之前的疑惑有解谜的作用,而不是逼着自己今天看几页,明天看几页,而这本书就是让我感觉比较舒服的一本,接下来这篇重点介绍redis的主从复制,哨兵模式和集群,这里也是很多面试爱问的点.

复制

这里的复制,指的就是我们都懂的主从复制.书中讲述了redis2.8版本之前的复制原理和2.8之后的复制原理,接下来描述中旧版指的就是2.8版本之前的,新版指的就是2.8版本之后的.

旧版复制功能的实现

Redis的复制功能分为同步(sync)和命令传播(command propagate)两个操作:

  • 同步操作将从服务器的数据库状态更新至主服务器当前所处的数据库状态
  • 命令传播操作用于当主服务器数据库状态被修改,导致主从不一致时,使主从数据库重新回到一致状态
同步

当客户端向从服务器发送SLAVEOF命令要求从服务器复制主服务器的时候,从服务器首先执行的就是同步操作.

从服务器会向主服务器发送SYNC命令来完成同步,大致过程如下:

  • 从服务器向主服务器发送SYNC命令
  • 主服务器收到命令后执行BGSAVE命令,在后台生成RDB文件,并使用一个缓冲区来记录从现在开始执行的所有写命令
  • 当主服务器执行完BGSAVE命令之后,主服务器会将生成的RDB文件发送给从服务器,从服务接受并载入文件,使自己数据库状态更新至主服务器执行BGSAVE命令时的数据库状态
  • 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器收到后执行,使自己数据库状态更新至主服务器当前的数据库状态
命令传播

当同步之后,主从服务器处于一致状态,但是当主服务器执行新的写命令之后,两者又不一致了,这时候主服务器会将刚才执行的写命令发送给从服务器让其执行,以使两者重新一致,这个过程就是命令传播

旧版复制功能缺陷

旧版复制分为以下两种情况:

初次复制: 从服务器之前没有复制过现在要复制的这台主服务器

断线后重复制: 处于命令传播阶段的主从服务器因为网络原因中断复制,之后从服务器通过自动重连重新接上了主服务器,并继续复制主服务器

缺陷之处: 对于初次复制,旧版复制有很好的支持,问题就在于断线后复制,在断线后复制的时候,理想的状态是将断线前从服务器目前复制到的位置之后所有的内容进行复制,但是旧版的断线复制,却是重新执行了所有的复制操作,依然是从服务器向主服务器发送SYNC指令,之后主服务器在后台生成对应的RDB文件…,将之前的老路重新走了一遍,这其实非常消耗性能

新版复制功能的实现

为了解决旧版断线重连后复制的低效问题,新版采用了PSYNC命令代替SYNC命令来执行复制时的同步操作.

PSYNC完整重同步部分重同步两种模式:

  • 完整从同步与初次复制的步骤类似
  • 部分重同步就是主服务器只将主从服务器断开这段时间执行的指令发给从服务器执行

可以看到部分重同步的开销比之前旧版的小了很多,实现部分重同步的三个部分如下:

  • 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量
  • 主服务器的复制积压缓冲区(replication backlog)
  • 服务器的运行ID(run ID)
复制偏移量

主服务器和从服务器在执行复制的过程中会分别维护一个复制偏移量:

主服务器每次向从服务器传播N个字节时,会在自己的复制偏移量上加N;而从服务器每次收到从主服务器传播来的N个字节的数据时,也会在自己的复制偏移量上加N

通过对主从复制偏移量的对比,可以判断主从服务器是否处于一致状态:如果复制偏移量相同,说明处于一致状态,否则不一致.

假设断线重连后,从服务器向主服务器发送PSYNC命令,同时汇报自己的复制偏移量,那么主服务器如何判断是该对从服务器进行全部重同步还是部分重同步,如果是部分重同步,又如何判断要传递的数据是哪些呢,这些都和复制积压缓冲区有关

复制积压缓冲区

复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为1MB

当主服务器向从服务器进行命令传播时,它会同时将命令放入复制积压缓冲区,如下图所示

在这里插入图片描述

复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量,如下表:

偏移量 10087 10088 10089 10090 10091 10092 10093 10095 10096 10096
字节值 ‘*’ 3 ‘\r’ ‘\n’ ‘$’ 3 ‘S’ ‘E’ ‘T’

当主从断线重连之后,从服务器向主服务器发送PSUNC命令同时汇报自己的复制偏移量offset之后,主服务器会拿着这个复制偏移量去复制积压缓冲区中查看,如果从该offset开始往后的数据仍然存在,就执行部分重同步,如果已经不存在了,就执行完整重同步操作.

复制积压加缓冲区的大小可以在配置文件中配置:repl-backlog-size

服务器运行ID

除了复制偏移量和复制积压缓冲区之外,实现部分重同步还需要用到服务器运行ID

  • 每个redis服务器,不论主从都有自己的运行ID
  • 运行ID在服务器启动时自动生成,由40个随机的16进制字符组成,如:53b9b28df…

当从服务器对主服务器进行初次复制的时候,主服务器会将自己的运行ID传送给从服务器,从服务器会将这个运行ID保存起来,当断线重连后,从服务器会向主服务器发送这个运行ID,如果与当前主服务器的运行ID相同,则可以由主服务器根据情况判断是否可以执行部分重同步,如果这个ID和当前主服务器的ID不同,那么直接执行完整重同步.

PSYNC命令的实现

PSYNC命令的调用方法有两种情况:

  • 如果从服务器之前没有复制过任何主服务器,或者之前执行过SLAVEOF NO ONE命令,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC ? -1命令,主动请求主服务器进行完整重同步
  • 如果从服务器已经复制过某个主服务器,那么重连后从服务器将向主服务器发送PSYNC runid offset命令,runid是上次复制的主服务器运行id,offset是从服务器的复制偏移量,之后主服务器会根据收到的信息判定是进行部分重同步还是完整重同步

关于复制过程的整体实现过程,可以看原书15.6节-复制的实现

Sentinel哨兵模式

哨兵模式是redis为保证高可用所提供的解决方案,由一个或多个哨兵组成的哨兵系统,监控系统中任意多个主服务器以及这些主服务器下的所有从服务器,当发生故障的时候,比如主服务器挂了,哨兵可以通过选举机制产生新的主服务器并进行故障转移,从而保证可用性

启动并初始化sentinel

启动一个sentinel可以使用命令:

redis-sentinel /path/to/your/sentinel.conf或者命令redis-server /path/to/your/sentinel.conf --sentinel

当一个sentinel启动时,需要执行以下步骤:

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

sentinel本质上只是一个运行在特殊模式下的redis服务器,因为sentinel并不使用数据库,所以初始化sentinel的时候就不会载入RDB文件或者AOF文件

使用sentinel专用代码

该步骤中将一部分普通redis使用的代码替换成sentinel专用代码,特别指出PING SENTINEL INFO SUBSCRIBE UNSUBSCRIBE PSUBSCRIBE 和 PUNSUBSCRIBE这七个命令是客户端可以对sentinel执行的全部命令

初始化sentinel状态

在应用了sentinel专用代码之后,服务器会初始化一个sentinel.c/sentinelState结构(sentinel状态),这个结构保存了服务器所有与sentinel功能有关的状态(服务器的一般状态仍然由redis.h/redisServer结构保存):

struct snetinelState{
    //当前纪元,用于实现故障转移(选举机制会用到)
    uint64_t current_epoch;
    //保存了所有被这个sentinel监视的主服务器
    //字典的键是主服务器的名字,值是一个指向sentinelRedisInstance结构的指针
    dict *masters;
    //是否进入了TILT模式;
    int tilt;
    //目前正在执行的脚本的数量
    int running_scripts;
    //进入TITL模式的时间
    mstime_t titl_start_time;
    //最后一次执行时间处理器的时间
    mstime_t previous_time;
    //一个FIFO队列,包含了所有需要执行的用户脚本
    list *scripts_queue;   
}sentinel
初始化sentinel状态的masters属性

每个sentinelRedisInstance(实例结构)结构代表一个被sentinel监视的redis服务器实例,这个实例可以是主服务器,从服务器或者另外一个sentinel.实例结构包含的属性较多,下面代码展示了作为主服务器使用时用到的一部分属性:

typedef struct sentinelRedisInstance{
    //标识值,记录了实例的类型以及该实例的当前状态
    int flags;
    //实例的名字,主服务的名字由用户在配置文件中配置,从服务器以及sentinel的名字由sentinel自动设置
    //格式为ip:port,比如"127.0.0.1:26379"
    char *name;
    //实例的运行id
    char *runid;
    //配置纪元,用于实现故障转移
    uint64_t config_epoch;
    //实例的地址
    sentinelAddr *addr;
    //SENTINEL down-after-milliseconds选项设定的值
    //实例无响应多少毫秒之后才会被判断为主观下线(subjectively down)
    mstime_t down_after_period;
    //SENTINEL monitor <master-name>  <IP> <port> <quorum>选项中的quorum参数
    //判断这个实例为客观下线(objectively down)所需的支持投票数量
    int quorum;
    //SENTINEL parallel-syncs <master-name> <number>选项的值
    //在执行故障转移时,可以同时对新的主服务器执行同步的从服务器数量
    int parallel_syncs;
    //SENTINEL failover-timeout<master-name> <ms> 选项的值
    //刷新故障迁移状态的最大时限
    mstime_t failover_timeout;
    //...
}sentinelRedisInstance

sentinelRedisInstance.addr属性是一个指向sentinel.c/sentinelAddr结构的指针,这个结构保存着实例的IP地址和端口号:

typedef struct sentinelAddr{
    char *ip;
    int port;
}sentinelAddr

对sentinel状态的初始化将引发对master字典的初始化,masters字典的初始化时根据被载入的sentinel配置文件来进行的.

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

初始化sentinel的最后一步是创建连向被监视主服务器的网络连接,sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关信息.

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

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

获取主服务器信息

sentinel会默认以每十秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并通过分析命令回复来获取主服务器当前的信息,包括:

  • 主服务器本身的信息:runid,role(服务器角色)
  • 主服务器下所有从服务器信息,sentinel无须用户提供从服务器的地址,可以自动根据主服务器的回复获取

获取从服务器信息

同样的,sentinel会默认以每十秒一次的频率,通过命令连接向从服务器发送INFO命令,并通过分析命令回复来获取从服务器当前的信息,包括:

  • 从服务器的运行ID run_id
  • 从服务器的角色role
  • 主服务器的ip地址master_host,以及主服务区的端口号master_port
  • 主从服务器的连接状态master_link_status
  • 从服务器的优先级slave_priority
  • 从服务器的复制偏移量slave_repl_offeset(这个在主服务器挂了,重新选主的时候有用)

向主服务器和从服务器发送信息

默认情况下,sentinel会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:

PUBLISH _sentinel_:hello “<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>”

该命令向服务器的_sentinel_:hello频道发送了一条信息,参数中以s_开头的是 sentinel本身的信息,m_开头的记录的是主服务器的信息,如果此时监控的是主服务器,就是主服务器自己的信息,如果监控的是从服务器,也是从服务器对应主服务器的信息

接收来自主服务器和从服务器的频道信息

当sentinel与一个主服务器或者从服务器建立起连接之后,sentinel就会通过订阅连接,向服务器发送以下命令:

SUBSCRIBE _sentinel_:hello

sentinel对 _sentinel_:hello频道的订阅会一直持续到sentinel与服务器断开为止,也就是说对于每个与sentinel连接的服务器,sentinel既通过命令连接向服务器的_sentinel_:hello 频道发送信息,又通过订阅连接从服务器的该频道接收信息.

用户在使用sentinel的时候不需要提供各个sentinel的地址信息,监视同一个主服务器的多个sentinel可以自动发现对方.

sentinel在连接主服务器或者从服务器的时候会同时创建命令连接和订阅连接,但是在连接其他sentinel的时候只会创建命令连接而不创建订阅连接.

主观下线

所谓主观下线(Subjectively Down, 简称 SDOWN)指的是单个Sentinel实例对服务器做出的下线判断,即单个sentinel认为某个服务下线(有可能是接收不到订阅,之间的网络不通等等原因)。

主观下线就是说如果服务器在down-after-milliseconds给定的毫秒数之内, 没有返回 Sentinel 发送的 PING 命令的回复, 或者返回一个错误, 那么 Sentinel 将这个服务器标记为主观下线(SDOWN )。

sentinel会以每秒一次的频率向所有与其建立了命令连接的实例(master,从服务,其他sentinel)发ping命令,通过判断ping回复是有效回复,还是无效回复来判断实例时候在线(对该sentinel来说是“主观在线”)。
sentinel配置文件中的down-after-milliseconds设置了判断主观下线的时间长度,如果实例在down-after-milliseconds毫秒内,返回的都是无效回复,那么sentinel回认为该实例已**(主观)下线**,修改其flags状态为SRI_S_DOWN。如果多个sentinel监视一个服务,有可能存在多个sentinel的down-after-milliseconds配置不同,这个在实际生产中要注意。

客观下线

客观下线(Objectively Down, 简称 ODOWN)指的是多个 Sentinel 实例在对同一个服务器做出 SDOWN 判断, 并且通过 SENTINEL is-master-down-by-addr 命令互相交流之后, 得出的服务器下线判断,然后开启failover。

客观下线就是说只有在足够数量的 Sentinel 都将一个服务器标记为主观下线之后, 服务器才会被标记为客观下线(ODOWN)。

只有当master被认定为客观下线时,才会发生故障迁移。

当sentinel监视的某个服务主观下线后,sentinel会询问其它监视该服务的sentinel,看它们是否也认为该服务主观下线,接收到足够数量(这个值可以配置)的sentinel判断为主观下线,既认为该服务客观下线,并对其做故障转移操作。

sentinel通过发送 SENTINEL is-master-down-by-addr ip port current_epoch runid,(ip:主观下线的服务ip,port:主观下线的服务端口,current_epoch:sentinel的纪元,runid:*表示检测服务下线状态,如果是sentinel 运行id,表示用来选举领头sentinel)来询问其它sentinel是否同意服务下线。

一个sentinel接收另一个sentinel发来的is-master-down-by-addr后,提取参数,根据ip和端口,检测该服务是否在该sentinel主观下线,并且回复is-master-down-by-addr,回复包含三个参数:down_state(1表示已下线,0表示未下线),leader_runid(领头sentinal id),leader_epoch(领头sentinel纪元)。

sentinel接收到回复后,根据配置设置的下线最小数量,达到这个值,既认为该服务客观下线。

客观下线条件只适用于主服务器: 对于任何其他类型的 Redis 实例, Sentinel 在将它们判断为下线前不需要进行协商, 所以从服务器或者其他 Sentinel 永远不会达到客观下线条件。只要一个 Sentinel 发现某个主服务器进入了客观下线状态, 这个 Sentinel 就可能会被其他 Sentinel 推选出, 并对失效的主服务器执行自动故障迁移操作。

在redis-sentinel的conf文件里有这么两个配置:
1)sentinel monitor <masterName> <ip> <port> <quorum>

四个参数含义:
masterName这个是对某个master+slave组合的一个区分标识(一套sentinel是可以监听多套master+slave这样的组合的)。
ip 和 port 就是master节点的 ip 和 端口号。
quorum这个参数是进行客观下线的一个依据,意思是至少有 quorum 个sentinel主观的认为这个master有故障,才会对这个master进行下线以及故障转移。因为有的时候,某个sentinel节点可能因为自身网络原因,导致无法连接master,而此时master并没有出现故障,所以这就需要多个sentinel都一致认为该master有问题,才可以进行下一步操作,这就保证了公平性和高可用。

2)sentinel down-after-milliseconds ** < masterName > <timeout>
这个配置其实就是进行
主观下线的一个依据**,masterName这个参数不用说了,timeout是一个毫秒值,表示:如果这台sentinel超过timeout这个时间都无法连通master包括slave(slave不需要客观下线,因为不需要故障转移)的话,就会主观认为该master已经下线(实际下线需要客观下线的判断通过才会下线)

那么,多个sentinel之间是如何达到共识的呢?
某个sentinel先将master节点进行主观下线,然后会将这个判定通过sentinel is-master-down-by-addr这个命令问对应的节点是否也同样认为该addr的master节点要做客观下线。最后当达成这一共识的sentinel个数达到前面说的quorum设置的这个值时,就会对该master节点下线进行故障转移。quorum的值一般设置为sentinel个数的二分之一加1,例如3个sentinel就设置2。

选举领头sentinel

一个redis服务被判断为客观下线时,多个监视该服务的sentinel协商,选举一个领头sentinel,对该redis服务进行故障转移操作。选举领头sentinel遵循以下规则:

1)所有的sentinel都有公平被选举成领头的资格。
2)所有的sentinel都有且只有一次将某个sentinel选举成领头的机会(在一轮选举中),一旦选举某个sentinel为领头,不能更改。
3)sentinel设置领头sentinel是先到先得,一旦当前sentinel设置了领头sentinel,以后要求设置其他sentinel为领头请求都会被拒绝。
4)每个发现服务客观下线的sentinel,都会要求其他sentinel将自己设置成领头。
5)当一个sentinel(源sentinel)向另一个sentinel(目标sentinel)发送is-master-down-by-addr ip port current_epoch runid命令的时候,runid参数不是*,而是sentinel运行id,就表示源sentinel要求目标sentinel选举其为领头。
6)源sentinel会检查目标sentinel对其要求设置成领头的回复,如果回复的leader_runid和leader_epoch为源sentinel,表示目标sentinel同意将源sentinel设置成领头。
7)如果某个sentinel被半数以上的sentinel设置成领头,那么该sentinel既为领头。
8)如果在限定时间内,没有选举出领头sentinel,暂定一段时间,再选举。

故障转移

所谓故障转移就是当master宕机,选一个合适的slave来晋升为master的操作,redis-sentinel会自动完成这个,不需要我们手动来实现。

一次故障转移操作大致分为以下流程:
发现主服务器已经进入客观下线状态。
对我们的当前集群进行自增, 并尝试在这个集群中当选。
如果当选失败, 那么在设定的故障迁移超时时间的两倍之后, 重新尝试当选。 如果当选成功, 那么执行以下步骤:

选出一个从服务器,并将它升级为主服务器
向被选中的从服务器发送 SLAVEOF NO ONE 命令,让它转变为主服务器。
通过发布与订阅功能, 将更新后的配置传播给所有其他 Sentinel , 其他 Sentinel 对它们自己的配置进行更新。
向已下线主服务器的从服务器发送 SLAVEOF 命令, 让它们去复制新的主服务器。
当所有从服务器都已经开始复制新的主服务器时, 领头 Sentinel 终止这次故障迁移操作。
每当一个 Redis 实例被重新配置(reconfigured) —— 无论是被设置成主服务器、从服务器、又或者被设置成其他主服务器的从服务器 —— Sentinel 都会向被重新配置的实例发送一个 CONFIG REWRITE 命令, 从而确保这些配置会持久化在硬盘里。

Sentinel 使用以下规则来选择新的主服务器:

  • 在失效主服务器属下的从服务器当中, 那些被标记为主观下线、已断线、或者最后一次回复 PING 命令的时间大于五秒钟的从服务器都会被淘汰。
  • 在失效主服务器属下的从服务器当中, 那些与失效主服务器连接断开的时长超过 down-after 选项指定的时长十倍的从服务器都会被淘汰。
  • 在经历了以上两轮淘汰之后剩下来的从服务器中, 我们选出复制偏移量(replication offset)最大的那个从服务器作为新的主服务器; 如果复制偏移量不可用, 或者从服务器的复制偏移量相同, 那么带有最小运行 ID 的那个从服务器成为新的主服务器。

Sentinel 自动故障迁移的一致性特质

Sentinel 自动故障迁移使用 Raft 算法来选举领头(leader) Sentinel , 从而确保在一个给定的纪元(epoch)里, 只有一个领头产生。

这表示在同一个纪元中, 不会有两个 Sentinel 同时被选中为领头, 并且各个 Sentinel 在同一个纪元中只会对一个领头进行投票。

更高的配置纪元总是优于较低的纪元, 因此每个 Sentinel 都会主动使用更新的纪元来代替自己的配置。

简单来说, 可以将 Sentinel 配置看作是一个带有版本号的状态。 一个状态会以最后写入者胜出(last-write-wins)的方式(也即是,最新的配置总是胜出)传播至所有其他 Sentinel 。

举个例子, 当出现网络分割(network partitions)时, 一个 Sentinel 可能会包含了较旧的配置, 而当这个 Sentinel 接到其他 Sentinel 发来的版本更新的配置时, Sentinel 就会对自己的配置进行更新。

如果要在网络分割出现的情况下仍然保持一致性, 那么应该使用 min-slaves-to-write 选项, 让主服务器在连接的从实例少于给定数量时停止执行写操作, 与此同时, 应该在每个运行 Redis 主服务器或从服务器的机器上运行 Redis Sentinel 进程。

Sentinel 状态的持久化

Sentinel 的状态会被持久化在 Sentinel 配置文件里面。每当 Sentinel 接收到一个新的配置, 或者当领头 Sentinel 为主服务器创建一个新的配置时, 这个配置会与配置纪元一起被保存到磁盘里面。这意味着停止和重启 Sentinel 进程都是安全的。

Sentinel 在非故障迁移的情况下对实例进行重新配置
即使没有自动故障迁移操作在进行, Sentinel 总会尝试将当前的配置设置到被监视的实例上面。 特别是:

根据当前的配置, 如果一个从服务器被宣告为主服务器, 那么它会代替原有的主服务器, 成为新的主服务器, 并且成为原有主服务器的所有从服务器的复制对象。
那些连接了错误主服务器的从服务器会被重新配置, 使得这些从服务器会去复制正确的主服务器。

不过, 在以上这些条件满足之后, Sentinel 在对实例进行重新配置之前仍然会等待一段足够长的时间, 确保可以接收到其他 Sentinel 发来的配置更新, 从而避免自身因为保存了过期的配置而对实例进行了不必要的重新配置。

总结来说,故障转移分为三个步骤:

1)从下线的主服务的所有从服务里面挑选一个从服务,将其转成主服务
sentinel状态数据结构中保存了主服务的所有从服务信息,领头sentinel按照如下的规则从从服务列表中挑选出新的主服务;
删除列表中处于下线状态的从服务;
删除最近5秒没有回复过领头sentinel info信息的从服务;
删除与已下线的主服务断开连接时间超过 down-after-milliseconds*10毫秒的从服务,这样就能保留从的数据比较新(没有过早的与主断开连接);
领头sentinel从剩下的从列表中选择优先级高的,如果优先级一样,选择偏移量最大的(偏移量大说明复制的数据比较新),如果偏移量一样,选择运行id最小的从服务。

2)已下线主服务的所有从服务改为复制新的主服务
挑选出新的主服务之后,领头sentinel 向原主服务的从服务发送 slaveof 新主服务 的命令,复制新master。

3)将已下线的主服务设置成新的主服务的从服务,当其回复正常时,复制新的主服务,变成新的主服务的从服务
同理,当已下线的服务重新上线时,sentinel会向其发送slaveof命令,让其成为新主的从。

温馨提示:还可以向任意sentinel发生sentinel failover 进行手动故障转移,这样就不需要经过上述主客观和选举的过程。

集群模式

Redis Cluster实现在多个节点之间进行数据共享,即使部分节点失效或者无法进行通讯时,Cluster仍然可以继续处理请求。若每个主节点都有一个从节点支持,在主节点下线或者无法与集群的大多数节点进行通讯的情况下, 从节点提升为主节点,并提供服务,保证Cluster正常运行,Redis Cluster的节点分片是通过哈希槽(hash slot)实现的,每个键都属于这 16384(0~16383) 个哈希槽的其中一个,每个节点负责处理一部分哈希槽。

数据sharding

Redis 集群使用数据分片(sharding)而非一致性哈希(consistency hashing)来实现: 一个 Redis 集群包含 16384 个哈希槽(hash slot), 数据库中的每个键都属于这 16384 个哈希槽的其中一个, 集群使用公式 CRC16(key) % 16384 来计算键 key 属于哪个槽, 其中 CRC16(key) 语句用于计算键 key 的 CRC16 校验和 。集群中的每个节点负责处理一部分哈希槽。 举个例子, 一个集群可以有三个节点, 其中:

  • 节点 A 负责处理 0 号至 5500 号哈希槽。
  • 节点 B 负责处理 5501 号至 11000 号哈希槽。
  • 节点 C 负责处理 11001 号至 16384 号哈希槽。

这种将哈希槽分布到不同节点的做法使得用户可以很容易地向集群中添加或者删除节点。 比如说:

  • 如果用户将新节点 D 添加到集群中, 那么集群只需要将节点 A 、B 、 C 中的某些槽移动到节点 D 就可以了。
  • 如果用户要从集群中移除节点 A , 那么集群只需要将节点 A 中的所有哈希槽移动到节点 B 和节点 C , 然后再移除空白(不包含任何哈希槽)的节点 A 就可以了。

因为将一个哈希槽从一个节点移动到另一个节点不会造成节点阻塞, 所以无论是添加新节点还是移除已存在节点, 又或者改变某个节点包含的哈希槽数量, 都不会造成集群下线

集群内部数据结构

Redis Cluster功能涉及三个核心的数据结构clusterState、clusterNode、clusterLink都在cluster.h中定义。这三个数据结构中最重要的属性就是:clusterState.slots、clusterState.slots_to_keys和clusterNode.slots了,它们保存了三种映射关系:

  • clusterState:集群状态
  • nodes:所有结点
  • migrating_slots_to:迁出中的槽
  • importing_slots_from:导入中的槽
  • slots_to_keys:槽中包含的所有Key,用于迁移Slot时获得其包含的Key
  • slots:Slot所属的结点,用于处理请求时判断Key所在Slot是否自己负责
  • clusterNode:结点信息
  • slots:结点负责的所有Slot,用于发送Gossip消息通知其他结点自己负责的Slot。通过位图方式保存节省空间,16384/8恰好是2048字节,所以槽总数16384不能随意定!
  • clusterLink:与其他结点通信的连接

集群状态,每个节点都保存着一个这样的状态,记录了它们眼中的集群的样子。另外,虽然这个结构主要用于记录集群的属性,但是为了节约资源,有些与节点有关的属性,比如 slots_to_keys 、 failover_auth_count 也被放到了这个结构里面。

typedef struct clusterState {
    ...
    //指向当前节点的指针
    clusterNode *myself;  /* This node */
 
    //集群当前的状态:是在线还是下线
    int state;            /* REDIS_CLUSTER_OK, REDIS_CLUSTER_FAIL, ... */
    //集群当前的配置纪元,用于实现故障转移
    uint64_t currentEpoch;
    //集群中至少处理着一个槽的节点的数量
    int size;
 
    //集群节点名单(包括 myself 节点)
    //字典的键为节点的名字,字典的值为 clusterNode 结构
    dict *nodes;          /* Hash table of name -> clusterNode structures */
 
    //记录要从当前节点迁移到目标节点的槽,以及迁移的目标节点
    //migrating_slots_to[i] = NULL 表示槽 i 未被迁移
    //migrating_slots_to[i] = clusterNode_A 表示槽 i 要从本节点迁移至节点 A
    clusterNode *migrating_slots_to[REDIS_CLUSTER_SLOTS];
 
    //记录要从源节点迁移到本节点的槽,以及进行迁移的源节点
    //importing_slots_from[i] = NULL 表示槽 i 未进行导入
    //importing_slots_from[i] = clusterNode_A 表示正从节点 A 中导入槽 i
    clusterNode *importing_slots_from[REDIS_CLUSTER_SLOTS];
 
    //负责处理各个槽的节点
    //例如 slots[i] = clusterNode_A 表示槽 i 由节点 A 处理
    clusterNode *slots[REDIS_CLUSTER_SLOTS];
 
    //跳跃表,表中以槽作为分值,键作为成员,对槽进行有序排序
    //当需要对某些槽进行区间(range)操作时,这个跳跃表可以提供方便
    //具体操作定义在 db.c 里面
    zskiplist *slots_to_keys;
    ...
} clusterState;
 
//节点状态
struct clusterNode {
    ...
    //创建节点的时间
    mstime_t ctime;
    //从节点的名字,由40个十六进制字符组成
    char name[REDIS_CLUSTER_NAMELEN];
    //节点标识
    //使用各种不同的标识值记录节点的角色(比如主节点或者从节点),
    //以及节点目前所处的状态(比如在线或者下线)。
    int flags;      /* REDIS_NODE_... */
    //节点当前的配置纪元,用于实现故障转移
    uint64_t configEpoch;
    //节点的ip地址
    char ip[REDIS_IP_STR_LEN]
    //节点的端口号
    int port;
    //由这个节点负责处理的槽
    //一共有 REDIS_CLUSTER_SLOTS / 8 个字节长
    //每个字节的每个位记录了一个槽的保存状态
    //位的值为 1 表示槽正由本节点处理,值为 0 则表示槽并非本节点处理
    //比如 slots[0] 的第一个位保存了槽 0 的保存情况
    //slots[0] 的第二个位保存了槽 1 的保存情况,以此类推
    unsigned char slots[REDIS_CLUSTER_SLOTS/8]; /* slots handled by this node */
 
    //指针数组,指向各个从节点
    struct clusterNode **slaves; /* pointers to slave nodes */
 
    //如果这是一个从节点,那么指向主节点
    struct clusterNode *slaveof; /* pointer to the master node */
    ...
    //保存连接点所需的有关信息
    clusterLink *link;
};
 
/* clusterLink encapsulates everything needed to talk with a remote node. */
//clusterLink 包含了与其他节点进行通讯所需的全部信息
typedef struct clusterLink {
    ...
    //TCP 套接字描述符
    int fd;                     /* TCP socket file descriptor */
    //输出缓冲区,保存着等待发送给其他节点的消息(message)
    sds sndbuf;
    //输入缓冲区,保存着从其他节点收到的消息
    sds rcvbuf;
 
    //与这个连接相关联的节点,如果没有的话就为 NULL
    struct clusterNode *node;   /* Node related to this link if any, or NULL */
    ...
} clusterLink;

Redis Cluster集群的处理流程

在单机模式下,Redis对请求的处理很简单。Key存在的话,就执行请求中的操作;Key不存在的话,就告诉客户端Key不存在。然而在集群模式下,因为涉及到请求重定向和Slot迁移,所以对请求的处理变得很复杂,流程如下:

  • 检查Key所在Slot是否属于当前Node?
  • 计算crc16(key) % 16384得到Slot
  • 查询clusterState.slots负责Slot的结点指针
  • 与myself指针比较
  • 若不属于,则响应MOVED错误重定向客户端
  • 若属于且Key存在,则直接操作,返回结果给客户端
  • 若Key不存在,检查该Slot是否迁出中?(clusterState.migrating_slots_to)
  • 若Slot迁出中,返回ASK错误重定向客户端到迁移的目的服务器上
  • 若Slot未迁出,检查Slot是否导入中?(clusterState.importing_slots_from)
  • 若Slot导入中且有ASKING标记,则直接操作
  • 否则响应MOVED错误重定向客户端

Redis Cluster容错机制

failover是redis cluster的容错机制,是redis cluster最核心功能之一;它允许在某些节点失效情况下,集群还能正常提供服务。

redis cluster采用主从架构,任何时候只有主节点提供服务,从节点进行热备份,故其容错机制是主从切换机制,即主节点失效后,选取一个从节点作为新的主节点。在实现上也复用了旧版本的主从同步机制。

从纵向看,redis cluster是一层架构,节点分为主节点和从节点。从节点挂掉或失效,不需要进行failover,redis cluster能正常提供服务;主节点挂掉或失效需要进行failover。另外,redis cluster还支持manual failover,即人工进行failover,将从节点变为主节点,即使主节点还活着。下面将介绍这两种类型的failover。

1)主节点失效产生的failover

  • (主)节点失效检测
    一般地,集群中的节点会向其他节点发送PING数据包,同时也总是应答(accept)来自集群连接端口的连接请求,并对接收到的PING数据包进行回复。当一个节点向另一个节点发PING命令,但是目标节点未能在给定的时限(node timeout)内回复时,那么发送命令的节点会将目标节点标记为PFAIL(possible failure)。

    由于节点间的交互总是伴随着信息传播的功能,此时每次当节点对其他节点发送 PING 命令的时候,就会告知目标节点此时集群中已经被标记为PFAIL或者FAIL标记的节点。相应的,当节点接收到其他节点发来的信息时, 它会记下那些被其他节点标记为失效的节点。 这称为失效报告(failure report)。

    如果节点已经将某个节点标记为PFAIL,并且根据节点所收到的失效报告显式,集群中的大部分其他主节点(n/2+1)也认为那个节点进入了失效状态,那么节点会将那个PFAIL节点的状态标记为FAIL。

    一旦某个节点被标记为FAIL,关于这个节点已失效的信息就会被广播到整个集群,所有接收到这条信息的节点都会将失效节点标记为FAIL。

  • 选举主节点
    一旦某个主节点进入 FAIL 状态, 集群变为FAIL状态,同时会触发failover。failover的目的是从从节点中选举出新的主节点,使得集群恢复正常继续提供服务。
    整个主节点选举的过程可分为申请、授权、升级、同步四个阶段:

    • 申请
      新的主节点由原已失效的主节点属下的所有从节点中自行选举产生,从节点的选举遵循以下条件:
      a、这个节点是已下线主节点的从节点;
      b、已下线主节点负责处理的哈希槽数量非空;
      c、主从节点之间的复制连接的断线时长有限,不超过 ( (node-timeout * slave-validity-factor) + repl-ping-slave-period )。

      如果一个从节点满足了以上的所有条件,那么这个从节点将向集群中的其他主节点发送授权请求,询问它们是否允许自己升级为新的主节点。
      从节点发送授权请求的时机会根据各从节点与主节点的数据偏差来进行排序,让偏差小的从节点优先发起授权请求。

    • 授权
      其他主节点会遵信以下三点标准来进行判断:
      a、 发送授权请求的是从节点,而且它所属的主节点处于FAIL状态 ;
      b、 从节点的currentEpoch〉自身的currentEpoch,从节点的configEpoch>=自身保存的该从节点的configEpoch;
      c、 这个从节点处于正常的运行状态,没有被标记为FAIL或PFAIL状态;

    ​ 如果发送授权请求的从节点满足以上标准,那么主节点将同意从节点的升级要求,向从节点返回 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK授权。

    • 升级
      一旦某个从节点在给定的时限内得到大部分主节点(n/2+1)的授权,它就会接管所有由已下线主节点负责处理的哈希槽,并主动向其他节点发送一个PONG数据包,包含以下内容:
      a、 告知其他节点自己现在是主节点了
      b、 告知其他节点自己是一个ROMOTED SLAVE,即已升级的从节点;
      c、告知其他节点都根据自己新的节点属性信息对配置进行相应的更新
    • 同步
      其他节点在接收到ROMOTED SLAVE的告知后,会根据新的主节点对配置进行相应的更新。特别地,其他从节点会将新的主节点设为自己的主节点,从而与新的主节点进行数据同步。
      至此,failover结束,集群恢复正常状态。

此时,如果原主节点恢复正常,但由于其的configEpoch小于其他节点保存的configEpoch(failover了产生较大的configEpoch),故其配置会被更新为最新配置,并将自己设新主节点的从节点。

另外,在failover过程中,如果原主节点恢复正常,failover中止,不会产生新的主节点。

2)Manual Failover
Manual Failover是一种运维功能,允许手动设置从节点为新的主节点,即使主节点还活着。
Manual Failover与上面介绍的Failover流程大都相同,除了下面两点不同:
a)触发机制不同,Manual Failover是通过客户端发送cluster failover触发,而且发送对象只能是从节点;
b)申请条件不同,Manual Failover不需要主节点失效,failover有效时长固定为5秒,而且只有收到命令的从节点才会发起申请。

另外,Manual Failover分force和非force,区别在于:非force需要等从节点完全同步完主节点的数据后才进行failover,保证不丢失数据,在这过程中,原主节点停止写操作;而force不进行进行数据完整同步,直接进行failover。

3)集群状态检测
集群有OK和FAIL两种状态,可以通过CLUSTER INFO命令查看。当集群发生配置变化时, 集群中的每个节点都会对它所知道的节点进行扫描,只要集群中至少有一个哈希槽不可用(即负责该哈希槽的主节点失效),集群就会进入FAIL状态,停止处理任何命令。
另外,当大部分主节点都进入PFAIL状态时,集群也会进入FAIL状态。这是因为要将一个节点从PFAIL状态改变为FAIL状态,必须要有大部分主节点(n/2+1)认可,当集群中的大部分主节点都进入PFAIL时,单凭少数节点是没有办法将一个节点标记为FAIL状态的。 然而集群中的大部分主节点(n/2+1)进入了下线状态,让集群变为FAIL,是为了防止少数存着主节点继续处理用户请求,这解决了出现网络分区时,一个可能被两个主节点负责的哈希槽,同时被用户进行读写操作(通过禁掉其中少数派读写操作,证保只有一个读写操作),造成数据丢失数据问题。
说明:上面n/2+1的n是指集群里有负责哈希槽的主节点个数。

参考文献

Redis Cluster集群知识学习总结

redis系列:集群

高可用Redis:Redis Cluster

redis cluster介绍
Redis哨兵模式(sentinel)学习总结及部署记录(主从复制、读写分离、主从切换)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章