Skip to main content

什么是Redis-Cluster集群?

作者:程序员马丁

在线博客:https://nageoffer.com

note

热门项目实战社群,收获国内众多知名公司面试青睐,近千名同学面试成功!助力你在校招或社招上拿个offer。

回答话术

分片集群是 Redis 在 3.0 以后提供的一个集群部署方案,它主要解决的是集群的横向扩展问题

Redis 集群是一个去中心化的结构,它没有类似注册中心这样的全局管理者,因此集群中的每个节点都会通过 Gossip 协议与其他节点保持通信,以监控彼此的健康状态,并交换包括哈希槽的分配情况、疑似下线的节点情报在内的各项数据,并最终在每个节点都全量的保存集群的各项元数据

Redis 集群中的每个主节点都可以拥有多个从节点,主节点将会通过主从复制向从节点同步数据,不过与传统主从模式不同的是,Redis 集群中的从节点一般不对外提供读服务,它们仅用于作为备份。当主节点下线后,集群中的其他节点将会通过投票选举选出新的主节点,并且自动的完成主从切换,实现故障转移。

在 Redis 集群中,共划分了 16384 (即 2 ^ 14)个哈希槽,集群中的每个节点都拥有其中的一部分槽位,当客户端发起请求时,可以直接请求集群中的任意节点,节点将会通过哈希函数确认 Key 落在哪一个槽位上,进而确认客户端的请求最终需要路由到集群中的哪一个节点。此时,如果当前节点即为目标节点,将直接执行命令,否则将会返回 MOVED 响应告知客户端应当改为请求哪一个节点。(常用的客户端基本都具备自动完成重定向功能,并且为了性能还都会在本地缓存槽位和节点的映射关系,直接在本地计算出最终要路由的节点,从而避免每次都要访问到错误的节点后再重定向)

基于这样的机制,当 Redis 集群需要进行扩容或缩容时,就可以通过重新分配哈希槽灵活变更集群中的节点数量,甚至可以通过为具备更强性能的节点分配更多的槽位,手动实现“数据倾斜”。

问题详解

1. 分片集群

当资源不够的时候,我们就需要对服务进行扩容,扩容可以分为横向和纵向,纵向很好理解,就是直接给机器加内存加 CPU。不过,单台服务器的硬件扩容始终是有上限的,因此我们会需要考虑横向扩容,比如:

  • 为了存储超出单台机器内存上限的数据,我们需要将数据拆分到不同的机器进行存储。
  • 面对越来越大的流量和计算规模,我们则需要允许同时让更多的 Redis 实例来一并提供服务以缓解压力。

简单的来说,就是同时上更多的机器从而分担单台机器的压力,Redis 集群(Cluster)就是类似这样的东西,某种程度上来说,它的概念比较接近传统概念的多主多从。

image.png

和 MySQL 等传统数据库的分库分表一样,Redis 集群同样基于分片算法实现。

在 Redis 集群中,共划分了 16384 (即 2 ^ 14)个哈希槽,集群中的每个节点都拥有其中的一部分槽位,当客户端发起请求时,可以直接请求集群中的任意节点,节点将会通过哈希函数确认 Key 落在哪一个槽位上,进而确认客户端的请求最终需要路由到集群中的哪一个节点。

其中,每个 Redis 实例都可以拥有多个从节点(不过从节点一般只作为备份,并不提供服务),并且不同的 Redis 实例之间会通过 Gossip 协议保持通信,它们将会分享彼此的哈希槽分配情况,并在主节点宕机时连接到新的备用节点。

虽然说从节点一般只用来做备份,但是你也可以通过将其设置为 readonly 后让它可以对外提供读服务。不过这个做法不太常见:一方面原因是主从复制有延迟,从节点的数据与主节点可能不一致;另一方面是既然都已经集群部署了,直接多加几台机器把切片分的更细点,要比搞读写分离更方便,而且性能提升的也更明显。

2. 集群搭建

当我们在配置文件中配置相关参数,并且指定 Redis 以集群模式启动后,实际上各个节点还仍然处于独立状态。

我们需要执行 CLUSTER MEET <IP> <PORT> 命令让节点之间互相发现。比如,在下图中,我们让 A 节点分别通过 CLUSTER MEET 192.168.0.2 6379CLUSTER MEET 192.168.0.3 6379 命令将 B 和 C 节点拉进集群。

image.png

此时,集群实际上已经搭建完成,但是在最开始的时候, B 和 C 并不知道彼此的存在,只有 A 掌握集群的全貌,随后,A 节点将会根据 Gossip 协议与 B 和 C 进行通信,并且一并把自己维护的集群状态元数据告知 B 和 C,此后,B 和 C 将会意识到彼此的存在,并且建立关系。

这里提到的“建立关系”,本质上是指 Redis 实例在本地为新节点建立 ClusterNode 对象,并且添加到 clusterState 字典的过程。有了这个字典,节点才可以在后续对其他节点进行健康检查和数据交换。

3. 节点间的通信

由于 Redis 的集群是去中心化的,这意味着集群中实际上没有一个类似注册中心一样的角色,所以每个节点都需要通过 Gossip 协议与其他节点保持通信,这个通信端口通常是默认的服务端口加 10000,比如默认的服务端口是 6379,那么集群通信端口就是 16379。

3.1. Gossip 协议

尽管我们常说节点之间会“保持通信”,但实际上,Redis 集群并不要求每个节点都必须一直与其他所有节点同时保持连接,这要归功于 Redis 使用的 Gossip 协议,Gossip 可以译为流言或者八卦,这很好的反映了这个协议的特点。

举个例子,假如现在有一个新节点 D 要加入:

  1. C 邀请 D 加入集群,现在 C 知道了 D 的存在;
  2. B 与 C 进行 ping & pong,通过 C 得知 D 加入了集群,现在 B 知道了 D 的存在;
  3. A 与 B 进行 ping & pong,通过 B 得知 D 加入了集群,现在 A 也知道了 D 的存在。

image.png

可见,当有一个新的事件发生后,它会逐步的在集群中传播,即使中间有节点挂掉,只要消息的传播路径没有完全被切断,那么其他节点也会最终会被通知到,比如新节点的加入,或者节点下线……等。

当然,实际上 Redis 也不是完全依赖这种方式传播消息,对于一些时效性要求比较强的消息 —— 比如节点下线或者主从切换 —— 则会直接通过广播的方式进行通知

3.2. 节点间的数据交换

组群后,每个 Redis 实例都会在本地维护一个集群实例列表,然后定期从中挑选节点发送 ping 消息,而另一个节点收到了之后会回以 pong 响应。两个节点通过这个步骤来确认彼此健康状态,并传递其其他节点的下线状态,以及交换彼此持有的槽位信息等数据。

需要注意的是,ping 的目标并不是完全随机的,它遵循两个规则:

  • 默认情况下,Redis 实例每隔一秒都会从已知的集群节点中挑选出 5 个实例,然后再从中挑选出一个最久没有 ping 过的节点发送消息。
  • 每隔一段时间,Redis 将会检查其他节点对当前节点请求的响应情况,如果发现有节点最近一次响应距今已接近超时时间 cluster-node-timeout,那么它会立刻向该节点发起 ping 请求,若再无响应则会标记为主观下线。

3.3. 为什么集群规模不是越大越好?

基于上述情况,我们不难意识到,Redis 的集群规模并不是越大越好。

由于 Redis 集群是去中心化集群,因此节点总是全量存储整个集群的信息,并且集群中的节点数量越多,需要元数据占用的存储空间就越多,并且节点间通信带来的性能开销也就越大此外,去中心化的结构也导致我们很难清晰的掌握整个集群的状态,节点状态变化的延迟性也会带来极大的管理成本

因此,对于超大规模集群,比起原生的 Redis Cluster,还是基于中间层代理的方式进行管理会更合适一些。关于这一点,可以参考:✅ Redis 如何应对海量请求?

4. 哈希槽

4.1. 哈希槽的分配

当我们通过 CLUSTER MEET 命令完成集群的搭建后,实际上集群依然是无法工作的,因为我们还没有为各个节点分配分配哈希槽。

我们可以通过 redis-cli -h <IP> –p <PORT> cluster addslots <begin, end> 为各个节点分配槽位:

redis-cli -h 192.168.0.1 –p 6379 cluster addslots 0,5461
redis-cli -h 192.168.0.2 –p 6379 cluster addslots 5462,10922
redis-cli -h 192.168.0.3 –p 6379 cluster addslots 10923,16383

image.png

一般情况下,我们均分即可,不过,如果某台 Redis 实例的性能格外强悍,你也可以为其分配更多的槽位。

4.2. Key 是如何路由的

和集群的搭建过程一样,在最开始,Redis 实例只知道本节点所拥有的槽位,随着节点之间通过 ping & pong 交换彼此的槽位数据,慢慢的每个节点都将会知道整个集群的槽位分配情况。

默认情况下,当你操作一个 Key 的时候,你可以直接将请求打到集群中任意一个节点上,然后:

  1. Redis 实例将会根据 CRC16 算法计算出一个值,再对 16384 取模,从而得到该 Key 要落到的槽位;
  2. 若 Key 恰好落在当前节点拥有的槽位上,那就直接执行命令;
  3. 否则就检查拥有槽位的节点是否存在,如果存在,则它会返回一个 MOVED 响应,告知客户端应该访问哪一个节点;
  4. 接着,客户端将会根据(如果支持的话)MOVED 命令将请求重定向到正确的 Redis 节点。

image.png

4.3. 请求到了错误的节点怎么办?

我们在上文提到,如果你请求到了一个错误的节点,那么 Redis 实例会返回 MOVED 响应,告知你应当去请求哪一个节点,然后你再去请求正确的节点

不过在实际中,出于性能考虑,通常支持 Redis 集群的客户端都会提供哈希槽映射缓存和遇到 MOVED 响应时自动重试的功能。

比如,Jedis 会在连接池初始化时缓存集群中的槽位和节点的映射关系,当操作 Key 时,将优先在本地完成路由,从而避免频繁的重复发生“访问了错误节点 Redis - 节点返回 REMOVED 响应 - 根据 REMOVED 响应再访问正确的节点”的尴尬情况。

当然,既然是缓存,总归要面对数据一致性问题。当集群重新调整槽位分配而客户端又未及时更新缓存时,客户端还是会由于访问到错误节点而接收到 MOVED 响应的,此时,它将会根据情况立即重新刷新缓存,并且自动再从新发送请求

4.4. 集群环境下如何进行批量操作?

由于 Redis 集群环境下需要根据 Key 进行路由,因此在此情况下,一些诸如 MGET 、MSET 这类的批量操作命令、Redis 的事务以及管道等涉及到批量操作的行为都将受到限制。比如,当你通过 Jedis 进行 MSET 的时候,如果这批 Key 是对应到不同的槽位的,那么将会直接报错。

为了提高效率,我们可以在客户端提前计算出 Key 所属的哈希槽,然后将落到相同槽位的 Key 再合并到一个批量操作中,不过这个做法又带来了命令执行的顺序性和原子性问题。

综上,最好的办法还是通过 Redis 提供的 HashTag 功能强行将一批 Key 指定落到同一个哈希槽中。简单的来说,当我们在 Key 后使用 {tag} 语句为其添加一个 tag 后,在获取哈希值时只需要根据 tag 计算即可。

4.5. 为什么要基于哈希槽而不是一致性哈希算法?

简单的来说,在 Redis Cluster 这个去中心化的架构中,一致性哈希比起哈希槽在实现和使用上都更加复杂。在同等复杂度下,哈希槽的方案更容易做到手动调整数据分布,更能避免数据倾斜,而一致性哈希则不行。并且,当节点宕机时,基于一致性哈希的方案很容易造成雪崩。

不过,虽然官方出于各种考虑没有基于一致性哈希算法实现集群,但是在 Redis Cluster 之前,很多公司的 Redis 集群解决方案就是基于一致性哈希算法实现的,比如推特的 Twemproxy 或者 Jedis 自带的集群功能。

更具体的内容请参见:✅ Redis 集群为什么不基于一致性哈希算法实现?

4.6. 为什么槽的数量是 16384?

这里直接放上作者的原话(GitHub issue#2576):

The reason is:

  1. Normal heartbeat packets carry the full configuration of a node, that can be replaced in an idempotent way with the old in order to update an old config. This means they contain the slots configuration for a node, in raw form, that uses 2k of space with16k slots, but would use a prohibitive 8k of space using 65k slots.
  2. At the same time it is unlikely that Redis Cluster would scale to more than 1000 mater nodes because of other design tradeoffs.

So 16k was in the right range to ensure enough slots per master with a max of 1000 maters, but a small enough number to propagate the slot configuration as a raw bitmap easily. Note that in small clusters the bitmap would be hard to compress because when N is small the bitmap would have slots/N bits set that is a large percentage of bits set.

简单的来说,主要还是考虑到节点间通信时,槽位信息在数据包中占用的大小问题。

Redis 节点间更新槽位信息的时候需要保证幂等,因此它们总是要全量的交换各自节点拥有的槽位信息,而槽位信息则是通过一个 16384 位的位图来表示,节点拥有哪个槽位,就把对应的 bit 设置为 1,否则就为 0。

基于这个前提:

  • 如果有 16384 个槽,那么一张图的大小就是 2KB (16384 / 8),但是如果有 65536 个槽,一张图的大小就是 8KB (65536 / 8),当集群的节点很多时,这个大小是不可接受的。
  • Redis Cluster 不太可能扩展到超过 1000 个主节点,太多可能导致网络拥堵,因此整个集群的槽位数量在 16k 左右是一个比较合适的数值,它对应的位图足够小,但是又能保证每个节点都能拥有足够数量的槽位,再结合一下第一个理由,因此选择了 16384。

5. 扩容缩容

当我们需要添加一个新的节点后,需要从其他节点里面匀出一部分哈希槽到这个新节点上。而移除节点的操作则相反,需要将当前节点的哈希槽分别导入到其他的节点。因此,Redis 集群的扩容和缩容,本质上就是哈希槽的重新分配

5.1. 数据的转移

image.png

在最底层,槽位是一个一个的迁移的,我们假设现在要从源节点(source)向目标节点(target)迁移一个槽,那么客户端将会依次向 target 和 source 发送命令完成迁移:

  1. 向 target 发送导入命令 cluster setslot {slot} importing {sourceId},表明 target 正在从 source 导出数据;
  2. 向 source 发送导出命令 cluster setslot {slot} migrating {targetId},表明 source 正在向 target 导入数据;
  3. 循环获取 source 指定槽位下的 Key,并将其导入到 target:
    1. 向 source 发送 cluster getkeysinslot {slot} 命令,获取源节点中,指定槽位下所有的 Key;
    2. 再向 source 发送 migrate {targetId} {targetPort} {key_name} 0 {timeout} keys {keys}命令;
    3. source 会将指定的 Key 数据转为 RDB 文件传输给 target;
    4. target 接受并加载数据后,返回成功;
    5. source 将传输完毕的 Key 删除。
  4. 向其他节点广播 cluster setslot {slot} node {targetNodeId} 命令,通知其他节点更新槽位信息。

至此,一个槽位就迁移完成了。上述过程适用于扩容,不过实际上缩容的过程也差不多,只不过最后缩容结束后,需要再发一个节点下线的广播。

另外,实际使用中,我们基本不会手动迁移,而是通过 Redis 提供的 redis-trib.rb 工具批量迁移槽位。

5.2. ASK 响应与 ASKING 请求

我们假设,现在源节点 A 正在向目标节点 B 进行迁移,并且客户端将要访问的 Key 正好正在进行迁移,那么整个流程大致如下:

  1. 如果客户端直接访问目标节点 B,由于 Key 正在迁移过程,因此 B 会认为这个节点不存在,因此直接返回 MOVED 响应,让客户端去找 A;
  2. 如果客户端直接或者遵循 MOVED 响应去访问源节点 A ,那么节点 A 首先会检查该 Key 是否还在本地存在,如果存在则会直接执行,否则将会返回 ASK 响应,指示该数据已经被导入到目标节点 B,客户端需要去目标节点 B 完成请求;
  3. 由于此时槽位的迁移并没有全部完成,如果直接去访问目标节点 B 只会再次得到一个 MOVED 响应。因此,客户端在重新发送命令前,需要先向节点 B 发送一条 ASKING 命令,以标记该客户端是根据源节点的 ASK 响应找上门的;
  4. 节点 B 确认请求的客户端已经带有 ASKING 标记后,会给这个客户端开一个后面,不会再直接返回 MOVED 响应拒绝客户端请求,而是正常的执行命令。

另外,与 MOVED 一样,大多数支持 Redis 集群的客户端也都针对 ASK 响应做了处理,会自动完成重定向。

总得来说,槽位重新分配的过程基本不会影响集群或节点的正常运行,不过这也不意味着高枕无忧,因为 Redis 执行命令是单线程的,当进行数据迁移时,如果数据量太大,那么接受数据的目标节点可能会长时间处于阻塞状态。因此当重新分配槽位时,最好不要一次性转移过多的数据。

6. 故障转移

6.1. 故障检测

解决故障的第一步是发现故障,在大型集群中由于节点较多,比较容易因为网络波动而导致节点掉线,因此 Redis 集群与哨兵模式一样,同样将节点下线分为了 主观下线”和“客观下线”两个阶段。

  • 主观下线:当 A 节点向 B 节点发起请求后,B 在规定的超时时间内没有响应,那么 A 节点就会认为 B 主观下线。不过正如它的名字一样,“主观下线”很可能是不准确的,因为此时 B 节点很有可能只是网络波动或者其他问题导致临时的掉线,过一会就会自己恢复,因此光有 A 一个节点认为 B 下线是不够的 。
  • 客观下线:当集群中有一半的节点都认为某个节点主观下线,那么说明它真的宕机了,将会认为它是客观下线。

整个过程大致如下:

  1. 发现节点主观下线: 在集群中,每一个节点都会定期与其他节点进行通信,当节点向另一节点发起请求后,如果对方在规定时间内没有响应,那么它就会认为这个没有及时回应的节点已经主观下线。不过此时它仍无法确认到底该节点是真的宕机了,还是只是因为网络波动导致临时没有响应,因此它会先添加一个计数器,等待其他节点的情报;
  2. 确认节点客观下线:在此之后,该节点将会继续与其他节点进行通信,在这个过程中它们将会交换各自记录的主观下线的节点的情报,每当它从另一个节点那里得知对方也认为该节点下线,它就让计数器加一;
  3. 广播节点下线:当计数器达到当前集群节点数量的一半时(即当前集群中有一半的节点都认为某个节点线下后),它将会认定那个节点客观下线,并且直接在集群中向其他节点发起广播。

image.png

6.2. 节点下线后集群还能运行吗?

在默认情况,当集群中的某个 Redis 节点宕机后,由于槽缺了一部分,因此整个集群将会处于不可用的状态。

不过,你可以通过 cluster-require-full-coverage 将配置为 false,这样集群就可以在缺失部分槽位的情况继续提供服务,只要你的 Key 在哈希取模后不要落到缺失的那部分槽位上,就依然可以正常被处理。

不过,在我们使用了集群的情况下,每个主节点都会至少配置一个从节点,如果主节点宕机,就会进入主从切换的流程。除非主从节点全部宕机,才会影响集群的高可用性。

6.3. 主从切换

当一个从节点收到了自己的主节点客观下线的广播后,它就会开始准备进行故障转移。

如果当前客观下线的主节点只有它一个从节点,那么它会直接执行 SLAVEOF no one 命令成为新的主节点。而如果客观下线的主节点有不止它一个从节点,那么它会根据 Raft 算法,让集群中的其他主节点投票选举出新的主节点。

这个过程如下:

  1. 从节点为自己设置一个投票计数器,然后向集群中的其他节点发起投票请求;
  2. 集群中的其他主节点收到投票请求后,将把票投给发起请求的从节点,如果此前该主节点已经投过票,那么它将不会再投票(相当于先到先得);
  3. 从节点收到投票后,令计数器加一。当任意一个从节点的得票数大于等于 (n/2) + 1,那么它就将成为新的主节点。而如果没有任何从节点的票数达到条件,那么就重新进行一轮投票,直到选出新的主节点为止。

当选举结束,从节点成为新的主节点后,它会将原先属于主节点的槽位都指派给自己,并且立刻向集群广播一条消息,让其他从节点知道自己已经成为的新的主节点。

关于主从间数据的如何复制的,请参见:✅ Redis 主从复制的原理是什么?