本文主要讲Redis集群相关的机制

哨兵机制请参阅专栏:https://segmentfault.com/a/11...

本文参考:

  • redis官方文档-Redis集群规范
  • 《redis设计与实现》

Redis集群定义

Redis集群是以高性能、安全和高可用为目标而设计的一套分布式实现。

高性能:高吞吐,同时具备高达1000个节点的线性扩展能力。没有代理、使用异步复制,同时也不会做值执行合并操作。

安全: 保证一定程度的写安全,尽最大努力保留连接大多数主节点的客户端的所有写操作。

高可用:在网络分区的情况,redis能够在拥有大多数主节数的分区下正常提供服务,并且保证每个主节点至少拥有一个从节点,当没有从节点的主节点发生故障需要转移时,可以从拥有多个从节点的其他主节点接收一个从节点来进行转移。

关于写安全

redis集群在主从节点间使用异步复制,而最后赢得选举的主节点的数据将会替换所有其他从节点的副本。那么,在分区期间总有可能丢失写入的时间窗口。而这个时间窗口在客户端连接的是大多数主节点和少数主节点这两种不情况的情况下有很大的不同。而Redis集群则是进最大努力来保证连接到大多数主节点的客户端执行的写入。

以下可能发生数据丢失的场景

1、redis主节点宕机:redis主节点在处理完写入后将消息返回给客户,再通过异步复制把写入操作同步到从节点,如果在写入消息还没有同步到从节点之前主节点宕机了,由于主节点长时间无法访问Redis集群将会把其从节点升级为主节点,而从节点又没有同步宕机前在主节点执行的写入操作,那么这时候这个写入操作就丢失了。

2、redis主节点所在的网络发生分区:

出现以下事件时可能发生写入丢失:

  1. 因为分区导致主节点不可达;
  2. 发生故障转移,从节点升级为主节点;
  3. 一段时间后主节点又可达;
  4. 在Redis集群把这个旧主节点转换为新主节点的从节点之前,客户端通过过期的路由表把写操作发送到旧主节点。

第1种情况发生可能写丢弃的时间窗口实际上非常小,因为把响应写回客户端跟把写入操作传播给从节点,这几乎是同时发生的。

第2种情况其实不太可能发生,因为故障的主节点在长时间(NODE_TIMEOUT)无法与大多数主节点通讯时,将会故障转移并且不再接受写入操作,而当分区恢复时,在其他节点同步配置给它之前,会有一段时间拒绝写入操作。同时,它还需要客户端有一个过期的路由表。

另外,在网络分区的情况下,如果客户端连接的主节点在在具有少数主节点的分区中,在大数节点主节点分区中Redis集群检测到并发生故障转移之前,会有一个比较大的丢失写入的时间容器。具体的来说,如果发生分区,在NODE_TIMEOUT到达之前恢复了网络,那么不会发生数据库,因为时间没有故障转移;而如果在NODE_TIMEOUT到达之前网络还没有恢复,那么将触发故障转移,在NODE_TIMEOUT这段时间的所有写入操作都将丢失了。

关于高性能

  • 在Redis Redis集群中,不会通过代理将请求转发到处理该key集的节点上,而是能够根据槽位的分布情况,直接地将向处理该key集合的节点发送请求命令。
  • 节点之间通过异步复制,节点不需要等待其他节点的对写入的确认(前提是没有使用wait命令)
  • 避免值合并。允许多键操作,前提是涉及多键操作的单个命令(或者事务、lua脚本)的所有键都是指向同一个hash槽。

不存在值合并操作

值合并操作通过发生在查询的键值对在不同节点上有不同的版本,需要获取多个节点的数据后对数据进行一次合并后再返回。

Redis的设计避免在不同节点上相同的键值存在版本冲突,并且redis的数据模型也不允许这种行为。因为在Redis,值通常非常大,通过会看到包含数百万个元素的列表和排序集,另外redis的数据类型也比较复杂。如果需要传输并且合并不同节点上不同版本的值,这可能会是一个性能瓶颈,并且非常非常严格的程序来控制,同时也需要额外分配一些内存来管理它们。

关于高可用

假设在大多数主节点的网络分区,有多个主节点,并且每个不可达主节点都有对应的从节点,在NODE_TIMEOUT后的几秒内就能够自动发生故障,而如果在拥有少数节点的网络分区,Redis将是不可用的。实际上Redis集群的高可用需要有一个前提,即只是发生少数节点的故障。如果发生大规模的节点故障,那么Redis集群将不是一个合适的解决方案。

Redis集群部署及相关配置

详见另一个链接:https://segmentfault.com/a/11...

Redis集群相关机制

key分布式

Reids集群的key分布不同于单机,单机上所有key都分布在一个主节点上,而在Redis集群中,key会通过一致性哈希算法分布到不同槽位上,再把这些槽位分配给各个主节点。在Redis集群中,键空间(key space)会被划分为16384个槽位,实际上是redis设计了一致性哈希算法,通过这一算法能够将不同的key散列到这13684个槽位上。对于集群中的每一个主节点,它将处理这16384个槽位的一个子集,并且不同主节点的子集之间的元素没有交集,也就是说,一个槽位只会分配到一个主节点上,而一个主节点允许同时负责多个槽位的处理。

HASH_SLOT = CRC16(key) mod 16384

key散列标签

除了通过一致性哈希来确认key所属的槽位,还可以通过哈希标签将一些相同标签的key强制散列到同一个槽位上。比如,key的名字组成为aaa.{xxx}.ccc,那么将仅对{}内的xxx做哈希散列来计算出槽位。

散列标签的规则为,取第一出现的{和第一次出来}中间的非空字符来做散列,以下列举几种取散列key的例子

key实际进行哈希散列的字符
{user1}.nameUser1
{{user1}}.name{user1
{}.{user1}.name{}.{user1}.name (第一出现的{和第一次出来}中间的字符为空字符串)
{class}.{user1}.nameclass

集群拓朴

redis集群由多个节点组成,每个节点与其它节点都会建立一个TCP连接,整个redis集群的网络拓朴呈现一个网状结构。redis集群中节间之间的连接为长连接,节点之间通过ping-pong的心跳来检测节点之间的状态,ping-pong的报文其中包含了gossip协议的报文,这可以用来做配置更新以及节点发现。

虽然节点之间有心跳,但通过gossip协议和配置更新机制可以避免正常运行情况下指数级的消息交换。这个具体下文进行说明。

节点间的握手

一个集群通常由多个节点组成,在刚开始的时候,每个节点都是独立的,它们都只处于一个包含自己的集群中。那么如何将多个节点组成成一个大的集群?

实际上redis除了数据同步端口(比例默认情况下是6379)外,还需要监听一个集群总线端口(syncPort+10000,默认即16379)。集群间的握手通过总线端口来连接,如果发送方与接受方同属一个集群,那么通过集群总线端口连接后,接受方会立即返回ping命令,否则将丢弃所有其他的数据包。

在redis集群,只有两种方式能够接受节点作为集群中的一部分:

  • 第一种情况是通过Meet命令。节点node1向指定ip和port的节点node2发送meet命令,如CLUSTER MEET ip port,那么node2将被纳入到node1所在的集群,作为集群的一部分。
  • 第二种情况下,redis集群节点间的心跳包含gossip协议的报文,这里边包含除其自身节点外,少数随机的其他节点的信息。如果一个被信任的节点通过gossip协议把其他节点的信息传播过来,那么当前节点也会认为它是可信任的,最终也会建立到该节点的连接。即A->B,B->C,那么A->C。

槽位分派

Redis集群支持通过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
  • ADDSLOTS和DELSLOTS仅用于节点分配槽,分配槽位意味着告诉给定的主节点它将负责为指定的哈希槽提供存储和服务。ADDSLOTS通常用于从头创建集群,为每个主节点分配所有16384个可用你哈希槽的子集;DELSLOTS用于主动修改集群配置或者调试,实际上很有真实场景需要使用到。
  • CLUSTER SETSLOT slot NODE node用于将槽分配给特定ID的节点。与ADDSLOTS的区别,个人认为,ADDSLOTS需要将命令发送到指定的主节点,而CLUSTER SETSLOT slot NODE node可以发送到集群中任一个主节点。
  • MIGRATING和IMPORTING用来表示节点当前槽位的状态,MIGRATING表示当前槽位正在迁移,IMPORTING表示当前槽位正在导入。

    • 主节点在收到CLUSTER SETSLOT slot MIGRATING node命令后,会修改指定槽位的状态,并将槽位的键逐步地迁移到指定ID的节点中,如果这时候收到这个槽位的查询命令,会先判断key是否存在于当前节点的键空间,如果存在将会处理该命令,如果不存在,则会返回ASK错误,通知客户端转向正在导入该槽位的节点,并在发送key的请求命令之前发送ASKING命令。
    • 主节点在收到CLUSTER SETSLOT slot IMPORTING node命令后,会开始准备接收指定ID节点的指定槽位,节点也将接受关于这个槽位的所有查询,当然前提必须是客户端提前发送了ASKING命令。否则将会返回一个MOVED错误。

    注意:在槽位重新分派未结束前,槽位分配配置还是不变,即某一个槽位正在进行迁移,负责该槽位的节点还是指向迁移前的结点,这时候还是由它来处理该槽位的请求命令,当槽位内的所有key都迁移到另一个节点时,槽位才会分派到迁移后的节点,槽位分配配置才会发生变化。

槽位重新分片

redis集群槽位重新分配操作是由redis的管理软件redis-trib负责执行的,redis提供了进行了进行重新分片所需的命令,而redis-trib则通过向源节点和目标节点发送命令进行重新分片操作。

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

  • Redis-trib向目标节点发送CLUSTER SETSLOT <SLOT> IMPORTING <SOURECE_ID>命令,让目标节点准备好从源节点导入属于slot槽的键值对。
  • Redis-trib向源节点发送CLUSTER SELSLO <SLOT> MIGRATING <TARGET_ID>命令,让源节点准备好将属于slot槽的键值对迁移到目标节点。
  • Redis-trib向源节点发送CLUSTER GETKEYSINSLOT <SLOT> <COUNT>命令,获取最多count个属于槽slot的键值对的键名。
  • 对于上一个步骤获取获得的键名,redis-trib都向源节点发送一个MIGRATE <TARGET_IP> <TARGET_PORT> <KEY_NAME> 0 <TIMEOUT>命令,将被选中的键原子地从源节点迁移至目标节点。
  • 重复前两个步骤,直至源节点保存的所有属于槽slot的键值对都迁移至目标节点为止。
  • Redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT <SLOT> NODE <TARGET_ID>命令,将槽指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所有节点都会知道槽slot已经指派给了目标节点。

在集群中执行命令

在redis集群中,由于key的分布式,key经常哈希后被指定到不同的槽位,而不同的槽位又不同的节点负责,并且在上文也提到,redis cluster并没有代理,客户端到负责该key的主节点,采用的是直连的方式,如果redis是如何将key的请求发送到准确的主节点上呢?

客户端可以自由地向每个节点发送与数据库有关的命令,接收命令的节点会计算出命令要处理的数据库键key属于哪个槽,并且检查这个槽是否指派给了自己(每个节点都会记录自己和其他节点分别负责的槽位)。如果发现当前槽位并没有分派给自己,那么将返回MOVED错误。

MOVED错误

如果发现这个数据库键key所属的槽并没有指派给自己,那么则会响应一个MOVED响应,格式为MOVED <slot> <ip>:<port>。通过MOVED响应来通知客户端负责处理槽位节点ip和端口,客户端在接受到MOVED响应后,将之间的请求命令转发到正确的节点上。

ASK错误

如果在节点处于重新分片的期间,源节点向目标节点迁移一个槽时,这时候槽的一部分数据可能还在源节点上,一部分数据已经迁移到目标节点上。这时如果源节点收到一个该槽的请求命令,

  • 源节点先在自己保存的数据中查找指定的键,如果找到的话,就直接执行客户端的命令。
  • 如果源节点没能在自己的键空间中找到指定的键,那么这个键有空间已经被迁移到了目标节点,源节点就会向客户端返回一个ASK(ASK <slot> <ip>:<port>),指引客户端转向正在导入槽的目标节点,并再次发送之前想要 请求的命令。

故障转移

心跳

在Redis集群中,节点之间通过ping包和pong包不断地进行数据交换,这两种报文结构相同,都携带重要的配置信息,唯一的区别是消息类型字段,ping和pong统称为心跳数据包。

一般情况下,节点发送ping包,接收者回复pong包。但也有例外的情况,在需要尽快进行配置更新时,节点会直接发送一个pong给其他节点。

在一个redis集群中,可能存在非常多的节点,一个节点并不会向全部其他的节点都发送ping心跳包,而是每秒只会随机的选择其中几个节点进行发送。另外,节点保存有其他节点最后的心跳刷新时间,如果某个或某些节点的最终心跳时间超过了NODE_TIMEOUT的一半,那么也会向这个或这些节点发送一个ping心跳包,并且在NODE_TIMEOUT时间到达之前,还会尝试重新建立一个TCP连接,以确保不会因为当前的TCP连接存在问题而认为节点不可达。

那么如果NODE_TIMEOUT设置为非常短并且节点数非常多,则可能导致集群中的心跳特别频繁。

心跳包的数据结构

上文我们提到ping包和pong包都属于心跳包。这两者包含一个对所有包类型公共的包头header,以及一个特定于ping和pong的特殊gossip部分。

公共包头包含以下信息:

  • Node ID:节点id,一个120bit的伪随机字符,在第一个创建节点时分配的,并在redis集群中整个生命周期保持不变(除非调用reset 命令)。
  • configEpoch和currentEpoch:配置纪元。(todo)
  • node Flag: 节点身份标识,表示是master还是slave,或者是其他sing-bit节点
  • myslots:一个用bitmap来表示的当前节点负责的槽位,如果是从节点,则发送的是其主节点的槽位bitmap。
  • port:发送方的tcp端口(加10000即可获得redis集群总线端口)
  • state:节点状态(ok或者down)
  • slaveof:正在复制的主节点ID,如果当前是主节点,则是一个40字节长的00000字符数组。
Gossip

除此之外 ,ping包和pong包还包含一个gossip部分。gossip部分向接收方提供了发送方对集群中其他节点的认知,这部分并不包含所有发送所有已知的其他节点信息,而是只包含其中随机的几个节点信息,具体的数量与集群的大小成正比。gossip部分中每个节点的结构如下所示:

  • Node ID
  • IP and Port:地址和端口
  • Node Flags:节点标识

Gossip部分允许接受者通过从发送者获取其他节点的状态信息,这对于故障检测和发现集群中的其他节点是非常有用的。

故障探测

Redis故障探测用于识别大多数节点不再可以访问主节点或从节点,并做出将一个从节点提升为主节点的响应。另外,当集群中的从节点无法升级时,集群将处理错误状态以停止接收来自客户端的查询。

正如我们上方提到的,每个节点都存储了已知其他节点的相关联的状态标识,其中有两个标识用于故障检测,即PFAIL和FAIL。

  • PFAIL表示可能的故障,是一种未确认的故障类型,与Sentinel模式下的主观下线类似。
  • FAIL表示节点已经故障,并且是经过大多数节点在固定时间内的确认。

PFAIL

当节点A发现节点B在超过NODE_TIMEOUT时间不可访问,节点A会使用PFAIL标识节点B。上文心跳章节我们提过节点之间通过心跳进行探测,每个节点保存有其他节点的最后心跳刷新时间,如果除了随机探测,还会在最后心跳刷新时间+NODE_TIMEOUT/2时,主要发起一次探测,同时会还重新创建TCP连接确保没有因为连接问题而误判。

FAIL

在节点被标识为PFAIL后还不足以触发从节点升级机制,因为PFAIL只是每个节点对于其他节点的一个主观认知。要 将一个节点判定为不可用,PFAIL需要升级为FAIL.

在心跳小节早提及过,节点间会通过心跳中的gossip部分来传递对其他节点信息的视图,如果在gossip部分中,一个节点的标识为PFAIL,那么则会记录这个节点的PFAIL报告,这个FPAIL报告具有时效性(默认NODE_TIMEOUT*2),如果过期,那么它会被移除。当一个节点的PFAIL报告足够多时(满足需要的票数),那么PFAIL将会升级为FAIL,发送FAIL包给所有其他节点。

FAIL状态是单向的,它不会再进行升级,只能在满足条件下进行移除

  • 节点恢复可达并且该节点的角色是从节点。在这种情况下FLAG可以清除,因为从节点没有故障转移。
  • 节点恢复可达,且该节点的角色是主节点,但这个主节点没有被分派任何槽位。在这种情况下,FAIL状态可以清除,因为没有分配槽位的节点并不是集群中真正的一部分,并且在等待集群配置来加入集群。
  • 节点恢复可达,且该节点的角色是主节点,但很长一段时间(n次NODE_TIMEOUT)后仍然没有任何的从节点升级(可能原因:网络分区)。它最好重新加入集群并且保持现状。

故障转移

在上一节中我们了解到,主节点是如何被判定为下线状态的。那么主节点下线后,从节点升级为主节点这个流程是如何进行的呢?实际上选取哪个从节点来进行升级,这个不是由主节点来选择的,而是靠从节点自行发起投标,获取得大多数票的从节点将升级主节点,接管旧主节点负责的槽位。具体流程如下所示:

  • 从节点收到FAIL包
  • 从节点与下线主节点的其他从节点进行通讯,根据从节点的数据复制的offset来计算自身的等级,offset越高的从节点等级越高,0表示最高级。
  • 根据等级延迟一定时间,目的是为了让等级高的优先开始投票选举(计算公式如下)

    DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds +
            SLAVE_RANK * 1000 milliseconds.
  • 向各个主节点发送FAILOVER_AUTH_REQUEST,等待响应获取同意;FAILOVER_AUTH_REQUEST包含epoch和需要负责的槽位bitmap
  • 主节点在以下条件都满足的条件下,返回同意投票;

    • FAILOVER_AUTH_REQUEST的currentEpoch大于主节点的lastVoteEpoch,且未对相同的epoch投过票。
    • 发起投票的从节点的主节点被当前主节点标识为FAIL
    • FAILOVER_AUTH_REQUEST的currentEpoch不能小于主节点的currentEpoch
  • 从节点在得到超过半数的投票后,它开始通过ping和ping包宣示自己成为主节点,包含自己的ConfigEpoch和接管的hash槽。同时为了尽快地其他节点的配置更新,它还会立即广播pong到所有其他节点。如果当前存在其他节点不可达,那么当这些节点恢复后将同其他节点的ping和pong包来更新更新,或者在其他节点发现故障恢复的节点发送的心跳包已经过期后,将会给后者发现更新包。

VLoye
51 声望5 粉丝