什么是Redis-Cluster集群?
作者:程序员马丁
热门项目实战社群,收获国内众多知名公司面试青睐,近千名同学面试成功!助力你在校招或社招上拿个offer。
回答话术
分片集群是 Redis 在 3.0 以后提供的一个集群部署方案,它主要解决的是集群的横向扩展问题。
Redis 集群是一个去中心化的结构,它没有类似注册中心这样的全局管理者,因此集群中的每个节点都会通过 Gossip 协议与其他节点保持通信,以监控彼此的健康状态,并交换包括哈希槽的分配情况、疑似下线的节点情报在内的各项数据,并最终在每个节点都全量的保存集群的各项元数据。
Redis 集群中的每个主节点都可以拥有多个从节点,主节点将会通过主从复制向从节点同步数据,不过与传统主从模式不同的是,Redis 集群中的从节点一般不对外提供读服务,它们仅用于作为备份。当主节点下线后,集群中的其他节点将会通过投票选举选出新的主节点,并且自动的完成主从切换,实现故障转移。
在 Redis 集群中,共划分了 16384 (即 2 ^ 14)个哈希槽,集群中的每个节点都拥有其中的一部分槽位,当客户端发起请求时,可以直接请求集群中的任意节点,节点将会通过哈希函数确认 Key 落在哪一个槽位上,进而确认客户端的请求最终需要路由到集群中的哪一个节点。此时,如果当前节点即为目标节点,将直接执行命令,否则将会返回 MOVED 响应告知客户端应当改为请求哪一个节点。(常用的客户端基本都具备自动完成重定向功能,并且为了性能还都会在本地缓存槽位和节点的映射关系,直接在本地计算出最终要路由的节点,从而避免每次都要访问到错误的节点后再重定向)
基于这样的机制,当 Redis 集群需要进行扩容或缩容时,就可以通过重新分配哈希槽灵活变更集群中的节点数量,甚至可以通过为具备更强性能的节点分配更多的槽位,手动实现“数据倾斜”。
问题详解
1. 分片集群
当资源不够的时候, 我们就需要对服务进行扩容,扩容可以分为横向和纵向,纵向很好理解,就是直接给机器加内存加 CPU。不过,单台服务器的硬件扩容始终是有上限的,因此我们会需要考虑横向扩容,比如:
- 为了存储超出单台机器内存上限的数据,我们需要将数据拆分到不同的机器进行存储。
- 面对越来越大的流量和计算规模,我们则需要允许同时让更多的 Redis 实例来一并提供服务以缓解压力。
简单的来说,就是同时上更多的机器从而分担单台机器的压力,Redis 集群(Cluster)就是类似这样的东西,某种程度上来说,它的概念比较接近传统概念的多主多从。
和 MySQL 等传统数据库的分库分表一样,Redis 集群同样基于分片算法实现。
在 Redis 集群中,共划分了 16384 (即 2 ^ 14)个哈希槽,集群中的每个节点都拥有其中的一部分槽位,当客户端发起请求时,可以直接请求集群中的任意节点,节点将会通过哈希函数确认 Key 落在哪一个槽位上,进而确认客户端的请求最终需要路由到集群中的哪一个节点。
其中,每个 Redis 实例都可以拥有多个从节点(不过从节点一般只作为备份,并不提供服务),并且不同的 Redis 实例之间会通过 Gossip 协议保持通信,它们将会分享彼此的哈希槽分配情况,并在主节点宕机时连接到新的备用节点。
虽然说从节点一般只用来做备份,但是你也可以通过将其设置为
readonly
后让它可以对外提供读服务。不过这个做法不太常见:一方面原因是主从复制有延迟,从节点的数据与主节点可能不一致;另一方面是既然都已经集群部署了,直接多加几台 机器把切片分的更细点,要比搞读写分离更方便,而且性能提升的也更明显。
2. 集群搭建
当我们在配置文件中配置相关参数,并且指定 Redis 以集群模式启动后,实际上各个节点还仍然处于独立状态。
我们需要执行 CLUSTER MEET <IP> <PORT>
命令让节点之间互相发现。比如,在下图中,我们让 A 节点分别通过 CLUSTER MEET 192.168.0.2 6379
与 CLUSTER MEET 192.168.0.3 6379
命令将 B 和 C 节点拉进集群。
此时,集群实际上已经搭建完成,但是在最开始的时候, 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 要加入:
- C 邀请 D 加入集群,现在 C 知道了 D 的存在;
- B 与 C 进行 ping & pong,通过 C 得知 D 加入了集群,现在 B 知道了 D 的存在;
- A 与 B 进行 ping & pong,通过 B 得知 D 加入了集群,现在 A 也知道了 D 的存在。
可见,当有一个新的事件发生后,它会逐步的在集群中传播,即使中间有节点挂掉,只要消息的传播路径没有完全被切断,那么其他节点也会最终会被通知到,比如新节点的加入,或者节点下线……等。
当然,实际上 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
一般情况下,我们均分即可,不过,如果某台 Redis 实例的性能格外强悍,你也可以为其分配更多的槽位。
4.2. Key 是如何路由的
和集群的搭建过程一样,在最开始,Redis 实例只知道本节点所拥有的槽位,随着节点之间通过 ping & pong 交换彼此的槽位数据,慢慢的每个节点都将会知道整个集群的槽位分配情况。
默认情况下,当你操作一个 Key 的时候,你可以直接将请求打到集群中任意一个节点上,然后:
- Redis 实例将会根据 CRC16 算法计算出一个值,再对 16384 取模,从而得到该 Key 要落到的槽位;
- 若 Key 恰好落在当前节点拥有的槽位上,那就直接执行命令;
- 否则就检查拥有槽位的节点是否存在,如果存在,则它会返回一个 MOVED 响应,告知客户端应该访问哪一个节点;
- 接着,客户端将会根据(如果支持的话)MOVED 命令将请求重定向到正确的 Redis 节点。
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 集群为什么不基于一致性哈希算法实现?