前言

上一篇介绍了 Redis 的哨兵机制。这节开始介绍 Redis 的切片集群。

当单个 Redis 实例存储的数据越来越多时,其所需的内存空间大小、磁盘空间大小、CPU 处理能力也会越来越高。此时有两种扩展方案:

  • 纵向扩展:升级单个 Redis 实例的资源配置。优点是简单直接;缺点是会受到硬件成本的限制,且单个实例处理数据的时间会增加。
  • 横向扩展:横向增加 Redis 实例的个数。优点是硬件成本低,且每个节点处理的效率都很高;缺点是实现较为困难,需要对数据进行切分和组织。

主从复制和哨兵机制保障了高可用,就读写分离而言虽然从节点扩展了主从的读并发能力,但是写能力存储能力无法进行扩展。

切片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。从而提高整个集群的写和存储能力,实现高可扩展。

信息维护

对于一个分布式集群来说,它的良好运行离不开集群节点信息和节点状态的正常维护。

通常我们可以选择两种方案进行信息的维护:

  • 中心化方法,使用一个第三方系统,比如 Zookeeper 或 etcd,来维护集群节点的信息、状态等。优点是可以及时更新数据,时效性好;缺点是更新数据的压力可能过大;
  • 去中心化方法,让每个节点都维护彼此的信息、状态,从而实现每个节点都能拥有一致的信息。优点是并发压力小;缺点是数据更新可能延迟;

redis集群切片方式 redis集群切片的三种方式_redis

Redis Cluster

Redis Cluster 是 Redis 官方推出的一种分布式解决方案。它通过将数据划分为多个槽,并将这些槽分配到不同的实例上,实现了数据的分布式存储和高可用性。

除了这个方案外,还有很多集群方案,比如在 Redis Cluster 推出前,是使用 Codis+Zookeeper 实现集群的。

为了保证高可用性,集群一般会采用主从架构,最少需要三个主节点,每个主节点建议至少配一个从节点。不同于主从架构,切片集群中的从节点主要实现数据的热备、主备切换,默认不采用读写分离的方式,读写请求都由主节点完成。

如果要提升高并发读的性能,可以通过增加主节点来提升读吞吐量,当然也可以通过修改配置将从库配置为可读,做读写分离模式,不过这种可能会复杂一点,同时客户端也需要支持读写分离。

切片集群采用去中心化方式来维护集群状态,集群中不需要哨兵来保证高可用,各个节点实现了故障节点探测、主从切换、发现节点等任务。各个节点之间通过 Gossip 协议和同步更新机制来保证数据的一致性。

redis集群切片方式 redis集群切片的三种方式_redis_02

每个节点在集群中有一个唯一的 ID,由 160 位随机十六进制数表示,保存在配置文件中。

每个节点维护的集群内其他节点的信息如下:

  • 节点信息:节点的 ID、IP、端口,节点类型(主从)、节点状态(在线、疑似下线、下线)等信息;如果是从节点还会有主节点 ID、复制偏移量等信息;
  • 哈希槽信息:一个长度为 16,384 bit 的 Bitmap(2KB),表示哈希槽的分配情况;
  • 连接信息:节点间的连接状态、最后一次发送 PING 的时间、最后一次接收 PONG 的时间等信息;
  • 故障转移信息:当前的配置纪元 configEpoch、其他节点对该节点的下线报告 fail_reports 等信息;

哈希槽

Redis Cluster 没有使用一致性哈希,而是引入了**哈希槽(hash slot)**的概念。Redis Cluster 中有 16384(2^14)个哈希槽,每个 key 通过 CRC16 校验后对 16383 取模来决定放置哪个槽。Redis Cluster 中的每个节点负责一部分哈希槽。

分配

Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。那么,客户端就可以在访问任何一个实例时,都能获得所有的哈希槽信息了。客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

Hash Tags

Hash Tags 是指加在键值对 key 中的一对花括号,能够将多个相关的 key 分配到相同的哈希槽中。例如:{user1000}.order 和 {user1000}.info 这两个 key 会被 hash 到相同的哈希槽中,因为只有 user1000 会被用来计算哈希槽的值。

重定向

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

  • 在集群中,实例有新增或删除,需要重新分配哈希槽;
  • 为了负载均衡,需要把哈希槽在所有实例上重新分配一遍。

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

Redis Cluster 方案提供了一种重定向机制来解决这个问题:客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。

当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,客户端会收到 MOVED 报错,其中包含哈希槽所在的新实例信息。

如果数据迁移只是部分完成,客户端会收到 ASK 报错,表示哈希槽正在迁移。此时,客户端需要先给新的实例发送一个 ASKING 命令。这个命令的意思是,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送 GET 命令,以读取数据。

和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息。所以,如果客户端再次请求哈希槽中的数据,它还是会旧的实例发送请求,再触发一次重定向完成缓存更新。

通信

Cluster Bus

集群总线,每个 Redis Cluster 节点有一个额外的 TCP 端口用来接受其他节点的连接。这个端口与用来接收客户端命令的 TCP 端口有一个固定的 offset(值为 10000,不可修改)。例如,一个节点在端口 6379 监听客户端连接,那么它的集群总线端口 16379 也会被打开。另外,如果加上 10000 后溢出(超过 65535),则会对 65536 取模。

节点到节点的通讯只使用集群总线,同时使用集群总线协议:有不同的类型和大小的帧组成的二进制协议。

通过集群总线,可以实现集群的节点自动发现、故障节点探测、主从切换等任务。

Gossip

Gossip 协议又称 epidemic 协议(epidemic protocol,翻译成中文就是流言协议、传染病协议),是基于流行病传播方式的节点或者进程之间信息交换的协议,在 P2P 网络和分布式系统中应用广泛,它的方法论也特别简单:在一个处于有界网络的集群里,如果每个节点都随机与其他节点交换特定信息,经过足够长的时间后,集群各个节点对该份信息的认知终将收敛到一致。

Gossip 的消息有以下四种常见的类型:

  • MEET:通过「cluster meet ip port」命令,集群中的节点会向新的节点发送邀请,加入现有集群。
  • PING:节点会按照配置的时间间隔向集群中其他节点发送 ping 消息,消息中带有自己的状态,还有自己维护的集群元数据,和部分其他节点的元数据;
  • PONG:节点用于回应 PING 和 MEET 的消息,结构和 PING 消息类似,也包含自己的状态和其他信息,也可以用于信息广播和更新;
  • FAIL:节点 ping 不通某节点后,会向集群所有节点广播该节点挂掉的消息。其他节点收到消息后标记已下线。

除了这四种之外,还有 PUBLISH、UPDATE 等,源码中总共定义了九种类型。

Gossip 协议通过定时 PING/PONG 实现节点之间的心跳检测和状态同步:

  1. 每个实例之间会按照一定的频率(默认每秒),从集群中随机挑选一些实例(默认 5 个),然后从这些实例中找出一个最久没有通信的实例,把 PING 消息发送给挑选出来的实例,用来检测实例是否在线,并交换彼此的状态信息。PING 消息中封装了发送消息的实例自身的状态信息、部分其它实例的状态信息,以及 Slot 映射表。
  2. 一个实例在接收到 PING 消息后,会回复一个 PONG 消息。PONG 消息包含的内容和 PING 消息一样。

因为是随机选取,所以可能会出现,有些实例一直没有被发送 PING 消息,导致它们维护的集群状态已经过期了

为了避免这种情况,实例还会按照每 100ms 一次的频率,扫描本地的实例列表,如果发现有实例最近一次接收 PONG 消息的时间大于 cluster-node-timeout/2,就会立刻给该实例发送 PING 消息,更新这个实例上的集群状态信息。

每个实例在发送一个 Gossip 消息时,除了会传递自身的状态信息,默认还会传递集群十分之一实例的状态信息,以此加快集群的同步。

集群状态的同步效率越高就意味通信造成的开销越大,只能在两者间进行取舍。

配置项 cluster-node-timeout 定义了集群实例被判断为故障的心跳超时时间,默认是 15 秒。调大该值,可以减少心跳检测的次数,但是如果真的发生了故障,被检测出的时间也会变长,可能影响到集群的正常使用。

Gossip 中各个节点之间的状态可能存在一定的延迟,导致节点之间的数据不一致。为了解决这个问题,Redis 采用了基于时间戳的数据冲突检测机制。当一个节点收到另一个节点的写请求时,它会先检查该节点最近一次的写操作时间戳,如果该时间戳比自己的时间戳早,则说明该节点保存的数据已经过期,需要进行更新。

高可用性

故障下线

节点有三种状态:在线状态、疑似下线状态 PFAIL(Possible Failure)、已下线状态 FAIL。

集群中每个节点都会定期地向其他节点发送 PING 消息,以此检测对方是否在线。如果接收到 PING 消息的节点没有在规定的时间内(cluster-node-timeout)返回 PONG 消息,就会被标记为 PFAIL 状态。

故障下线的具体过程如下:

  1. 假设节点 A 将节点 X 标记为 PFAIL,随后会通过 PING 消息告知集群中的其他节点;
  2. 如果在集群中,超过半数以上负责处理槽的主节点都将节点 X 标记为 PFAIL,则当前节点就会将节点 X 标记为 FAIL;
  3. 之后当前节点会立刻广播到这条消息,这样其他所有的节点都会立即将节点 X 标记为 FAIL。

FAIL 消息会强制每个接收到这消息的节点把某标记为 FAIL 状态。

PFAIL 是有时效性的,如果超过了 cluster-node-time*2 的时间,就会恢复到正常状态。

选举

主节点故障下线后,需要在下属的从节点中选举出一个,来成为新的主节点。

从节点的选举由从节点发起由其他主节点投票要提升哪个从节点。

一个从节点的选举是在主节点被至少一个具有成为主节点必备条件的从节点标记为 FAIL 的状态的时候发生的。

从节点并不是在标记主节点为 FAIL 时立刻发起选举的,而是延迟一个随机的时间,这样是为了避免多个从节点同时发起选举。也可以确保主节点的 FAIL 状态在整个集群内传开。

整个选举的流程大致如下:

  1. 当从节点发现自己的主节点进入已下线状态时,从节点会向集群广播消息,要求其他主节点进行投票;
  2. 一个主节点每轮只有一次投票机会,且只能投给下线主节点的某个从节点;
  3. 如果某个从节点得到了足够多(N/2+1,N 为主节点个数)的投票,则开始故障转移;否则进入下一轮新的选举。

若如果无法选举出从节点,那么整个集群就置为错误状态并停止接收客户端的查询,此时需要手动恢复。

选举机制大致如下:

首先将不适合作为主节点的从节点过滤掉:把已经下线的从节点、网络连接状态不好的从节点、5 秒内没有回复过 INFO 命令的从节点、与主节点断开连接的时间超过 cluster-node-timeout * cluster-replica-validity-factor 的从节点都给过滤掉;

接下来要对剩余的所有从节点进行三轮考察:优先级、复制进度、ID 号。在进行每一轮考察的时候,哪个从节点优先胜出,就选择其作为新主节点;如果有多个节点情况相同,则进入下一轮考察:

  • 第一轮考察:根据从节点的优先级来进行排序,优先级越高排名越靠前
  • 第二轮考察:如果优先级相同,则查看复制的下标,复制偏移量越大,排名越靠前
  • 第三轮考察:如果优先级和下标都相同,就选择从节点 ID 较小的那个。
故障转移

被选举出来的从节点会执行故障转移,大致过程如下:

  1. 被选中的从节点会执行 SLAVEOF no one 命令,成为新的主节点;
  2. 新主节点会撤销对所有对已下线主节点的槽指派,并将这些槽全部派给自己;
  3. 新主节点向集群广播一条 PONG 消息,让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且已经接管了原本由已下线节点负责处理的槽;
  4. 新主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

新增节点

Redis Cluster 添加新节点需要在某个节点上执行 CLUSTER MEET ip port 命令,之后会进行如下的握手操作:

  1. 节点一会先检查 IP 和端口的合法性,然后根据 IP 和端口,向新节点发送一条 MEET 消息,邀请加入集群;
  2. 新节点接收到后会返回一条 PONG 消息;节点一收到 PONG 消息后,得知新节点已经成功接收到了自己的 MEET 消息,已经成功加入集群;

加入集群后,新节点就可以定期和其他节点进行 PING/PONG 通信了,新节点加入集群的消息会通过节点间的 PING/PONG 通信主键传播开来,最后整个集群都知道了新节点的加入。

如果新增的是一个主节点,则会进行重新分片操作;如果是一个从节点,则会进行主从复制。

分布式问题

数据倾斜

在切片集群中,数据会按照一定的分布规则分散到不同的实例上保存,可能会导致数据倾斜的问题。数据倾斜分为两类:数据量倾斜和数据访问倾斜。

数据量倾斜

在某些情况下,实例上的数据分布不均衡,某个实例上的数据量特别大。可能导致数据量倾斜的原因主要有以下三个:

  1. bigkey:某个实例上正好保存了 bigkey,导致数据量增加。解决办法是避免把过多的数据保存在同一个键值对中;以及将大的集合类型数据拆分,分散保存在不同的实例上。
  2. 哈希槽分配不均:哈希槽可能存在分配不均的问题,以及由于不知道数据和哈希槽的对应关系,所以可能导致哈希槽中的数据分配不均。
  3. Hash Tags:使用了 Hash Tags 之后,可能导致大量数据被集中到某个实例中。如果真的发生这种情况,可能要考虑关闭 Hash Tags。
数据访问倾斜

虽然每个集群实例上的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁

可以将热点数据复制多份,并在每一个副本的 key 中加一个随机前缀,从而映射到不同的哈希槽这种。但是这种方法只能应用于只读的热点数据,如果是针对可读写的热点数据,只能考虑实例的横向和纵向扩展了。

最后

本文介绍了 Redis 的切片集群,主要是官方提供的 Redis Cluster 方案。下一节将介绍 Redis 用于缓存方面的问题。