作为 K-V 存储的代表,Redis 原生支持了集群模式(Redis Cluster),支持服务注册、发现、请求动态路由、数据自动分片、主从复制、故障转移
结构
- 数据分散存储到各个主节点,减少单个节点的读写压力、存储压力
- 从节点冗余存储主节点数据,保障了可用性,还可以实现读写分离,进一步减少主节点读压力
- 主节点之间互相关联,分享集群状态,支持故障转移、副本迁移
如何实现服务注册、服务发现
首先看看怎么向已有集群添加一个主节点
redis-cli --cluster add-node <new_host:new_port> <existing_host:existing_port>
可以看到:只需要指定集群中任意一个节点的 host+ip 即可,那么新的节点是怎么发现其它节点的位置的呢?
实际上,集群中的每一个节点都会缓存 节点与 host 的映射关系,添加新节点时,会与集群中任意一个节点建立 TCP 链接,该节点会发送 节点与 host 的映射关系给新的节点
这样,新的节点就知道其余节点的网络位置了
节点间怎么通信的?
和 Redis-Server 与 Redis-Client 的 RESP 通信协议不同,集群中任意节点通过 Cluster Bus(集群总线)通信
Cluster bus protocol: a binary protocol composed of frames of different types and sizes. Every node is connected to every other node in the cluster using the cluster bus.
Cluster Bus 是基于 Gossip 协议的
什么是 Gossip 协议?
Gossip 协议是一种在分布式系统中用于节点间通信的协议,它通过 流言蜚语(即 Gossiping)的方式来交换信息,从而实现信息的传播和一致性。
Gossip 协议与我们平常所说的流言传播相似,一个节点会 随机选择其他几个节点 分享信息,这些被选择的节点又会同样选择其他节点进行信息的传播,这样信息就 像病毒一样快速扩散开 来。
Gossip 的特点就是 去中心化,具有可伸缩性和鲁棒性
为什么 Redis Cluster bus 要使用 gossip 协议呢?
来看看 Redis 官方怎么说的:
While Redis Cluster nodes form a full mesh, nodes use a gossip protocol and a configuration update mechanism in order to avoid exchanging too many messages between nodes during normal conditions, so the number of messages exchanged is not exponential.
在一个大型集群中,如果节点需要以直接的方式交换状态信息或配置更新,那么每个节点都需要与其他每个节点进行通信。这意味着在 N 个节点的集群中,每个节点都需要发送 N-1 个消息来通知每个其他节点,总共就有 N*(N-1) 次通信。对于大型集群,这个数字会非常大,随着节点数目线性增加,需要的通信次数会以平方级别(N 的平方)增长
如果使用 gossip 协议,每个节点发送数据,仅仅会随机选择一些节点发送,然后慢慢传播开来,就像流言(病毒)传播一样,这样,整个集群内的消息数量处于一个可控范围,不会造成太大的压力
当然,gossip 协议存在 消息不及时 的缺点:消息在节点间传播是需要时间的,经过多个节点的转发,时效性不如直接发送好(不过大型集群,直接发送的压力太大,可能造成网络拥塞,时效性可能还不如 gossip)
总结
通过缓存 节点与 host 的映射关系,以及基于 Gossip 的 Cluster Bus 通信机制,Redis Cluster 实现了服务注册与服务发现
如何将请求路由到正确的节点
这里假设你知道了 Redis Cluster 数据分片的方式
客户端 第一次 向集群发起请求时,只需要请求任意一个节点 就可以了,Redis Cluster 实现了路由的动态重定向
具体来说:
- 客户端第一次向集群发起请求时,请求任意一个节点
- 该节点会「判断」请求的数据是否存储在本节点,如果是,直接响应
- 否则,给客户端发送 MOVED 重定向 ,告诉客户端应该请求哪个节点
- 无论请求的数据是否存储在本节点,都会发送一份
hash slot 到 cluster node 的映射给客户端 - 客户端会 缓存 这个映射关系,下一次请求就有很大概率直接请求到正确节点,避免重定向
可以看出,与 MySQL 不同,Redis 原生支持了请求的动态路由
C、A 如何做权衡(trade-off)
这里讨论的是读写分离的情况
默认情况下,即使一个节点有若干个从节点,读请求也不会在从节点进行,如果客户端尝试在从节点读取数据,会收到一个 MOVED 重定向
也就是说,Redis Cluster 默认情况下,选择的是 C,即一致性保障
但是我们也可以更改配置:READONLY
however clients can use replicas in order to scale reads using the READONLY command.
READONLY tells a Redis Cluster replica node that the client is willing to read possibly stale data and is not interested in running write queries.
要实现这一点,客户端可以使用 READONLY 命令通知从节点接受读请求。一旦进入只读模式,客户端就可以向该从节点发送读请求,读取存储在该节点的数据。
当客户端给从节点发送了 READONLY 命令后,从节点之后再收到读请求,只要这个 key 对应的 hash slot 由自己的 master 负责,那么就不会重定向客户端的请求,而是自己处理,降低 master 的压力
启用 READONLY,说明选择的是 A,即可用性保障
注意
虽然 Redis 支持读写分离,但是请求的负载均衡是不被原生支持的,这意味着 读请求的负载均衡需要在应用层手动做
如何做数据分片
实现数据的分片,依赖的就是 key 分发模型
Redis 给整个集群分配了 16384 个哈希槽(hash slots),每个 cluster 节点都拥有一部分 hash slots
确定 key 在哪个 hash slot,这个过程使用 CRC16 算法,具体来说:
hash_slot = CRC16(key) % 16384
在客户端读写数据时:
- 先计算出 hash slot
- 读取本地缓存,获取这个 hash slot 在哪个 cluster 节点上存储
- 请求对应的 cluster 节点
cluster 节点收到数据后:
- 校验一下这个 key 是否在当前 cluster 节点
- 如果在,那么处理客户端的请求即可
- 如果不在,读取本地缓存,确定这个 hash slot 在哪个 cluster 节点上存储,并发送 MOVE 错误给客户端,以重定向到正确的 cluster 节点
hash slots 和一致性哈希思想上是一样的,都是尽可能的将数据分散来存储,避免热点问题,虽然范围查询能力不太好,但是作为 K-V 存储的 Redis 来说,范围查询的需求不是特别高
数据如何同步(复制)到各个节点
这里讨论的是 Redis 的主从复制
第一次同步(全量复制)
从节点第一次同步主节点的数据的过程如下:
- 从节点向主节点发起同步请求(PSYNC),并携带主服务器的 server_id(这里为?)、offset(这里为 -1)
- 主节点收到请求后,发送自己的 server_id 和 offset 给从节点
- 从节点记录下主节点的 server_id 和同步的 offset
- 主节点 fork 一个子进程后台生成 RDB 文件,发送给从节点
- 从节点收到 RDB 文件后,开始同步主节点数据
这个过程存在一个问题:在生成 RDB 期间,如果有新的写命令,是不会包含在 RDB 中的,那么从节点如何获取这些增量数据呢?
为了解决这个问题:
- 主进程会在生成 RDB 期间,将
增量写命令写到 replication buffer 中 - 从节点同步完 RDB 以后,会给主节点发送同步完成的消息
- 主节点收到同步完成的消息以后,会将 replication buffer 的命令发给从节点
- 从节点重放 replication buffer 的命令,第一次同步完成
增量同步
第一次同步结束后,从节点与主节点会建立 TCP 长连接,用于同步增量数据
当主节点执行了一个写命令后:
- 主节点将该命令添加到 repl_backlog_buffer 中;
- 主节点通过已经建立的 TCP 长连接,将这个写命令发送给所有的从节点;
- 从节点接收到这个命令后,会将其放入自己的本地队列中,然后按序执行这个命令来更新自己的数据集;
- 主节点在发送命令后不会等待从节点的响应,它会继续处理自己的操作请求。
如果同步进度差距太大,怎么办?
由于网络问题,从节点的同步进度较慢,落后的数据可能较多
由于 repl_backlog_buffer 是一个环形缓冲区,如果落后的数据被覆盖了,就只能重新做全量同步
如何实现故障转移
Redis Cluster 的故障转移是基于心跳机制实现的
为了检测整个集群的状态,节点间会定期地互相发送心跳包(Heartbeat Packet),包含了节点本身的元数据,以及 发送节点视角下的集群状态信息
这里详细说一下 发送节点视角下的集群状态信息,对于后面理解 Redis Cluster 的错误检测会有帮助:
当一个节点发出心跳包时,它会在包里面包含它所观察到的集群状态的信息。这有助于其他节点获得关于集群健康状况的信息,比如:
- 下线状态(down):如果发送心跳包的节点观察到集群或者特定的节点出现了问题(比如无法达到或不再发送心跳信号),它会在心跳包中报告该节点或集群处于 down 状态。
- 正常状态(ok):相反,如果发送心跳包的节点认为集群状态良好,所有节点都是活跃的并且响应心跳信号,那么它会报告集群状态是 ok 的。
这样的设计能让集群中的其他节点根据接收到的心跳信息来更新自己的状态视图,从而使整个集群能够对节点失效做出快速响应,并相应地进行故障转移或重组。
检测
当节点超过 NODE_TIMEOUT 时间无法访问时,其他节点会给该节点标记 PFAIL。 无论是主节点还是副本节点,都可以为其它类型的节点标记 PFAIL。Redis 集群中节点不可达的概念是指我们发送了 ping,但在 NODE_TIMEOUT 时间内还未收到回复。
一个节点单独的 PFAIL 标志只是该节点关于其他节点的本地信息,不足以触发故障转移。要视为节点已经 down 掉,需要将 PFAIL 状态升级为 FAIL。
那么如何将 PFAIL 状态升级为 FAIL?
升级需要满足三个条件:
- 某节点(称为 A)将另一个节点(称为 B)标记为 PFAIL。
- 通过心跳检测机制,A 获取到了其它主节点对于 B 是否为 down 的意见
- 大多数的 有效(在
NODE_TIMEOUT * FAIL_REPORT_VALIDITY_MULT内报告 PFAIL 或者 FAIL 状态) 意见都认为这个节点为 down
那么:
- 节点 A 将 B 标记为 FAIL
- 给其它节点发送 FAIL message
FAIL message 会强制其它节点将 B 标记为 FAIL
转移
当一个主节点被标记为 FAIL,并且 有可用的从节点,那么故障转移可以进行
故障转移的过程是这样的:
首先,每个从节点都是新的主节点的候选者
从节点会递增 currentEpoch(投票轮数)
每个从节点会通过 cluster bus 发送 FAILOVER_AUTH_REQUEST 包,请求其它主节点给自己投票
每个主节点在 NODE_TIMEOUT * 2 时间内,只有一次投票机会(防止多个从节点当选),当主节点收到 FAILOVER_AUTH_REQUEST 包后,如果有投票机会,就会给这个从节点投票(发送 FAILOVER_AUTH_ACK)
从节点会 抛弃 不属于当前 currentEpoch 的投票
当一个从节点收到大多数主节点的投票后,它就成为了新的主节点
如果超过 NODE_TIMEOUT * 2 时间,还没投票完毕,本轮投票失败,等待 NODE_TIMEOUT * 4 开启新的一轮投票
一旦选举出新的主节点,其他节点会被通知这一变更,确保整个集群中所有节点的配置信息保持一致。
旧的主节点重新上线,不再担任主节点的角色,而是成为新的主节点的从节点
此外,Redis 还引入了一个 副本迁移机制
当一个主节点没有从节点时,副本迁移机制就会起作用:
- 选择某个拥有最多从节点的主节点
- 将该主节点的部分从节点迁移到孤儿主节点,成为孤儿主节点的从节点
副本迁移可以进一步减少整个集群不可用的情况
如何实现分布式事务
分两种情况:
- 如果事务内涉及的 Key 均在同一个节点上
- 如果事务内涉及的 Key 不在同一个节点上
对于第一种情况,比较简单,使用 Redis 原生的事务就可以
对于第二种情况,只能引入第三方中间件,来实现多节点要么同时成功,要么同时失败
总结
作为 K-V 存储的代表,Redis 原生支持了集群模式(Redis Cluster)
要想进一步了解 Redis Cluster,可以看看这篇文章:Redis 集群 ,也可以直接看 Redis 官方文档:Redis cluster specification