Redis 高可用篇:Cluster 集群能支撑的数据有多大?

本文将对集群的节点、槽指派、命令执行、重新分片、转向、故障转移、消息等各个方面进行深入拆解。



Redis 集群原理总览
目的在于掌握什么是 Cluster ?Cluster 分片原理,客户端定位数据原理、故障切换,选主,什么场景使用 Cluster,如何部署集群 …...

将数据分成多份存在不同实例上

哈希槽与 Redis 实例映射

复制与故障转移

故障检测

故障转移

选主流程

用表保存键值对和实例的关联关系可行么

重新分配哈希槽

MOVED 错误

ASK 错误

Gossip 消息

实例的通信频率

降低实例间的通信开销

为什么需要 Cluster

65 哥:码哥,自从用上了你说的哨兵集群实现故障自动转移后,我终于可以开心的跟女朋友么么哒也不怕 Redis 宕机深夜宕机了。

可是最近遇到一个糟心的问题,Redis 需要保存 800 万个键值对,占用 20 GB 的内存。

我就使用了一台 32G 的内存主机部署,但是 Redis 响应有时候非常慢,使用 INFO 命令查看 latest_fork_usec 指标(最近一次 fork 耗时),发现特别高。


主要是 Redis RDB 持久化机制导致的,Redis 会 Fork 子进程完成 RDB 持久化操作,fork 执行的耗时与 Redis 数据量成正相关。

而 Fork 执行的时候会阻塞主线程,由于数据量过大导致阻塞主线程过长,所以出现了 Redis 响应慢的表象。


65 哥:随着业务规模的拓展,数据量越来越大。主从架构升级单个实例硬件难以拓展,且保存大数据量会导致响应慢问题,有什么办法可以解决么?


保存大量数据,除了使用大内存主机的方式,我们还可以使用切片集群。俗话说「众人拾材火焰高」,一台机器无法保存所有数据,那就多台分担。

使用 Redis Cluster 集群,主要解决了大数据量存储导致的各种慢问题,同时也便于横向拓展。

两种方案对应着 Redis 数据增多的两种拓展方案:垂直扩展(scale up)、水平扩展(scale out)。

垂直拓展:升级单个 Redis 的硬件配置,比如增加内存容量、磁盘容量、使用更强大的 CPU。
水平拓展:横向增加 Redis 实例个数,每个节点负责一部分数据。
比如需要一个内存 24 GB 磁盘 150 GB 的服务器资源,有以下两种方案:



水平拓展与垂直拓展
在面向百万、千万级别的用户规模时,横向扩展的 Redis 切片集群会是一个非常好的选择。


65 哥:那这两种方案都有什么优缺点呢?


垂直拓展部署简单,但是当数据量大并且使用 RDB 实现持久化,会造成阻塞导致响应慢。另外受限于硬件和成本,拓展内存的成本太大,比如拓展到 1T 内存。
水平拓展便于拓展,同时不需要担心单个实例的硬件和成本的限制。但是,切片集群会涉及多个实例的分布式管理问题,需要解决如何将数据合理分布到不同实例,同时还要让客户端能正确访问到实例上的数据。
什么是 Cluster 集群
Redis 集群是一种分布式数据库方案,集群通过分片(sharding)来进行数据管理(「分治思想」的一种实践),并提供复制和故障转移功能。

将数据划分为 16384 的 slots,每个节点负责一部分槽位。槽位的信息存储于每个节点中。

它是去中心化的,如图所示,该集群有三个 Redis 节点组成,每个节点负责整个集群的一部分数据,每个节点负责的数据多少可能不一样。



Redis 集群架构
三个节点相互连接组成一个对等的集群,它们之间通过 Gossip协议相互交互集群信息,最后每个节点都保存着其他节点的 slots 分配情况。

开篇寄语

技术不是万能的,程序员也不是最厉害的,一定要搞清楚,不要觉得「老子天下第一」。一旦有了这个意识,可能会耽误我们的成长。

技术是为了解决问题的,如果说一个技术不能解决问题,那这个技术就一文不值。

不要去炫技,没有意义。


集群安装

点击 -> 《Redis 6.X Cluster 集群搭建》查看


一个 Redis 集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。

连接各个节点的工作可以通过 CLUSTER MEET 命令完成:CLUSTER MEET <ip> <port> 。

向一个节点 node 发送 CLUSTER MEET 命令,可以让 node 节点与 ip 和 port 所指定的节点进行握手(handshake),当握手成功时,node 节点就会将 ip 和 port 所指定的节点添加到 node 节点当前所在的集群中。



CLUSTER MEET
就好像 node 节点说:“喂,ip = xx,port = xx 的老哥,要不要加入「码哥字节」技术群,加入集群就找到了一条大神成长之路,关注「码哥字节」公众号回复「加群」,是兄弟就跟我一起来!”






关于 Redis Cluster 集群搭建详细步骤,请点击文末左下角「阅读原文」或者点击 -> 《Redis 6.X Cluster 集群搭建》查看,官方关于 Redis Cluster 的详情请看:https://redis.io/topics/cluster-tutorial。

Cluster 实现原理

65 哥:数据切片后,需要将数据分布在不同实例上,数据和实例之间如何对应上呢?


Redis 3.0 开始,官方提供了 Redis Cluster 方案实现了切片集群,该方案就实现了数据和实例的规则。Redis Cluster 方案采用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系。

跟着「码哥字节」一起进入 Cluster 实现原理探索之旅…...

将数据分成多份存在不同实例上
集群的整个数据库被分为 16384 个槽(slot),数据库中的每个键都属于这 16384 个槽的其中一个,集群中的每个节点可以处理 0 个或最多 16384 个槽。

Key 与哈希槽映射过程可以分为两大步骤:

根据键值对的 key,使用 CRC16 算法,计算出一个 16 bit 的值;
将 16 bit 的值对 16384 执行取模,得到 0 ~ 16383 的数表示 key 对应的哈希槽。
Cluster 还允许用户强制某个 key 挂在特定槽位上,通过在 key 字符串里面嵌入 tag 标记,这就可以强制 key 所挂在的槽位等于 tag 所在的槽位。

哈希槽与 Redis 实例映射

65 哥:哈希槽又是如何映射到 Redis 实例上呢?


在 部署集群的样例中通过 cluster create 创建,Redis 会自动将 16384 个 哈希槽平均分布在集群实例上,比如 N 个节点,每个节点上的哈希槽数 = 16384 / N 个。

除此之外,可以通过 CLUSTER MEET 命令将 7000、7001、7002 三个节点连在一个集群,但是集群目前依然处于下线状态,因为三个实例都没有处理任何哈希槽。

可以使用 cluster addslots 命令,指定每个实例上的哈希槽个数。


65 哥:为啥要手动制定呢?


能者多劳嘛,加入集群中的 Redis 实例配置不一样,如果承担一样的压力,对于垃圾机器来说就太难了,让牛逼的机器多支持一点。

三个实例的集群,通过下面的指令为每个实例分配哈希槽:实例 1负责 0 ~ 5460 哈希槽,实例 2 负责 5461~10922 哈希槽,实例 3 负责 10923 ~ 16383 哈希槽。

redis-cli -h 172.16.19.1 –p 6379 cluster addslots 0,5460
redis-cli -h 172.16.19.2 –p 6379 cluster addslots 5461,10922
redis-cli -h 172.16.19.3 –p 6379 cluster addslots 10923,16383
键值对数据、哈希槽、Redis 实例之间的映射关系如下:



数据、Slot与实例的映射
Redis 键值对的 key 「码哥字节」「牛逼」经过 CRC16 计算后再对哈希槽总个数 16384 取模,模数结果分别映射到实例 1 与实例 3 上。

切记,当 16384 个槽都分配完全,Redis 集群才能正常工作。

复制与故障转移

65 哥:Redis 集群如何实现高可用呢?Master 与 Slave 还是读写分离么?


Master 用于处理槽,Slave 节点则通过《Redis 主从架构数据同步》方式同步主节点数据。

当 Master 下线,Slave 代替主节点继续处理请求。主从节点之间并没有读写分离, Slave 只用作 Master 宕机的高可用备份。

Redis Cluster 可以为每个主节点设置若干个从节点,单主节点故障时,集群会自动将其中某个从节点提升为主节点。

如果某个主节点没有从节点,那么当它发生故障时,集群将完全处于不可用状态。

不过 Redis 也提供了一个参数cluster-require-full-coverage可以允许部分节点故障,其它节点还可以继续提供对外访问。

比如 7000 主节点宕机,作为 slave 的 7003 成为 Master 节点继续提供服务。当下线的节点 7000 重新上线,它将成为当前 70003 的从节点。

故障检测

65 哥:在《Redis 高可用篇:Sentinel 哨兵集群原理》我知道哨兵通过监控、自动切换主库、通知客户端实现故障自动切换,Cluster 又如何实现故障自动转移呢?


一个节点认为某个节点失联了并不代表所有的节点都认为它失联了。只有当大多数负责处理 slot 节点都认定了某个节点下线了,集群才认为该节点需要进行主从切换。

Redis 集群节点采用 Gossip 协议来广播自己的状态以及自己对整个集群认知的改变。比如一个节点发现某个节点失联了 (PFail),它会将这条信息向整个集群广播,其它节点也就可以收到这点失联信息。

关于 Gossip 协议可阅读悟空哥的一篇文章:《病毒入侵,全靠分布式》

如果一个节点收到了某个节点失联的数量 (PFail Count) 已经达到了集群的大多数,就可以标记该节点为确定下线状态 (Fail),然后向整个集群广播,强迫其它节点也接收该节点已经下线的事实,并立即对该失联节点进行主从切换。

故障转移
当一个 Slave 发现自己的主节点进入已下线状态后,从节点将开始对下线的主节点进行故障转移。

从下线的 Master 及节点的 Slave 节点列表选择一个节点成为新主节点。
新主节点会撤销所有对已下线主节点的 slot 指派,并将这些 slots 指派给自己。
新的主节点向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
新的主节点开始接收处理槽有关的命令请求,故障转移完成。
选主流程

65 哥:新的主节点如何选举产生的?


集群的配置纪元 +1,是一个自曾计数器,初始值 0 ,每次执行故障转移都会 +1。
检测到主节点下线的从节点向集群广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。
这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的主节点。
参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,如果收集到的票 >= (N/2) + 1 支持,那么这个从节点就被选举为新主节点。
如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。
跟哨兵类似,两者都是基于 Raft 算法来实现的,流程如图所示:



集群Leader选举
用表保存键值对和实例的关联关系可行么

65 哥,我来考考你:“Redis Cluster 方案通过哈希槽的方式把键值对分配到不同的实例上,这个过程需要对键值对的 key 做 CRC 计算并对 哈希槽总数取模映射到实例上。如果用一个表直接把键值对和实例的对应关系记录下来(例如键值对 1 在实例 2 上,键值对 2 在实例 1 上),这样就不用计算 key 和哈希槽的对应关系了,只用查表就行了,Redis 为什么不这么做呢?”


使用一个全局表记录的话,假如键值对和实例之间的关系改变(重新分片、实例增减),需要修改表。如果是单线程操作,所有操作都要串行,性能太慢。

多线程的话,就涉及到加锁,另外,如果键值对数据量非常大,保存键值对与实例关系的表数据所需要的存储空间也会很大。

而哈希槽计算,虽然也要记录哈希槽与实例时间的关系,但是哈希槽的数量少得多,只有 16384 个,开销很小。

客户端如何定位数据所在实例

65 哥:客户端又怎么确定访问的数据到底分布在哪个实例上呢?


Redis 实例会将自己的哈希槽信息通过 Gossip 协议发送给集群中其他的实例,实现了哈希槽分配信息的扩散。

这样,集群中的每个实例都有所有哈希槽与实例之间的映射关系信息。

在切片数据的时候是将 key 通过 CRC16 计算出一个值再对 16384 取模得到对应的 Slot,这个计算任务可以在客户端上执行发送请求的时候执行。

但是,定位到槽以后还需要进一步定位到该 Slot 所在 Redis 实例。

当客户端连接任何一个实例,实例就将哈希槽与实例的映射关系响应给客户端,客户端就会将哈希槽与实例映射信息缓存在本地。

当客户端请求时,会计算出键所对应的哈希槽,在通过本地缓存的哈希槽实例映射信息定位到数据所在实例上,再将请求发送给对应的实例。



Redis 客户端定位数据所在节点
重新分配哈希槽

65 哥:哈希槽与实例之间的映射关系由于新增实例或者负载均衡重新分配导致改变了咋办?







集群中的实例通过 Gossip 协议互相传递消息获取最新的哈希槽分配信息,但是,客户端无法感知。

Redis Cluster 提供了重定向机制:客户端将请求发送到实例上,这个实例没有相应的数据,该 Redis 实例会告诉客户端将请求发送到其他的实例上。


65 哥:Redis 如何告知客户端重定向访问新实例呢?


分为两种情况:MOVED 错误、ASK 错误。

MOVED 错误
MOVED 错误(负载均衡,数据已经迁移到其他实例上):当客户端将一个键值对操作请求发送给某个实例,而这个键所在的槽并非由自己负责的时候,该实例会返回一个 MOVED 错误指引转向正在负责该槽的节点。

GET 公众号:码哥字节
(error) MOVED 16330 172.17.18.2:6379
该响应表示客户端请求的键值对所在的哈希槽 16330 迁移到了 172.17.18.2 这个实例上,端口是 6379。这样客户端就与 172.17.18.2:6379 建立连接,并发送 GET 请求。

同时,客户端还会更新本地缓存,将该 slot 与 Redis 实例对应关系更新正确。



MOVED 指令
ASK 错误

65 哥:如果某个 slot 的数据比较多,部分迁移到新实例,还有一部分没有迁移咋办?


如果请求的 key 在当前节点找到就直接执行命令,否则时候就需要 ASK 错误响应了,槽部分迁移未完成的情况下,如果需要访问的 key 所在 Slot 正在从从 实例 1 迁移到 实例 2,实例 1 会返回客户端一条 ASK 报错信息:客户端请求的 key 所在的哈希槽正在迁移到实例 2 上,你先给实例 2 发送一个 ASKING 命令,接着发发送操作命令。

GET 公众号:码哥字节
(error) ASK 16330 172.17.18.2:6379
比如客户端请求定位到 key = 「公众号:码哥字节」的槽 16330 在实例 172.17.18.1 上,节点 1 如果找得到就直接执行命令,否则响应 ASK 错误信息,并指引客户端转向正在迁移的目标节点 172.17.18.2。



ASK 错误
注意:ASK 错误指令并不会更新客户端缓存的哈希槽分配信息。

所以客户端再次请求 Slot 16330 的数据,还是会先给 172.17.18.1 实例发送请求,只不过节点会响应 ASK 命令让客户端给新实例发送一次请求。

MOVED指令则更新客户端本地缓存,让后续指令都发往新实例。

集群可以设置多大?

65 哥:有了 Redis Cluster,再也不怕大数据量了,我可以无限水平拓展么?


答案是否定的,Redis 官方给的 Redis Cluster 的规模上线是 1000 个实例。


65 哥:到底是什么限制了集群规模呢?


关键在于实例间的通信开销,Cluster 集群中的每个实例都保存所有哈希槽与实例对应关系信息(Slot 映射到节点的表),以及自身的状态信息。

在集群之间每个实例通过 Gossip协议传播节点的数据,Gossip 协议工作原理大概如下:

从集群中随机选择一些实例按照一定的频率发送 PING 消息发送给挑选出来的实例,用于检测实例状态以及交换彼此的信息。PING 消息中封装了发送者自身的状态信息、部分其他实例的状态信息、Slot 与实例映射表信息。
实例接收到 PING 消息后,响应 PONG 消息,消息包含的信息跟 PING 消息一样。
集群之间通过 Gossip协议可以在一段时间之后每个实例都能获取其他所有实例的状态信息。

所以在有新节点加入,节点故障,Slot 映射变更都可以通过 PING,PONG 的消息传播完成集群状态在每个实例的传播同步。

Gossip 消息
发送的消息结构是 clusterMsgDataGossip结构体组成:

typedef struct {
    char nodename[CLUSTER_NAMELEN];  //40字节
    uint32_t ping_sent; //4字节
    uint32_t pong_received; //4字节
    char ip[NET_IP_STR_LEN]; //46字节
    uint16_t port;  //2字节
    uint16_t cport;  //2字节
    uint16_t flags;  //2字节
    uint32_t notused1; //4字节
} clusterMsgDataGossip;
所以每个实例发送一个 Gossip消息,就需要发送 104 字节。如果集群是 1000 个实例,那么每个实例发送一个 PING 消息则会占用 大约 10KB。

除此之外,实例间在传播 Slot 映射表的时候,每个消息还包含了 一个长度为 16384 bit 的 Bitmap。

每一位对应一个 Slot,如果值 = 1 则表示这个 Slot 属于当前实例,这个 Bitmap 占用 2KB,所以一个 PING 消息大约 12KB。

PONG与PING 消息一样,一发一回两个消息加起来就是 24 KB。集群规模的增加,心跳消息越来越多就会占据集群的网络通信带宽,降低了集群吞吐量。

实例的通信频率

65 哥:码哥,发送 PING 消息的频率也会影响集群带宽吧?


Redis Cluster 的实例启动后,默认会每秒从本地的实例列表中随机选出 5 个实例,再从这 5 个实例中找出一个最久没有收到 PING 消息的实例,把 PING 消息发送给该实例。


65 哥:随机选择 5 个,但是无法保证选中的是整个集群最久没有收到 PING 通信的实例,有的实例可能一直没有收到消息,导致他们维护的集群信息早就过期了,咋办呢?


这个问题问的好,Redis Cluster 的实例每 100 ms 就会扫描本地实例列表,当发现有实例最近一次收到 PONG 消息的时间 > cluster-node-timeout / 2。那么就立刻给这个实例发送 PING 消息,更新这个节点的集群状态信息。

当集群规模变大,就会进一步导致实例间网络通信延迟怎加。可能会引起更多的 PING 消息频繁发送。

降低实例间的通信开销
每个实例每秒发送一条 PING消息,降低这个频率可能会导致集群每个实例的状态信息无法及时传播。
每 100 ms 检测实例 PONG消息接收是否超过 cluster-node-timeout / 2,这个是 Redis 实例默认的周期性检测任务频率,我们不会轻易修改。
所以,只能修改 cluster-node-timeout的值:集群中判断实例是否故障的心跳时间,默认 15 S。

所以,为了避免过多的心跳消息占用集群宽带,将 cluster-node-timeout调成 20 秒或者 30 秒,这样 PONG 消息接收超时的情况就会缓解。

但是,也不能设置的太大。都则就会导致实例发生故障了,却要等待 cluster-node-timeout时长才能检测出这个故障,影响集群正常服务、

总结

哨兵集群实现故障自动转移,但是当数据量过大导致生成 RDB 时间过长。而 Fork 执行的时候会阻塞主线程,由于数据量过大导致阻塞主线程过长,所以出现了 Redis 响应慢的表象。
使用 Redis Cluster 集群,主要解决了大数据量存储导致的各种慢问题,同时也便于横向拓展。在面向百万、千万级别的用户规模时,横向扩展的 Redis 切片集群会是一个非常好的选择。
集群的整个数据库被分为 16384 个槽(slot),数据库中的每个键都属于这 16384 个槽的其中一个,集群中的每个节点可以处理 0 个或最多 16384 个槽。
Redis 集群节点采用 Gossip 协议来广播自己的状态以及自己对整个集群认知的改变。
客户端连接到集群候任何一个实例后,实例会将哈希槽与实例映射信息发送给客户端,客户端将信息保存,用于将 key 定位到对应的节点。
集群并不能无限增加,由于集群通过 Gossip协议传播集群实例信息,所以通信频率是限制集群大小的主要原因,主要可以通过修改 cluster-node-timeout调整频率。

作者:MageByte技术团队


欢迎关注微信公众号 :码哥字节