引言
第三部分,属于多机数据库的实现,相较而言是很受关注的一部分,也是面试的高频考点,总体包含三个部分:主从复制、Sentinel 以及 集群。这三部分(加上之前介绍到的根据 RDB 和 AOF 实现的数据持久化)实现了 Redis 的高可用性
复制
在 Redis 中,用户可以通过执行 SLAVEOF 命令或者设置 slaveof 选项,让一个服务器去复制(replicate) 另一个服务器,我们称呼被复制的服务器为主服务器,而对主服务器进行复制的服务器则成为从服务器
旧版复制功能的实现
复制分为 同步 和 命令传播 两个操作:
- 同步:将从服务器的数据库状态更新至主服务器当前所处的服务器状态
- 命令传播:在主服务器数据库状态被修改时,让主从服务器的数据库状态恢复一致
同步
当客户端向从服务器发送 SLAVEOF 命令,要求从服务器复制主服务器时,从服务器首先需要进行同步操作
同步操作需要通过向主服务器发送 SYNC 命令来完成:
- 从服务器向主服务器发送 SYNC 命令
- 收到 SYNC 命令的主服务器执行 BGSAVE 命令,在后台生成一个 RDB 文件,并用一个缓冲区记录从现在开始的所有写命令
- 当主服务器的 BGSAVE 命令执行完毕时,主服务器会将 BGSAVE 命令生成的 RDB 文件发送给从服务器,从服务器接收并载入这个 RDB 文件
- 主服务器将缓冲区的所有写命令发送给从服务器,从服务器执行这些命令
命令传播
同步操作执行过后,主从服务器状态一致,但要保持一致,则需要进行命令传播将主数据库执行的写命令传播给从数据库
旧版复制方式的缺陷
由于在使用过程中,有可能发生由于各种原因导致的复制中断,而中断重连后,再想恢复主从服务器的一致性对于旧版复制方式而言效率很低
上述例子还只是理想化情况,实际情况是,在断线过程中,主服务器执行的写命令可能会有成百上千条,因此重新执行一遍 SYNC 命令无疑十分低效
新版复制功能的实现
在新版复制功能中(Redis 2.8 版本开始)使用 PYNSC 代替 SYNC 来执行复制时的同步问题。
PYNSC 命令具有 完整重同步 以及 部分重同步 两种模式
- 完整重同步用于处理初次复制的情况:与 SYNC 命令的执行步骤基本一致
- 部分重同步则用于处理断线后重复制的情况:如果条件允许,只需要将连接断开期间的写命令发送给从服务器
部分重同步的实现
部分重同步功能包括三个部分:
- 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量
- 主服务器的复制积压缓冲区
- 服务器的运行ID
复制偏移量
执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:
- 主服务器每次向从服务器传播 N 个字节的数据时,就将自己的复制偏移量的值 + N
- 从服务器每次收到主服务器传播来的 N 个字节的数据时,就将自己的复制偏移量 + N
复制积压缓冲区
复制积压缓冲区是由主服务器维护的一个固定长度、先进先出队列,默认大小 1 MB
主服务器命令传播过程中,它不仅会将写命令发送给所有从服务器,还会将写命令放入复制积压缓冲区
因此,主服务器的复制积压缓冲区会保存一部分近期传播的写命令
当从服务器重新连接上主服务器时,从服务器会通过 PSYNC 命令将自己的复制偏移量 offset 发送给主服务器,主服务器根据这个复制偏移量来决定对从服务器执行何种同步方式
- 如果 offset 偏移量之后的数据(offset + 1 开始)仍然存在于复制积压缓冲区,那么主服务器将对从服务器执行部分重同步操作
- 相反,则执行完整重同步操作
服务器运行ID
用于表示服务器的 runID
PSYNC 命令的实现
复制的实现
连接建立
从服务器将 SLAVEOF 命令中的主服务器 IP 以及 端口号 保存至 masterhost 属性和 masterport 属性中:
随后服务器返回 OK,并开始执行实际的复制工作(因此 SLAVEOF 命令为异步命令)
根据 ip 以及 端口号,从服务器与主服务器建立套接字连接,并发送 PING 命令
这个 PING 命令有两个作用
- 通过发送 PING 命令可以检查套接字的读写状态是否正常
- 检查主服务器能否正常处理命令请求
检验完毕之后,需要进行身份验证
- 如果从服务器设置了 masterauth 选项,则进行身份验证
- 否则则不验证
验证完身份之后,从服务器执行 REPLCONF listening-port ,向主服务器发送从服务器的监听端口号,主服务器接收到信息后,将端口号记录在从服务器所对应的客户端状态的 slave_listening_port 中。
数据同步阶段
即之前介绍的 同步
命令传播阶段
数据同步阶段完成后,主从节点进入命令传播阶段;在这个阶段主节点将自己执行的写命令发送给从节点,从节点接收命令并执行,从而保证主从节点数据的一致性。在命令传播阶段,除了发送写命令,主从节点还维持着心跳机制:PING 和 REPLCONF ACK。
在心跳机制中:从服务器默认会以1s一次的频率,向主服务器发送命令:
REPLCONF ACK <repliction_offset>
主要有3个作用
- 检测主从服务器的网络连接状态
- 辅助实现 min-slaves 选项
- 检测命令丢失
Redis 的 min-slaves-to-write 和 min-slaves-max-log 两个选项可以防止主服务器在不安全的情况下执行写命令
举个例子,如果我们向主服务器提供以下设置:
min-slaves-to-write 3
min-slaves-max-lag 10
那么在从服务器数量小于3,或者3个从服务器的延迟(lag)值都大于等于 10s 时,主服务器将拒绝执行写命令
总结
优点
- 高可靠性:一方面,采用双机主备架构,能够在主库出现故障时自动进行主备切换,从库提升为主库提供服务,保证服务平稳运行;另一方面,开启数据持久化功能和配置合理的备份策略,能有效的解决数据误操作和数据异常丢失的问题。
- 读写分离策略:从节点可以扩展主库节点的读能力,有效应对大并发量的读操作。
缺点
- 故障恢复复杂,如果没有 RedisHA 系统(需要开发),当主库节点出现故障时,需要手动将一个从节点晋升为主节点,同时需要通知业务方变更配置,并且需要让其它从库节点去复制新主库节点,整个过程需要人为干预,比较繁琐。
- 主库的写能力受到单机的限制,可以考虑分片。
- 主库的存储能力受到单机的限制,可以考虑 Pika。
- 原生复制的弊端在早期的版本中也会比较突出,如:Redis 复制中断后,Slave 会发起 psync,此时如果同步不成功,则会进行全量同步,主库执行全量备份的同时可能会造成毫秒或秒级的卡顿;又由于 COW 机制,导致极端情况下的主库内存溢出,程序异常退出或宕机;主库节点生成备份文件导致服务器磁盘 IO 和 CPU(压缩)资源消耗;发送数 GB 大小的备份文件导致服务器出口带宽暴增,阻塞请求,建议升级到最新版本。
Sentinel
Sentinel (岗哨、哨兵)是 Redis 实现高可用性的解决方案:一个 Sentinel 系统由一个或多个 Sentinel 实例组成,Sentinel 系统可同时监视多个主服务器及这些主服务器下的所有从服务器。
为什么需要 Sentinel 系统
在 Redis 运行的过程中,主从切换技术的方法是:当主服务器宕机后,需要手动把一台从服务器切换为主服务器,这就需要人工干预,费事费力,还会造成一段时间内服务不可用。这不是一种推荐的方式,Redis 2.8 以后提供了 Redis Sentinel 哨兵机制 来解决这个问题。
当 Sentinel 系统监视的主服务器进入下线状态时,系统会自动将下线主服务器树下的某个从服务器升级为新的主服务器,假设升级过后,原先的主服务器重新上线,它会被系统降级为新主服务器的从服务器。
从一个具体例子来过一遍流程:
Sentinel 系统(内部有一个或多个 Sentinel)监视了主服务器 Server1 以及其下的从服务器 Server2、 Server3、Server4。
主服务器 Server1 下线,Sentinel 系统及时察觉,当 Server1 的下线时长超出用户设定的超出时长上线时,Sentinel 系统自动进行故障转移操作
- 首先,挑选主服务器下的从服务器作为新的主服务器
- 之后,Sentinel 系统会向下线主服务器的所有从服务器发送新的复制指令,让它们成为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障转移操作完成
- 此外,Sentinel 系统还会继续监视已下线的 Server1,在其重新上线的时候将其设置为新的主服务器的从服务器
具体流程
启动并初始化 Sentinel (注意不是 Sentinel 系统)
当一个 Sentinel 启动时,它需要执行以下步骤
- 初始化服务器
- 将普通 Redis 服务器使用的代码替换成 Sentinel 专用代码
- 初始化 Sentinel 状态
- 根据给定的配置文件,初始化 Sentinel 的监视主服务器列表
- 创建连向主服务器的网络连接
初始化服务器
首先需要明确的是, Sentinel 实际上是一个特殊模式的 Redis 服务器,所以最初只需要初始化一个普通的 Redis 服务器,但由于 Sentinel 执行的工作与普通 Redis 不同,因此初始化过程并不完全一样。
具体不同可参考下表:
使用 Sentinel 专用代码
启动 Sentinel 的第二个步骤就是将一部分普通 Redis 服务器使用的代码替换成 Sentinel 专用代码。
比如:普通 Redis 服务器使用 redis.h/REDIS_SERVERPORT 常量作为服务器端口,但 Sentinel 则使用 sentinel.c/REDIS_SENTINEL_PORT 常量作为服务器端口。
初始化 Sentinel 状态的 master 属性
Sentinel 需要保存其监视的主服务器的相关信息,使用 master 字典进行记录
- 字典的键是被监视主服务器的名字
- 字典的值是被监视服务器对应的 Sentinel.c/sentineRedisInstance 结构
每个 sentinelRedisInstance 结构(实例结构)都代表一个被 Sentinel 监视的 Redis 服务器实例(Instance),结合之前的介绍(即 Sentinel 是特殊的 Redis 服务器),因此这个实例可以是 主服务器、从服务器以及另一个 Sentinel。
Master 字典的初始化,是根据被载入的 Sentinel 配置文件来进行的
举个例子:
# 配置文件
#####################
# master1 configure #
#####################
sentinel monitor master1 127.0,0.1 6379 2
sentinel down-after-millisenconds master1 30000
sentinel parallelsyncs master1 1
sentinel failover-timeout master1 900000
#####################
# master2 configure #
#####################
sentinel monitor master1 127.0,0.1 12345 5
sentinel down-after-millisenconds master2 50000
sentinel parallelsyncs master2 5
sentinel failover-timeout master2 450000
读取配置文件后,Sentinel 会为主服务器 master1、master2 创建如下实例结构:
而 Sentinel 保存的字典结构如下:
创建连向主服务器的网络连接
最后,需要创建连向被监视主服务器的网络连接,Sentinel 将作为客户端与主服务器进行交互。对于每个被监视的主服务器来说,Sentinel 会创建两个连向主服务器的异步网络连接
- 一个是命令连接,这个连接专门向主服务器发送命令,并接收命令回复
- 另一个是订阅连接,这个连接专门用于订阅主服务器的 sentinel:hello 频道
为什么需要两个连接
Redis 目前的发布与订阅功能中,被发送的消息都不会保存在 Redis 服务器里,如果在消息发送过程中,想要接收信息的客户端不在线或者断线,那么这个客户端就会丢失这条数据。因此为了不丢失 sentinel:hello 频道的任何消息, Sentinel 必须专门用一个订阅连接来接收这个频道的消息
另一方面,Sentinel还需要向主服务发送命令,所以 Sentinel 必须向主服务器创建命令连接
因为需要与多个实例创建多个网络连接,因此采用异步连接
获取主服务器信息
Sentinel 默认以每10秒一次的频率,通过命令连接,发送 Info 命令给被监视的主服务器,通过分析 Info 命令回复来获取该主服务器的当前信息
依据上图结构,Sentinel 持续向 master 发送 Info 命令,并获取下图的回复
- 一方面,Sentinel可以根据回复获取主服务器本身的信息
- 另一方面是获取主服务器属下所有从服务器的信息,每个从服务器都由一个 ”slave“ 字符串开头的行记录,根据这些IP地址和端口号,Sentinel 无需用户提供从服务器的地址信息,就能自动发现从服务器。
获取从服务器信息
获取从服务器的 IP 地址和端口号之后,Sentinel 除了会为从服务器创建对应实例结构外,还会创建连接到从服务器的命令连接和订阅连接
根据 Info 命令的回复,Sentinel 会提取出以下信息:
- 从数据库的运行ID run_id
- 从数据库的角色 role
- 主服务器的 IP 地址 master_host, 以及其端口号 master_port
- 主从服务器的连接状态 master_link_status
- 从服务器的优先级 slave_priority
- 从服务器的复制偏移量 slave_repl_offset
根据这些信息,Sentinel 会对从服务器的实例结构进行更新
向主服务器和从服务器发送信息
默认2秒一次,Sentinel 通过命令连接向所有被监视的主服务器和从服务器发送固定格式的命令
PUBLISH _sentinel_ : hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"
- 其中 s_ 开头的参数记录的是 Sentinel 本身的信息
- 而 m_ 开头的参数记录的是主服务器的信息。(如果Sentinel监视的是从服务器,这些参数就是从服务器所属的主服务器的信息)
相关参数的意义:
参数 | 意义 |
---|---|
s_ip | IP地址 |
s_port | 端口号 |
s_runid | 运行ID |
s_epoch | 可以理解为 Sentinel 当前的纪元 (之后的raft算法介绍会涉及) |
m_name | 主服务器名 |
m_ip | 主服务器ip |
m_port | 主服务器端口号 |
m_epoch | 主服务器当前的配置纪元 |
接收来自主服务器和从服务器的频道信息
当 Sentinel 与主、从服务器建立订阅连接后,Sentinel 就会通过订阅连接,向服务器发送命令
SUBSCRIBE _sentinel_: hello
Sentinel 对 sentinel:hello 频道的订阅会一直持续到 Sentinel 与服务器连接断开为止。
可能存在的情况是,多个 Sentinel 同时监听一个服务器,那么当一个 Sentinel 向服务器的 sentinel : hello 频道发送一条消息时,所有订阅了该频道的 Sentinel(包括自己)都会收到这条信息。
- 收到信息后,Sentinel 会对 ID 进行判断,如果就是自己的ID,则抛弃这条信息
- 如果不相同,则根据信息的各个参数,对记录的 sentinelRedisInstance 进行更新
检测主观下线状态
默认情况下,Sentinel 每秒一次对所有与它创建了命令连接的实例(主、从服务器、其他Sentinel)发送 PING 命令,并通过实例返回的 PING 命令回复判断该实例是否在线。
- 有效回复:实例返回 +PONG、-LOADING、-MASTERDOWN 三种回复中的任意一种
- 无效回复:实例返回除以上三种回复之外的其他回复,或者在指定时限内没有返回任何回复
之前配置文件实例中的 down-after-millisecond 选项指定了 Sentinel 判断实例进入主观下线所需的时间长度。(即:如果在 down-after-milliseconds 毫秒内,Sentinel 没有收到 目标节点 的有效回复,则会判定 该节点 为 主观下线。)
检查客观下线状态
当 Sentinel 将一个主服务器判断为主观下线后,会通过 is-master-down-by-addr 命令,向同样监视这一主服务器的其他 Sentinel 进行询问,看他们是否也认为主服务器已经进入了下线状态(主、客观都行),当收到足够数量的已下线判断后,将主服务器判定为客观下线,并对主服务器执行故障转移操作。
选举领头 Sentinel
当一个主服务器被判断客观下线时,监视这个下线主服务器的各个 Sentinel 会进行协商,选出一个领头 Sentinel,并由领头的 Sentinel 对下线主服务器进行故障转移操作(选举采用Raft算法,需要详细了解可看下篇文章->还没写- -)
故障转移
主要包括三个步骤
- 在已下线的主服务器树下的所有从服务器里挑选一个从服务器,转换为主服务器
- 让已下线主服务器属下的所有从服务器改为复制新的主服务器
- 将已下线的主服务器设置为新的主服务器的从服务器
之前在介绍 Sentinel 的时候有提到过,接下来介绍一下详细步骤
选出新的服务器
修改从服务器的复制目标
当新的主服务器出现后,其他所有从服务器使用 SLAVEOF 命令去复制新的主服务器
将旧服务器变为从服务器
最后,需要将已下线的主服务器设置为新服务器的从服务器,因为旧的主服务器以及下线,所有这种设置是保存在 Sentinel 存储的对应旧服务器的 SentinelRedisInstance 中的,当旧主服务器重新上线时,Sentinel 就会向它发送 SLAVEOF 命令,使其成为新主服务器的从服务器。
总结
优点
- 哨兵模式是基于主从模式的,所有主从的优点,哨兵模式都具有。
- 主从可以自动切换,系统更健壮,可用性更高。
缺点
- Redis较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂,无法解决主节点 单点写入和单节点无法扩容等问题
- 需要额外的资源来启动sentinel进程,实现相对复杂一点
集群
Redis 的哨兵模式基本已经可以实现高可用,读写分离 ,但是在这种模式下每台 Redis 服务器都存储相同的数据,很浪费内存,所以在 Redis3.0 上加入了 cluster 模式,实现的 Redis 的分布式存储,也就是说每台 Redis 节点上存储不同的内容。
节点
一个 Redis 集群通常由多个节点(node)组成,刚开始时,每个节点相互独立,它们都处于一个只包含自己的集群之中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。
连接命令为 CLUSTER MEET 命令,向一个节点发送该命令,可以让 node 节点与 ip:port 指定的节点进行握手,当成功时,node节点就会将 ip 和 port 所指定的节点添加到 node 节点当前所在的集群中
启动节点
一个节点就是一个运行在集群模式下的 Redis 服务器,Redis 服务器在启动时会根据 cluster-enabled 配置选项来决定是否开启集群模式
节点(运行在集群模式下的 Redis 服务器)会继续使用所有单机模式中使用的服务器组件(第二章介绍的)
初次之外,节点会继续使用之前介绍的 redisServer 结构保存服务器状态,使用 redisClient 保存客户端状态,只有在集群模式下会用到的数据,节点将其保存到来 cluster.h/clusterNode 结构、 cluster.h/clusterLink 结构以及 cluster.h/clusterState 结构。
- clusterNode 结构用于保存一个节点的当前状态,比如节点的创建事件、节点的名字、节点的当前配置纪元、IP地址、端口等,用于存储自己的状态以及同集群的其他节点的状态。
- clusterLink 结构,保存连接节点所需的有关信息,比如套接字描述符,输入输出缓冲区。
- clusterState 结构,记录当前节点的视角下,集群目前所处状态,如:集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元等。
至于 CLUSTER MEET 命令的实现原理,类似于 TCP 的三次握手,可参考下图:
槽指派
Redis 集群采用 slot 分布式存储机制,集群的整个数据库被分为 16384 个槽(slot),每个键都属于这 16384 个槽之一,集群的每个节点可以处理 0~16384 个槽。
当所有的槽都有节点处理时,集群处于上线状态,只要有一个槽没有得到处理,集群处于下线状态,Redis 通过 CLUSTER ADDSLOTS 命令将一个或多个槽指派给节点。
记录节点的槽记录
之前提到的 clusterNode 数据结构中的 slots 属性 和 numslot 属性记录了节点负责的槽:
struct clusterNode {
unsigned char slots[16384/8];
int numslots;
}
slots 是一个二进制位数组,如果 slots 数组在索引 i 上的二进制位的值为 1,那么表示节点负责槽 i,为 0,则表示不负责。
上图表示,节点负责槽 0~7。
传播节点的槽指派信息
一个节点同时还会将自己的 slot 数组通过消息发送给集群中的其他节点,其他节点接收到消息后,会更新记录在 clusterState.node 字典中的对应节点的 clusterNode 信息。通过这种方式,集群中的每个节点都可以知道 16384 个槽被分配给了哪些节点。
记录集群所有槽的指派信息
之前提到,cluster.node 字典中记录了对应节点的 clusterNode 信息,具体结构如下:
- 如果 slots[i] 指针指向 NULL,表示槽 i 未被分配给任意节点
- 如果 slots[i] 指针指向一个 clusterNode 结构,则表示槽 i 已经指派给了 clusterNode 结构对应的节点
CLUSTER ADDSLOTS 命令实现原理
# 伪代码
# 遍历所有输入槽,检查它们是否都是未指派槽
for i in all_input_slots:
# 如果存在一个槽被指派给了其他节点,返回错误
if clusterState.slots[i] != NULL:
reply_error()
return
# 如果所有槽都是未指派槽
# 那么再次遍历所有输入槽,将这些槽指派给当前节点
for i in all_input_slots:
# 设置 clusterState 结构的 slots 数组
# 将 slots[i] 的指针指向代表当前节点的 clusterNode 结构
clusterState.slots[i] = clusterState.myself
# 将 clusterNode 结构的 slots 数组上的索引 i 二进制位置为1
setSlotBit(clusterState.myself.slots, i)
在集群中执行命令
当所有的槽都被分配之后,集群进入上线状态,这时客户端就能对集群中的节点发送命令了。
当客户端向节点发送与数据库键相关的命令时,接收命令的节点会计算出键所属的槽,并检查槽是否指派给了自己
- 若指派给了自己,则节点直接执行这个命令
- 如果没有指派给自己,节点会给客户端返回一个 MOVED 错误,指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令
计算键属于哪个槽
def slot_number(key):
return CRC16(key) & 16383
其中 CRC16(key) 计算键的 CRC-16 校验和,而 &16383 则用于计算出一个介于0~16383之间的整数作为键 key 的槽号。
判断槽是否由当前节点负责处理
- 如果 clusterState.slots[i] 等于 clusterState.myself,说明槽为当前节点负责
- 如果不等,说明不是当前节点负责,节点会根据 clusterState.slots[i] 指向的 clusterNode 结构所记录的节点IP和端口号,向客户端返回 MOVED错误,指引客户端转向至处理槽 i 的节点
MOVED 错误
MOVED 错误的格式为:
MOVED <slot> <ip>:<port>
其中 slot 为键所在的槽,ip 与 port 为负责处理该槽的节点的 IP 地址与端口号
节点数据库的实现
集群节点保存键值对以及键值对过期时间的方式,与之前第二部分的单机 Redis 服务器保存键值对以及键值对过期时间的方式完全相同。
值得注意的是,节点只能使用0号数据库,而单机 Redis 服务器则没有这个限制。
下图展示一个节点的数据库状态,数据库中包含列表键"lst", 哈希键“book”,以及字符串键“data”,其中键“lst”和键“book”带有过期时间
除此之外,节点会用 clusterState 结构中的 slots_to_keys 跳表记录槽与键之间的关系
重新分片
重新分片,即将任意数量已经分配给一个节点的槽重新分配给另一个节点,并且相关槽所属的键值对也会从源节点被移动到目标节点。
上图展现了对槽 slot 进行重新分片的整个过程
阅读过程中产生了疑问:既然都是使用db0,为什么还需要进行键迁移,不应该在同一个数据库吗?
仔细阅读后发现,是阅读时的疏忽大意导致概念不清晰,每一个节点都会维护一个redisDB,这也正好印证了“Redis Cluster 采用的是无中心架构 ,每个节点保存数据和整个集群状态,每个节点都和其他节点有所连接”这句话。
注意要和第二部分单机数据库一起理解节点数据库的构成。
ASK 错误
在重新分片过程中,有一种场景是键迁移过程中,属于被迁移槽的一部分键值对保存在源节点内,而另一部分键值对则保存在目标节点内。
当客户端向源节点发送一个与数据库有关的命令,并且命令要处理的数据库键恰好属于正在被迁移的槽时:
- 源节点会先在自己的数据库里查找指定的键,如果找到就直接执行客户端的命令
- 否则,源节点向客户端返回一个 ASK 错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令
很像之前的 MOVED 命令,但两者还是有区别,下面对 ASK 实现原理和两者区别进行分析
CLUSTER SETSLOT IMPORTING 命令的实现
clusterState 结构的 importing_slots_from 数组记录了当前节点正在从其他节点导入的槽,当该数组的值不为NULL,而是指向一个 clusterNode 结构,说明当前节点正在从 clusterNode 所代表的的节点导入该槽。
typedef struct clusterState {
clusterNode *improting_slots_from[16384];
} clusterState
在进行重新分片时,可以使用
CLUSTER SETSLOT <i> IMPORTING <source_id>
将目标节点 clusterState.improting_slots_from[i] 的值设置为 source_id 所代表节点的 clusterNode 结构
CLUSTER SETSLOT MIGRATING 命令的实现
clusterState 结构的 migrating_slots_to 数组记录了当前节点正在迁移至其他节点的槽:
typedef struct clusterState {
clusterNode *migrating_slot_to[16384];
}
如果该数组的值不为空,而是指向一个 clusterNode 结构,那么表示当前节点正在将槽 i 迁移至 clusterNode 所表示的节点。
在进行重新分片时,向源节点发送下列命令
CLUSTER SETSLOT <i> MIGRATING <target_id>
ASK 错误
之前有介绍,这里举一个例子:
假设在节点 7002 向节点 7003 迁移槽 16198 过程中,一个客户端向节点 7002 发送命令
GET “love”
而恰好 “love” 键属于槽 16198,所以节点 7002 先在自己的数据库中查找,但没找到,通过检车自己的 clusterState.migrating_slots_to[16198] 发现正在进行槽 16198 的迁移,指向的 clusterNode 对应节点为 7003,因此它向客户端返回错误
ASK 16198 127.0.0.1:7003
而接到 ASK 错误后的客户端会根据错误提供的 IP 地址和端口号,转向至正在导入槽的目标节点,然后首先向目标节点发送一个 ASKING 命令(7003),之后再重新发送原本想要执行的命令。
ASKING 命令
ASKING 命令唯一要做的就是打开发送该命令的客户端的 REDIS_ASKING 标识,以下为伪代码
def ASKING():
client.flags |= REDIS_ASKING
reply("OK)
一般情况下,如果客户端向节点发送一个关于槽 i 的命令,而槽 i 又没有指派给这个节点的话,那么节点会向客户端返回一个 MOVED 命令,但若节点的 clusterState.improting_slots_from[i] 显示节点正在导入槽 i, 并且发送命令的客户端带有 REDIS_ASKING 标识,那么节点将破例执行一次关于槽 i 的命令。
ASK 错误与 MOVED 错误的区别
- MOVED 错误代表槽的负责权已经从一个节点转移到了另一个节点:之后客户端可以直接将关于槽 i 的命令请求发送至 MOVED 错误所指向的节点
- 而 ASK 错误知识一种临时的措施:这种转向不会对客户端今后发送关于槽 i 的命令请求产生任何影响
复制与故障转移
Redis 集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。
从节点设置
使用 CLUSTER REPLICATE <node_id> 命令,可以到接收命令的节点成为 node_id 所指定节点的从节点,并开始对主节点进行复制。
故障检测
集群中的每个节点都会定期的向集群中的其他节点发送PING信息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线。
集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态消息,如果在一个集群里,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL),将主节点x标记为已下线的节点会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线。
故障转移
- 复制下线主节点的所有从节点里面,会有一个从节点被选中
- 被选中的从节点会执行 SLAVEOF no one 命令,成为新的主节点
- 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
- 新的主节点向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道这个节点已经从从节点变为主节点,且接管了所负责的槽
- 新节点开始正常工作,故障转移完成
选举
和之前的 Sentinel 选举很像,节点的选举流程也是基于 raft 算法实现,不展开(之后会在开ES介绍的时候一起说)
消息
节点发送的消息主要由5种:
- MEET 信息:当发送者接到客户端发送的 CLUSTER MEET 命令时,发送者会向接收者发送 MEET 消息,请求接收者加入到发送者当前集群。
- PING 消息:集群里每个节点每隔默认1s就会从已知节点列表中随机选出5个节点,然后对其中最长事件未发过 PING 消息的节点发送 PING 消息,以此检测节点是否在线。除此之外,如果节点 A 最后一次收到节点 B 发送的 PONG 消息的时间,距离当前时间已经超过了节点 A 的 cluster-node-timeout 选项设置的时长的一半,也会发送PING消息,防止节点B信息滞后。
- PONG 消息:用于确认PING消息,以及,一个节点可以通过向集群广播 PONG 消息让集群的其他节点立即刷新对这个节点的认知(例如故障转移时,被选中的从节点广播PONG消息)
- FAIL 消息:当一个主节点判断另一个主节点进入 FAIL 状态时,会发送一条 FAIL 信息,收到这条信息的所有节点都会立即将 FAIL 的节点标记为下线
- PUBLISH 消息:当一个节点接收到 PUBULISH 命令时,节点会执行这个命令,并向集群广播一条 PUBLISH 命令,所有接收到 PUBLISH 的节点都会执行同样的 PUBLISH 命令
总结
优点:
- 无中心架构。 数据按照 slot 存储分布在多个节点,节点间数据共享,可动态调整数据分布
- 可扩展性:可线性扩展到 1000
- 多个节点,节点可动态添加或删除。 高可用性:部分节点不可用时,集群仍可用。通过增加 Slave 做 standby
- 数据副本,能够实现故障自动 failover,节点之间通过 gossip 协议交换状态信息,用投票机制完成 Slave 到 Master的角色提升。
- 降低运维成本,提高系统的扩展性和可用性。
缺点:
- Client 实现复杂,驱动要求实现 Smart Client,缓存 slots mapping 信息并及时更新,提高了开发难度,客户端的不成熟影响业务的稳定性。目前仅 JedisCluster 相对成熟,异常处理部分还不完善,比如常见的“max redirect exception”。
- 节点会因为某些原因发生阻塞(阻塞时间大于 clutser-node-timeout),被判断下线,这种 failover 是没有必要的。
- 数据通过异步复制,不保证数据的强一致性。
- 多个业务使用同一套集群时,无法根据统计区分冷热数据,资源隔离性较差,容易出现相互影响的情况。
- Slave 在集群中充当“冷备”,不能缓解读压力,当然可以通过 SDK 的合理设计来提高 Slave 资源的利用率。
- Key 批量操作限制,如使用 mset、mget 目前只支持具有相同 slot 值的 Key 执行批量操作。对于映射为不同 slot 值的 Key 由于 Keys 不支持跨 slot 查询,所以执行 mset、mget、sunion 等操作支持不友好。
- Key 事务操作支持有限,只支持多 key 在同一节点上的事务操作,当多个 Key 分布于不同的节点上时无法使用事务功能。
- Key 作为数据分区的最小粒度,不能将一个很大的键值对象如 hash、list 等映射到不同的节点。
- 不支持多数据库空间,单机下的 redis 可以支持到 16 个数据库,集群模式下只能使用 1 个数据库空间,即 db0。
- 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
- 避免产生 hot-key,导致主库节点成为系统的短板。
- 避免产生 big-key,导致网卡撑爆、慢查询等。
- 重试时间应该大于 cluster-node-time 时间。
- Redis Cluster 不建议使用 pipeline 和 multi-keys 操作,减少 max redirect 产生的场景。
本文参考
Redis 单机模式,主从模式,哨兵模式(sentinel),集群模式(cluster)优缺点分析