第三部分 多机数据库的实现
[toc]
1. 复制
在Redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制另一个服务器,我们把被复制的服务器成为主服务器,对主服务器进行复制的服务器成为从服务器.也就是我们常说的主从复制.
命令使用方式如下:
redis> SLAVEOF xxx.xxx.xxx.xxx 6379
进行复制的主从服务器双方将==保存相同的数据==,概念上称为==服务器状态一致==.
(1). 旧版复制功能的实现(Redis 2.8之前)
Redis的复制功能分为同步(将从服务器的状态更新至主服务器)和命令传播(主服务器被更改后,让主从服务器重新一致)两个操作.
1). 同步
当客户端向发送了SLAVEOF命令,==进入主从复制模式==的时候,从服务器首先要进行同步操作,也就是==将从服务器中的数据更新为主服务器的数据库状态==.
通过SYNC命令完成,执行步骤如下:
- 从服务器向主服务器发送SYNC命令
- 主服务器收到后执行BGSAVE命令,生成一个RDB文件后使用一个缓冲区记录从现在开始执行的所有写命令
- 主服务器的BGSAVE执行完之后,主服务器将RDB文件发给从服务器,从服务器进行加载,这时从服务器就会更新成主服务器执行BGSAVE命令前的数据库状态
- 主服务器将命令缓冲中的命令全部发送给从服务器,从服务器执行完之后达到主从一致的效果
2). 命令传播
当主服务器执行写命令的时候,主服务器的数据库状态就可能被改变,导致打破主从一致.
为了保持主从一致,主服务器需要将自己执行的写命令发送给从服务器执行,从服务器执行后,从新回到主从一致状态.
(2). 旧版功能的缺陷
对于从服务器从来没有复制过任何服务器的初次复制情况,这种方案能够完成任务.但是如果是从服务器由于网络原因断线后的重新复制来说,效率就很低了.
因为这时还是会使用RDB文件作为同步的媒介,很大一部分数据是重复的.
(3). 新版复制功能的实现
Redis从2.8版本开始使用PSYNC命令代替SYNC命令来执行复制时的同步操作.
PSYNC具有完成重同步和部分重同步两种模式:
- 完整重同步用于初次复制,和SYNC命令基本一样
- 部分重同步用于断线后的重复制,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器
(4). 部分重同步的实现
部分重同步功能由一下三部分构成:
- 主服务器的肤质偏移量和从服务器的复制偏移量
- 从服务器的复制积压缓存
- 服务器的运行ID
1). 复制偏移量
主服务器和从服务器都会维护一个复制偏移量.
- 主服务器每次向从服务器传播N个字节数据时,九江自己的复制偏移量加上N
- 从服务器每次收到数据也会更新复制偏移量
通过==对比==主从服务器的==复制偏移量==就可以知道主从服务器是否处于主从一致状态
2). 复制积压缓冲区
复制积压缓冲区是主服务器维护的一个==固定长度先进先出队列==,默认大小1MB.内部存储了一系列命令的字节数组表示和其中每一个字节对应的复制偏移量(也就是说,是可以清楚的知道每一条命令的复制偏移量).
主服务器进行==命令传播==时,不仅仅会将命令==发给从服务器==,还==写入复制积压缓冲区中==.因此复制积压缓冲区中会保存最新的1MB命令数据.当从服务器重新连上主服务器时,从服务器根据自己的复制偏移量决定接下来的操作:
- 如果从服务器的复制偏移量不在复制积压缓冲区中,那么进行完整重同步操作
- 否则,说明从服务器中缺失的命令在复制积压缓冲区中都存在,根据复制偏移量选择起始位置进行部分重同步操作
3). 服务器运行ID
每一个Redis服务器,不论主从都有自己的运行ID,这个ID由服务器启动时自动生成,是一个40位十六进制数.在进行主从复制时,从服务器会保存主服务器的运行ID.
在进行断线重连之后,从服务器会将之前自己保存的主服务器的运行ID发送给新的主服务器,如果一致,那么说明这是一次重连,进行部分重同步操作.不一致则说明两次连接的不是一个服务器,要进行完整重同步操作.
(5). PSYNV命令的实现
PSYNC命令的调用方法有两种:
- 如果从服务器从来没有连接过主服务器,或者已经主动断开了,那么在开始第一次新的复制时,主动请求服务器进行完整重同步.
- 如果从服务器已经父之过某个服务器,那么将之前复制的主服务器的运行ID和自己的复制偏移量发送给主服务器,由主服务器决定进行哪一种同步操作;
主服务器的回应会是下面三种之一:
- 如果主服务器要与从服务器进行完整重同步,那么会把主服务器的运行ID发送给从服务器保存,将主服务器的复制偏移量发送给从服务器作为复制偏移量的起始值
- 如果主服务器要与从服务器进行部分重同步,那么从服务器只需要等到主服务器将自己缺少的数据发送过来即可
- 如果主服务器的版本低于2.8,识别不了PSYNC命令,那么会让从服务器重新发送一个SYNC命令进行完整的重同步
(6). 复制的实现
通过向服务器(这里是从服务器)发送SLAVEOF命令也可以让服务器去和其他服务器进行主从连接.
1). 步骤1:设置主服务器的地址和端口
当客户端向服务器发送SLAVEOF命令请求时,从服务器首先要做的就是将发送来给定的主服务器IP和端口保存在服务器状态中:
struct redisServer{
// .....
// 主服务器的地址
char *masterhost;
// 主服务器端口
int masterport;
// .....
}
然后服务器会向客户端回复OK,随后才开始真正执行命令的内容.
2). 步骤2:建立连接套接字
从服务器根据命令设置的IP和端口,创建连向主服务器的套接字连接,如果成功创建,那么从服务器将为这个套接字关联一个专门处理复制工作的事件处理器,复制工作的所有内容在后面都是由这个套接字完成的,
主服务器在接受这个套接字连接后,会创建响应的客户端状态,并将从服务器当做链接到主服务器的一个客户端来看待.
3). 步骤3:发送PING命令
从服务器成为主服务器的客户端后,第一件事就是发送PING命令,PING命令有两个作用:
- 检查套接字的读写状态是否正常
- 检查主服务器能否正常处理命令请求
从服务器会收到三种响应之一:
- 主服务器发送了一个命令回复,但是从服务器并没有在有限时间内读取出回复的内容,那么说明当前的网络连接状态不佳.会进行断开重连
- 主服务器向从服务器返回一个错误,表示主服务器暂时没办法处理从服务器的命令请求.从服务器会进行断开重连
- 从服务器收到PONG回复,那么说明状况良好,可以进行下面的操作
4). 步骤4:身份验证
从服务器收到PONG回复后,如果设置了masterauth,那么进行身份验证.
从服务器将向主服务器发送一条AUTH命令,参数为从服务器masterauth选项的值.
从服务器可能遇到的情况:
- 主服务器没有设置requirepass选项,并且从服务器没有设置masterauth,那么跳过这个阶段
- 如果从服务器通过AUTH命令发送的密码和主服务器requirepass选项设置的相同,那么验证成功,继续进行
- 如果主从服务器只有一个进行了设置,那么返回一个错误
5). 步骤5:发送端口信息
从服务器向主服务器发送从服务器监听的端口号.主服务器收到后,会记录在从服务器的客户端状态中.
6). 步骤6:同步
从服务器向主服务器发送PSYNC命令,执行同步操作.
值得一提的是,在执行同步操作过后,主服务器也将成为从服务器的客户端(双方可以相互发送请求和回复),因为无论怎样,主服务器都需要向从服务器发送命令请求来同步数据.
7). 步骤7:命令传播
主从服务器进入命令传播阶段,主服务器一直想自己执行的写命令发送给从服务器,从服务器一直接收并执行就可以保持主从一致.
(7). 心跳检测
从服务器会以每秒一次的频率向服务器发送命令:
// 参数是从服务器的复制偏移量
REPLCONF ACK
有三个作用:
- 检测主从服务器的网络连接状态
- 辅助实现min-slaves选项
- 检测命令丢失
1). 检测网络连接状态
如果至服务器超过一秒没有接收到REPLCONF ACK命令,那么主服务器就知道网络连接出现问题了.
这个计时的值被称为lag,应该在0~1浮动.
2). 辅助实现min-slaves配置选项
这个选项可以防止主服务器在不安全的情况下执行写命令.
可以配置从服务器少于多少,lag值大于多少的情况下,主服务器不执行写命令.
3). 检测命令丢失
主服务器接收到REPLCONF ACK命令,取出其中的从服务器复制偏移量,与自己的复制偏移量进行对比,如果不一致,那么从复制积压缓冲区中找到服务器缺少的数据,重新发送给从服务器.
这部分和部分重同步操作的原理很像.
Redis 2.8版本之前没有检测丢失的功能.
2. Sentinel
Sentinel(哨兵)是Redis的高可用性解决方案:==有一个过着多个Sentinel实例组成的Sentinel系统可以监视任意多个主服务器以及每一台主服务器下的所有从服务器,当被监视的一台主服务器下线时,自动将这台主服务器下的某个从服务器升级为新的主服务器,然后继续维持主从一致==.
[图片上传失败...(image-305527-1590395250247)]
稳定状态1:
[图片上传失败...(image-d22c78-1590395250247)]
主服务器下线:
[图片上传失败...(image-3e5155-1590395250247)]
哨兵进行调整:
[图片上传失败...(image-2f92d5-1590395250247)]
- Sentinel会挑选原主服务器下的其中一个从服务器,将这个从服务器升级为主服务器
- Sentinel系统向原主服务器下的其他所有从服务器发送新的复制指令,建立新的主从复制
- Sentinel继续监视那个下线的服务器,当这个服务器重新上线时,成为一个从服务器
(1). 启动并初始化Sentinel
启动Sentinel可以使用命令:
redis-sentinel /path/to/your/sentinel.conf
或者
redis-server /path/to/your/sentinel.conf --sentinel
一个Sentinel启动时,会执行以下步骤:
- 初始化服务器
- 将普通的Redis服务器使用的代码转换成Sentinel专用代码
- 初始化Sentinel状态
- 根据指定的配置文件,初始化Sentinel监视的主服务器列表
- 创建连向主服务器的网络连接
1). 初始化服务器
Sentinel本质上只是一个运行在特殊模式下的Redis服务器,但是因为执行的工作不相同,所以初始化方式也不完全相同.比如:不进行RDB和AOF数据恢复等等.
2). 使用Sentinel专用代码
Sentinel使用一些新的代码代替原先的部分Redis代码中,比如默认端口号.使用不同的服务器命令表(因为执行的命令完全不同)
这也解释了Sentinel不具备有些Redis服务器的功能.
3). 初始化Sentinel状态
服务器会初始化一个sentinelState结构体,也就是Sentinel状态,保存了服务器中所有和Sentinel功能相关的状态.
struct sentinelState{
// 当前纪元,用于实现故障转移
uint64_t current_epoch;
// 保存了所有被这个Sentinel监视的主服务器
// 键是主服务器的名字,值是sentinelRedisInstance结构体指针
dict *masters;
//是否进入TILT模式
int tilt;
//目前正在执行的脚本的数量
int running_scripts;
// 进入TILT模式的时间
mstime_t tile_start_time;
// 最后一次执行时间处理器的时间
mstime_t previous_time;
// 一个FIFO队列,包含了所有需要执行的用户脚本
list *scripts_queue;
};
4). 初始化Sentinel状态的masters属性
每一个主服务器都对应一个sentinelRedisInstance结构体:
typedef struct sentinelRedisInstance{
// 标识值,记录当前实例的状态
int flags;
// 实例的名字
// 主服务器的名字从配置文件中得到
// 从服务器和Sentinel的名字由Sentinel自动设置
// 格式为ip:port
char *name;
// 实例的运行ID
char *runid;
// 配置纪元,用于实现故障转移
uint64_t config_epoch;
// 实例的地址
sentinelAddr *addr;
//主观下线的判定时间
mstime_t down_after_period;
// 客观下线的投票数量
int quorum;
//进行故障转移时,可以同时对新的主服务器进行同步的从服务器的数量
int parallel_syncs;
// 刷新故障迁移状态的最大时限
msmtime_t failover_timeout;
// .....
}sentinelRedisInstance;
typedef struct sentinelAddr{
char *ip;
int port;
}sentinelAddr;
Sentinel状态的masters字典的初始化根据载入的配置文件进行.也就是说,配置文件中应该记录所有要监视的主服务器的属性.
5). 创建连向主服务器的网络连接
对于每个被监视的主服务器来说,Sentinel会创建两个异步网络连接:
- 命令连接:用于专门向主服务器发送命令,并接收回复
- 订阅连接:专门用于订阅主服务器的__sentinel__:hello频道
为什么需要订阅连接:
Redis目前的发布订阅功能,发送的消息不会保存在Redis服务器中,一旦客户端不在线就会丢失,所以只用一个专门的订阅连接来接受该频道的消息.
(2). 获取主服务器信息
Sentinel默认会以每十秒的频率通过命令连接来向被监视的主服务器发送INFO命令,通过分析这个命令的回复来获得对应主服务器的状态.
通过INFO命令的回复,Sentinel可以得到以下信息:
- 关于主服务器本身的信息:服务器运行ID,服务器角色等
- 关于主服务器属下所有从服务器的信息,每个从服务器由一个"slave"字符串开头的行记录,每一行记录了从服务器的ip和端口
根据主服务器本身的信息,Sentinel会对主服务器的实例结构进行更新.而从服务器的信息将被用于更新主服务器实例结构的slaves属性,这个属性记录了下属的所有从服务器字典(键是从服务器名字ip:port,值是从服务器对应的结构),如果从服务器不存在,新建
(3). 获取从服务器信息
当Sentinel发现主服务器有新的从服务器出现时,Sentinel除了会建立对应的实例结构之外,还会创建链接到从服务器的命令连接和订阅连接.也就是说,从服务器和Sentinel也是直接相连的.
创建命令连接之后,Sentinel也会每隔十秒型从服务器发送依次INFO命令,获得从服务器的运行id,角色,所连接的主服务器的id和端口,连接状态,从服务器的优先级,复制偏移量.根据这些信息,对从服务器的实例结构进行更新.
(4). 向主服务器和从服务器发送信息
默认情况下,Sentinel会以每两秒一次的频率向所有被连接的主从服务器发送一条命令,这条命令会向服务器的__sentinel__:hello频道发送一条信息,包含一下内容:
参数 | 意义 |
---|---|
s_ip | Sentinel的ip |
s_port | 端口 |
s_runid | 运行id |
s_epoch | 配置纪元 |
m_name | 主服务器的名字 |
m_ip | ip |
m_port | 端口 |
m_rpoch | 当前配置纪元 |
如果是从服务器,那么其中的主服务器属性指的是当前进行复制的主服务器的属性.
(5). 接受来自主服务器和从服务器的频道信息
当Sentinel和一个服务器建立订阅连接以后,Sentinel就会通过订阅连接向服务器发送以下命令:
SUBSCRIBE __sentinel__:hello
Sentinel对__sentinel__:hello频道的订阅会一致持续到Sentinel与服务器的连接断开.
也就是说,Sentinel不仅仅可以向__sentinel__:hello频道发送消息,也可以从__sentinel__:hello频道读取消息.
对于监视同一个服务器的多个Sentinel来说,一个Sentinel发送的消息会被其他Sentinel接收到,这些消息会被用于更新其他Sentinel对于这个服务器的认知.
1). 更新sentinels字典
Sentinel为主服务创建的实例结构中的sentinels字典会保存除了自己之外其他连接这个主服务器的Sentinel的资料.这个字典的键是其中一个Sentinel的名字(ip:port),值是对应的Sentinel的实例结构
当Sentinel(目标Sentinel)接收到其他Sentinel(源Sentinel)发来的消息时,就会从中得到以下信息:
- 源Sentinel的ip,端口,运行ID和配置纪元
- 源Sentinel正在监视的主服务器的名字,ip,端口和配置纪元
根据这些消息,Sentinel会主服务器的sentinels字典
2). 创建连向其他Sentinel的命令连接
当Sentinel通过频道信息发现一个新的Sentinel是,不仅会更新sentinels字典,还会创建连向这个Sentinel的命令连接.
==最终监视同一主服务器的多个Sentinel将形成相互连接的网络.==
(6). 检测主观下线状态
默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(主服务器,从服务器,其他Sentinel),发送PING命令,名通过实例返回的PING命令回复判断是否在线.
返回的回复中处理+PONG,-LOADING,-MASTERDOWN之外都是无效回复.当连续返回无效回复时长达到设定的主观下线时间长度之后,Sentinel就会修改这个实例的实际结构为主观下线.
不同的Sentinel可能设置的主观下线时长不同,当一个Sentinel认为某个实例主观下线之后,其他的Sentinel可能认为这个实例是在线的.
(7). 检查客观下线状态
当Sentinel认为某个主服务器主观下线后,为了进行确认,它会向同样监视这个主服务器的其他Sentinel进行询问,当Sentinel从其他Sentinel收集到足够数量的答复(主观下线或者客观下线)能够做出判断之后,Sentinel就会判定主服务器为客观下线,并进行故障转移.
1). 发送SENTINEL is-master-down-by-addr命令
该命令的参数:询问是否下线的主服务器的ip,duank,该Sentinel的配置纪元,Sentinel的运行id(这个也可以是*代替)
2). 接收SENTINEL is-master-down-by-addr命令
接收这个命令的Sentinel会提取出其中的各个参数,返回向源Sentinel返回一个回复.回复包含:该主服务器是否下线,局部领头Sentinel的运行id和配置纪元.
3). 接收SENTINEL is-master-down-by-addr,命令的回复
源Sentinel接收到所有监视这个主服务器的Sentinel的回复后,就得到了认为该服务器下线的Sentinel的数量,通过和配置指定的判断客观下线的数量进行对比(超过,则下线),就能得出是否客观下线的结论.并对这个主服务器对应结构的标记属性进行修改.
注意,客观下线的标准在各个Sentinel中也是不一样的,所以也会出现不同的Sentinel认知不一样的情况.
(8). 选举领头Sentinel
当一个主服务器被认为客观下线时,监视这个主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,由这和Sentinel对这个下线的主服务器进行故障转移操作.选举的规则和方法:
- 每个在线的Sentinel都有资格
- 每次选举,不论是否成功,配置纪元都自增1
- 在一个配置纪元中(选举之后,自增,一直到下一次自增中间的时间),只有一个Sentinel是领头Sentinel,不可被更改
- 每个发现主服务器客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头Sentinel
- SENTINEL is-master-down-by-addr命令的参数中运行id不为*,则表示自己要做局部领头Sentinel
- 局部Sentinel规则先到先得
- SENTINEL is-master-down-by-addr命令的回复中有源Sentinel的局部领头Sentinel的运行id和配置纪元,如果配置纪元相同,且运行id是自己,那么就说明发送回复的Sentinel将自己设置成了局部领头
- 如果一个Sentinel被半数以上的Sentinel设置为局部领头Sentinel,那么它成为领头Sentinel
- 如果在给定的时间内没有完成选举,那么会重新进行
进行选举的SENTINEL is-master-down-by-addr命令和进行客观下线判断的SENTINEL is-master-down-by-addr不重叠.
(9). 故障转移
选举出领头Sentinel后,由这个Sentinel对已经下线的主服务器进行故障转移.包含以下三个步骤:
- 从这个主服务器的下属中选出一个,升级为主服务器
- 让其他下属从服务器对这个新的主服务器进行主从连接
- 将已经下线的主服务器设置为新主服务器的从服务器,当它重新上线时,直接得到身份
1). 选出新的主服务器
领头Sentinel会将下线的主服务器的从服务器保存到一个列表中,然后按照以下规则进行过滤:
- 删除下线或者断线的从服务器
- 删除5秒内没有回复过领头Sentinel的INFO命令的从服务器
- 删除与主服务器断开连接超过设定值*10毫秒的从服务器(保证从服务器中的数据较新)
然后,按照从服务器的优先级进行选择,选出其中优先级最高的从服务器(相同优先级选择复制偏移量大的,然后进一步选择运行id小的),充当新的主服务器.
2). 修改从服务器的复制目标
领头Sentinel向其他所有从服务器发送SLAVEOF命令来让其他的从服务器都与新的主服务器建立主从连接.
3). 将旧的主服务器变为从服务器
领头Sentinel还是发送SLAVEOF命令完成这一步.
3. 集群
Redis集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能.
(1). 节点
一个Redis有多个节点构成.刚开始,每一个Redis服务器本身都可以被视为一个单个节点的集群,它们相互联通后就构成了一个多个节点的集群.
连接各个节点可以使用以下命令完成:
CLUSTER MEET
向一个服务器发送这个命令,会让接收命令的服务器向ip:port的服务器进行握手,握手成功时就会将ip:port对应的节点添加到接收命令的服务器所处的集群中.
1). 启动节点
一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器会在启动时根据ckuster-enabled配置选项是窦唯yes来决定是否开启服务器的集群模式.
单机Redis的所有功能,组件和数据都没有发生改变.Redis会将集群模式下使用到的数据保存到cluster.h/clusterNode结构,cluster.h/clusterLink结构和cluster.h/clusterState结构中.
2). 集群数据结构
clusterNode结构保存了一个节点当前的状态,每一个节点都会创建一个clusterNode结构保存自己的状态,并为集群中的其他节点各自创建一个该结构体.
// 节点信息
struct clusterNode{
// 创建节点的时间
mstime_t ctime;
// 节点的名字,字符数组的长度是固定的
char name[REDIS_CLUSTER_NAMELEN];
// 节点表示
// 记录节点的角色(主从)和所处状态(上线,下线)
int falgs;
// 节点当前的配置纪元
uint64_t configEpoch;
// 节点的ip
char ip[REDIS_IP_STR_LEN];
// 端口号
int port;
// 保存连接节点所需的有关信息
clusterLink *link;
// .....
};
// 与这个节点连接的相关信息
typedef struct clusterLink{
// 连接的创建时间
mstime_t ctime;
// TCP套接字描述符
int fd;
// 输出缓冲区
sds sndbuf;
// 输入缓冲区
sds rcvbuf;
// 与这个节点所相连的所有节点
struct clusterNode *node;
}clusterLink;
// 当前节点视角下的集群状态
// 每一个节点中都存储集群信息
typedef struct clusterState{
// 指向当前节点的指针
clusterNode *myself;
// 结群当前的配置纪元
uint64_t currentEpoch;
// 集群当前的状态(在线或则下线)
int state;
// 集群中至少处理着一个槽的节点的数量
int size;
// 集群节点的名单(包括myself节点)
dict *nodes;
// .....
}clusterState;
3). CLUSTER MEET命令的实现
客户端向节点A发送CLUSTER MEET,让A去和节点B握手:
- A会为B创建一个clusterNode结构,并添加到自己的clusterState.nodes字典中
- 节点A根据命令中的ip和端口向B发送一条MEET消息
- B接收到消息之后为A创建clusterNode结构,添加到自己的clusterState.nodes字典中
- B向A返回一条PONG消息,通过这个返回,A可以知道B准备好了
- A向B返回一条PING消息,通过这个消息B知道A成功收到了自己的反馈
- 握手完成
- A节点会将B节点的信息通过Gossip协议传播给集群中的其他节点,让其他节点与B进行握手
- 一段时间后,B会被集群中的所有节点认识
(2). 槽指派
Redis集群通过分片的方式保存数据库中的键值对:集群的整个数据库被分为16384个槽,数据库中的每一个键被分配给这16384个槽中的一个,每个节点可以处理0~16384个槽.当数据库中的所有槽都被处理时,集群处于上线状态,否则就是下线状态.
以下命令可以将一个或者多个槽指派给接==受命令的节点==负责:
CLUSTER ADDSLOTS [slot ...]
// 下面这个命令可以将0~5000的槽都交给127.9.9.1:7000的节点处理
127.0.0.1:7000> CLUSTER ADDSLOTS 0 1 2 3 ... 5000
1). 记录节点的槽指派信息
clusterNode结构的slots属性和numslot属性记录了节点负责处理那些事情:
struct clusterNode{
// .....
// 二进制为数组,包含16384个二进制位,每一位表示这个槽是否被这个节点处理
unsigned char slots[16384/8];
// 这个节点处理的槽的数量
int nummslots;
// .....
};
2). 传播节点的槽指派信息
一个节点除了负责记录自己的slots属性和numslots属性之外,还会通过消息将自己的这两个属性发送给集群中的其他所有节点.当其他节点收到时,就会保存在自己节点的clusterState结构中的slots数组中.
所以集群中的每一个节点都能知道任意一个槽被分派的节点.
3). 记录集群所有槽的指派信息
clusterState结构中的slots数组记录了所有槽的指派信息:
typedef struct clusterState{
// .....
clusterNode *slots[16384];
// .....
}clusterState;
slots数组中包含16384个clusterNode指针,分别代表了每一个槽的分派节点.
这个数组存在的意义是:O(1)时间复杂度获取某个槽的分派节点
但是如果只有这个数组存储分派信息,那么在分派信息的传播时,就会一次又一次的遍历这个数组,得到每一个节点的负责区域.很低效.
4). CLUSTER ADDSLOTS命令的实现
CLUSTER ADDSLOTS命令接受一个或者多个槽作为参数,将这个槽指派给接收命令的节点负责
伪代码实现:
def CLUSTER_ADDSLOTS(*all_input_slots):
# 检查传入的槽是否有已经被分派的
for i in all_input_slots:
if clusterState.slots[i] != NULL:
# 如果有,报错
reply_error()
return
# 再次遍历
for i in all_input_slots:
# 更新这个槽的负责节点指针
clusterState.slots[i] = clusterState.myself
# 更新负责节点中slots属性对应位上的值为1
setSlotBit(clusterState.myself.slots, i)
# 告诉其他所有节点,自己目前正在负责的槽
call_all_other_node()
(3). 在集群中执行命令
对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这是客户端就可以向集群中的节点发送数据命令了.
当客户端向一个节点发送一个命令请求时,接收命令的节点会计算出命令要处理的数据库键处于的槽,并判断这个槽是不是自己负责的,如果是,那么自己处理这个命令.如果不是,那么向客户端返回一个MOVED错误,引导客户端转向正确的节点,再次发送要执行的命令.
1). 计算键属于哪个槽
算法同以下伪代码:
def slot_number(key):
# CRC16()方法用于计算key的CRC-16校验和
# 然后进行除法散列
return CRC16(key) & 16383
2). 判断槽是否由当前节点负责
从clusterState.slots中取出这个槽对应的节点指针,和clusterState.myself进行对比即可.
3). MOVED错误
MOVED错误的格式为:
MOVED :
内容包括当前key的槽,和负责这个槽的节点的ip:端口
一个连接集群的客户端通常会与集群中的多个节点创建连接,这里客户端就会根据MOVED返回的信息选择正确的套接字发送命令.如果碰巧这时这个节点未与当前客户端连接,那么会先根据ip和端口创建套接字连接,然后在进行转向.
集群模式的客户端由以下命令启动:
redis-cil -c -p
集群模式下MOVED错误是被隐藏的,客户端会自行进行节点的转向,用户是察觉不到的.单机模式下就会打印出MOVED错误.
4). 节点数据库的实现
节点只能使用0号数据库.
其他保存键值对和设置过期时间的方式和单机数据库完全一样.
但是节点数据库会用clusterState结构中的slots_to_keys跳跃表来==存储槽和键==的关系,其中分值表示槽号,成员是数据库键.
这样就可以批量的对某个节点中的一个槽进行统一处理.
typedef struct clusterState{
// .....
zskiplist *slots_to_keys;
// .....
}clusterState;
(4). 重新分片
Redis的重新分片操作可以将任意的已经进行了指派的槽重新指派给另一个节点,这个操作可以在线进行.
Redis集群的重新分派有Redis的集群管理软件redis-trib负责执行.Redis提供了命令,redis-trib通过向源节点和目标节点发送命令完成.过程如下:
- redis-trib对目标节点发送命令让其做好准备接受节点(CLUSTER SETSLOT
IMPORTING ) - redis-trib对源节点发送命令让其做好迁移槽的准备(CLUSTER SETSLOT
MIGRATING ) - redis-trib对源节点发送命令获得==不小于一定数量的某个槽中的key==(CLUSTER GETKEYSINSLOT
) - redis-trib向源节点发送命令,将3过程中被选中的键值对原子性的迁移到目标节点
- 重复执行3和4,直到全部迁移完成
- 向集群中任意一个节点发送命令,告知已经将某个槽重新指派给了新的节点,其他的节点更新自己内部的信息
(5). ASK错误
在重新分片时,有可能会遇到一个槽中的键值对只迁移了一部分的情况,这个时候,如果对这些键进行访问,集群会先在源节点中查找,如果没有找到,则表示有可能会出现在目标节点中.(也可能这个键不存在)
这时源节点会向客户端返回一个ASK错误,引导客户端转向目标节点再次发送命令.
1). CLUSTER SETSLOT IMPORTING命令的实现
clusterState结构中的importing_slots_from数组记录了当前节点正在从其他节点导入的槽
typedef struct clusterState{
// .....
clusterNode *importing_slots_from[16384];
// .....
}clusterState;
该数组中的元素是clusterNode指针,指向源节点
重新分片中这个命令就是更改目标节点中的这个数组,从而在后面发生ASK错误的时候,可以分辨.
2). CLUSTER SETSLOT MIGRATING命令的实现
clusterState结构中的migrating_slots_to数组记录了当前节点正在迁移至其他节点的槽.
typedef struct clusterState{
// .....
clusterNode *migrating_slots_from[16384];
// .....
}clusterState;
这个命令更新了源节点中的这个属性.
3). ASK错误
如果一个节点接收到一个键key的请求,并且这个键所属的槽i正好被指派给当前节点,那么就会尝试在自己数据库中查找这个key.如果找到了,就执行客户端发送的命令.
如果没找到,那么会先检查自己的migrating_slots_from数组中是否记录这个槽正在被迁移.如果在迁移,那么向客户端发送一个ASK错误,引导其去目标节点中寻找.如果没在被迁移,那么表示这个key不存在.
ASK :
接到ASK错误的客户端根据努错误提供的ip和端口转向目标节点,然后先向目标节点发送一个ASKING命令,然后重新发送原先的命令请求.
4). ASKING命令
ASKING命令的唯一目的就是打开发送该命令客户端的REDIS_ASKING标识.
一般情况下,未完成重新分片的情况下,目标节点是不知道自己负责这个槽的.但是当自己的importing_slots_from数组显示这个槽正在被导入自己,并且发送来命令的客户端带有REDIS_ASKING标识.那么这个节点就会破例执行关于这个没有完成导入的槽的命令.
客户端的REDIS_ASKING标识使用过一次就会被清除.
5). ASK错误的MOVED错误的区别
MOVED错误指这个槽的负责权已经交给另一个节点,而ASK错误是指当前节点虽然还保存这个槽的负责权,但是这个槽正在被转移,并且当前要查找的key恰好未找到,怀疑被转移了.
MOVED错误会改变客户端对槽的认知,也就是说,会通知客户端下次寻找这个槽时,直接去正确的节点.而ASK错误没有这种功能,只是临时的进行一次处理.
(6). 复制与故障转移
Redis集群中的节点是分主从的,主节点负责处理槽,从节点用于复制某个主节点,并在复制的主节点下线时进行升级代替.
1). 设置从节点
向一个节点发送命令:
CLUSTER REPLICATE
可以让接受命令的节点成为node_id所指定节点的从节点,并开始进行复制.
- 接受带这个命令的节点现在自己的clusterState.nodes字典中找到对应的主节点的clusterNode,并将自己的clusterState.myself.slaveof指针指向这个结构,表示正在复制这个主节点
- 修改自己在clusterState.myself.flags中的属性,将标识由主节点修改为从节点
- 调用复制代码,根据主节点的clusterNode结构中的ip和端口进行复制
- 然后这个节点就成为了主节点的从节点
- 这个信息会通过消息发送给集群中的其他节点,所有的节点都会在各自的内存中的该主节点的clusterNode中进行记录
2). 故障检测
集群中每个节点都会定期地向集群中的其他节点发送PING命令,当其他节点一定时间内没有返回PONG命令之后,就会认为这个节点疑似下线,并在clusterState的nodes字典中找到疑似下线的节点的clusterNode结构,将其的标识位标识为疑似下线.
集群中的每一个节点都会通过互相发送消息来传递这些信息.
当A节点从别处得知节点B疑似下线时,会在自己的clusterState的nodes字典中找到节点B对应的clusterNode结构,在其内部添加一条下线报告(这时A节点还并不主观认为B节点疑似下线).
struct clusterNode{
// .....
// 一个链表,记录了其他节点对该节点的下线报告
list *fail_reports;
// .....
};
// 下线报告的结构体
struct clusterNodeFailReport{
// 报告这个节点下线的节点
struct clusterNode *node;
// 最后一次从node节点收到下线报告的时间
// 防止报告过期
mstime_t time;
};
如果在一个集群中,半数以上的主节点都将某个主节点x标记为主观疑似下线,那么这个主节点x将被标记为==已下线==,并且进行标记的节点会向集群发送广播告知整个集群这个消息.收到这个广播的节点更改下线节点的状态和标识.
3). 故障转移
当一个从节点发现自己正复制的主节点已进入下线状态时,从节点将对下线主节点进行故障转移:
- 选举新的主节点
- 被选中的从节点执行SLAVEOF no one命令,成为新的主机诶单
- 新的主节点撤销对已下线主节点的槽指派,并将这些槽指派给自己
- 新的主节点向集群广播一条PONG消息,告知所有节点自己替代了原来的主节点,并且接管了原先的槽
- 新的主节点开始接收和处理有关于槽的命令请求.
4). 选举新的主节点
- 集群的配置是一个自增计数器,初始为0
- 每次进行依次故障转移操作时,集群的配置纪元会自增1
- 对于每个配置纪元,每个主节点都有一次投票的机会.而第一个向主节点要求投票的从节点将获得主节点的投票
- 当从节点发现自己的主节点下线时,会发广播,通知所有主节点对自己投票
- 如果一个主节点还没进行过投票时,收到了广播,那么进行相应节点的投票
- 参与选举的从节点通过之前广播的回应进行统计,得到自己的票
- 当一个节点你的票数过主节点数量的一半时,成为主节点
- 如果再一次选举中没有一个从节点获得半数投票,那么进入新的纪元,从新选举
(7). 消息
集群中各个节点通过发送和接受消息来进行通信.节点发送的消息主要有一下五种:
- MEET消息:申请加入集群的消息
- PING消息:集群例每个节点每隔一秒就会在自己已知的所有节点中随机选出5个,然后给其中最久没有发送过PING消息的节点发送PING消息,以此来检测是否该节点在线.另外,如果当前结点最后一次收到某个节点的PONG消息的时长超过了设置值的一半,就会主动向这个节点发送PING消息
- PONG消息:接收到MEET消息或者PING消息时,PONG消息可以认为是回应.另外,一个节点可以通过向集群广播自己的PONG消息来让其他节点刷新对自己的认知(主从节点身份更换)
- FAIL消息:A节点向B节点发送FAIL消息表示自己认为C节点已经下线,得到这个消息的节点都要更新对下线节点的认知
- PUBLISH消息:当一个节点收到PUBLISH命令是,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这个消息的节点都会执行同样的命令
每个消息都由消息头组成,消息头又封装了正文和发送者自身的一些信息.
1). 消息头
typedef struct {
// 消息的长度(整个消息头,包括正文等等)
uint32_t totlen;
// 消息的类型
uint16_t type;
// 正文包含的节点信息数量
uint16_t count;
// 发送者所处的配置纪元
uint64_t currentEpoch;
// 如果发送者是一个主节点,那么这里是发送者的配置纪元
// 如果是一个从节点,这里记录的是所复制的主节点的配置纪元
uint64_t configEpoch;
// 发送者的名字
char sender[REDIS_CLUSTER_NAMELEN];
// 发送者目前的槽指派信息
unsigned char myslots[REDIS_CLUSTER_SLOTS/8];
// 正在复制的主节点的名字,如果当前节点是主节点,那么为某个常量
char slaveof[REDIS_CLUSTER_NAMELEN];
// 发送者的端口
uint16_t port;
// 发送这的标识
uint16_t flags;
// 发送者所处的集群状态
unsigned char state;
// 消息的正文
union clusterMsgData data;
}clusterMsg;
// 正文的联合属性
union clusterMsgData{
// MEET,PING,PONG消息的正文
struct{
// 每条MEET,PING,PONG消息都包含两个clusterMsgDataGossip结构
clusterMsgDataGossip gossip[1];
}ping;
// FAIL消息的正文
struct{
clusterMasDataFil about;
}fail;
// PUBLISH消息的正文
struct{
clusterMsgDataPublish msg;
}publish;
// 其他消息的正文
}
clusterMsg结构的currentEpoch,sender,myslots等属性记录了发送者本身的节点信息,接受者根据这些信息,在自己的clusterState中的nodes字典中找到这个节点,对结构进行更新.
2). MEET.PING,PONG消息的实现
Redis集群中的各个节点通过Gossip协议来交换各自冠以不同节点的状态信息,其中Gossip协议有Meet,Ping,Pong三种消息实现.
MEET.PING,PONG三种消息使用相同的消息头,所以通过type字段来判断是这三种中的哪一种.
每次发送这三种消息时,发送这都从自己已知的列表中随机选出两个节点(不分主从),将这两个节点的信息分别保存到两个clusterMsgDataGossip结构中,其中记录了一系列信息
typedef struct{
// 节点名字
char nodename[REDIS_CLUSTER_NAMELEN];
// 最后一次向该节点发送PING消息的时间戳
uint32_t ping_sent;
// 最后一次从该节点接受PONG消息的时间戳
uint32_t pongReceived;
// 节点的ip
char ip[16];
// 端口
uint16_t port;
// 标示值
uint16_t flags;
}clusterMsgDataGossip;
当接到这三种命令时,接受者如果不认识被选中的节点,那么说明与接受者进行握手,如果认识,对节点的信息进行更新.
3). FAIL消息的实现
typedef struct{
// 记录下线节点的名字
char nodename[REDIS_CLUSTER_NAMELEN];
}clusterMasDataFil;
4). PUBLISH消息的实现
PUBLISH消息用于广播一条命令.
命令格式如下:
PUBLISH
意为向channel频道发送消息massage,并进行广播,让其他收到广播的节点也向channel频道发送message消息.
typedef struct{
uint32_t channel_len;
uint32_t message_len;
// 数据内容,长度为8字节是为了对齐,实际长度不一定
// 其中的前channel_len字节保存的是channel参数
// 后面的message_len字节保存的是message参数
unsigned char bulk_data[8];
}clusterMsgDataPublish;