掘金 后端 ( ) • 2022-08-10 15:34

深入理解Redis集群

为什么需要Redis集群

当前有一个需求要用Redis保存大量的数据,这里以5000万个键值对为例,每个键值对大小为512B,那么需要的内存空间为25GB(5000 万 *512B),那么这个时候就需要一台32G的云主机来部署,还有7GB的剩余内存空间,可以保证系统的正常运行,不会导致Linux OOM以至于随机kill进程。

同时,使用RDB做Redis的持久化,保证宕机后可以进数据的恢复工作,毕竟需要数据库进行redis缓存的预热需要的时间较长。

但是,在使用的过程中,我发现,Redis 的响应有时会非常慢。后来,我们使用 INFO 命令查看 Redis 的 latest_fork_usec 指标值(表示最近一次 fork 的耗时),结果显示这个指标值特别高,快到秒级别了。

这跟 Redis 的持久化机制有关系。在使用 RDB 进行持久化时,Redis 会 fork 子进程来完成,fork 操作的用时和 Redis 的数据量是正相关的,而 fork 在执行时会阻塞主线程。数据量越大,fork 操作造成的主线程阻塞的时间越长 (fork主要做的就是生成子进程,拷贝内存数据映射表,所以内存越大,拷贝的数据映射表就越大) 。所以,在使用 RDB 对 25GB 的数据进行持久化时,数据量较大,后台运行的子进程在 fork 创建时阻塞了主线程,于是就导致 Redis 响应变慢了。这也是为什么说Redis对应机器的内存不宜过大的原因。

那么这个时候就需要进行集群切片了,就是将多个redis实例组成一个集群以达到存储大量内存数据的目的。

那么,在切片集群中,实例在为 5GB 数据生成 RDB 时,数据量就小了很多,fork 子进程一般不会给主线程带来较长时间的阻塞。采用多个实例保存数据切片后,我们既能保存 25GB 数据,又避免了 fork 子进程阻塞主线程而导致的响应突然变慢。

集群的可扩展性好,增加和删除的成本低,并且相比于单机的主从和哨兵模式,在主从切换时存在访问瞬断的情况,导致有一小会的不可提供服务,而集群则可能会存在一小部分数据的无法读写,将影响减小。但是变成切片集群了之后也会需要解决两大问题:

  • 数据切片后,在多个实例之间如何分布?
  • 客户端怎么确定想要访问的数据在哪个实例上?

数据切片和实例的对应分布关系

Redis从 3.0 开始,官方提供了一个名为 Redis Cluster 的方案,用于实现切片集群。Redis Cluster 方案中就规定了数据和实例的对应规则。

具体来说,Redis Cluster 方案采用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。

具体的映射过程分为两大步:首先根据键值对的 key,按照CRC16 算法计算一个 16 bit 的值;然后,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。关于 CRC16 算法,在这里就不进行详细说明,感兴趣可以自己去了解。

那么,这些哈希槽又是如何被映射到具体的 Redis 实例上的呢?

我们在部署 Redis Cluster 方案时,可以使用 cluster create 命令创建集群,此时,Redis 会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。

接下来我们来看下如何进行集群的搭建:

redis集群需要至少三个master节点,我们这里搭建三个master节点,并且给每个master再搭建一个slave节点,总共6个redis节点,这里用三台机器部署6个redis实例,每台机器一主一从,

第一步:在第一台机器的/usr/local下创建文件夹redis‐cluster,然后在其下面分别创建2个文件夾如下
(1)mkdir ‐p /usr/local/redis‐cluster
(2)mkdir 8001 8004
​
第二步:把之前的redis.conf配置文件copy到8001下,修改如下内容:
(1)daemonize yes
(2)port 8001(分别对每个机器的端口号进行设置)
(3)pidfile /var/run/redis_8001.pid # 把pid进程号写入pidfile配置的文件
(4)dir /usr/local/redis‐cluster/8001/(指定数据文件存放位置,必须要指定不同的目录位置,不然会丢失数据)
(5)cluster‐enabled yes(启动集群模式)
(6)cluster‐config‐file nodes‐8001.conf(集群节点信息文件,这里800x最好和port对应上)
(7)cluster‐node‐timeout 15000 (根据业务需求配置对应的超时时间)
(8) # bind 127.0.0.1(bind绑定的是自己机器网卡的ip,如果有多块网卡可以配多个ip,代表允许客户端通过机器的哪些网卡ip去访问,内网一般可以不配置bind,注释掉即可) 
(9)protected‐mode no (关闭保护模式)
(10) appendonly yes 
如果要设置密码需要增加如下配置: 
(11)requirepass cxy (设置redis访问密码) 
(12)masterauth cxy (设置集群节点间访问密码,跟上面一致)
​
第三步:把修改后的配置文件,copy到8004,修改第2、3、4、6项里的端口号,可以用批量替换: 
:%s/源字符串/目的字符串/g
​
第四步:另外两台机器也需要做上面几步操作,第二台机器用8002和8005,第三台机器用8003和8006
​
第五步:分别启动6个redis实例,然后检查是否启动成功
​
第六步:用redis‐cli创建整个redis集群(redis5以前的版本集群是依靠ruby脚本redis‐trib.rb实现)
 # 下面命令里的1代表为每个创建的主服务器节点创建一个从服务器节点
 # 执行这条命令需要确认三台机器之间的redis实例要能相互访问,可以先简单把所有机器防火墙关掉,如果不 关闭防火墙则需要打开redis服务端口和集群节点gossip通信端口16379(默认是在redis端口号上加1W)
/usr/local/redis‐5.0.3/src/redis‐cli ‐a cxy ‐‐cluster create ‐‐cluster‐replicas 1 192.168.0.1:8001 192.168.0.2:8002 192.168.0.3:8003 192.168.0.1:8004 192.168.0.2:8005 192.168.0.3:8006
Ps:
  1.再次启动集群不需要使用上述命令,只需要启动对应的redis实例即可,因为创建成功后,会将配置持久化
  2.不需要手动指定主从关系,redis会自动进行匹配,尽量让主库和从库不在同一台机器上,可以登录客户端使用cluster node命令查看信息
​
第七步:验证集群,可以通过设置key,去到不同的客户端查看key或者通过cluster info、cluster nodes查看,闭集群则需要逐个进行关闭,使用命令/usr/local/redis‐5.0.3/src/redis‐cli ‐a cxy 192.168.0.x ‐p 800* shutdown

当然, 我们也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。

cluster meet {ip} {port}
redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0..5461}

上述方式是在集群未创建的情况下,接下来在redis5.0以上的版本,在创建好一个集群的情况下新增节点和进行分片,例子如下:

# 使用add-node新增节点,以上述集群创建过程中的例子为当前集群,存在192.168.0.1:8001 192.168.0.2:8002 192.168.0.3:8003 3个集  # 群主节点,192.168.0.1:8004 192.168.0.2:8005 192.168.0.3:8006三个集群子节点,master-slave的对应关系分别为 8001-8004     # 8002-8005 8003-8006
# 将8007节点加入集群中
/redis‐cli ‐a cxy ‐‐cluster add‐node 192.168.0.61:8007 192.168.0.61:8001
​
# 查看集群状态
192.168.0.4:8007>cluster nodes

image.png 可以看到8007节点已经加入到集群中,但是新增的节点不会有任何数据,因为他还没有分配任何slot,接下来我们进行分配,需要找到集群中的任意一个主节点,对其进行重新分片工作:

/redis‐cli ‐a cxy ‐‐cluster reshard 192.168.0.1:8001

在命令终端输出如下:

#需要多少个槽移动到新的节点上,自己设置,比如600个hash槽
How many slots do you want to move (from 1 to 16384)? 600
​
#把这600个hash槽移动到哪个节点上去,需要指定节点id
What is the receiving node ID? 2728a594a0498e98e4b83a537e19f9a0a3790f38
​
#输入all为从所有主节点(8001,8002,8003)中分别抽取相应的槽数指定到新节点中,抽取的总槽数为600个
Please enter all the source node IDs. 
  Type 'all' to use all the nodes as source nodes for the hash slots. 
  Type 'done' once you entered all the source nodes IDs. 
Source node 1:all
​
#输入yes确认开始执行分片任务
Do you want to proceed with the proposed reshard plan (yes/no)? yes

查看下集群的最新状态

192.168.0.4:8007>cluster nodes

image.png

如上图所示,现在我们的8007已经有hash槽了,也就是说可以在8007上进行读写数据啦!到此为止我们的8007已经加入到集群中,并且

是主节点(Master) 。

演示完了集群配置,接下来我再用一张图来简单表示下数据、哈希槽、实例这三者的关系:

image.png

在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 5 取模,再根据各自的模数结果,就可以被映射到对应的实例 1 和实例 3 上了。

另外,如果是手动分配,需要把16384个槽都分配完,否则Redis集群无法正常工作。

好了,通过哈希槽,切片集群就实现了数据到哈希槽、哈希槽再到实例的分配。但是,即使实例有了哈希槽的映射信息,客户端又是怎么知道要访问的数据在哪个实例上呢?接下来,我就来和你聊聊。

客户端如何定位数据

在定位键值对数据时,它所处对哈希槽是可以通过计算得到的,这个计算可以在客户端发送请求时就计算好hash值。但是需要进一步定位实例,还需要知道哈希槽分布在哪个实例上。

一般来说,客户端和集群实例建立连接后,实例就会把哈希槽点分配信息发送给客户端。但是,在集群刚刚建立成功后,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。

所以为了客户端在访问任何一个实例的时候,都能获得所有的哈希槽信息,Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:

  • 在集群中,实例有新增或删除,Redis 需要重新分配哈希槽;

  • 为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍

    初次均分哈希槽之后,可能由于热点key的原因,部分槽位的访问频繁,且可能这些槽位都对应在某一或几个实例上,导致所有实例之间压力不均衡,需要进行重新分布。

此时,实例之间还可以通过相互传递消息,获得最新的哈希槽分配信息,但是,客户端是无法主动感知这些变化的。这就会导致,它缓存的分配信息和最新的分配信息就不一致了,那该怎么办呢?

Redis Cluster 方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。

那客户端又是怎么知道重定向时的新实例的访问地址呢?当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址。

GET hello:key
(error) MOVED 13320 172.16.19.5:6379

其中,MOVED 命令表示,客户端请求的键值对所在的哈希槽 13320,实际是在 172.16.19.5 这个实例上。通过返回的 MOVED 命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。这样一来,客户端就可以直接和 172.16.19.5 连接,并发送操作请求了。

我画一张图来说明一下,MOVED 重定向命令的使用方法。可以看到,由于负载均衡,Slot 2 中的数据已经从实例 2 迁移到了实例 3,但是,客户端缓存仍然记录着“Slot 2 在实例 2”的信息,所以会给实例 2 发送命令。实例 2 给客户端返回一条 MOVED 命令,把 Slot 2 的最新位置(也就是在实例 3 上),返回给客户端,客户端就会再次向实例 3 发送请求,同时还会更新本地缓存,把 Slot 2 与实例的对应关系更新过来。

image.png

需要注意的是,在上图中,当客户端给实例 2 发送命令时,Slot 2 中的数据已经全部迁移到了实例 3。在实际应用时,如果 Slot 2 中的数据比较多,就可能会出现一种情况:客户端向实例 2 发送请求,但此时,Slot 2 中的数据只有一部分迁移到了实例 3,还有部分数据没有迁移。在这种迁移部分完成的情况下,客户端就会收到一条 ASK 报错信息,如下所示:

GET hello:key
(error) ASK 13320 172.16.19.5:6379

这个结果中的 ASK 命令就表示,客户端请求的键值对所在的哈希槽 13320,在 172.16.19.5 这个实例上,但是这个哈希槽正在迁移。此时,客户端需要先给 172.16.19.5 这个实例发送一个 ASKING 命令。这个命令的意思是,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送 GET 命令,以读取数据。

关于ASKING命令和MOVED命令,下面会有详细说明。

在下图中,Slot 2 正在从实例 2 往实例 3 迁移,key1 和 key2 已经迁移过去,key3 和 key4 还在实例 2。客户端向实例 2 请求 key2 后,就会收到实例 2 返回的 ASK 命令。

ASK 命令表示两层含义:第一,表明 Slot 数据还在迁移中;第二,ASK 命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例 3 发送 ASKING 命令,然后再发送操作命令。

image.png

和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息。所以,在上图中,如果客户端再次请求 Slot 2 中的数据,它还是会给实例 2 发送请求。这也就是说,ASK 命令的作用只是让客户端能给新实例发送一次请求,而不像 MOVED 命令那样,会更改本地缓存,让后续所有命令都发往新实例。

接下来我们再仔细说明下ASKING命令和MOVED命令的处理细节。

节点内部处理

为了支持ASK重定向,源节点和目标节点在内部的clusterState结构中维护了当前正在迁移的槽信息,用于识别槽迁移的情况,结构如下:

typedef struct clusterState {
  clusterNode *myself /*自身节点*/
  clusterNode *slots[CLUSTER_SLOTS]; /*槽和节点映射数组*/
  clusterNode *migrating_slots_to[CLUSTER_SLOTS]; /*正在迁出的槽节点数组*/
  clusterNode *importing_slots_from[CLUSTER_SLOTS]; /*正在迁入的槽节点数组*/
  ...
} clusterState;

节点每次接收到键命令时,都会根据clusterState内的迁移属性进行命令处理:

  • 如果键所在的槽由当前节点负责,但键不存在则查找migrating_slots_to数组查看槽是否正在迁出,如果是返回ASK重定向
  • 如果客户端发送asking命令打开了CLIENT_ASKING标识,则该客户端下次发送键命令查找importing_slots_from数组获取clusterNode,如果指向自身则执行命令
  • 批量操作。ASK重定向对单键命令支持是很完善的,但是,在开发中我们经常使用批量操作,如mget或pipeline。当槽处于迁移的状态是,批量操作会收到影响

对于单键的操作,如果节点收到一个关于key的命令请求,会查看当前的库中是否可以找到键key,如果找不到,那么节点就会去检查自己的clusterState.migrating_slots_to[i],看键key所属的槽是否正在进行迁移,若正在迁移,则会发送ASK错误给到客户端,引导客户端到正在导入该槽的的节点去查找键key。

接收到ASK错误的客户端会根据错误提供的IP地址和端口号,首先向目标节点发送一个ASKING命令,之后在重新发送原本想要执行的命令,例如上述的例子

GET hello:key
(error) ASK 13320 172.16.19.5:6379

该例子中会首先发送ASKING命令,再发送GET hello:key。

ASKING命令唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识,以下是该命令伪代码的实现:

def asking():
#打开标识
client.flag |= REDIS_ASKING
#向客户端返回OK回复
reply("OK")

一般情况下,客户端向节点发送一个关于槽i的命令,而槽i又没有指派给这个节点的话,那么节点将向客户端返回一个MOVED错误,但是,如果节点的clusterState.importing_slots_from[i]显示节点正在导入槽i,并且发送命令的客户端带有REDIS_ASKING标识,那么节点将破例执行这个关于槽i的命令一次。节点可以通过下图的importing_slots_from映射找到对应导入的clusterNode和目标节点,若节点为NULL则表示未处于导入状态,有对应的clusterNode引用则表示存在导入的槽并引用目标节点。

image.png

当客户端接收到ASK错误并转向正在导入槽点节点时,首先发送一个ASKING命令,再发送对应的执行命令,这是因为如果客户端不发送ASKING命令,而直接发送执行命令的话会被目标节点拒绝,并返回MOVED的命令,这也是ASKING的作用所在。

说完了ASKING和MOVED的详细机制,我们再来谈谈各节点之间是如何进行通信的吧。

集群的Gossip消息

Gossip协议的主要职责就是进行信息交换。常用的Gossip消息可以分为:ping消息、pong消息、meet消息、fail消息等。

image.png

  • meet消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
  • ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此的状态信息。ping消息封装了自身节点和部分其他节点的状态数据。
  • pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong信息来通知整个集群对自身状态进行更新。
  • fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。

虽然Gossip协议的信息交换机制具有天然的分布式特性,但它是有成本的。由于内部需要频繁地进行节点信息交换,而ping/pong信息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。因此节点每次选择要通信的节点列表变得非常重要。通信节点选择过多虽然可以做到信息及时交换但成本过高。节点选择过少会降低集群内所有节点彼此交换频率,从而影响故障判定、新节点发现等需求的速度。

因此具体做法为:集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息,用与保证Gossip信息交换的随机性。每100ms都会扫描本地节点列表,如果发现节点最近一次接受pong信息的时间大于cluster_node_timeout/2,则立刻发送ping消息,防止该节点信息太长时间未更新。

每个ping消息的数据量体现在消息头和消息体中,其中消息头主要占用空间的字段是myslots[CLUSTER_SLOTS/8],占用2KB,这块空间是相对固定的。消息提会携带一定数量的其他节点信息用于交换。具体数量见以下伪代码:

def get_wanted():
    int total_size = size(cluster.nodes);
    # 默认包含节点总量的1/10
    int wanted = floor(total_size/10);
    if wanted < 3:
        # 至少携带3个其他节点信息
        wanted = 3;
    if wanted > total_size -2 
        # 最多包含total_size - 2 个节点
        wanted = total_size -2;
  return wanted;

可见,更大的集群每次消息通信的成本也就更高,所以redis集群不是越大越好,当我们的带宽资源紧张时,可以通过调大cluster_node_timeout参数来降低通信频次以达到降低带宽占用率的目的。

说完了通信机制,我们再来看看集群时如何进行故障转移的,就是其中一个master宕机了如何进行处理。

故障转移

当集群的某个节点出现问题时,可以通过消息机制来判断主从状态及节点故障等。节点故障的处理主要包含两个环节:主观下线(pfail)和客观下线(fail) 。有点和哨兵机制类似。关于哨兵机制有疑问的童鞋可以看一下我上篇关于哨兵机制的文章。

主观下线,指某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点真正的下线,可能存在误判的情况。

客观下线,指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。

主观下线

集群中每个节点都会定期发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一致失败,则发送节点会认为接收节点存在故障,标记为主观下线。

每个节点内的clusterState结构都需要保存其他节点的信息,用于从自身视角判断其他节点的状态。关键结构如下:

typedef struct clusterState {
    clusterNode *myself; /* 自身节点 */
    dict *node; /* 当前集群内所有节点的字典集合,key为节点ID,value为对应节点clusterNodee结构 */
    ...
} clusterState;
​
typedef struct clusterNode {
    int flags; /* 当前节点状态,如主从角色,是否下线等 */
    mstime_t ping_sent; /* 最后一次与该节点发送ping消息的时间 */
    mstime_t pong_received; /* 最后一次接收到该节点pong消息的时间 */
    ...
} clusterNode;

Ps:想继续深入的同学可以查看redis的源码,当前代码块位于cluster.h文件中。

将某节点的信息维护在自己的结构体后会将该信息通过消息机制继续传播。

客观下线

通过Gossip消息的不断传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程。这里再说明两个问题:

1、为什么必须要是负责槽的主节点参与故障发现决策?因为集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主节点数据和状态信息的复制。

2、为什么半数以上处理槽的主节点?必须半数以上是为了应对网络分区等原因造成的集群分割情况,一方面是防止误判,另一方面集群分割的情况,即小集群因为无法完成从主观下线到客观下线这一关键过程,从而防止小集群完成故障转移之后继续对外服务 (防止在外界感知正常导致无法将分割问题显示抛出)

接下来,我们来详细说明下流程:

(1)通过消息机制进行传播,当消息体内含有其他节点的pfail状态会判断发送节点的状态,如果发送节点是主节点则对报告的pfail状态进行处理,从节点则忽略。

(2)找到pfail对应的节点结构,更新clusterNode内部下线报告链表。

上述我们说了clusterState会有来保存节点的下线信息,到这里又出现一个下线报告链表来维护下线信息,他们之间的在于clusterState中是从自身视角来判断的,而下线报告链表(clusterNodeFailReport)是被动告知的,也就是说,这个链表可以维护多条某个节点的下线报告,达到半数以上则进行客观下线。

我们来看下下线报告链表的结构:

typeof struct clusterNodeFailReport {
  struct clusterNode *node; /* 报告该节点为主观下线的节点 */
  mstime_t time; /* 最近收到下线报告的时间 */
} clusterNodeFailReport;

下线报告中保存了报告故障的节点结构和最近收到下线报告的时间,当接收到fail状态时,会维护对应的节点的下线上报链表,伪代码如下:

def clusterNodeAddFailureReport(clusterNode failNode, clusterNode senderNode):
    //获取故障节点的下线报告链表
    list report_list = failNode.fail_reports;
    //查找发送节点的下线报告是否存在
    for(clusterNodeFailReport report : report_list):
        //存在发送节点的下线报告上报
        if(senderNode == report.node)
        {
            //更新下线报告时间
            report.time = now();
            return 0;
        }
        //如果下线报告不存在,插入新的下线报告
        report_list.add(new clusterNodeFailReport(senderNode, now()));
  return 1;

每个下线报告都存在有效期,每次在尝试触发客观下线时,都会检测下线报告是否过期,对于过期的下线报告将被删除,相同的节点发送的下线报告则会进行时间的更新。

但是,如果在cluster-node-time*2的时间内该下线报告没有得到更新则过期并删除。主要是针对故障误报的情况。例如节点A在上一小时报告节点B主观下线,但是之后又恢复正常。现在又有其他节点上报节点B主观下线,根据实际情况之前的属于误报不能被使用。

所以不建议把cluster-node-time设置的过小,导致节点由于短暂的网络原因导致被客观下线。

(3)根据更新后的下线报告和尝试进行客观下线。

节点每次收到pfail都会尝试客观下线。

  • 首先会统计有效下线报告的数量,如果小于集群内持有槽的主节点总数的一半则退出
  • 当下线报告大雨槽主节点数量一半时,标记对应故障节点为客观下线
  • 向集群广播一条fail消息,通过所有的节点将故障节点标记为客观下线,fail消息的消息体只包含故障节点的ID

这里再强调下关于集群分割的问题,由于网络分区会导致小集群无法收到大集群的fail消息,因此如果故障节点所有的从节点都在小集群内将导致无法完成后续故障转移,因此部署主从结构时需要根据自身机房/机架拓扑结构,降低主从被分区的可能性。

说完了下线,接下来就需要进行故障恢复了,就是使用有效的从节点替换主节点。

故障恢复

知道哨兵机制的童鞋应该可以很快理解,原理和哨兵差不多,就是先进行资格检查、准备选举时间,再发起选举、选举投票,最后替换主节点。

1、每个从节点都要检查最后与主节点断线的时间,判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。参数cluster-slave-validity-factor为从节点的有效因子,默认为10。

2、 当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。

故障选举时间相关字段如下:

struct clusterState {
  ...
  mstime_t failover_auth_time; /* 记录之前或者下次将要执行故障选举时间 */
  int failover_auth_rank; /* 记录当前节点排名 */
}

看到这里是否对故障选举时间有些疑问,为什么需要这个东西,目的是什么?别急,我们来慢慢分析。

最终,我们要选出的从库一定是数据最接近主库的,也就是复制的偏移量要最大的。关于偏移量我在主从原理的文章中有说明。

那么对于偏移量高的从节点,它应该具有更高的优先级来替换下故障主节点,所以redis也是这么做了,偏移量越大,则优先级就越高,这里使用rank代表优先级,rank越小,优先级越大。

我们来看下故障触发时间设置的伪代码:

def updateFailoverTime():
    //默认触发选举时间:发现客观下线后一秒内执行
    server.cluster.failover_auth_time = now() + 500 + random()%500;
    //获取当前从节点排名
    int rank = clusterGetSlaveRank();
    long added_delay = rank * 1000;
    //使用added_delay时间累加到failover_auth_time中
    server.cluster.failover_auth_time += added_delay;
    //更新当前从节点排名
    server.cluster.failover_auth_rank = rank;

image.png 看了伪代码,我们再结合下图,偏移量大的从节点延迟的时间最短,保证了复制延迟低的从节点优先发起选举。那么如果存在偏移量一致如何保成错开发起选举时间呢,在伪代码中我们可以看到 [server.cluster.failover_auth_time = now() + 500 + random()%500;] 有一个随机参数,这就保证了基本上不会同时发起选举的可能。

3、当从节点定时任务检测到达故障选举时间后,发起选举操作。

节点和集群有一个配置纪元的概念,个人觉得比较重要,所以在这里还是要说明下。

这里的配置纪元我们就直接理解为一个里程或者是版本的概念,首先我们来看下配置纪元长什么样子:

image.png

配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元(clusterNode.configEpoch)表示当前主节点的版本,所有主节点的配置纪元都不相等,从节点会复制主节点的配置纪元。整个集群又维护一个全局的配置纪元(clusterState.currentEpoch),用于记录集群内所有主节点配置纪元的最大版本。

配置纪元会跟随ping/pong消息在集群内传播,在进行数据交换时,配置纪元大的主节点的数据会覆盖配置纪元小的主节点数据,这里的数据指发送方节点的数据和发送方带上的其他节点数据,相同的节点会进行数据的更新(覆盖) 。但是当发送方与接收方都是主节点且配置纪元相等时代表出现了冲突,nodeId更大的一方会递增全局配置纪元并赋值给当前节点来区分冲突。至于为什么分配给nodeId更大的节点,个人理解是因为最新的节点通过ping/pong交换时间的更晚,拿到的数据更新。

对于配置纪元,我们来总结下它的主要作用:

  • 标示集群内每个主节点的不同版本和当前集群最大的版本
  • 每次集群发送重要事件时,这里的重要事件指出现新的主节点(新加入的或者由从节点转换而来),从节点竞争选举。都会递增集群全局的配置纪元并赋值给相关主节点,用于记录这一关键操作
  • 主节点具有更大的配置纪元代表了更新的集群状态,因此当节点间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群

配置纪元的应用场景:

  • 新节点加入
  • 槽节点映射冲突检测
  • 从节点投票选举冲突检测

从节点每次发起投票时都会自增集群的全局配置纪元,并单独保存在clusterState.failover_auth_epoch变量中用于标识本次从节点发起选举的版本。

接着,就是在集群内广播选举消息,并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。消息内容如同ping消息只是将type类型变为FAILOVER_AUTH_REQUEST。

只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST),因为每个持有槽的节点在一个配置纪元内都有唯一一张选票,当接收到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的选举消息将忽略。

当从节点收到N/2 + 1个持有槽的主节点投票时,从节点可以执行替换主节点操作,这也是为什么要求集群中持有槽的主节点数量不小于3的原因,不然无法进行主从切换。

投票作废: 每个配置纪元代表了一次选举周期,如果在开始投票之后的cluster-node-timeout*2时间内从节点没有获取到足够数量的投票,则本次选举作废。从节点对配置纪元发起自增并发起下一轮投票,知道选举成功为止。

当从节点收到足够的票数后:

  • 当前从节点取消复制变为主节点
  • 执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己
  • 向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息

好了,到这里故障转移就讲完了,是不是觉得redis的机制很完备,可以从中学到不少。

最后我们来说下集群运维方面的事情。

集群运维

集群完整性

为了保证集群完整性,默认情况下当集群16384个槽任何一个没有指派到节点时整个集群不可用,这块在上面说过了,我在这里再强调下。但是如果持有槽的主节点在下线进行主从切换时整个集群处于不可用状态,那么这是不可接受的,所以建议将参数cluster-require-full-coverage配置为no,当主节点故障时只影响它负责的槽,不会影响其他主节点,这也是集群优于哨兵的点之一。

带宽消耗

集群内Gossip消息通信本身会消耗带宽,官方建议集群最大规模在1000以内,也是出于对消息通信成本的考虑,节点间消息通信队带宽的消耗主要体现下以下几个方面:

  • 消息发送频率:跟cluster-node-timeout密切相关,当节点发现于其他节点最后通信时间超过cluster-node-timeout/2时会直接发送ping消息。
  • 消息数据量:每个消息主要的数据占用包含:slots槽数组(2KB)和整个集群1/10的状态数据(10个节点状态数据约为1KB)
  • 节点部署的机器规模:机器带宽的上限是固定的,因此相同规模的集群分布的机器越多美态机器划分的节点越均匀,则集群内整体可用带宽越高

例如,一个总节点200的Redis集群,部署在20台物理机,每台10个节点,如果这时cluster-node-timeout设置为15s,计算得到的ping/pong消息占用带宽达到25Mb,如果设置为30s,那么就会降到15Mb以下,但是其会影响故障转移的时间,所以需要均衡两者。

Pub/Sub广播问题

Redis提供了发布订阅功能,用于频道实现消息的发布和订阅。但是集群内部发布消息,所有的节点都会接收到,可能会加重带宽负担,所以该功能尽量不要频繁使用,特别是大量的集群节点中。如果需要进行发布订阅,建议使用sentinel。

集群倾斜

集群倾斜指不同节点之间数据量和请求量出现明显差异,这种情况将加大负载均衡和开发运维的难度。

1、数据倾斜

  • 节点和槽分配严重不均

    针对每个节点的分配不均的问题可以通过cluster node查看,也可以通过redis自带的redis-trib.rb info {ip:port},不过需要额外安装ruby。

  • 不同槽对应键数量差异过大

    键通过CRC16哈希函数映射到槽上,正常情况下槽内键数量相对均匀,但当大量使用hash_tag时,会产生不同的键映射在同一个槽的情况,造成部分槽数据量过大。可以使用cluster countkeysinslot {slot}获取槽对应的键数量,再通过cluster getkeysinsolts {slot} {count}迭代出槽下所有的键。

    Hash_tag:在某些场景下,我们希望同一组key映射在同一个槽内,例如用户的多种数据,user:user1:name 和 user:user1:age,这两个键会在同一个槽下,因为包含相同的hash_tag为user,所以如果同一类的数据过多,就为造成槽点数据过大。

  • 集合对象包含大量元素

    可以通过redis-cli --bigkeys命令识别,根据业务场景进行拆分。bigkey的造成redis读写需要较长的处理时间,会阻塞后续的请求处理;如果一个key的大小为1MB,每秒访问量为1000,那么每秒会产生1000MB的流量。这对于普通千兆网卡的服务器来说是灾难性的。同时集群槽数迁移是对键执行migrate操作完成,过大的键集合容易造成migrate命令的超时。

    migrate是一个原子操作,它在执行的时候会阻塞进行迁移的两个实例,直到以下任意结果发生:迁移成功,迁移失败,等到超时。

  • 内存相关配置不一致

    内存相关配置指hash-max-ziplist-value、set-max-intset-entries等压缩数据结构配置。当集群大量使用hash、set等数据结构是,如果内存压缩数据结构配置不一致,极端情况下会相差数倍的内存,从而造成节点内存的倾斜。这点大家知道下就好。

2、请求倾斜

该问题常发生在热点键场景,当键命令消耗较低时如小对象的get、set、incr等,即使请求量差异较大也不会出现负载严重不均。但是当热键对应高算法复杂度当命令或者是大对象操作如hgetall、smembers等,会导致对应节点负载过高。避免方式如下:

  • 合理设计键,热点大对象做拆分或使用hmget替代hgetall避免整体读取
  • 不要使用热键作为hash_tag,避免映射到同一槽
  • 对于一致性不高的场景,客户端可以使用本地缓存减少热键调用。也就是我们所说的多级缓存,先是在网关层看是否有,接下来到应用层,最后到redis缓存。

好了,集群的主要内容和原理机制在这里就说完了,其中对于集群的脑裂问题我将在下一遍文章中进行说明,不然真的会篇幅过大。

欢迎小伙伴们沟通指正。

参考

《redis开发与运维》

《redis设计与实现》

极客时间 - Redis核心技术与实战