Redis事件模型/主从复制/哨兵模型/集群模式

1. Redis的事件模型

Redis服务器需要处理两类事件:文件事件和时间事件。

1.1 文件事件

Redis服务器通过套接字与客户端进行连接,而文件事件就是服务器对套接字操作的抽象

Redis基于Reactor模式开发了网络事件处理器,由四部分组成:套接字、I/O多路复用程序、文件事件分派器以及事件处理器

  • 套接字:当有一个套接字准备好执行连接应答、写入、读取、关闭等操作时,就会产生一个文件事件(多个套接字就会有多个文件事件产生;

    • 事件类型有AE_READABLE和AE_WRITABLE
      • 如果客户端对套接字执行write或close操作,或者客户端对服务端的监听套接字执行connect操作,那么产生一个AE_READABLE事件。
      • 如果客户端对套接字执行read操作,那么产生一个AE_WRITABLE事件。
      • 如果一个事件既可读又可写,则先处理AE_READABLE事件,再处理AE_WRITABLE事件。
  • I/O多路复用程序:负责监听多个套接字,并向文件事件分派器传送产生事件的套接字。

    • I/O多路复用程序会将产生的所有事件的套接字放在一个队列中,以有序(sequentially)、同步(synchronously)、每次一个的方式向文件事件分派器传送套接字,只有一个套接字的事件处理完成后才会再发下一个:
    • I/O多路复用的功能是evport、epoll、kqueue和select这些常见的I/O多路复用函数的包装。Redis会在编译时自动选择系统中性能最高的I/O多路复用函数。默认实现是epoll。关于I/O多路复用可见本博客文章《详解IO多路复用和其三种模式——select/poll/epoll》
  • 文件事件分派器

    • 接收I/O多路复用程序传来的套接字,根据套接字产生的事件类型,调用相应事件处理器
  • 文件事件处理器

    • 连接应答处理器:acceptTCPhandler
      • 对连接服务器的各个客户端进行应答
      • Redis初始化时,将监听套接字的AE_READABLE事件与该处理器关联
      • 客户端连接服务器时,监听套接字产生AE_READABLE事件,触发该处理器执行。
      • 服务器将会创建一个redisClient结构的实例,并添加进自身的RedisServer结构的clients链表中。
      • 处理器对客户端请求进行应答,并创建客户端套接字,将客户端套接字的AE_READABLE事件与命令请求处理器关联。
    • 命令请求处理器:readQueryFromClient
      • 接收客户端传来的命令请求
      • 客户端成功连接服务器后,连接应答处理器将该客户端套接字的AE_READABLE事件与命令请求处理器关联
      • 客户端向服务器发送命令请求时,客户端套接字产生AE_READABLE事件
      • 命令请求处理器读取命令内容,传给相关程序执行。
    • 命令回复处理器:sendReplyToClient
      • 向客户端返回命令执行结果
      • 服务器有命令执行结果要传送给客户端时,将客户端套接字的AE_WRITABLE事件始终与该处理器关联
      • 客户端准备好接收命令执行结果时,客户端套接字产生AE_WRITABLE事件,触发命令回复处理器执行。
      • 将全部回复写入套接字后,关联解除

1.2 时间事件

一个时间事件包括三要素:

  • id
    • 时间时间的全局唯一表示,新事件id大于旧事件。
  • when
    • 毫秒精度的unix时间戳,记录了时间事件的到达时间。
  • timeproc(时间事件处理器)
    • 时间事件处理器,一个函数,时间事件到达时,服务器调用对应处理器来执行。

时间事件分为两类:

  • 定时事件:让一段程序在指定时间后执行一次。

    • 定时事件的处理器返回值是固定的数值,存在ae.h/AE_NOMORE中,如果一个事件的返回为该值,那么该事件在到达一次后,就会被删除
  • 周期事件:让一段程序每隔一段指定时间就执行一次。

    • 周期事件的处理器返回值是非ae.h/AE_NOMORE的值,这时,返回值会覆写when值。让这个时间过一段时间再次到达,以此类推。

服务器将时间事件都放在一个无序链表中(不是按时间顺序排序,而是按照ID排序,新产生的时间事件放在链表的表头),每次时间事件执行器运行时,processTimeEvents函数遍历整个链表,查找所有已经到达的时间事件,并调用相应的事件处理器。

1.2.1 serverCron函数

时间时间最典型的实例就是serverCron函数,它平均100毫秒执行一次,负责Redis定期对自身资源和状态的调整,包括:

  • 更新服务器的统计信息:时间,内存,数据库占用情况
  • 清理过期键值对
  • 关闭失效的连接
  • 尝试进行aof\rdb持久化操作
  • 对从服务器进行定期同步
  • 集群模式下的定期同步,连接测试

1.3 事件调度与执行

aeProcessEvents函数负责何时处理文件事件、何时处理时间事件,以及花费多久的时间

将aeProcessEvents函数放置在循环中,加上初始化、清理函数,就构成了redis服务器的主函数

文件事件和时间事件是合作关系,服务器会轮流处理这两种事件,并且处理过程中也不会抢占线程。因此时间事件的实际处理时间要比设定的时间晚一些。

2. Redis主从复制

关系数据库通常会使用一个主服务器向多个从服务器发送更新,并使用从服务器来处理所有的读请求,Redis采用了同样方法来实现自己的复制特性。

2.1 旧版复制功能

Redis 2.8以前采用的复制都为旧版复制,主要使用SYNC命令同步复制,SYNC存在很大的缺陷严重消耗主服务器的资源以及大量的网络连接资源。Redis 2.8之后采用PSYNC命令替代SYNC,解决完善这些缺陷,但在介绍新版复制功能之前,必须先介绍旧版复制过程,这样才能更好地形成对比。

复制功能有两种模式,分为同步sync命令传播(command propagate),两个过程配合执行才能实现Redis复制。

  • SYNC命令同步操作
    • 通过从服务器发送到SYNC命令给 主服务器
    • 主服务器 执行BGSAVE命令,在后台生成RDB文件,并从现在开始将所有写命令记录进缓冲区
    • 并发送给 从服务器,同时发送缓冲区保存的所有写命令给 从服务器
    • 从服务器 清空之前数据并执行解释RDB文件,然后执行缓冲区的写命令。
    • 保持数据基本一致(还需要命令传播过程才能保持一致)
  • 命令传播操作:
    • 在同步之后,主服务器 仍然在不断的接受写命令,这会导致好不容易一致的主从状态再次不一致。
    • 通过发送让主从服务器不一致的命令(主服务器接收到的新写命令)给从服务器并执行,让主从服务器的数据库重新回到一致状态。

SYNC命令的缺陷:如果因为网络问题,导致主从断开链接一段时间,那么重新同步的时候, SYNC无法做到断点继续,而是仍要清空之前数据,并重新开始复制操作。

SYNC命令非常消耗资源,原因有三点:

  1. 主服务器执行BGSAVE命令生成RDB文件,这个生成过程会大量消耗主服务器资源(CPU、内存和磁盘I/O资源)

  2. 主服务器需要将自己生成的RBD文件发送给从从服务器,这个发送操作会消耗主从服务器大量的网络资源(带宽与流量)

  3. 接收到RDB文件你的从服务器需要载入RDB文件,载入期间从服务器会因为阻塞而导致没办法处理命令请求。

2.2 新版复制功能

为了解决旧版本中断线情况下SYNC低效问题,在Redis 2.8之后使用PSYNC命令代替SYNC命令执行复制同步操作,自然PSYNC具备完整重同步和部分重同步模式

  • 完整重同步:跟旧版复制基本是一致的,可以理解为“全量”复制。
  • 部分重同步:在命令传播阶段,断线重复制只需要发送主服务器在断开期间执行的写命令给从服务器即可,可以理解为“增量”复制。

2.3 复制的实现

Redis不管是旧版还是新版,复制的实现都可以分为七个步骤,流程图如下:

  1. 设置主服务的地址与端口
    • 当客户端向从服务器发送一下命令时或者在配置文件中配置slaveof选项
    • 127.0.0.1:12345> SLAVEOF 127.0.0.1 6379
  2. 建立套接字连接
    • 从服务器根据设置的套接字创建连向主服务器的套接字连接
    • 主服务器接收从服务器的套接字连接之后,为该套接字创建响应的客户端状态,并将此时的从服务器看做是主服务器的客户端,也就是该从服务器同时具备服务器与客户端两个身份。
  3. 发送PING命令
    • 从服务器成为主服务器的客户端之后,做的第一件事就是向主服务器发送PING命令。PING命令主要有两种作用:
      1. 虽然建立了套接字连接,但是还未使用过,通过发送PING命令检查套接字的读写状态是否正常
      2. 通过发送PING命令检查主服务器能否正常处理命令请求
    • 从服务器在发送PING命令之后将遇到以下三种情况的其中一种:
  4. 身份验证
    • 从服务器接收到主服务器返回的“PONG”回复,接下来就需要考虑身份验证的事。
  5. 发送端口信息
    • 在身份验证步骤之后,从服务器将执行命令REPLCONF listening-port <port>,向主服务器发送从服务器的监听端口号。
  6. 同步
    • 就是上述所指的同步操作,从服务器向主服务器发送PSYNC命令,执行同步操作,值得注意的是开始只有从服务器是主服务器的客户端,但是执行同步操作之后,主服务器也会成为从服务器的客户端。
  7. 命令传播
    • 主从服务器就会进入命令传播阶段,主服务器只要将自己执行的写命令发送给从服务器,而从服务器只要一直执行并接收主服务器发来的写命令(上述已经介绍过,这里不过多介绍)

3. Redis哨兵模型

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

在替换了新的主服务器之后,如果之前下线的主服务器上线了,就会被降为新的主服务器的从服务器。

3.1 Sentinel的启动

1
2
3
$ redis-sentinel /path/to/your/sentinel.conf
或者
$ redis-sentinel /path/to/your/sentinel.conf --sentinel

这两个命令都能启动Sentinel,效果都是一样的。

Sentinel启动后,会有五个步骤:

  1. 初始化服务器

    • Sentinel的本质是一个运行在特殊模式下的Redis服务器,因此启动时必须对其进行初始化,但是由于Sentinel与普通的服务器不同,它的初始化需要执行的操作也不同
    • 下表是Sentinel 模式下Redis服务器的主要功能的使用情况
  2. 使用Sentinel专用代码

    • 启动Sentinel的第二步,就是将普通Redis服务器使用的代码替换成Sentinel专用的代码。
    • 比如 普通Redis服务器使用 redis.h/REDIS_SERVERPORT常量作为服务端口(#define REDIS_SERVERPORT 6379),使用 redis.h/redisCommandTable 作为服务器的命令表。
    • 而Sentinel使用 reids.h/REDIS_SENTINEL_PORT 常量作为服务器端口,默认26379,使用 redis.h/sentinelcmds 作为服务器的命令表
  3. 初始化Sentinel状态

    • 接下来,服务器会初始化一个 sentinel.c/sentinelState 结构(简称“Sentinel状态”),这个结构保存了服务器所有和Sentinel功能有关的状态,服务器的一般状态仍然由 redis.h/redisServer 结构保存:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      typedef struct sentinelState{
      // 当前纪元,用于实现故障转移
      uint64_t current_epoch;
      // 保存了所有被这个 Sentinel监视的主服务器
      // 字典的键是主服务器的名字
      // 字典的值是一个指向 sentinelRedisInstance 结构的指针
      dict *masters;
      // 是否进入了 TILT 模式
      int tilt;
      // 目前正在执行的脚本数量
      int running_scripts;
      // 进入 TILT 模式的时间
      mstime_t tilt_start_time;
      // 最后一次执行事件处理器的时间
      mstime_t previous_time;
      // 一个 FIFO 队列,包含了所有需要执行的用户脚本
      list *scripts_queue;
      }sentinel;
  4. 初始化Sentinel状态的 masters 属性

    • 接下来要做的是将sentinel状态的 masters 属性进行初始化,上面已经说过了,masters 里面保存的是所有被监视的主服务器的信息。master属性是字典,键是主服务器的名字,值是一个指向 sentinelRedisInstance 结构的指针。

    • 我们先介绍一下 sentinelRedisInstance 结构(简称“实例结构”),这个结构代表着一个被Sentinel监视的Redis服务器实例,可以是主服务器、从服务器或者另外一个Sentinel.

      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
      ypedef struct sentinelRedisInstance {
      // 标识值,记录了当前Redis实例的类型和状态
      int flags; /* See SRI_... defines */
      // 实例的名字
      // 主节点的名字由用户在配置文件中设置
      // 从节点以及Sentinel节点的名字由Sentinel自动设置,格式为:ip:port
      char *name; /* Master name from the point of view of this sentinel. */
      //实例的运行 ID
      char *runid; /* Run ID of this instance, or unique ID if is a Sentinel.*/
      //配置纪元,用于实现故障转移
      uint64_t config_epoch; /* Configuration epoch. */
      //实例的地址:ip和port
      sentinelAddr *addr; /* Master host. */
      //实例的连接,有可能是被Sentinel共享的
      instanceLink *link; /* Link to the instance, may be shared for Sentinels. */
      // 最近一次通过 Pub/Sub 发送信息的时间
      mstime_t last_pub_time; /* Last time we sent hello via Pub/Sub. */
      // 只有被Sentinel实例使用
      // 最近一次接收到从Sentinel发送来hello的时间
      mstime_t last_hello_time; /* Only used if SRI_SENTINEL is set. Last time
      we received a hello from this Sentinel
      via Pub/Sub. */
      // 最近一次回复SENTINEL is-master-down的时间
      mstime_t last_master_down_reply_time; /* Time of last reply to
      SENTINEL is-master-down command. */
      // 实例被判断为主观下线的时间
      mstime_t s_down_since_time; /* Subjectively down since time. */
      // 实例被判断为客观下线的时间
      mstime_t o_down_since_time; /* Objectively down since time. */
      // 实例无响应多少毫秒之后才会被判断为主观下线(subjectively down)
      mstime_t down_after_period; /* Consider it down after that period. */
      // 从实例获取INFO命令回复的时间
      mstime_t info_refresh; /* Time at which we received INFO output from it. */

      /* Role and the first time we observed it.
      * This is useful in order to delay replacing what the instance reports
      * with our own configuration. We need to always wait some time in order
      * to give a chance to the leader to report the new configuration before
      * we do silly things. */
      // 实例的角色
      int role_reported;
      // 角色更新的时间
      mstime_t role_reported_time;
      // 最近一次从节点的主节点地址变更的时间
      mstime_t slave_conf_change_time; /* Last time slave master addr changed. */

      /* Master specific. */
      /*----------------------------------主节点特有的属性----------------------------------*/
      // 其他监控相同主节点的Sentinel
      dict *sentinels; /* Other sentinels monitoring the same master. */
      // 如果当前实例是主节点,那么slaves保存着该主节点的所有从节点实例
      // 键是从节点命令,值是从节点服务器对应的sentinelRedisInstance
      dict *slaves; /* Slaves for this master instance. */
      // 判定该主节点客观下线(objectively down)的投票数
      // 由SENTINEL monitor <master-name> <ip> <port> <quorum>配置
      unsigned int quorum;/* Number of sentinels that need to agree on failure. */
      // SENTINEL parallel-syncs <master-name> <number> 选项的值
      // 在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
      int parallel_syncs; /* How many slaves to reconfigure at same time. */
      // 连接主节点和从节点的认证密码
      char *auth_pass; /* Password to use for AUTH against master & slaves. */

      /* Slave specific. */
      /*----------------------------------从节点特有的属性----------------------------------*/
      // 从节点复制操作断开时间
      mstime_t master_link_down_time; /* Slave replication link down time. */
      // 按照INFO命令输出的从节点优先级
      int slave_priority; /* Slave priority according to its INFO output. */
      // 故障转移时,从节点发送SLAVEOF <new>命令的时间
      mstime_t slave_reconf_sent_time; /* Time at which we sent SLAVE OF <new> */
      // 如果当前实例是从节点,那么保存该从节点连接的主节点实例
      struct sentinelRedisInstance *master; /* Master instance if it's slave. */
      // INFO命令的回复中记录的主节点的IP
      char *slave_master_host; /* Master host as reported by INFO */
      // INFO命令的回复中记录的主节点的port
      int slave_master_port; /* Master port as reported by INFO */
      // INFO命令的回复中记录的主从服务器连接的状态
      int slave_master_link_status; /* Master link status as reported by INFO */
      // 从节点复制偏移量
      unsigned long long slave_repl_offset; /* Slave replication offset. */

      /* Failover */
      /*----------------------------------故障转移的属性----------------------------------*/
      // 如果这是一个主节点实例,那么leader保存的是执行故障转移的Sentinel的runid
      // 如果这是一个Sentinel实例,那么leader保存的是当前这个Sentinel实例选举出来的领头的runid
      char *leader; /* If this is a master instance, this is the runid of
      the Sentinel that should perform the failover. If
      this is a Sentinel, this is the runid of the Sentinel
      that this Sentinel voted as leader. */
      // leader字段的纪元
      uint64_t leader_epoch; /* Epoch of the 'leader' field. */
      // 当前执行故障转移的纪元
      uint64_t failover_epoch; /* Epoch of the currently started failover. */
      // 故障转移操作的状态
      int failover_state; /* See SENTINEL_FAILOVER_STATE_* defines. */
      // 故障转移操作状态改变的时间
      mstime_t failover_state_change_time;
      // 最近一次故障转移尝试开始的时间
      mstime_t failover_start_time; /* Last failover attempt start time. */
      // 更新故障转移状态的最大超时时间
      mstime_t failover_timeout; /* Max time to refresh failover state. */
      // 记录故障转移延迟的时间
      mstime_t failover_delay_logged; /* For what failover_start_time value we
      logged the failover delay. */
      // 晋升为新主节点的从节点实例
      struct sentinelRedisInstance *promoted_slave; /* Promoted slave instance. */
      /* Scripts executed to notify admin or reconfigure clients: when they
      * are set to NULL no script is executed. */
      // 通知admin的可执行脚本的地址,如果设置为空,则没有执行的脚本
      char *notification_script;
      // 通知配置的client的可执行脚本的地址,如果设置为空,则没有执行的脚本
      char *client_reconfig_script;
      // 缓存INFO命令的输出
      sds info; /* cached INFO output */
      } sentinelRedisInstance;
    • 其中的 addr 属性是一个指向 sentinel.c/sentinelAddr 结构的指针,这个结构保存实例的IP地址和端口号:

      1
      2
      3
      4
      typedef struct sentinelAddr{
      char *p;
      int port;
      }sentinelAddr;
    • 对Sentinel 状态的初始化将引发对 masters 字典的初始化,而 masters 字典的初始化是根据被载入的Sentinel配置文件来进行的。假设我们有master1和master2,由如下图1的配置文件导入

    • 那么我们得到两个sentinelRedisInstance:

    • 最终sentinelRedisInstance为:

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

    • 这是最后一步啦,这一步是创建连向被监视主服务器的网络连接,Sentinel将成为主服务器的客户端,可以向主服务器发送命令,并从命令回复中获取相关的信息。
    • 每个被Sentinel监视的主服务器,Sentinel会创建两个连向主服务器的异步网络连接:
      1. 命令连接,用于向主服务器发送命令,并接收命令回复
      2. 订阅连接,用于订阅主服务器的__sentinel__:hello频道
    • 3.2 Sentinel与服务器的交互

Sentinel作为一个监视Redis服务器的监控系统,必然需要有如下的权利或者义务:

  1. 要能掌握被自己监视的主服务器和其从服务器的状态信息

    • INFO命令:每十秒一次,通过命令链接向被监视的主服务和从服务器发送INFO命令。分析主服务器的应答得到主服务器的状态信息。
  2. 要有为“与自己监视了相同服务器的其他Sentinel”感知到自己提供便利的义务。

    • 广播频道消息:Sentinel每两秒一次,通过命令链接向所有被自己监视的主服务器和从服务器发送PUBLISH命令,发布自己的一些状态信息到对应主服务器的sentinel:hello频道,以便让其他监视了同一服务器的Sentinel(当然这些Sentinel也订阅了该服务器的sentinel:hello频道)感知到自己的存在,宣誓主权。
  3. 要能感知到“与自己监视了同一服务器的其他Sentinel”的状态信息。

    • 接收频道消息:Sentinel在与主服务器创建订阅链接后就会通过订阅命令来订阅主服务器的sentinel:hello频道。通过订阅链接,Sentinel能接收到该频道上其他Sentinel发布的他们各自的状态信息。从而感知到他们的存在。
    • 创建Sentinel之间的链接:Sentinel A 感知到另一个Sentinel B 时,如果是第一次感知到,那么A会创建连向B的命令链接。当然B也会有一个发现A的过程,所以对于监视相同服务器的Sentinel来说,他们是这样相互关联的。

归纳完毕,现在我们来一一展开介绍:

3.2.1 INFO命令

假设有个主服务器和三个从服务器。

Sentinel 默认每十秒一次,通过命令连接向被监视的主服务器发送 INFO 命令,得到如下信息:

  • 关于服务器本身的信息
    • 包括 run_id 域记录的服务器运行ID,以及 role 域记录的服务器角色
  • 关于主服务器属下的所有从服务器信息
    • 每个从服务器都由一个“slave”字符串开头的行记录,每行的 ip= 域记录了从服务器的IP地址, port= 域记录了从服务器的端口号。根据这些IP地址和端口号,Sentinel无须用户提供从服务器的地址信息,就可以自动发现从服务器。

根据 run_id 域和 role 域的信息,Sentinel将对主服务器的实例结构(sentinelRedisInstance)进行更新。而主服务器返回的从服务器信息,将会被用于更新主服务器实例结构(sentinelRedisInstance)的 slaves 字典(记录了属下从服务器的名单,key为ip:port格式,值指向从服务器的sentinelRedisInstance实例)。

Sentinel 分析 INFO 命令中包含的从服务器信息时,会检查这个从服务器实例结构(sentinelRedisInstance)是否已经存在于主服务器的 slaves 字典: 如果存在,就对从服务器的实例结构进行更新,如果不存在(表明这个从服务器是新发现的从服务器),Sentinel会在 slaves 字典中为这个从服务器创建一个新的实例结构。

当Sentinel发现主服务器有新的服务器出现时,除了会为这个新从服务器创建相应的实例结构之外,还会创建连接到从服务器的命令连接和订阅连接

创建了命令连接之后,每10秒一次向从服务器发送 INFO 命令,依次来维护从服务器的实例结构(sentinelRedisInstance)的状态。

主服务器实例结构的 flags 值为 SRI_MASTER,从服务器是 SRI_SLAVE

3.2.2 广播频道消息

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

1
2
PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,
<m_name>,<m_ip>,<m_port>,<m_epoch>"

这条命令就表示向服务器的 __sentinel__:hello 频道发送一条信息,信息由一下部分组成:

  • 以 s_ 开头的参数记录Sentinel本身的信息
  • 以 m_ 开头的参数则是该频道所属的主服务器的信息,当然如果监视的是从服务器,这个信息表示的就是所属的从服务器的信息
  • 具体含义如下图

3.2.3 接收频道消息

在建立起订阅连接之后,Sentinel会通过这个连接,向服务器发送SUBSCRIBE __sentinel__:hello命令,也就是订阅这个频道,这个订阅关系会一直持续到Sentinel与服务器的连接断开之后。

对于监视同一服务器的多个Sentinel来说,一个Sentinel发送的信息会被其他的Sentinel接收到,并用于更新其他Sentinel对发送信息Sentinel的认知,和被用于更新其他Sentinel对被监视服务器的认知。

假如该Sentinel A从其监控的主服务器M__sentinel__:hello频道中,接收到其他Sentinel B发来的<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>格式的信息后,Sentinel A会从信息中分析出以下信息:

  • 与Sentinel相关的参数:Sentinel B的IP、port、run_id、配置纪元
  • 与主服务器相关参数:Sentinel B 正在监视的这个主服务器(也就是主服务器M)的名字、IP、port、配置纪元

服务器实例结构(sentinelRedisInstance)中除了slave字典外,还有一个sentinels字典,存放着其他共同监控着这个主服务器的sentinels的状态信息。这个字典的键是Sentinel的名字,格式:ip:port。值是对应Sentinel的实例结构(还是sentinelRedisInstance结构)。

根据之前那些主服务器参数,Sentinel A 会在自己的Sentinel状态(sentinelState)的 masters 字典中查找相应的主服务器实例结构(sentinelRedisInstance),然后根据Sentinel参数,检查主服务器实例结构的 sentinels 字典中,Sentinel B的实例结构是否存在:

  • 存在,就对Sentinel B的实例结构进行更新
  • 存在,说明Sentinel B是才开始监视主服务器的新Sentinel,Sentinel A 会为Sentinel B创建一个新的实例结构,并将这个结构添加到 sentinels 字典里面

3.2.4 创建Sentinel之间的链接

当Sentinel通过频道信息发现了一个新的Sentinel时,它不仅会为新的Sentinel在 sentinels 字典中创建相应的实例结构,还会创建一个连向新Sentinel的命令连接。

新的Sentinel同样会创建连向这个Sentinel的命令连接,最终监视同一主服务器的多个Sentinel将形成相互连接的网络:SentinelA有连向SentinelB的命令连接,SentinelB也有连向SentinelA的命令连接。

Sentinel之间不会创建订阅连接

3.3 监控下线和故障转移

了解了Sentinel与服务器/其他Sentinel的交互方式后,就可以来着手解决实际问题了,Sentinel的使命主要有两点:

  • 监控下线
  • 选举领头sentinel
  • 故障转移

3.3.1 监控下线

3.3.1.1 检测主观下线状态

主观下线状态,即单个sentinel认为某个主服务器/从服务器/sentinel下线了,至于是不是真的已经下线,则不一定。

  • 默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送 PING 命令,并通过实例返回的 PING 命令回复来判断实例是否在线。
  • PING 命令的回复,Redis只认两种含义:
    • 有效回复:实例返回 +PONG 、 -LOADING 、-MASTERDOWN 三种其中一种
    • 无效回复,除了上面三种之外的其它回复,或者在指定时限内没有返回任何回复
  • Sentinel配置文件中的 down-after-millseconds 选项指定了Sentinel判断实例进入主观下线所需的时间长度:如果一个实例在 down-after-millseconds 毫秒内,连续向Sentinel返回无效回复,那么Sentinel会修改这个实例所对应的实例结构,在结构的 flags 属性中打上 SRI_S_DOWN 标识,用于表示这个实例已经进入主观下线状态。(也就是我认为你已经下线了)

主观下线时长选项,即 down-after-millseconds 的值,不仅会被Sentinel用于判断其监控的主服务器的主观下线状态,还会被用于判断该主服务器属下的所有从服务器,以及所有同样监视这个主服务器的其他Sentinel的主观下线状态。

多个Sentinel设置的主观下线时长可能不同,对于监视同一个主服务器的多个Sentinel来说,这些Sentinel设置的 down-after-milliseconds 选项的值可能不同,因此,当一个Sentinel将主服务器判断为主观下线时,其它Sentinel可能任然会认为主服务器处于在线状态。

3.3.1.2 检测客观下线状态

客观下线状态,即经过确认后,可断定为事实上确实下线了。

当Sentinel将一个主服务器判断为主观下线之后,为确定这个服务器是否真的下线,它会向同样监视这个主服务器的其它Sentinel进行询问,当接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器进行故障转移操作。

  • 发送 SENTINEL is-master-down-by-addr 命令
    • entinel会发送下面的命令询问其它Sentinel是否同意主服务器下线:
    • SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>
  • 接收 SENTINEL is-master-down-by-addr 命令
    • 当一个Sentinel(目标Sentinel)接收到另外一个Sentinel(源Sentinel)发来的 SENTINEL is-master-by-addr命令时,目标Sentinel会分析并取出命令请求中包含的各个参数,并根据其中的IP和port,判断主服务器是否已经下线,然后向源Sentinel返回一个包含三个参数的 Multi Bulk 回复作为这个命令的回复。这三个参数分别是:
      1. :返回目标Sentinel对主服务器的检查结果,1表示主服务器已下线,0表示主服务器未下线
      2. :可以是 * 符号或者目标Sentinel的局部领头Sentinel的运行ID,*表示命令仅仅用于检测主服务器的下线状态,而局部领头Sentinel的运行ID则用于选举领头Sentinel
      3. :目标Sentinel的局部领头Sentinel的配置纪元,用于选举领头Sentinel。仅在 leader_runid 值不为 * 时有效,如果其值为 * ,这个参数总为0
  • 接收 SENTINEL is-master-down-by-addr 命令之后
    • 根据其他Sentinel发回的 SENTINEL is-master-down-by-addr回复,Sentinel会统计反馈了“同意这个主服务器已经下线”这个信息的sentinel数量。
    • 当这个值达到配置指定的判断客观下线所需的数量时(即 quorum 属性的值),Sentinel会将主服务器实例结构中(sentinelRedisInstance) flags 属性的 SRI_O_DOWN 标识打开,标识主服务器已经进入客观下线状态。

3.3.2 选举领头sentinel

当一个Master服务器客观下线后,监控这个Master服务器的所有Sentinel将会选举出一个领头Sentinel。并由领头Sentinel对客观下线的Master进行故障转移。

选举领头Sentinel的规则和方法:

  1. 所有监控客观下线Master的Sentinel都有可能成为领头Sentinel。每次进行领头Sentinel选举之后,不论是否选举成功,所有Sentinel的配置纪元(configuration epoch)的值都会自动增加一次

  2. 在一个配置纪元里面,所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,并且局部领头Sentinel一旦设置,在这个配置纪元里面将不能再更改

  3. 监视Master客观下线的所有在线Sentinel都有要求其它Sentinel将自己设置为局部领头Sentinel的机会。

  4. 当一个Sentinel(源Sentinel)向另一个Sentinel(目标Sentinel)发送SENTINEL is-master-down-by-addr命令,并且命令中的runid参数不是“*”符号而是当前Sentinel的运行ID时,这表示当前Sentinel要求目标Sentinel将自己设置为领头Sentinel

  5. Sentinel设置局部领头Sentinel的规则是先到先得。即最先向目标Sentinel发送设置要求的Sentinel将会成为局部领头Sentinel,之后接受到的请求都会被拒绝

  6. 目标Sentinel接收到SENTINEL is-master-down-by-addr命令后,将向源Sentinel返回一条命令回复,回复中的leader_runid参数和leader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元

  7. 源Sentinel在接收到目标Sentinel返回的命令回复之后,会检查回复中leader_epoch参数的值和自己的配置纪元是否相同,如果相同的话,那么源Sentinel继续取出回复中的leader_runid参数,如果leader_runid参数的值和源Sentinel的运行ID一直,那么表示目标Sentinel将源Sentinel设置成了局部领头Sentinel,记录下来

  8. 记录之后,如果有某个Sentinel发现自己已经被半数以上的Sentinel设置成了局部领头Sentinel,那么这个Sentinel就会成为领头Sentinel。

  9. 领头Sentinel的产生需要半数以上的Sentinel支持,并且每个Sentinel在每个配置纪元里面只能设置一次局部Sentinel,所以在一个配置纪元里面,只会出现一个领头Sentinel。

  10. 如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举,直到选出领头Sentinel为止,(所以建议哨兵设置奇数个,且数量不小于3)。

3.3.3 故障转移

接收到SENTINEL is-master-down-by-addr命令回复的源Sentinel可以统计出有多少个Sentinel将自己设置成局部领头Sentinel。如果超过半数,则当前Sentinel就会被选为领头Sentinel并进行故障转移。

故障转移包括以下三步:

  1. 在已下线的Master主机下面挑选一个他的Slave服务器,并将其转换为主服务器。
  2. 其余所有Slave服务器复制新的Master服务器。
  3. 让已下线的Master服务器变成新的Master服务器的Slave。当已下线的服务器再次上线后将复新的Master的数据。

3.3.3.1 选举新的主服务器的过程

领头Sentinel会在所有Slave中选出新的Master,发送SLAVEOF no one命令,将这个服务器确定为主服务器。

领头Sentinel会将已下线Master的所有从服务器保存在一个列表中,按照以下规则,一项一项进行过滤

  1. 删除列表中所有处于下线或者短线状态的Slave。(保证剩下都是在线的)

  2. 删除列表中所有最近5s内没有回复过领头Sentinel的INFO命令的Slave。(保证剩下都是近期成功通信过的)

  3. 删除所有与下线Master连接断开超过down-after-milliseconds * 10毫秒的Slave。(过滤掉过早的和下线Master断开连接的,这样可以保证剩下的Slave,数据都比较新)

  4. 领头Sentinel将根据Slave优先级,对列表中剩余的Slave进行排序,并选出其中优先级最高的Slave。

  5. 如果有多个具有相同优先级的Slave,那么领头Sentinel将按照Slave复制偏移量,选出其中偏移量最大的Slave。(复制偏移量的slave就是保存的最新数据的slave)

  6. 如果有多个优先级最高,偏移量最大的Slave,那么根据运行ID最小原则选出新的Master。

确定新的Master之后,领头Sentinel会以每秒一次的频率向新的Master发送INFO命令,当得到确切的回复:role由slave变为master之后,当前服务器顺利升级为Master服务器。

3.3.3.2 修改从服务器的复制目标

选出新的Master服务器后,领头Sentinel会向下线Master的剩余Slave发送SLAVEOF命令,让它们复制新的Master。

3.3.3.3 将旧的Master变成Slave

当已下线的Master重新上线后,领头Sentinel会向此服务器发送SLAVEOF命令,将当前服务器变成新的Master的Slave。

4. 集群模式

Redis集群是Redis的分布式数据库方案,通过分片来进行数据共享,并提供复制和故障转移功能。集群为Redis提供了更加便利的水平拓展能力,是现代企业级Redis实现高吞吐高并发的重要实现。

  • 集群模式和主从模式的区别:
    • 主从模式
      • 指的是针对多台redis实例时候,只存在一台主服务器master,提供读写的功能,同时存在依附在这台主服务器的从服务器slaver,只提供读服务。
      • 主从作用是:读写分离,分散访问量,提高访问可读性,同时保证数据的冗余和备份。
    • 集群模式
      • 指的是针对多个redis实例,去中心化,去中间件,集群中的每个节点都是平等的关系,都是对等的。
      • 集群的作用是:实现扩容、分摊压力、无中心配置相对简单。

4.1 节点

节点,指的就是我们之前说的Redis服务器

一个Redis 集群通常由多个节点组成,在刚开始的时候,每个节点都是独立的,只处于只包含自己的集群中(也就是之前我们说到的单机模式),当要组成一个真正可工作的集群时,就需要将这些独立的节点连接起来,构建成一个包含多个节点的集群。

如何连接各个节点?使用CLUSTER MEET命令

CLUSTER MEET <ip> <port>

向一个节点发送CLUSTER MEET命令,可以让节点与ip和port所指定的节点进行握手,握手成功,节点就会将ip和port指定的节点添加到当前的集群中。

节点A向节点B发送CLUSTER MEET命令,那么B将会加入A的集群中。反之,A加入B的集群中。

在启动时,服务器会根据 cluster-enabled 配置选项来决定是否开启集群模式。

4.2 集群数据结构

Redis使用clusterNode结构来保存一个节点的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//一个节点的当前状态
struct clusterNode{
// 创建节点的时间
mstime_t ctime;
// 节点的名字,由40个16进制字符组成
char name[REDIS_CLUSTER_NAMELEN];
// 节点标识
int flags;
// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;
// 节点的ip地址
char ip[REDIS_IP_STR_LEN];
// 节点的端口号
int port;
// 保存连接节点所需的有关信息
clusterLink *link;
//……
};

clusterNode 结构的 link 属性:

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

// 连接的创建时间
mstime_t ctime;

// TCP 套接字描述符
int fd;

// 输出缓冲区,保存着等待发送给其他节点的消息(message)。
sds sndbuf;

// 输入缓冲区,保存着从其他节点接收到的消息。
sds rcvbuf;

// 与这个连接相关联的节点,如果没有的话就为 NULL
struct clusterNode *node;

} clusterLink;

最后,每个节点都保存着一个clusterState结构,这个结构记录了在当前节点的视角下集群目前所处的状态,比如集群是在线还是下线,包含多少个节点,当前的配置纪元等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef struct clusterState {

// 指向当前节点的指针
clusterNode *myself;

// 集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;

// 集群当前的状态:是在线还是下线
int state;

// 集群中至少处理着一个槽的节点的数量
int size;

// 集群节点名单(包括 myself 节点)
// 字典的键为节点的名字,字典的值为节点对应的 clusterNode 结构
dict *nodes;

// ...
} clusterState;

4.2.1 CLUSTER MEET 命令的实现

向节点A发送 CLUSTER MEET 命令,能让接收命令的节点A将另一个节点B(ip和port指向的节点)添加到节点A当前所处的集群里。

收到命令的节点A 和节点B进行握手,以此来确认彼此的存在,并为将来的进一步通信打好基础:

  1. 节点A为节点B创建一个clusterNode结构,并将该结构添加到节点A自己的clusterState.nodes字典中。

  2. 节点A根据ip和port发送meet消息给节点B。

  3. 如果一切顺利,节点B收到meet消息,为节点A创建一个clusterNode结构,并将该结构添加到节点B自己的clusterState.nodes字典中。

  4. 如果一切顺利,节点B向节点A发送PONG消息

  5. 如果一切顺利,节点A向节点B返回PING消息

  6. 如果一切顺利,至此,握手完成

  7. 最后节点A会向自己处于的集群内的其他节点发送信息,让其他节点也和B节点握手,最终达到集群内的所有节点都相互认识彼此。至此,B节点加入进集群。

4.3 redis负载均衡算法——hash slot

Redis使用分片的方式来保存数据库中的键值对:整个集群被分为16384个槽(slot),数据库中的每个键都位于这其中的某个槽上,集群中的节点,最少可以处理0个槽,最多可以处理16384个槽。

当数据库中的 16384 个槽都有节点在处理时,集群处于上线状态,否则,处于下线状态。

我们可以使用CLUSTER ADDSLOTS命令来给某个节点指派要处理的槽:

CLUSTER ADDSLOTS [slot ...]

这个命令接受一个或多个槽的编号作为参数,并将所有输入的槽指派给接收该命令的节点负责。

CLUSTER ADDSLOTS 0 1 2 3 ... 1000

表示将槽0到槽1000指派给接收到命令的整个节点负责。

4.3.1 记录节点的槽指派信息

clusterNode结构中的slots数组和numslots字段记录了该节点负责处理的槽。

1
2
3
4
5
6
7
8
9
10
11
// 一个节点的当前状态
struct clusterNode{
//……
// 记录处理那些槽
// 二进制位数组,长度为 2048 个字节,包含 16384 个二进制位
// 如果slots数组在索引i上的二进制位的值为1,那么表示节点负责处理槽i;否则表示节点不负责处理槽i
unsigned char slots[16384/8];
//记录自己负责处理的槽的数量
int numslots;
//……
};

slots数组有16384个二进制位,第i项上的二进制值如果为1,则表示槽i由自己负责。为0则表示槽i不是由自己负责

数组的定位时间复杂度是O(1),这样的设计可以让节点非常快速的知道某个槽到底是不是由自己负责。

4.3.2 传播节点的槽指派信息

一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,它还会将自己的slots数组通过消息发送给集群中其他的节点,以此来告知其他节点自己目前负责处理哪些槽。

当节点A通过消息从节点B那里接收到节点B的slots数组时,节点A会在自己的clusterState.nodes字典中查找节点B对应的clusterNode结构,并对该clusterNode结构中的slots数组进行保存或者更新。

每个节点都相互分享自己的槽指派信息,每个节点又在自己的clusterState.nodes字典中保存其他节点的槽指派信息,因此,集群中的每个节点都会知道整个集群数据库的全部槽,都分别被分派给了哪些节点。

4.3.3 记录集群所有槽的指派信息

我们知道,每个节点都保存着一个clusterState结构,这个结构,我们可以看做节点自己对整个集群所描绘的详细概念地图

节点除了会在clusterState.nodes字典中维护每个节点的槽分派信息外,还会在clusterState结构结构中维护一个clusterNode *slots[16384]数组。

clusterState.slots数组有16384项,每个数组项都是一个指向clusterNode的指针:

  • 如果slots[i]指向NULL,那么表示槽i尚未指派给任何节点。
  • 如果slots[i]指向一个clusterNode,那么表示槽i已经指派给了这个clusterNode所对应的节点

假设槽被指派给了集群中的三个节点,那么slots数组结构如下图:

  • clusterState.nodes[i].slots
    • 节点A在自己的clusterState.nodes字典中的某个clusterNode结构(假设对应节点B)中保存的槽分派信息,是以单节点(节点B)为视角的槽分派信息,即——我这个节点负责如下这些槽
  • clusterState.slots
  • 节点A在自己的clusterState.slots数组中保存的槽分派信息,是以每个槽为视角的槽分派信息。即——我这个槽被某个节点负责

4.4 集群处理命令

当集群中的所有槽都被指派之后,集群就会进入上线状态,这是客户端就可以向集群中的节点发送命令了。

一张图解释如下过程:

4.4.1 计算键属于哪个槽

节点用以下算法计算给定键 key 属于哪个槽:

1
2
def slot_number(key):
return CRC16(key) & 16383 //CRC16(key) 计算key的CRC-16校验和,然后和16383与出一个0-16383的序号来。
1
2
# 用于查看一个给定键属于哪个槽
CLUSTER KETSLOT <key>

4.4.2 判断某个槽是否由当前节点负责

当节点计算出键所属槽 i 之后,节点会检查自己在 clusterState.slots 数组中的项 i ,判断键所处的槽是否由自己负责:

  • 如果 clusterState.slots[i] 等于 clusterState.myself ,那么说明槽 i 由当前节点负责,节点可以执行客户端发送的命令;
  • 否则,槽 i 不由当前节点负责,节点会根据 clusterState.slots[i] 所指向的 clusterNode 结构所记录的节点IP和端口号,向客户端返回 MOVED 错误并指引客户端转向正在处理槽i的节点,格式如下:
    • MOVED <slot> <ip>:<port>
    • 客户端接收到 MOVED 命令之后,根据其提供的IP和端口,转向负责处理槽 slot 的节点,并向节点重新发送之前想要执行的命令
    • 客户端会和每个节点创建套接字连接,所谓的转向,其实就是换一个套接字来发送命令。

4.5 节点数据库的实现

我们在文章Redis数据库结构/键空间/过期字典/事务/锁/持久化中讨论过Redis服务器的键空间(即如何存储键值对)redisDb结构的dict字典数组,保存了所有的键值对。redisDb结构的expires字典数组,保存了所有的过期字典。

单机服务器和集群服务器(节点)的保存键值对以及键值对过期时间,实现都是一样的。只不过节点只能使用 0 号数据库,单机服务器没有限制。

和单机服务器不同的是,除了键值对之外,节点还需要维护槽和键的关系,节点会用 clusterState 结构中的 slots_to_keys 跳跃表来保存槽和键之间的关系:

1
2
3
4
5
typedef struct clusterState{
// ...
zskiplist *slots_to_keys;
// ...
} clusterState;

这个跳跃表每个节点的分值( score )都是一个槽号,节点的成员( member )都是一个数据库键:

  • 每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到 slots_to_key s跳跃表

  • 当节点删除数据库中的某个键值对时,节点就会在slots_to_keys跳跃表解除被删除键与槽号的关联

图例:

该图表示:

  • 键”book”所在跳跃表节点的分值为1337.0,这表示键”book”所在的槽为1337
  • 键”date”所在跳跃表节点的分值为2022.0,这表示键”date”所在的槽为2022
  • 键”lst”所在跳跃表节点的分值为3347.0,这表示键”lst”所在的槽为3347

slots_to_keys 的存在是为了使节点可以很方便的对属于某个或者某些槽的所有键做批量操作。例如命令CLUSTER GETKEYSINSLOT <slot> <count>命令可以返回最多count个属于槽slot的数据库键,而这个命令就是通过遍历 slots_to_keys跳跃表来实现的

4.6 重新分片

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

重新分片由Redis的集群管理软件 redis-trib 负责执行,redis-trib 通过向源节点和目标节点发送命令来进行重新分片:

  • redis-trib对集群的单个槽 slot 进行重新分片的步骤如下:

    1. redis-trib 对目标节点发送 CLUSTER SETSLOT <slot> IMPORTING <source_id> 命令,让目标节点准备好从源节点导入槽 slot 的键值对

    2. redis-trib 对源节点发送 CLUSTER SETSLOT <slot> MIGRATING <source_id>命令,让源节点准备好将属于槽 slot的键值对迁移至目标节点

    3. redis-trib 对源节点发送 CLUSTER GETKEYSINSLOT<slot> <count> 命令,获得最多 count 个属于槽 slot 的键值对的键名。

    4. 对于步骤三获得的每个键名,redis-trib 都向源节点发送一个MIGRATE <target_ip> <target_port> <key_name> 0 <timeout> 命令,将被选中的键原子的从源节点迁移至目标节点

      1. 重复步骤3和4,直到源节点保存的所有属于槽slot的键值对都被迁移到目标节点为止。
      2. redis-trib向集群中的任意一个节点发送 CLUSTER SETSLOT <slot> NODE <target_id> 命令,将槽slot指派给目标节点的信息发送给整个集群。
  • 如果重新分片涉及多个槽,那么 redis-trib 将对每个给定的槽 分别执行上面给出的步骤。

重新分片的实战操作,可以参考该篇文章:redis cluster管理工具redis-trib.rb详解

4.6 ASK 错误

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

这时候如果客户端向源节点发送了一个key的操作请求,就可能会触发ASK 错误。

当客户端向源节点发送关于键key的命令,源节点先在自己的数据库里查找这个键,如果找到就直接返回执行客户端命令,如果没找到,这个键可能已经被迁移到了目标节点,源节点向客户端返回一个 ASK 错误,指引客户端转向正在导入槽的目标节点,并再次发送之前要执行的命令。

接到ASK 错误的客户端会根据错误提供的IP地址和端口号,转向至正在导入槽的目标节点,然后向目标节点发送一个 ASKING 命令, 之后再重新发送原本想要执行的命令。

4.6.1 ASKING 命令的实现

在重新分片过程中,我们对目标节点执行了 CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,这会使得clusterState状态的importing_slots_from数组会记录当前节点的哪些槽正在从哪些节点导入:

1
2
3
4
5
6
7
typedef struct clusterState{
// ……
// 如果importing_slots_from[i]的值不为NULL,而是指向一个clusterNode结构,表示当前节点正在从
// clusterNode所代表的节点导入槽i
clusterNode *importing_slots_from[16384];
// ……
}clusterState;

在重新分片过程中,我们对源节点执行了CLUSTER SETSLOT <slot> MIGRATING <source_id>命令,这会使得clusterState状态的migrating_slots_to数组会记录当前节点正在迁移至其他节点的槽:

1
2
3
4
5
6
7
typedef struct clusterState{
// ……
// 如果migrating_slots_to[i]的值不为NULL,而是指向一个clusterNode结构,表示当前节点正在将
// 槽i迁移至clusterNode所代表的节点
clusterNode *migrating_slots_to[16384];
// ……
}clusterState;

接到ASK 错误的客户端会根据错误提供的IP地址和端口号,转向至正在导入槽的目标节点,然后向目标节点发送一个 ASKING 命令, 之后再重新发送原本想要执行的命令。

ASKING命令要做的就是打开发送该命令的客户端的 REDIS_ASKING 标识。

如果该客户端的 REDIS_ASKING 标识未打开,直接发送请求,由于槽的迁移过程还未完成,请求的键还属于源节点,此时直接请求目标节点,目标节点会返回一个MOVED错误。(因为迁移未完成,所以虽然部分的键已经迁移至目标节点了,但这部分键的归属,还是记在源节点上)

但是,如果节点的clusterState.importing_slots_from[i]显示节点正在导入槽 i ,并且发送命令的客户端带有 REDIS_ASKING 标识,那么节点将破例执行这个关于槽 i 的命令一次

客户端的 REDIS_ASKING 标识是一个一次性标识,当节点执行了一个带有 REDIS_ASKING 标识的客户单发送的命令之后,客户端的这个表示就会被移除。

ASK错误和MOVED错误的区别:
MOVED错误代表槽的负责全已经从一个结点转移到了另一个节点
ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施

4.7 节点的复制与故障转移

集群中的节点分为主节点和从节点,主节点负责处理槽,而从节点负责复制某个主节点,并在被复制的主节点下线时,替代下线主节点继续处理命令请求。

4.7.1 设置从节点

向一个节点发送命令:CLUSTER REPLICATE <node_id>

这个命令可以让接收命令的节点成为 node_id 所指定的从节点,并开始对主节点进行复制:

  • 这个节点会先在自己的 clusterState.nodes 字典中找到 node_id 所对应节点的 clusterNode 结构,并将自己的 clusterState.myself.slaveof指针指向这个结构,以此来记录这个节点正在复制的主节点

  • 然后节点修改自己在 clusterState.myself.flags 中的属性,关闭原本的 REDIS_NODE_MASTER标识,打开 REDIS_NODE_SLAVE标识,表示这个节点由原来的主节点变成了从节点

  • 最后,节点调用复制代码,并跟据 clusterState.myself.slaveof 指向的 clusterNode 结构所保存的IP地址和端口号,对主节点进行复制。就是相当于向从节点发送命令 SLAVEOF <master_ip> <maste_port>

一个节点开始成为从节点的时候,会向集群广播这一事实,以便集群中的其他节点更新从节点和主节点的关系。

4.7.2 故障检测

集群中的每个节点都会定期地向集群中的其他节点发送 PING 消息,以此来检测对方是否在线

如果接受 PING 消息的节点没有在规定时间内返回 PONG ,那么发送 PING 的节点就会将该节点标记为疑似下线(PFAIL)。

当一个主节点A通过消息得知主节点B认为主节点C进入疑似下线状态,主节点A会在自己的 clusterState.nodes 字典中找到主节点C所对应的 clusterNode 结构,并将主节点B的下线报告添加到这个结构的 fail_reports 链表里面

1
2
3
4
5
6
struct clusterNode{
// ...
// 一个链表,记录了所有其他节点对该节点的下线报告
list *fail_reports;
// ...
}

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

比较绕,我们来举例:假设一个集群有ABCD四个节点,A和B节点都认为D节点进入了疑似下线状态,这时刚好半数的主节点认为D疑似下线。然后,C节点通过消息交换,也将D节点标记为疑似下线状态。这时候数量就超过了半数了,于是C节点会将D节点标记为已下线,并向整个集群广播一条D节点已下线的消息。这时A和B接到消息,会将D节点标记为已下线

4.7.3 故障转移

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

  1. 复制下线主节点的所有从节点里面,会有一个从节点被选中
  2. 被选中的从节点将执行 slaveof no one 命令,成为新的主节点
  3. 新的主节点撤销已下线主节点对指派槽的管理,并将这些槽全部指派给自己
  4. 新的主节点向集群广播一条PONG消息,告诉集群中的其他节点自己成为了新的主节点。
  5. 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

这里面涉及到新的主节点的选举:

  1. 集群的配置纪元是一个自增计数器,初始值为0

  2. 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值就会加一

  3. 对于每个配置纪元,集群中的每个负责处理槽的主节点都有一次投票机会,而第一个向主节点要求投票的从节点将获得主节点的投票

  4. 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息,要求所有收到这条消息、并具有投票权的主节点向这个从节点投票

  5. 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,表示这个主节点支持从节点成为新的主节点

  6. 每个参与选举的从节点都会接受 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,并根据自己受到了多少条这种消息来统计自己获得了多少主节点 的支持

  7. 如果集群库有N个具有投票权的朱及诶单,那么当一个从节点收集到大于等于N/2+1张支持票,这个从节点当选为新的主节点

  8. 因为在每个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N个节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的主节点只会有一个

  9. 如果在一个配置纪元里没有从节点能搜集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。

和选举领头sentinel的算法一样,都是基于raft算法。

0%