前面,我们一起学习了 Redis 高可用架构的两种模式:主从模式、哨兵模式。解决了:在Redis实例发生故障时,具备主从自动切换、故障转移的能力,最终
保证服务的高可用。
随着我们业务规模的不断扩展,用户量膨胀,并发量持续提升,原有的主从架构,已不足以支撑。
Redis 并发量
10万 / 每秒 ,但是有些业务需要 100万的 QPS
数据量
,我们普通机器 16~256g,而我们的业务需要500g
解决方案:
并发量大了 -> 主从复制解决
主从稳定性 -> 哨兵解决
单节点的写能力、存储能力、动态扩容都很麻烦 -> 集群 Cluster 解决。
集群提供了以下关键的特性:
可扩展性
高可用性
负载均衡
错误恢复
下面,由一张思维导图来开始学习 Redis Cluster 模式:
Redis Cluster 是⼀种分布式数据库⽅案,集群通过分⽚(sharding)来进⾏数据管理(分治思想的⼀种实践),并提供复制和故障转移功能。
Redis Cluster 采用无中心结构
,每个节点都可以保存数据和整个集群状态,每个节点都和其他所有节点连接。要让集群正常运作至少需要三个主节点
,即 Cluster 至少为6个才能保证组成完整高可用的集群。
Redis Cluster 将数据划分为 16384 (2的14次方)个哈希槽(slots),每个实例节点管理⼀部分槽位,槽位的信息存储于每个节点中。以下图为例,该集群有3个 Redis 节点,每个节点负责集群中的一部分数据,数据量可以不均匀。比如性能好的实例节点可以多分担一些压力:
当 16384 个哈希槽都有节点进行管理的时候,集群处于 online 状态。同样的,如果有一个哈希槽没有被管理到,那么集群处于 offline 状态。集群之间的信息通过 Gossip协议 进行交互,每个节点记录其他节点的哈希槽(slots)的分配情况。
单机的吞吐无法承受持续扩增的流量的时候,最好的办法是从垂直拓展(scale up)和水平拓展(scale out)两方面进行扩展:
垂直扩展(scale up):将单个实例的硬件资源做提升,比如 CPU核数量、内存容量、SSD容量。
水平扩展(scale out):水平扩增 Redis 实例数,这样每个节点只负责一部分数据就可以,分担一下压力,典型的分治思维。
垂直拓展(scale up)部署简单,但是当数据量⼤并且使⽤ RDB 实现持久化,会造成阻塞导致响应慢。另外受限于硬件和成本,拓展内存的成本太⼤,⽐如拓展到 1T 内存。
⽔平拓展(scale out)便于拓展,同时不需要担⼼单个实例的硬件和成本的限制。但是,切⽚集群会涉及多个实例的分布式管理问题,需要解决如何将数据合理分布到不同实例,同时还要让客户端能正确访问到实例上的数据。
将数据自动切分到多个节点的能力;
当集群中的一部分节点失效或者无法进行通讯时,仍然可以继续处理命令请求的能力,拥有自动故障转移的能力。
Replication:一个 master,多个 slave,要几个 slave 跟你的要求的读吞吐量有关系,结合 sentinel 集群,去保证 Redis 主从架构的高可用就行了;
Redis Cluster:主要是针对海量数据 + 高并发 + 高可用
的场景,海量数据,如果数据量很大,建议用 Redis Cluster。
这里不多赘述,附上网上的搭建方案:Redis 6.X Cluster 集群搭建。
⼀个 Redis 集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是隔离且毫无联系的。要组建⼀个真正可⼯作的集群,我们必须将各个独⽴的节点连接起来,构成⼀个包含多个节点的集群。
各个节点的联通是通过 CLUSTER MEET
命令完成的:CLUSTER MEET
。
其中一个节点 node 向另⼀个节点 node (指定 ip 和 port)发送 CLUSTER MEET 命令
,这样就可以让两个节点进⾏握⼿(handshake)
,当握⼿成功后,node 节点就会将指定 ip 和 port 的节点添加到 node 节点当前所在的集群中。就这样一步步的将需要聚集的节点都圈入同一个集群中:
整个 Redis 数据库被划分为16384个哈希槽,Redis 集群可能有 n 个实例节点,每个节点可以处理 0个 到至多 16384个 槽点,这些节点把 16384个槽位瓜分完成。
实际存储的 Redis 键值信息也必然归属于这 16384 个槽的其中一个。slots 与 Redis Key 的映射是通过以下两个步骤完成的:
Cluster 还允许⽤户强制某个 key 挂在特定的 slot 上面,也就是同一个实例节点上。这时候可以用 hash tag 能力,强制 key 所归属的槽位等于 tag 所在的槽位。
其实现方式为在 key 中加个 {},例如 test_key{1}。使用 hash tag 后客户端在计算 key 的CRC16 时,只计算 {} 中数据。如果没使用 hash tag,客户端会对整个 key 进行 CRC16 计算:
127.0.0.1:6379> cluster keyslot user:case{1}
(integer) 1024
127.0.0.1:6379> cluster keyslot user:favor
(integer) 1023
127.0.0.1:6379> cluster keyslot user:info{1}
(integer) 1024
如上,使用 hash tag 后会对应到同一个 hash slot:1024 中。
哈希槽⼜是如何映射到 Redis 实例上呢?主要有两种方案:
一种是初始化的时候均匀分配
,使用 cluster create
创建,会将 16384 个 slots 平均分配在我们的集群实例上,比如你有n个节点,那每个节点的槽位就是 16384 / n 个了 。
另一种是通过 CLUSTER MEET 命令将 node1、node2、ndoe3 三个节点联通成一个集群,刚联通的时候因为还没分配哈希槽,所以处于 offline 状态。可以使⽤ cluster addslots
命令,指定每个实例上的哈希槽个数。
为什么要通过 addslots 命令指定哈希槽范围?
加⼊集群中的 Redis 实例配置不⼀样、所处理的业务也不一样,可以通过 addslots 命令指定哈希槽范围,让性能好的实例节点可以多分担一些压力。
比如下图中,我们哈希槽是这么分配的:实例 1 管理 0 ~ 7120 哈希槽,实例 2 管理 7121~9945 哈希槽,实例 3 管理 9946 ~ 13005 哈希槽,实例 4 管理 13006 ~ 16383 哈希槽:
redis-cli -h 192.168.0.1 –p 6379 cluster addslots 0,7120
redis-cli -h 192.168.0.2 –p 6379 cluster addslots 7121,9945
redis-cli -h 192.168.0.3 –p 6379 cluster addslots 9946,13005
redis-cli -h 192.168.0.4 –p 6379 cluster addslots 13006,16383
slots 和 Redis 实例之间的映射关系如下:
key testkey_1
和 testkey_2
经过 CRC16 计算后再对 slots 的总个数 16384 取模,结果分别匹配到了 cache1 和 cache3 上。
Cluster 是具备 Master 和 Slave 模式
,Redis Cluster 中的每个 Master 实例节点分管了不同的槽位区间。而每个 Master 至少需要一个 Slave 节点,Slave 节点是通过《Redis 主从复制》方式同步主节点数据。
节点之间保持 TCP 通信,当 Master 下线, Redis Cluster 自动会将对应的 Slave 节点选为 Master 来继续提供服务。主从节点之间并没有读写分离, Slave 只⽤作 Master 宕机的⾼可⽤备份。
如果主节点没有从节点,那么一旦发生故障时,集群将完全处于不可用状态。 但也允许配置 cluster-require-full-coverage
参数:即使部分节点不可用,其他节点正常提供服务,这是为了避免全盘宕机。
主从切换之后,故障恢复的主节点,会转化成新主节点的从节点。这种自愈模式对提高可用性非常有帮助。
通过《Redis 高可用架构之哨兵模式 - Sentinel》,我们知道:哨兵通过监控、⾃动切换主库、通知客户端实现故障⾃动切换, Cluster ⼜如何实现故障⾃动转移呢?
一个节点认为某个节点宕机不能说明这个节点真的挂了,无法提供服务了。只有占据多数的实例节点都认为某个节点挂了,这时候 cluster 才进行下线和主从切换的工作。
Redis 集群节点采用 Gossip 协议 来广播信息:
每个节点都会定期向其他节点发送 PING 命令,如果接受 PING 消息的节点在指定时间内没有回复 PONG,则会认为该节点失联了(PFail),则发送 PING 的节点就把接受 PING 的节点标记为主观下线。
如果集群半数以上
的主节点都将主节点 xxx 标记为主观下线,则节点 xxx 将被标记为客观下线,然后向整个集群广播,让其它节点也知道该节点已经下线,并立即对下线的节点进行主从切换。
当一个从节点发现自己正在复制的主节点进入了已下线,则开始对下线主节点进行故障转移,故障转移的步骤如下:
如果只有一个 slave 节点,则从节点会执行 SLAVEOF no one 命令,成为新的主节点。
如果是多个 slave 节点,则采用选举模式进行,竞选出新的 Master
集群中设立一个自增计数器,初始值为 0 ,每次执行故障转移选举,计数就会 +1。
检测到主节点下线的从节点向集群所有 master 广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息,所有收到消息、并具备投票权的主节点都向这个从节点投票。
如果收到消息、并具备投票权的主节点未投票给其他从节点(只能投一票),则返回一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,表示支持。
参与选举的从节点都会接收 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,如果收集到的选票大于等于 (n/2) + 1 支持,n 代表所有具备选举权的 master,那么这个从节点就被选举为新主节点。
如果这一轮从节点都没能争取到足够多的票数,则发起再一轮选举(自增计数器+1),直至选出新的 master。
新的主节点会撤销所有对已下线主节点的 slots 指派,并将这些 slots 全部指派给自己。
新的主节点向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
跟哨兵类似,两者都是基于 Raft 算法来实现的,流程如图所示:
Redis 中的每个实例节点会将自己负责的哈希槽信息通过 Gossip协议 广播给集群中其他的实例,实现了slots 分配信息的扩散。这样的话,每个实例都知道整个集群的哈希槽分配情况以及映射信息。
客户端想要快捷的连接到服务端,并对某个 Redis 数据进行快捷访问,一般是经过以下步骤:
客户端连接集群的任一实例,获取到 slots 与实例节点的映射关系,并将该映射关系的信息缓存在本地;
将需要访问的 Redis 信息的 key,经过 CRC16 计算后,再对 16384 取模得到对应的 slot 索引,返回所有 slots 与实例的映射信息;
通过 slot 的位置进一步定位到具体所在的实例,再发送请求到对应的实例上。
哈希槽与实例之间的映射关系由于新增实例或者负载均衡重新分配导致改变了怎么办?
集群中的实例通过 Gossip 协议互相传递消息获取最新的哈希槽分配信息,但是,客户端⽆法感知。
Redis Cluster 提供了重定向机制:客户端将请求发送到实例上,这个实例没有相应的数据,该 Redis 实例会告诉客户端将请求发送到其他的实例上。
Redis 如何告知客户端重定向访问新实例呢?
分为两种情况:MOVED 错误、ASK 错误。
MOVED
错误(负载均衡,数据已经迁移到其他实例上):当客户端将⼀个键值对操作请求发送给某个实例,⽽这个键所在的槽并⾮由⾃⼰负责的时候,该实例会返回⼀个 MOVED 错误指引转向正在负责该槽的节点。
GET redis:pointer
(error) MOVED 16330 192.168.0.1:6379
该响应表示客户端请求的键值对所在的哈希槽 16330 迁移到了 192.168.0.1 这个实例上,端⼝是 6379。这样客户端就与 192.168.0.1:6379 建⽴连接,并发送 GET 请求。
同时,客户端还会更新本地缓存,将该 slot 与 Redis 实例对应关系更新正确。
如果某个 slot 的数据⽐较多,部分迁移到新实例,还有⼀部分没有迁移怎么办?
如果请求的 key 在当前节点找到就直接执⾏命令,否则时候就需要 ASK 错误响应,槽部分迁移未完成的情况下,如果需要访问的 key 所在 slot 正在从 实例1 迁移到 实例2,实例1 会返回客户端⼀条 ASK 报错信息:客户端请求的 key 所在的哈希槽正在迁移到 实例2 上,先给 实例2 发送⼀个 ASKING 命令,接着发送操作命令。
GET redis:pointer
(error) MOVED 16330 192.168.0.1:6379
⽐如客户端请求定位到 key = redis:pointer
的槽 16330 在实例 192.168.0.1 上,节点1 如果找得到就直接执⾏命令,否则响应 ASK 错误信息,并指引客户端转向正在迁移的⽬标节点 192.168.0.1。
注意:ASK 错误指令并不会更新客户端缓存的哈希槽分配信息。
所以客户端再次请求 slot 16330 的数据,还是会先给 192.168.0.1 实例发送请求,只不过节点会响应 ASK 命令让客户端给新实例发送⼀次请求。MOVED 指令则更新客户端本地缓存,让后续指令都发往新实例。
参考资料:https://redis.io/topics/cluster-spec、https://redis.io/commands/cluster-setslot、 https://github.com/go-redis/redis/blob/master/cluster.go。
Redis Cluster ⽅案通过哈希槽的⽅式把键值对分配到不同的实例上,这个过程需要对键值对的 key 做 CRC 计算并对哈希槽总数取模映射到实例上。如果⽤⼀个表直接把键值对和实例的对应关系记录下来(例如键值对 1 在实例 2 上,键值对 2 在实例 1 上),这样就不⽤计算 key 和哈希槽的对应关系了,只⽤查表就⾏了,Redis 为什么不这么做呢?
使⽤⼀个全局表记录的话,假如键值对和实例之间的关系改变(重新分⽚、实例增减),需要修改表。如果是单线程操作,所有操作都要串⾏,性能太差。多线程的话,就涉及到加锁,另外,如果键值对数据量⾮常⼤,保存键值对与实例关系的表数据所需要的存储空间也会很⼤。
⽽哈希槽计算,虽然也要记录哈希槽与实例时间的关系,但是哈希槽的数量少得多,只有 16384 个,开销很⼩。
Redis Cluster 可以⽆限⽔平拓展么?
答案是否定的,Redis 官⽅给的 Redis Cluster 的规模上线是 1000 个实例。
关键在于实例间的通信开销,Cluster 集群中的每个实例都保存所有哈希槽与实例对应关系信息(slot 映射到节点的表),以及⾃身的状态信息。
在集群之间每个实例通过 Gossip协议 传播节点的数据, Gossip协议 ⼯作原理⼤概如下:
从集群中随机选择⼀些实例按照⼀定的频率发送 PING 消息发送给挑选出来的实例,⽤于检测实例状态以及交换彼此的信息。 PING 消息中封装了发送者⾃身的状态信息、部分其他实例的状态信息、slot 与实例映射表信息;
实例接收到 PING 消息后,响应 PONG 消息,消息包含的信息跟 PING 消息⼀样。
集群之间通过 Gossip协议 可以在⼀段时间之后每个实例都能获取其他所有实例的状态信息。所以在有新节点加⼊,节点故障,slot 映射变更都可以通过 PING、PONG 的消息传播完成集群状态在每个实例的传播同步。
发送的消息结构是 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。集群规模的增加,⼼跳消息越来越多就会占据集群的⽹络通信带宽,降低了集群吞吐量。
发送 PING 消息的频率会影响集群带宽么?
Redis Cluster 的实例启动后,默认会每秒从本地的实例列表中随机选出 5 个实例,再从这 5 个实例中找出⼀个最久没有收到 PING 消息的实例,把 PING 消息发送给该实例。
随机选择 5 个,但是⽆法保证选中的是整个集群最久没有收到 PING 通信的实例,有的实例可能⼀直没有收到消息,导致他们维护的集群信息早就过期了,该怎么办呢?
Redis Cluster 的实例每 100ms 就会扫描本地实例列表,当发现有实例最近⼀次收到 PONG 消息的时间 > cluster-node-timeout / 2
,那么就⽴刻给这个实例发送 PING 消息,更新这个节点的集群状态信息。
当集群规模变⼤,就会进⼀步导致实例间⽹络通信延迟怎加。可能会引起更多的 PING 消息频繁发送。
如何降低实例间的通信开销?
每个实例每秒发送⼀条 PING 消息,降低这个频率可能会导致集群每个实例的状态信息⽆法及时传播。
每 100ms 检测实例 PONG 消息接收是否超过 cluster-node-timeout / 2 ,这个是 Redis 实例默认的周期性检测任务频率,不会轻易修改。
所以,只能修改 cluster-node-timeout
的值:集群中判断实例是否故障的⼼跳时间,默认 15s
。为了避免过多的⼼跳消息占⽤集群宽带,将 cluster-node-timeout 调成 20s 或者 30s,这样 PONG 消息接收超时的情况就会缓解。
但是,也不能设置的太⼤。否则:一旦实例发⽣故障,就要等待 cluster-node-timeout 时⻓才能检测出这个故障,影响集群正常服务。
参考资料:Cluster 集群能支撑的数据有多大?、深入分析Cluster 集群模式