第三部分,属于多机数据库的实现,相较而言是很受关注的一部分,也是面试的高频考点,总体包含三个部分:主从复制、Sentinel 以及 集群。这三部分(加上之前介绍到的根据 RDB 和 AOF 实现的数据持久化)实现了 Redis 的高可用性
在 Redis 中,用户可以通过执行 SLAVEOF 命令或者设置 slaveof 选项,让一个服务器去复制(replicate) 另一个服务器,我们称呼被复制的服务器为主服务器,而对主服务器进行复制的服务器则成为从服务器
复制分为 同步 和 命令传播 两个操作:
当客户端向从服务器发送 SLAVEOF 命令,要求从服务器复制主服务器时,从服务器首先需要进行同步操作
同步操作需要通过向主服务器发送 SYNC 命令来完成:
同步操作执行过后,主从服务器状态一致,但要保持一致,则需要进行命令传播将主数据库执行的写命令传播给从数据库
由于在使用过程中,有可能发生由于各种原因导致的复制中断,而中断重连后,再想恢复主从服务器的一致性对于旧版复制方式而言效率很低
上述例子还只是理想化情况,实际情况是,在断线过程中,主服务器执行的写命令可能会有成百上千条,因此重新执行一遍 SYNC 命令无疑十分低效
在新版复制功能中(Redis 2.8 版本开始)使用 PYNSC 代替 SYNC 来执行复制时的同步问题。
PYNSC 命令具有 完整重同步 以及 部分重同步 两种模式
部分重同步功能包括三个部分:
执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:
复制积压缓冲区是由主服务器维护的一个固定长度、先进先出队列,默认大小 1 MB
主服务器命令传播过程中,它不仅会将写命令发送给所有从服务器,还会将写命令放入复制积压缓冲区
因此,主服务器的复制积压缓冲区会保存一部分近期传播的写命令
当从服务器重新连接上主服务器时,从服务器会通过 PSYNC 命令将自己的复制偏移量 offset 发送给主服务器,主服务器根据这个复制偏移量来决定对从服务器执行何种同步方式
用于表示服务器的 runID
从服务器将 SLAVEOF 命令中的主服务器 IP 以及 端口号 保存至 masterhost 属性和 masterport 属性中:
随后服务器返回 OK,并开始执行实际的复制工作(因此 SLAVEOF 命令为异步命令)
根据 ip 以及 端口号,从服务器与主服务器建立套接字连接,并发送 PING 命令
这个 PING 命令有两个作用
检验完毕之后,需要进行身份验证
即之前介绍的 同步
数据同步阶段完成后,主从节点进入命令传播阶段;在这个阶段主节点将自己执行的写命令发送给从节点,从节点接收命令并执行,从而保证主从节点数据的一致性。在命令传播阶段,除了发送写命令,主从节点还维持着心跳机制:PING 和 REPLCONF ACK。
在心跳机制中:从服务器默认会以1s一次的频率,向主服务器发送命令:
REPLCONF ACK <repliction_offset>
主要有3个作用
Redis 的 min-slaves-to-write 和 min-slaves-max-log 两个选项可以防止主服务器在不安全的情况下执行写命令
举个例子,如果我们向主服务器提供以下设置:
min-slaves-to-write 3
min-slaves-max-lag 10
那么在从服务器数量小于3,或者3个从服务器的延迟(lag)值都大于等于 10s 时,主服务器将拒绝执行写命令
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 实际上是一个特殊模式的 Redis 服务器,所以最初只需要初始化一个普通的 Redis 服务器,但由于 Sentinel 执行的工作与普通 Redis 不同,因此初始化过程并不完全一样。
具体不同可参考下表:
使用 Sentinel 专用代码
启动 Sentinel 的第二个步骤就是将一部分普通 Redis 服务器使用的代码替换成 Sentinel 专用代码。
比如:普通 Redis 服务器使用 redis.h/REDIS_SERVERPORT 常量作为服务器端口,但 Sentinel 则使用 sentinel.c/REDIS_SENTINEL_PORT 常量作为服务器端口。
初始化 Sentinel 状态的 master 属性
Sentinel 需要保存其监视的主服务器的相关信息,使用 master 字典进行记录
每个 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 会创建两个连向主服务器的异步网络连接
为什么需要两个连接
Redis 目前的发布与订阅功能中,被发送的消息都不会保存在 Redis 服务器里,如果在消息发送过程中,想要接收信息的客户端不在线或者断线,那么这个客户端就会丢失这条数据。因此为了不丢失 sentinel:hello 频道的任何消息, Sentinel 必须专门用一个订阅连接来接收这个频道的消息
另一方面,Sentinel还需要向主服务发送命令,所以 Sentinel 必须向主服务器创建命令连接
因为需要与多个实例创建多个网络连接,因此采用异步连接
Sentinel 默认以每10秒一次的频率,通过命令连接,发送 Info 命令给被监视的主服务器,通过分析 Info 命令回复来获取该主服务器的当前信息
依据上图结构,Sentinel 持续向 master 发送 Info 命令,并获取下图的回复
获取从服务器的 IP 地址和端口号之后,Sentinel 除了会为从服务器创建对应实例结构外,还会创建连接到从服务器的命令连接和订阅连接
根据 Info 命令的回复,Sentinel 会提取出以下信息:
根据这些信息,Sentinel 会对从服务器的实例结构进行更新
默认2秒一次,Sentinel 通过命令连接向所有被监视的主服务器和从服务器发送固定格式的命令
PUBLISH _sentinel_ : hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"
相关参数的意义:
参数 | 意义 |
---|---|
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 每秒一次对所有与它创建了命令连接的实例(主、从服务器、其他Sentinel)发送 PING 命令,并通过实例返回的 PING 命令回复判断该实例是否在线。
之前配置文件实例中的 down-after-millisecond 选项指定了 Sentinel 判断实例进入主观下线所需的时间长度。(即:如果在 down-after-milliseconds 毫秒内,Sentinel 没有收到 目标节点 的有效回复,则会判定 该节点 为 主观下线。)
当 Sentinel 将一个主服务器判断为主观下线后,会通过 is-master-down-by-addr 命令,向同样监视这一主服务器的其他 Sentinel 进行询问,看他们是否也认为主服务器已经进入了下线状态(主、客观都行),当收到足够数量的已下线判断后,将主服务器判定为客观下线,并对主服务器执行故障转移操作。
当一个主服务器被判断客观下线时,监视这个下线主服务器的各个 Sentinel 会进行协商,选出一个领头 Sentinel,并由领头的 Sentinel 对下线主服务器进行故障转移操作(选举采用Raft算法,需要详细了解可看下篇文章->还没写- -)
主要包括三个步骤
之前在介绍 Sentinel 的时候有提到过,接下来介绍一下详细步骤
选出新的服务器
修改从服务器的复制目标
当新的主服务器出现后,其他所有从服务器使用 SLAVEOF 命令去复制新的主服务器
将旧服务器变为从服务器
最后,需要将已下线的主服务器设置为新服务器的从服务器,因为旧的主服务器以及下线,所有这种设置是保存在 Sentinel 存储的对应旧服务器的 SentinelRedisInstance 中的,当旧主服务器重新上线时,Sentinel 就会向它发送 SLAVEOF 命令,使其成为新主服务器的从服务器。
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 结构。
至于 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 信息,具体结构如下:
# 伪代码 # 遍历所有输入槽,检查它们是否都是未指派槽 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)
当所有的槽都被分配之后,集群进入上线状态,这时客户端就能对集群中的节点发送命令了。
当客户端向节点发送与数据库键相关的命令时,接收命令的节点会计算出键所属的槽,并检查槽是否指派给了自己
def slot_number(key): return CRC16(key) & 16383
其中 CRC16(key) 计算键的 CRC-16 校验和,而 &16383 则用于计算出一个介于0~16383之间的整数作为键 key 的槽号。
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 采用的是无中心架构 ,每个节点保存数据和整个集群状态,每个节点都和其他节点有所连接”这句话。
注意要和第二部分单机数据库一起理解节点数据库的构成。
在重新分片过程中,有一种场景是键迁移过程中,属于被迁移槽的一部分键值对保存在源节点内,而另一部分键值对则保存在目标节点内。
当客户端向源节点发送一个与数据库有关的命令,并且命令要处理的数据库键恰好属于正在被迁移的槽时:
很像之前的 MOVED 命令,但两者还是有区别,下面对 ASK 实现原理和两者区别进行分析
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 结构
clusterState 结构的 migrating_slots_to 数组记录了当前节点正在迁移至其他节点的槽:
typedef struct clusterState { clusterNode *migrating_slot_to[16384]; }
如果该数组的值不为空,而是指向一个 clusterNode 结构,那么表示当前节点正在将槽 i 迁移至 clusterNode 所表示的节点。
在进行重新分片时,向源节点发送下列命令
CLUSTER SETSLOT <i> MIGRATING <target_id>
之前有介绍,这里举一个例子:
假设在节点 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 命令唯一要做的就是打开发送该命令的客户端的 REDIS_ASKING 标识,以下为伪代码
def ASKING(): client.flags |= REDIS_ASKING reply("OK)
一般情况下,如果客户端向节点发送一个关于槽 i 的命令,而槽 i 又没有指派给这个节点的话,那么节点会向客户端返回一个 MOVED 命令,但若节点的 clusterState.improting_slots_from[i] 显示节点正在导入槽 i, 并且发送命令的客户端带有 REDIS_ASKING 标识,那么节点将破例执行一次关于槽 i 的命令。
Redis 集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。
使用 CLUSTER REPLICATE <node_id> 命令,可以到接收命令的节点成为 node_id 所指定节点的从节点,并开始对主节点进行复制。
集群中的每个节点都会定期的向集群中的其他节点发送PING信息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线。
集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态消息,如果在一个集群里,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL),将主节点x标记为已下线的节点会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线。
和之前的 Sentinel 选举很像,节点的选举流程也是基于 raft 算法实现,不展开(之后会在开ES介绍的时候一起说)
节点发送的消息主要由5种:
优点:
缺点:
Redis 单机模式,主从模式,哨兵模式(sentinel),集群模式(cluster)优缺点分析
Redis设计与实现