Skip to content

Latest commit

 

History

History
665 lines (345 loc) · 45 KB

README.md

File metadata and controls

665 lines (345 loc) · 45 KB

Redis Cluster Specification

主要特性和基本原理

Redis集群目标

Redis 集群是Redis的分布式实现,包含的设计目标下面按照设计的重要性排序:

1、高性能和线性扩展到1000个节点,没有代理,使用异步复制,操作value的时候不存合并操作

2、接受一定程度的写安全:系统尝试(尽最大努力)去保留所有的与绝大多数主节点相连的客户端发起的写请求 ,通常来说,也有小概率写请求的响应会丢失。 丢失请求响应的概率会较大,如果客户端在一个小的网络分区里面

3、可用性:Redis集群能够确保当大多数master节点可达或者master节点不可达但是至少它的有一个slave节点可达的时候,依旧保证分区可用,更多的时候,使用副本迁移,master选择仍然有多余副本的master节点做副本迁移

本文档描述是Redis 3.0或者以上

实现的子集

Redis集群,单个key或者聚合key的操作,不论是不是分布式的版本,都能保证相同的key会被hash到相同的槽

Redis集群实现了一个叫做hash-tags的概念,这个概念可以确保确定的key一定被hash到相同的槽。但是,在手动进行resharding的时候,单key操作可以保留以上特性,多key操作则不做保证

客户端和服务端在Redis集群协议中扮演的角色

在Redis集群中,node负责存储数据,保留集群的状态,包括把key映射到正确的node.集群节点通常能够自动发现其他的节点,探测不工作的节点,如果主节点不能工作了,能够升级从节点为主节点,确保发生了网络分区集群也能够继续工作

为了完成这些任务,所有的集群node通过一个tcp bus(总线)和一个二进制协议,叫做Redis 集群总线。每一个node都与其他node通过集群总线相连,节点使用gossip协议去传播信息,这些信息包括发现新的节点,发送ping包确保其他节点是否工作,特定条件下发送信号,集群总线同样可以传播Pub/Sub消息通过集群和管理员的手动维护failover工作

由于集群node不能代理请求,客户端可能会被重定向到其他节点,使用重定向errors -MOVED and -ASK。客户端理论上能向所有的node发送请求,然后被重定向,所以客户端并不需要保存集群的状态。但是客户端能够缓存映射关系(key与node)以便于提升性能

写安全

Redis 集群node之间使用异步复制,并且last failover wins 负责合并功能。这意味着最后一个被选举为master的节点的数据集将会最终替换其他的副本。从这个层面来说,终究是存在一个窗口期,在网络分区的时候会丢失写请求。

Redis 集群做最大努力去保留连接到大多数master节点(相比较于哪些连接少数节点)的客户端的写请求,下面是在大多数分区都失败的情况下会丢失响应的场景:

1、一个写请求到了一个master,但是当这个master想回应client的时候,这个写请求可能还没有通过异步复制到slave。这个时候,如果master在写请求还没复制到slave之前就已经挂掉了,这个写请求在这个master不可达并且slave被晋升为master期间就会丢失。这种情况通常来说很难观察到,但是确实是有可能发生的

2、另外一个理乱上写请求可能失败的场景是:

  1. 一个master由于网络分区不可达
  2. 其中一个slave有可能晋升为master
  3. 一段时间智慧,这个master又可达了
  4. 客户端可能有过期的路由表,导致把写请求路又到了旧的master(在这个master降级为slave之前)

第二种失败模型不大可能发生,因为如果master节点不能与其他大多数master节点通信的时候,将不能接受写请求,并且当这个网络分区被修复的时候,也不允许这个master立即接受写请求,而必须等待一段时间让其他的节点确认配置的改变,这个失败模型,同样要求客户端的路由表还没有被更新。

向少数节点的网络分区写入数据丢失的概率比较大,举个例子,如果在一个分区里面有一个或者多个client以及少量的master,这个时候发生了网络分区,客户端所有的写请求都会丢失。

特别说明,对于一个master来说,只有当与集群中大多数的其他master节点发生NODE_TIMEOUT的时候才会被判定不可达,所以在那段时间之前,网络分区被修复,没有请求被丢失,当分区持续到NODE_TIMEOUT,所有的少数分区的写请求会被丢失。然而这少数分区里面的集群将会开始拒绝请求,因为这些少数分区里面的节点将变得不可用,最关键的是,不会再接受新的写请求了。

可用性

Redis 集群在少数分区是不可用的,在多数分区里面假定包括大多数master和少数分区里面master节点的一个slave,这个集群在一段时间之后变得再次可用,这个时间包括两个部分,一个是NODE_TIMEOUT,一个是其中一个slave被选举成master的过程(通常来说只需1-2s)

这意味着,Redis集群设计成容忍集群中一部分节点的失败,但是这个策略对那些在大量网络分割的场景下仍然需要保持可用性的应用就不太适合了

我们举个例子,一个集群有N个master节点,每一个节点有一个slave,如果集群中有一个节点被分隔出去了,这个集群仍然保持可用,那么可以算出来整个集群的可用性是1-(1/(N2-1)),第一个节点被分隔出去之后,我们剩下的节点个数是N2-1,那么这个master节点被分隔出去的概率是(1/(N2-1)),假设集群中有5个节点,每个节点一个slave,所以有1/(52-1) = 11.11%的可能性,两个节点从集群中被分区出去,这个集群将变得不可用

感谢Redis集群有一个特色称之为副本迁移,整个集群的可用性在现实世界中被提升了,所以如果发生了一次副本迁移,这个时候master就没有slave了,为了保证可用性,需要为这个已经从slave晋升为master的节点重新安排一个slave

性能

Redis集群中的node并不代理任何请求到正确的节点,而是将请求重定向到正确的node,集群中的node都有属于自己的key space

最终客户端将会获取最新的集群映射关系(slot-->node),即哪个node包括哪些key子集,所以在正常的client操作的时候,客户端是直接将请求发送到正确的节点。

因为使用了异步复制,节点并不会等待其他节点的回应(除非明确的使用了WAIT命令)才给客户端响应

同样的,因为多key命令被限制到了必须是相邻的key(同一个槽),数据永远不会在node之前移动,除非重新分片(resharding)

通常来说,请求对于Redis集群和单个Redis实例没什么差别,一个有N个节点的集群完全可以想像成单个节点按线性扩展到了N个节点。同时查询操作也只需要一个RTT,因为客户端通常保留长连接,所以与单个节点的延迟一样。

Redis集群的设计目标就是在保证合理的数据安全性和可用性的同时,追求高性能和高扩展性

为什么合并操作是应该避免的?

Redis集群的设计避免了相同的key-value在多个节点之间的版本冲突,Redis里面的Values通常来说非常大;几百万个元素的list或者set都很常见,而且这些数据类型都很复杂,转移和合并这些value会成为主要的性能瓶颈点

Redis集群主要组件

Keys distribution model

key space被分成了16384个槽,所以集群的master节点个数最多是16384个(但是建议最多到1000个)

每一个master节点处理16384个hash槽的一部分子集,只要不是处于配置中的集群(有些hash槽会从一个节点挪到其他节点),都可以认为是稳定的,当集群是稳定的,每一个hash 槽将会被一个node所拥有(但是这个拥有的node可以有一个或者多个slave能够代替这个master当发生网络分区的时候,而且这些slave节点能够扩展读的能力)

基本的用于将keys映射到hash 槽的算法如下:

HASH_SLOT = CRC16(key) mod 16384

CRC16的含义是:

1.Name:XMODEM(或者叫ZMODEM or CRC-16/ACORN) 2.Width: 16 bit 3.Poly: 1021 (实际上是 x16 + x12 + x5 + 1) 4.Initialization 0000 5.Reflect Input byte: False 6.Reflect Output CRC: False 7.Xor constant to output CRC: 0000 8.Output for "123456789": 31C3

16bit输出的时候只用了14bit,这就是为什么是对16394取模

Keys hash tags

如果key 包括一个"{...}" 模式,为了确保多个key被分配到相同的hash 槽的时候就需要使用hash tags了,这个点此处不做翻译。

集群节点属性

集群中的节点都有一个唯一的name,节点名字是一个160bit的数字的16进制表示,包括这个节点第一次启动的时间(通常使用/dev/urandom),节点将会保存ID到配置文件,同时将会一直使用相同的ID,只要节点的配置文件没有被系统管理员删除或者通过CLUSTER RESET 指令重置

node ID并不是唯一一个与node相关联的属性,却是唯一一个始终保持全局一致的属性。有一些信息是关于这个node在集群中的配置详情,并且最终贯穿于整个集,有一些信息,比如node最近一次收到其他节点的ping包的时间,则在集群的整个生命周期内会时刻变化。

每个节点维护了集群中其他还活着的节点的下列信息:node ID,其他节点的IP和port,一系列的标识,slave节点对应的master节点,最后一次收到ping包的时间,最后一次回应pong包的时间,这个当前的配置的epoch(纪元)连接的状态以及节点对应的hash槽子集

更详尽的关于节点属性的解释在CLUSTER NODES 文档中

CLUSTER NODES的指令可以发送到任意一个节点以获取集群状态以及每个节点对集群中其他节点的本地视图

下面是一个发送给3个节点 的集群中任意一个节点的CLUSTER NODES指令的输出结果

$ redis-cli cluster nodes d1861060fe6a534d42d8a19aeb36600e18785e04 127.0.0.1:6379 myself - 0 1318428930 1 connected 0-1364 3886e65cc906bfd9b1f7e7bde468726a052d1dae 127.0.0.1:6380 master - 1318428930 1318428931 2 connected 1365-2729 d289c575dcbc4bdd2931585fd4339089e461a27d 127.0.0.1:6381 master - 1318428931 1318428931 3 connected 2730-4095

上面按照输出顺序分别是:node ID,IP:port,标识,最后一次ping的发送时间,最后一次pong的接收时间,配置的epoch,连接状态,槽子集

Cluster bus

每一个Redis集群node有一个附加的TCP port用于接收集群中其他节点的连接。这个port与接收客户端连接的port之间固定偏移的,假定偏移是10000,正常的客户端连接port是6379,那么Cluster bus的port就是16379

Node-to-node之间的通信必须使用Cluster bus和Cluster bus协议,Cluster bus协议是一个组合了不同类型和大小报文的二进制协议。Cluster bus binary protocol并不打算出一个公开的文档描述,因为外部并不会使用到这个协议,但是如果你对这个感兴趣,可以直接去看源码,源码在cluster.h 和cluster.c里面

集群拓扑

Redis集群是一个全网格连接,因为每个节点都会通过TCP相连,在一个有N个节点的集群中,每一个Node会有N-1个向外的连接,以及N-1个向内的连接

这些TCP连接会一直保活,除非有必要否则不会创建新的连接。只有当没有收到pong的时候,才有可能重建连接

Redis集群组成了一个全网格,node之间使用gossip协议交换消息,但是Redis集群使用了一种配置更新的机制去避免在节点正常的条件下交换太多的消息,所以消息的数量并不是指数的。

Nodes handshake

Node通常通过cluster bus端口号接收连接,发送和接收ping-pong包,哪怕这个ping节点是不可信的。但是如果这个node不认为是集群的一部分,它报文会被丢弃

一个节点通过两种方式接收其他的节点作为集群的部分:

1.如果一个节点向另一个节点发送MEET消息,MEET消息很像PING消息,但是可以迫使接收者接收发送者作为集群的一部分。只有当系统管理员发送CLUSTER MEET ip port指令的时候node才会会发送MEET消息给其他的节点

2.一个节点会接收集群中已经被其他节点信任的节点作为集群的一部分,比如说A认识B,B认识C,B会传播消息给A和C,同样的A将会把C作为集群的一部分,并且连接到C

这意味着只要我们把节点加入已经建立连接的拓扑,他们最终会自动组成一个全网格的拓扑,同时意味着只有当系统管理员已经强制建立信任关系的时候,集群才能够自动发现其他节点

这种机制能够加强集群的强健性,同时能够避免由于某种意外导致不同的Redis集群组成新的集群

重定向和重新分片

MOVED重定向

Redis客户端可以向集群中任意一个node发送查询请求,包括slave节点。这个节点将会分析这个查询,如果是可以接受的(如果查询只涉及到一个key或者虽然是多个key但是对应到了相同的槽),它将会查询哪个节点拥有的hash槽里面包括这些key,如果这个hash槽属于这个node本身,那问题就比较简单了,否则这个节点将会检查内部的hash槽映射表,并且返回客户端一个MOVED错误,类似于:

GET x -MOVED 3999 127.0.0.1:6381

这个错误包括了key对应的hash槽,以及槽所在节点的ip和port,这个客户端需要向这个ip对应的node去发起新的查询。注意尽管客户端发起重定向之前已经等待了很长时间,但是如果这个时间段内集群有了新的配置,目的节点依旧会返回MOVED错误如果key对应的 hash槽不属于该节点

这里面作了简化,用hash槽和节点的ip:port保存映射关系,而不是用ID

客户端不被强制要求,但是应该去缓存这个映射关系,有一种方式是,当发生MOVED的时候,就刷新所有的映射关系,因为当集群稳定的时候,应该不会有MOVED发生的,当发生MOVED的时候,大概率是集群被重新配置了。最终所有的客户端会会存储slots -> nodes的映射关系,将会使整个集群的运作更加高效,因为没有了重定向,代理和单点失败

一个客户端必须能够处理 -ASK和-MOVED重定向,关于-ASK将会在下面的文档描述,只有实现了MOVED和ASK重定向的客户端才能称之为一个完成的客户端

集群存活期间重配置

Redis支持集群在运行过程中增加和移除节点,增加或移除节点被抽象成了相同的操作:把一些hash槽从一个节点挪到另外一个。这意味着为了重新平衡集群可以使用相同的基础机制,添加或移除节点等等

1.添加一个新的节点到集群:一个空的节点添加到集群,同时需要将一些hash槽从一个已经存在的节点迁移到这个新的节点

2.从集群移除一个节点,需要把这个节点的hash槽挪到另外一个已经存在的节点

3.为了重新平衡集群,需要在节点之间重新分配hash槽

核心的能力是在集群中任意移动hash槽,从一个更实际的角度来看,hash槽其实就是key的集合,所以Redis集群重新分片的本质就是把一些key从一个节点挪到另外一个节点,移动一个hash槽就意味着移动所有分配到这个hash槽的key集合

为了理解重新分片是怎么工作的,我们一起看看在Redis集群中如何使用CLUSTER 子命令去维护hash槽的迁移

下面的子命令都是可用的

1.CLUSTER ADDSLOTS slot1 [slot2] ... [slotN] 2.CLUSTER DELSLOTS slot1 [slot2] ... [slotN] 3.CLUSTER SETSLOT slot NODE node 4.CLUSTER SETSLOT slot MIGRATING node 5.CLUSTER SETSLOT slot IMPORTING node

前两个指令,ADDSLOTS和DELSLOTS被用于向一个node分配和移除hash槽,分配hash槽意味着告诉这个节点,它有责任去存储这些指定的hash槽的内容,这些hash槽被分配之后,将会通过gossip协议传播到集群的其他节点

ADDSLOTS指令被用于向集群添加一个新的节点,同时把16384的一部分分配给这个节点

DELSLOTS主要用于修改集群的配置,通常用于debug任务,实际上很少用到

SETSLOT子命令,如果是用的上面第三个,意味着向一个指定的node分配一个hash槽。如果用的是MIGRATING,这个时候如果key已经存在于这个hash槽了,那么接受查询请求,否则把查询请求重定向到目标节点。如果用的是IMPORTING,该节点将会接受带有ASKING指令的请求,如果客户端发送ASKING指令,查询请求将会被重定向到槽的拥有者

举个例子能更清楚一点,假定我们有两个节点,分别叫做A和B,我们想把槽8从A挪到B,我们假定用了下面这些指令:

我们向B发送指令:CLUSTER SETSLOT 8 IMPORTING A 我们向A发送指令:CLUSTER SETSLOT 8 MIGRATING B

所有其他节点将会继续向A发送查询请求,将会发生什么?

1.所有已经存在的key将会继续由A处理 2.所有A中不存在的key将会由A重定向到B

通过这种方式,不会再有新的key到A,同时有一个程序叫做redis-trib在重新分片期间将槽8里面已经存在的key从A迁移到B,可以使用CLUSTER GETKEYSINSLOT slot count来查询一个hash槽里面的key的总数,对于每一个返回的key,redis-trib向A发送一个MIGRATE指令,能够将指定的key以一种原子的方式从A迁移到B(两个节点都会被锁住,通常来说时间很短),具体的MIGRATE如下:

MIGRATE target_host target_port key target_database id timeout

MIGRATE将会连接到指定的实例,发送一个key的序列化版本,一旦收到了一个OK code,旧的key将会被删除。从客户端的视角来看,在同一时间,同一个key要么存在于A,要么存在于B。

MIGRATE被优化的尽可能快,哪怕是移动像list这样复杂的key,但是在那些存在大key的集群中做重新配置不是一个明智的做法,特别是有些应用不能接受大的延迟

当迁移过程完成的时候,SETSLOT NODE 指令不仅会发送给两个node也会发给集群中的其他node,表示这些槽恢复到了正常状态

ASK 重定向

在上一个章节里面我们简要的介绍了ASK重定向,为什么不直接用MOVED重定向呢?因为MOVED意味着这个hash槽是被另外一个节点管理的同时后面所有的对于这个hash槽的请求都应该到重定向到这个节点,但是ASK仅仅表示本次查询重定向到这个节点,下一个则不一定了,之所以需要这个指令,是因为很有可能下一个槽8中的key仍然在A里面(还没被迁移到B,一个槽里面有很多key),所以我们需要分别对A和B进行尝试,由于这个仅仅发生在一个hash槽里面,所以性能也是可以接受的。

ASKING的基本语意是设置一个实时的标识,强迫客户端去处理一个处于IMPORTING中的槽,具体解释如下:

1.如果客户端接收到了ASK重定向,本次查询重定向到指定的node,但是接下来的查询依旧到旧的node 2.发送ASKING开始重定向查询 3.不要更新本地的映射关系,槽8-->B

一旦hash槽8的迁移工作完成,A将会发送一个MOVED消息给客户端,同时客户端将会更新本地的映射表,注意如果一个差劲的客户端过早的更新了本地的映射表也不是一个问题,因为请求到了B,B没有收到ASKING指令,将会接着将查询请求通过MOVED重定向到A

客户端第一次连接和重定向的处理

Redis集群虽然允许客户端不缓存槽与节点的映射表,只随机的选择节点并且重定向来完成操作,但是这样一来,客户端的效率很低

Redis客户端应该自己去缓存(slot,node)的映射表,然而这个映射表并不要求是实时的,因为查询到了错误的节点仅仅会引起重定向,而重定向会导致映射表的更新

客户端在下面两种情况下通常需要获取一个完整的映射表:

1.启动的时候需要做初始化 2.当发生MOVED重定向的时候

注意客户端在处理MOVED重定向的时候,可能只会更新对应的槽的于映射关系,而不是整个映射表,但是这并不是一个高效的方式,因为通常来说是一批槽会同时被重新配置,比如说一个slave被晋升为master,这个时候之前那个master处理的所有的槽的映射关系都要发生变更,变更到新晋升的master节点。最简单的方式就是当发生MOVED重定向的时候重新抓取所有的映射表

为了获取槽的配置信息,Redis集群提供了一个CLUSTER SLOTS指令,可以为客户端直接提供槽的配置信息,下面是一个例子: 127.0.0.1:7000> cluster slots

    1. (integer) 5461
    2. (integer) 10922
      1. "127.0.0.1"
      2. (integer) 7001
      1. "127.0.0.1"
      2. (integer) 7004
    1. (integer) 0
    2. (integer) 5460
      1. "127.0.0.1"
      2. (integer) 7000
      1. "127.0.0.1"
      2. (integer) 7003
    1. (integer) 10923
    2. (integer) 16383
      1. "127.0.0.1"
      2. (integer) 7002
      1. "127.0.0.1"
      2. (integer) 7005

数组的前两个子元素是槽的开始和结束编号,其他的是处理这些槽的node地址,第一个是master的地址,剩下的是slave的地址,举个例子,上面输出的第一个数组表明槽是从5461到10922(包括开始和结束)被127.0.0.1:70001处理,以及只读的slave 127.0.0.01:7004

CLUSTER SLOTS在错误配置的情况下,并不保证返回所有的16384个槽的范围信息,所以客户端会用NULL对象填充目标节点,当有用户访问到未分配的槽的时候,应该返回错误,但是在返回错误之前,客户端应该重新抓取新的配置,防止集群做了重新配置

Multiple keys操作

使用hash tag,客户端能够自由的使用multi-key,下面的操作是有效的:

MSET {user:1000}.name Angela {user:1000}.surname White

Multi-key操作在重新分片的过程中有可能失效,除非Multi-key的key同时存在于source节点或者destination节点

如果Multi-key一部分存在于source,一部分存在于destination,将会产生一个TRYAGAIN 错误,客户端将会在一段时间只会再次尝试,当这个操作直到迁移操作完成的时候操作会成功

使用slave节点进行读的扩展

通常来说从节点会把请求重定向到授权的主节点,但是客户端可以使用从节点来执行只读的操作

READONLY指令告诉Redis集群中的从节点,客户端只会进行读取操作,不会有写操作

当连接是只读模式的时候,只有一种情况会发生重定向,那就是key不属于master节点,以下两种场景会发生这种情况:

1.客户端操作的槽不属于master 2.集群进行了重新配置(比如说重新分片),从节点不能处理对应的hash槽了

当发生以上情况的时候,客户端应该更新自己的映射表,只读模式可以通过 READWRITE 指令来清除

容错

心跳和gossip消息

Redis集群节点持续的交换ping和pong包,这两种类型的包有相同的结构,都带有重要的配置信息。唯一一个不同是消息类型的字段。我们把ping包和pong包统一称之为心跳包,通常来说,发送了一个ping包,就会促发回应一个pong包,但也不完全是这样,有的时候仅仅会发送pong包,比如需要尽快向集群广播新的配置的时候(后面会讲到)

通常来说一个节点会每秒随机的向一部分节点发送ping包,这样能保证整个集群中的ping包是一个常量,不会随着集群中节点的数量的增加而线性增加,然而每一个节点必须保证在半个NODE_TIMEOUT时间内向所有的节点发送ping包,在超过半个NODE_TIMEOUT的时候,节点也必须去重建TCP连接,确定到底是网络不可达还是当前的TCP连接的问题。

如果NODE_TIMEOUT设置的非常小,但是集群节点数(N)非常大的时候,总的ping报文个数是相当大的,举个例子,一个集群有100个节点,NODE_TIMEOUT=60,每个节点在30s内需要发送99个ping包,相当于每秒3.3个,乘以100,集群每秒总共330个ping包。

虽然有办法能够降低ping包的数量,但是目前还没有使用者提出,说是这是一个问题,所以这种方式就被保留到现在,而且330个报文被拆分到了100个节点,每个节点每秒的报文数量还是可以接受的

心跳报文的内容

心跳报文有和于其他报文相同的头(比如发起failover的投票),头包括以下信息:

1.Node ID,当node被创建的时候分配的一个160 bit的随机字符串,整个node生命周期内,该ID不该改变

2.currentEpoch,configEpoch,这两个字段后面会讲解

3.node flag,表明整个节点是slave还是master

4.slot map,发送节点处理的hash 槽

5.基础端口号(用于处理客户端请求的端口号,可以算出来cluster bus,上面有讲过)

6.从发送者角度看到的集群状态(down or ok)

7.master的node ID,如果发送者是一个slave

PING和PONG包同样包含gossip section(redis cluster protocol协议的一部分),这个部分告诉接收者,发送者是怎么看待集群中其他节点的,而且是随机的抽取集群中的一部分节点。

对于每一个被添加到gossip section 里面的节点,都会上报以下字段:

1.Node ID

2.node ip 和port

3.node flag

Gossip section 允许接收者从发送者这里获取到集群中其他节点的信息,这个对于失败探测和自动发现集群中的其他节点就很有用了

失败探测

Redis 集群失败探测,被用于识别出一个master或者slave节点,是否从大多数节点多不可达了,识别出来后然后作出响应,晋升一个slave节点为一个master节点。如果slave节点不能晋升,集群将会停止接受客户端的请求并且返回错误

就像上面提到的那样,每个节点都会保存它所认识的其他的节点的一系列标识。有两个标识会用于失败探测,PFAIL和FAIL,PFAIL意味着可能失败,是在节点无响应的时候出现的错误类型,FAIL意味着这个错误已被集群大多数其他的节点确认过。

PFAIL flag:

一个节点超过了NODE_TIMEOUT时间发现另外一个节点仍然不可达的时候会标记这个节点为PFAIL,master和slave节点都可以标记其他节点为PFAIL。

不可达的意思是指发送了ping报文但是超过NODE_TIMEOUT时间没有收到pong报文,这个机制能够运行必须保证NODE_TIMEOUT大于RTT,为了增加正常操作的可靠性,节点将会在超过一半的NODE_TIMEOUT的时间没有收到pong报文的时候重建连接,防止是因为tcp连接导致的无响应

FAIL flag:

PFAIL标识仅仅表示每个节点自身对其他节点的判断,但是仅仅因为这一点就发起slave晋升是不太明智的。一个节点被判断挂掉需要从PFAIL升级为FAIL,正如上面的心跳章节里面讲的,每个节点会向每一个其他的节点随机发送自己知道的一些节点的状态,每个节点最终会收到每一个其他节点的标识集合。通过这种方式每一个节点有一种机制能够通知其他节点自己的探测到的失败情况

当发生以下条件的时候,一个PFAIL情况会被升级为FAIL情况:

1.A节点标记B节点为PFAIL

2.A节点通过gossip消息告诉集群中大多数的其他节点B的状态

3.集群中的大多数节点在NODE_TIMEOUT * FAIL_REPORT_VALIDITY_MULT时间内,促发了PFAIL或者FAIL消息(validity factor在当前的实现里面被设置成了2,所以就是2被的NODE_TIMEOUT)

如果上面的条件都被促发了,A将会:

1.标记该节点为FAIL

2.发送一个FAIL消息给所有可达的节点

FAIL消息将会强迫所有接收的节点将该节点标记为FAIL状态,不论它是否已经被标记成了PFAIL状态

记住 FAIL标识是单向的,这意味着,一个节点只能从PFAIL到FAIL状态,而不能从FAIL转成PFAIL状态,FAIL只能在以下的情况下被清除掉:

1.节点是一个slave,并且再次可达了,这种情况下,FAIL标识将会被清理掉

2.节点是一个master,并且再次可达了,但是该节点没有处理任何hash槽,这种情况下FAIL会被清理掉,因为这种情况下说明这个节点当前已经不是集群的一部分了,很有可能系统管理员正在配置这个节点重新加入集群

3.节点是一个master,并且再次可达了,但是很长时间(通常来说是几倍的NODE_TIMEOUT)依然没有探测到slave晋升,最好让该节点重新加入集群

从PFAIL -> FAIL这种过度是很有用的,这个过度用了一种很微弱的协议:

1.节点都需要一段时间去收集其他节点的状态,通过这段时间大多数节点能够达成一致。

2.并不能保证消息能够达到集群中的每一个节点,但是这种机制能够促发当一个节点开始探测到FAIL的场景,使集群中的其他节点也去探测FAIL

但是Redis集群的失败探测有一个存活要求:所有的节点应该就一个指定的节点的状态达成最终一致。有两种场景会导致脑裂,要么一部分节点相信某个节点是FAIL状态,要么一部分节点相信某个节点不是FAIL状态,但是这两种场景最终都会达成一致:

case 1:如果大多数节点已经标记某个节点为FAIL,由于失败探测和级联影响,每一个节点都会最终标记这个节点为FAIL,因此在指定的时间窗口内,将会产生很多FAIL报文

case 2:当仅仅一部分节点将某个节点标记为FAIL,slave晋升将不会发生,最终每个节点在一段时间之后(N倍的NODE_TIMEOUT)清理掉FAIL状态,这样最终也是一致的

PFAIL -> FAIL状态过度,弱一致性协议,广播FAIL消息能够迫使集群中那些相互可达的节点在一段时间内达成一致,而且如果整个集群处于FAIL的状态(没有达成最终一致),整个集群都会开始拒绝接收写请求,这样从外部看整个集群的状态仍然是一致的

配置处理,传播,故障转移

集群当前的纪元

Redis的这个epoch的概念类似于Raft协议"term",epoch用于标记集群中事件的版本,当多个节点提供的消息冲突的时候,epoch能让一个节点知道另外一个节点的纪元更新一些

当前的epoch是一个64 bit的二进制数字

当前node被创建的时候,每个节点都将自己的epoch设置为0(currentEpoch)

每次接收到一个报文,如果发现发送者的epoch比自己的大,接收者就会将自己的epoch更新成发送者的epoch

由于有这个协议,最终所有的节点都会同意使用集群中epoch最大的那个节点的epoch(通常是配置的,叫做configEpoch)

epoch的用处是当集群状态发生变化的时候,节点能够通过它来在整个集群中寻求一致性。当前来说,明确使用到的就是slave晋升,这个下一章节会详细描述。本质上来说,epoch是一个集群中的逻辑时钟,它能表明一个节点提供的消息比另外一个epoch小的节点提供的消息更具有代表性,更可取。

config epoch

每个节点都会在它的ping和pong报文里面附上自己的configEpoch,以及自己可以处理的hash槽

当新的节点启动的时候configEpoch被设置成0

在slave选举的时候会产生一个新的configEpoch,slave尝试去代替挂掉的master,同时增加自己的epoch,并且努力得到大多数其他master的认可。当一个slave获得了大多数的认可,一个新的唯一的configEpoch会产生,同时slave会转成master并且开始使用这个新的epoch

configEpoch用于在发生配置分歧的时候解决冲突(发生网络分区或者节点挂掉的时候会产生分歧)

slave节点也会在ping和pong包里面加上自己的configEpoch,但是却是自己master的configEpoch,这就允许其他的实例去探测一个从节点是否拥有旧的配置(主节点不会投票给那些配置比自己旧的从节点)

每次configEpoch和currentEpoch发生变更的时候,都会被持久化到nodes.conf里面,这两个变量能够保证在节点进行下一步操作之前使用fsync-ed同步保存到文件里面

configEpoch在失败转移(failover)的时候变化,并且能够保证是新的,递增的,唯一的

从节点选举和晋升

从节点选举和晋升发生在所有的从节点当中,其他的主节点会通过投票帮助从节点晋升。当一个从节点发现它的主节点处在FAIL状态的时候,它会发起从节点选举

一个从节点为了想办法让自己晋升,它需要去发起选举并且赢得选举,处于FAIL状态的所有从节点都可以发起选举,但是最终只会有一个从节点赢得选举,并且成为新的master

促发一个从节点发起选举的条件如下:

1.从节点的master节点处于FAIL状态

2.master节点负责处理了一些hash槽(如果没有处理hash槽,说明该master及其从节点根本就不算集群的一部分)

3.从节点以及于master节点断开连接超过一段时间,为了确保这个晋升的从节点的数据是新的,这个超时时间由用户自己配置

为了保证能够被选举上,从节点的第一步是增加自己的currentEpoch计数器,并且向所有的master节点发起投票请求,投票通过FAILOVER_AUTH_REQUEST报文被广播出去,然后等着其他master节点的回应,最多等待2倍的NODE_TIMEOUT时间(通常来说至少等待2s)。

一旦一个master节点已经通过FAILOVER_AUTH_ACK报文向从节点的投票请求发起了回应,NODE_TIMEOUT * 2时间内就不能再向相同的主节点的从节点发起投票回应。这个并不需要保证安全性,但是对于阻止多个slave同时请求选举却很有用。

一个slave会丢弃AUTH_ACK回应,如果发现这个回应的currentEpoch比自己的小,这个确保了它不会收集上一个选举的投票回应

一旦slave收集到了大多数master的ACK,它就赢了这个选举。否则如果大多数节点没有在2倍的NODE_TIMEOUT时间内给出回应(至少2s),本次选举就会被抛弃,同时一个新的选举过程会产生,新的选举的尝试时间是NODE_TIMEOUT * 4(至少4s).

Slave 排名

当一个master处于FAIL状态,一个slave会等一段时间只会再去发起选举,等待的时间计算方式如下:

DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds + SLAVE_RANK * 1000 milliseconds

固定的延迟确保FAIL状态能够在集群中传播开来,否则其他的主节点有可能还没有意识到发起投票的从节点的主节点是处于FAIL,这个时候会拒绝这个从节点发起的投票

随机的延迟是为了防止所有的从节点在同一时间去发起投票

SLAVE_RANK考虑到了从节点从主节点那里接收到的副本数据量,当master处于failing的时候,从节点们交换消息去建立一个排名:从主节点那同步到了最多数据的从节点排名第0位,第二名排第1位,依此类推,通过这种方式拥有最新数据的从节点会比其他节点先发起投票

排名顺序并非严格要求,如果一个排名靠前的选举失败了,其他节点也会很快发起投票

一旦一个slave赢得了选举,它就会获得一个唯一的递增的比其他已经存在的master节点都要大的configEpoch。它开始通过ping包和pong包向master节点广播自己的configEpoch,就算这个时候旧的master又活过来了,但是由于它的configEpoch比新晋升的master要小(对相同的hash槽宣示主权),但是其他的master节点也只认configEpoch大的那个

为了加速其他节点的重新配置,会直接广播一个pong包(不会先发一个ping包),那些不可达的节点当他们可达的时候,最终会收到ping包或者pong包或者UPDATE包(当其他节点发现某个节点落后的时候会像它发送这个包)

其他的节点将会探测到有一个新的master有更大的configEpoch,并且更新自己的映射表(slot->node映射表),旧master的从节点们不仅仅会更新自己的配置,也会从新的master开始同步数据

Master对从节点投票请求的响应

上一个章节讲解了从节点如果发起选举,这个章节从master的角度去看一看,master节点如何向一个特定的slave发起投票

Masters通过FAILOVER_AUTH_REQUEST报文来接收从节点的投票请求

master会在具备以下条件的情况下投slave一票

1.master对一个指定的epoch只投票一次,同时拒绝向旧的epoch发起投票;每个master节点都有一个lastVoteEpoch字段,如果currentEpoch字段比这个字段小,master将会拒绝投票,只要一个master响应了一个从节点的投票,lastVoteEpoch就会便更新,并且安全的同步到磁盘

2.master只会对标记为FAIL的节点的slave发起投票

3.授权请求的currentEpoch如果比master小,会被忽略。因为master的响应的currentEpoch通常情况下应该与授权请求一样,如果同一个slave发起了两次选举请求,它应该增加currentEpoch,这样能保证从master发出的延迟的投票会被做废掉(后面会再次讲解到)

下面举个例子,如果不遵守规则3会发生什么问题

Master当前的currentEpoch是5,lastVoteEpoch是1(有可能经历了几次失败的选举)

1.Slave currentEpoch 是3

2.Slave尝试用epoch 4(3+1)去发起选举,master响应了一个currentEpoch=5,然后这个响应被延迟了

3.Slave带着epoch(4+!)再次发起选举,这个延迟的响应这个时候到了,currentEpoch=5,并且被认为有效,于是选举过程既不对了

发生网络分区的时候epoch的作用

本章节举个发生网络分区的例子:

1.一个master确定不可达了,这个master有三个salve,分别是A,B,C

2.Slave A赢得了选举,摒弃成功晋升为master

3.这个时候又发生了一个网络分区,导致A与集群中的大多数节点失联

4.Slave B赢得了选举,并且成功晋升为master

5.这个时候又发生了网络分区,导致B与集群中的大多数失联

6.上一个分区被修复了,A又变得可达了

在这个时刻,B挂掉了但是A是可用的,C尝试去发起选举,想要替换掉B,下面的事情将会发生:

1.C将会去尝试选举,并且成功,由于它的master(B)却是是挂掉了,所以它将会获取一个新的递增了的configEpoch

2.A再也不能对外宣称自己是master并且负责处理相应的hash槽了,因为又别的节点已经宣称处理这些hash槽同时这个节点有更高的epoch(B的)

3.所以最终所有的节点都会接收C成为新的master,并且使用C的configEpoll,整个集群将会继续工作

就像下一章节要讲的,一个落后的节点如果重新加入集群,通常会在它ping其他节点的时候收到一个UPDATE消息,要求它尽快的更新自己的配置,而不是再做其他无谓的请求

Hash槽配置传播

Redis集群里面一个重要的机制就是去传播那个节点处理哪些hash槽的消息,这个机制在集群启动或者slave晋升的时候都很重要

这个机制允许发生网络分区的节点再次恢复加入到集群里面,有两种方式会促发hash槽配置传播:

1.Heartbeat 消息,发送者会把自己处理的hash通过ping和pong报文发送出去

2.UPDATE 消息,由于在每个心跳报文里面都会附带上发送者的configEpoch和自己负责的hash 槽,接收者如果发现发送者是落后的(通过configEpoch判断),就会向发送者发送一个UPDATE消息强迫发送者更改自己的配置信息,UPDATE消息里面带上了最新的配置信息,即哪些hash槽由谁负责

heartbeat或者UPDATE消息的接收者使用非常简单的规则去更新自己的映射表。当一个集群被创建的时候,本地hash槽会被简单的初始化成为NULL,表示这些hash槽没有绑定到任何一个节点,就像下面这样:

0 -> NULL 1 -> NULL 2 -> NULL ... 16383 -> NULL

更新自己的映射表所遵循的第一个规则是:

Rule 1:如果一个hash槽还没有被分配(设置为NULL),一个认识的节点宣称拥有它,那么节点直接更新。比如说,收到了一个心跳报文宣称A拥有槽1和槽2,configEpoch是3,映射表会被更新为:

0 -> NULL 1 -> A [3] 2 -> A [3] ... 16383 -> NULL

当一个集群被创建的时候,系统管理员需要手动维护分配(使用CLUSTER ADDSLOTS,通过redis-trib 命令行工具)hash 槽,每一个master节点需要负责处理的hash槽,并且这个信息会在集群中很快的传播开

但是仅仅有这条规则还不够,我们知道有两种情况hash槽映射表需要更新:

1.一个slave代替了它的master节点,发生了failover

2.一个hash槽从一个节点重新分配(resharded)给了另一个节点

Rule 2:如果一个hash槽已经被分配了,一个认识的节点宣称自己拥有这些hash槽,并且这个节点的configEpoch要比自己的大,那么就重新绑定hash槽到这个新的节点

所以如果接收到了B的消息,B宣称自己拥有hash槽 1和2,B的configEpoch是4(自己是3),接收者会更新自己的映射表:

0 -> NULL 1 -> B [4] 2 -> B [4] ... 16383 -> NULL

由于第二条规则,最终所有的节点都会认可configEpoch最大的那个节点的声明,这个机制称之为last failover wins

这个机制同样适用于resharding,当一个节点从另外一个节点importing完成,它的configEpoch会递增,确保这个变更会传播到整个集群

UPDATE 消息

如果还记得上面章节讲的,就会很容易明白update消息是如何工作的,Node A在某一时刻可能会重新加入集群,它会向集群中的节点发送心跳报文,宣称自己负责处理槽1和2,configEpoch是3.所有已经因为B而更新了最新的映射表的节点会发现A的configEpoch比自己的旧,于是就会向A发送一个UPDATE消息

节点如果重新加入集群

当一个节点重新加入集群的时候也会使用到这个基本的机制,继续上面的例子,node A将会被通知槽1和2现在归node B管,假设A原来只负责这两个槽,那么现在A负责的槽变成了0!所以A将会被重新配置成刚晋升的master的slave

实际情况可能比这复杂,因为有可能当A重新加入的时候,发现它原来负责的槽现在归两个节点负责了,这个时候就遵循一个新的规则:它将会成为最后一个偷了自己的槽的节点的slave

副本迁移

Redis集群用了一个叫做副本迁移的概念去提升整个系统的可用性,说白了就是给一个master配置salve,但是如果把master和slave的关系给固定死了,也不太好。举个例子,一个集群里面,每个master有一个slave,不论是master还是slave谁挂掉了,集群都能正常工作,但是如果两者同时死了就不行,但是这里有一种情况是master和salve是前后分别挂掉的情况,也会导致集群不可用,看下面的例子:

1.Master A有一个slave A1

2.Master A 挂掉了,A1晋升为master

3.三个小时之后A1也挂掉了,但是由于这个时候A还活着但是没有成为A1的slave,虽然A和A1都活着,但是集群不可用了

当然可以通过为每个master增加slave去解决这种情况,但是这样整个集群的成本就上去了,还有一个方法是让master-slave的关系更加自动化。比如,有三个master A,B和C,A和B各自有一个salve A1和B1 ,但是C有两个slave,C1和C2.副本迁移允许自动的将一个主节点的salve变成另外一个节点的slave:

1.Master A挂掉了,A1晋升了

2.C2迁移成为A1的slave,否则A1就没有slave了

3.3小时只会A1也挂掉了

4.C2晋升为master代替了A1

5.整个集群继续工作

副本迁移算法

副本迁移算法与前面讲的configEpoch没有关系,整个算法只保证最终每个master都至少有一个好的slave

在讲解这个算法的详细过程之前我们得定义什么样的节点是一个好的的slave:这个slave不能是处于FAIL状态的。

这个算法执行的促发点是有一个slave探测到了有一个master没有好的slave,举个例子,集群中10个master各有一个salve,有两个master各有5个slave ,会将那些有大于一个的slave的master的所有salve里面nodeId最小的slave,迁移给没有好的slave的master节点

configEpoch冲突解决算法

当新的configEpoch通过slave晋升产生的时候,能够保证唯一。但是有两种情况会导致configEpoch冲突,两种情况都是由系统管理员促发的:

1.CLUSTER FAILOVER 指令手动将一个slave晋升为master

2.手动迁移hash槽,不需要其他master节点的同意

因为有可能管理员在进行上面两个操作的时候,集群中发生了failover,两套程序各自运行,这样就会产生两个node有相同的configEpoch,这个就是需要configEpoch冲突算法来解决,算法如下:

1.如果一个master探测到另外一个master宣称自己有相同的configEpoch

2.如果这个node发现那个宣称的node的NodeId比自己小

3.将自己的currentEpoch加1,并且作为最新的configEpoch