Redis——高可用性与持久化

488 阅读25分钟

      Redis支持不同级别的磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动分区(Cluster)提供高可用性(high availability)。

Redis的高可用

主从复制

         在Redis中,通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(replicate)另一个服务器,我们称呼被复制的服务器为主服务器(master),而对主服务器进行复制的服务器则被称为从服务器(slave)。进行复制中的主从服务器双方的数据库将保存相同的数据,概念上将这种现象称作“数据库状态一致”,或者简称“一致”。

复制的实现

步骤1:设置主服务器的地址和端口

当客户端向从服务器发送以下命令时:

127.0.0.1:12345>SLAVEOF 127.0.0.1 6379

        从服务器首先要做的就是将客户端给定的主服务器IP地址127.0.0.1以及端口6379保存到服务器状态的masterhost属性和masterport属性里面:

struct redisServer{ 
	//… 
	//主服务器的地址 
	char *masterhost; 
	//主服务器的端口 
	int masterport; 
	//… 
};

          SLAVEOF命令是一个异步命令,在完成masterhost属性和masterport属性的设置工作之后,从服务器将向发送SLAVEOF命令的客户端返回OK,表示复制指令已经被接收,而实际的复制工作将在OK返回之后才真正开始执行。

步骤2:建立套接字连接

        在SLAVEOF命令执行之后,从服务器将根据命令所设置的IP地址和端口,创建连向主服务器的套接字连接。如果从服务器创建的套接字能成功连接(connect)到主服务器,那么从服务器将为这个套接字关联一个专门用于处理复制工作的文件事件处理器,这个处理器将负责执行后续的复制工作,比如接收RDB文件,以及接收主服务器传播来的写命令。

         而主服务器在接受(accept)从服务器的套接字连接之后,将为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端来对待,这时从服务器将同时具有服务器(server)和客户端(client)两个身份:从服务器可以向主服务器发送命令请求,而主服务器则会向从服务器返回命令回复

步骤3:发送PING命令

       从服务器成为主服务器的客户端之后,第一件事就是向主服务器发送一个PING命令。有两个作用:

  • 通过发送PING命令可以检查套接字的读写状态是否正常。
  • 通过发送PING命令可以检查主服务器能否正常处理命令请求。

如果从服务器读取到"PONG" 回复,那么表示主从服务器之间的网络连接状态正常。


步骤4:身份验证

       从服务器在收到主服务器返回的“PONG”回复之后,下一步就是是进行身份验证:如果从服务器设置了masterauth选项,那么进行身份验证,否则不进行身份验证。

步骤5:发送端口信息

       在身份验证步骤之后,从服务器将执行命令,向主服务器发送从服务器的监听端口号。主服务器在接收到这个命令之后,会将端口号记录在从服务器所对应的客户端状态属性中

步骤6:同步

        在这一步,从服务器将向主服务器发送 PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态。在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,主服务器也会成为从服务器的客户端。PSYNC命令具有完整重同步和部分重同步两种模式:

  • 完整重同步用于处理初次复制情况,通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步。
  • 部分重同步则用于处理断线后重复制情况,当从服务器在断线后重新连接主服务器时,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。

步骤7:命令传播

        当完成了同步之后,主从服务器就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从服务器一直保持一致了以上就是。在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送心跳检测命令。

Sentinel

         Sentinel(哨岗、哨兵)由一个或多个Sentinel实例组成的 Sentinel系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

  

启动并初始化 Sentinel

    当一个 Sentinel启动时,它需要执行以下步骤

步骤1:初始化服务器

         Sentinel本质上只是一个运行在特殊模式下的 Redis服务器,所以启动Sentinel的第一步,就是初始化一个普通的 Redis服务器,因为Sentinel执行的工作和普通 Redis服务器执行的工作不同,所以 Sentinel的初始化过程和普通 Redis服务器的初始化过程并不完全相同。 例如,普通服务器在初始化时会通过载入RDB文件或者AOF文件来还原数据库状态,但是因为 Sentinel并不使用数据库,所以初始化 Sentinel时就不会载入RDB文件或者AOF文件

步骤2:使用 Sentinel专用代码

       接着就是将一部分普通 Redis服务器使用的代码替换成 Sentinel专用代码。

步骤3:初始化 Sentinel状态

         在应用了 Sentinel的专用代码之后,接下来,服务器会初始化一个sentinelstate结构,这个结构保存了服务器中所有和Sentinel功能有关的状态

步骤4:初始化 Sentinel|状态的 masters属性

         Sentinel状态中的 masters字典记录了所有被 Sentinel监视的主服务器的相关信息,其中: 

  • 字典的键是被监视主服务器的名字。 
  • 而字典的值则是被监视主服务器对应的sentinelRedisInstance结构。 每个结构代表一个被 Sentinel监视的 Redis服务器实例( Instance),这个实例可以是主服务器、从服务器,或者另外一个 Sentinel

**步骤5:**创建连向主服务器的网络连接

        最后一步是创建连向被监视主服务器的网络连接, Sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息。 对于每个被 Sentinel监视的主服务器来说, Sentinel会创建两个连向主服务器的异步网络连接: 

  • 一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复。
  • 另一个是订阅连接,这个连接专门用于订阅主服务器的频道。

主观下线

        在默认情况下, Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他 Sentinel在内)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。 实例对PING命令的回复可以分为以下两种情况: 

  • 有效回复:实例返回+PONG、- LOADING、-MASTERDOWN三种回复的其中一种。 
  • 无效回复:上面三种回复之外的其他回复,或者在指定时限内没有返回任何回复。

       如果配置文件指定Sentinel的down-after-milliseconds选项的值为50000毫秒,那么当主服务器 master连续50000毫秒都向 Sentinel返回无效回复时, Sentinel就会将 master标记为主观下线,并在 master所对应的实例结构的flags属性中打开 SRI_S_DOWN标识。

客观下线

        当 Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他 Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当 Sentinel从其他 Sentinel 那里接收到足够数量的已下线判断之后, Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移操作。

选举领头Sentinel

        当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个 Sentinel会进行协商,选举出一个领头 Sentinel,并由领头 Sentinel对下线主服务器执行故障转移操作。

       假设现在有三个 Sentinel正在监视同一个主服务器,并且这三个 Sentinel之前已经通过命令确认主服务器进入了客观下线状态,那么为了选出领头 Sentinel,三个 Sentinel将再次向其他Sentinel发送 SENTINEL ismaster-down-by-addr命令,最先收到命令的胜出领头 Sentinel的选举,然后这个领头 Sentinel就可以开始对主服务器执行故障转移操作了。

故障转移

       在选举产生出领头 Sentinel之后,领头 Sentinel将对已下线的主服务器执行故障转移操作,该操作包含以下三个步骤:

步骤1:选出新的主服务器

        故障转移操作第一步要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送SLAVEOF no one命令,将这个从服务器转换为主服务器。

步骤2:修改从服务器的复制目标

       当新的主服务器出现之后,领头Sentinel下一步要做的就是,让已下线主服务器属下的所有从服务器去复制新的主服务器,这一动作可以通过向从服务器发送SLAVEOF命令来实现。

步骤3:将旧的主服务器变为从服务器

        故障转移操作最后要做的是,将已下线的主服务器设置为新的主服务器的从服务器。 Sentinel还会继续监视已下线的服务,并在它重新上线时,Sentinel就会向它发送SLAVEOF命令将它设置为新的主服务器的从服务器。

集群

       Redis 集群是Redis提供的分布式数据库方案,集群通过分片(sharding))来进行数据共享,并提供复制和故障转移功能。一个Redis集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。

哈希槽

      Redis 集群引入了哈希槽的概念。Redis 集群有16384个哈希槽, Redis集群通过分片的方式来保存数据库中的键值对,集群中的每个节点可以处理0个或最多16384个槽。 当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。

槽的分配信息

       在clusterNode 结构的slots属性和numslot属性记录了节点负责处理哪些槽:

struct clusterNode{
	//…
	unsigned char slots[16384/8];
	int numslots;
	//…
};

slots 属性是一个二进制位数组(bit array),这个数组的长度为16384/8=2048个字节,共包含16384个二进制位。 Redis以0为起始索引,16383为终止索引,对slots 数组中的16384个二进制位进行编号:

  • 如果slots 数组在索引i上的二进制位的值为1,那么表示节点负责处理槽i。
  • 如果slots 数组在索引i上的二进制位的值为0,那么表示节点不负责处理槽i。

        一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,它还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。

        当节点A通过消息从节点B那里接收到节点B的slots数组时,节点A会在自己的字典中查找节点B对应的clusterNode结构,并对结构中的slots数组进行保存或者更新。 因为集群中的每个节点都会将自己的slots数组通过消息发送给集群中的其他节点,并且每个接收到s1ots数组的节点都会将数组保存到相应节点的clusterNode结构里面,因此,集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。

槽的指派信息

    在clusterstate结构中的slots数组记录了集群中所有16384个槽的指派信息:

typedef struct clusterstate{
	//…c
	clusterNode *slots[16384];
	//…
}clusterstate;

 slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针: 

  • 如果slots[i]指针指向NULL,那么表示槽i尚未指派给任何节点。 
  • 如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点。

键的计算

     节点使用以下算法来计算给定键key属于哪个槽:

def slot_number(key): 
	return CRC16(key)& 16383

      其中CRC16(key)语句用于计算键key的CRC-16校验和,而&16383语句则用于计算出一个介于0至16383之间的整数作为键key的槽号。

 使用CLUSTER KEYSLOT命令可以查看一个给定键属于哪个槽:

127.0.0.1:7000>CLUSTER KEYSLOT "date" 
(integer)2022

在集群中执行命令

       在对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。

       当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己: 

  • 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。 
  • 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向至正确的节点,并再次发送之前想要执行的命令。

重新分片的实现原理

       Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。 重新分片操作可以在线进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。

       Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。 redis-trib对集群的单个槽slots进行重新分片的步骤如下:

  1. redis-trib对目标节点发送CLUSTER SETSLOT IMPORTING 命令,让目标节点准备好从源节点导入属于槽slots的键值对。
  2. redis-trib对源节点发送CLUSTER SETSLOT MIGRATING 命令,让源节点准备好将属于槽slots的键值对迁移至目标节点。
  3. redis-trib向源节点发送CLUSTER GETKEYSINSLOT命令,获得最多count个属于槽slots的键值对的键名。 
  4. 对于步骤3获得的每个键名,redis-trib都向源节点发送一个MIGRATE<target_ip><target_port><key_name>0命令,将被选中的键原子地从源节点迁移至目标节点。
  5. 重复执行步骤3和步骤4,直到源节点保存的所有属于槽slots的键值对都被迁移至目标节点为止。
  6. redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT NODE<target_id>命令,将槽slots指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所有节点都会知道槽slot已经指派给了目标节点。

故障检测

        集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线。在集群中,超过一半以上主节点标记没有返回消息的节点为疑似下线,会将此标记为已下线状态,并向集群广播一条关于此节点FAIL消息。

故障转移

        当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤: 

  1. 复制下线主节点的所有从节点里面,会有一个从节点被选中。
  2. 被选中的从节点会执行SLAVEOF no one命令,成为新的主节点。
  3. 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。 
  4. 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
  5. 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

选举新的主节点

       新的主节点是通过选举产生的。 以下是集群选举新的主节点的方法:

  1. 集群里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票。
  2. 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。 
  3. 如果一个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条 ACK消息,表示这个主节点支持从节点成为新的主节点。
  4. 每个参与选举的从节点都会接收ACK消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。 
  5. 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张支持票时,这个从节点就会当选为新的主节点。

Redis的持久化

         redis 是一个支持持久化的内存数据库,也就是说 redis 需要经常将内存中的数据同步到磁盘来保证持久化。redis 支持两种持久化方式,一种是 Snapshotting(快照)也是默认方式,另 一种是 Append-only file(缩写 aof)的方式。

RDB持久化

        快照是默认的持久化方式。这种方式是就是将内存中数据以快照的方式写入到二进制文件中, 默认的文件名为 dump.rdb。也叫RDB持久化

RDB文件的创建

      有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE。

  • SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求
  • BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求

       因为BGSAVE命令可以在不阻塞服务器进程的情况下执行,所以Redis允许用户通过设置服务器配置的save 选项,让服务器每隔一段时间自动执行一次BGSAVE命令。 用户可以通过save选项设置多个保存条件,但只要其中任意一个条件被满足,服务器就会执行BGSAVE命令。

save 900 1 
save 300 10 
save 60  10000

那么只要满足以下三个条件中的任意一个,BGSAVE命令就会被执行:

  • 服务器在900秒之内,对数据库进行了至少1次修改。
  • 服务器在300秒之内,对数据库进行了至少10次修改
  • 服务器在60秒之内,对数据库进行了至少10000次修改

RDB文件的载入

       RDB文件的载入工作是在服务器启动时自动执行的,只要Redis服务器在启动时检测到RDB文件存在,它就会自动载入RDB文件。服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止。

       因为AOF文件的更新频率通常比RDB文件的更新频率高,因此

  • 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。
  • 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。

实现原理

         当Redis 服务器启动时,用户可以通过指定配置文件或者传入启动参数的方式设置 save选项,如果用户没有主动设置save选项,那么服务器会为save选项设置默认条件

save 900 1 
save 300 10 
save 60  10000

        接着,服务器程序会根据save 选项所设置的保存条件,设置服务器状态redisServer结构的saveparams数组,数组中的每个元素都保存了一个save选项设置的保存条件

struct saveparam{ 
	//秒数 
	time t seconds;
	//修改数 
	int changes; 
 };

除了saveparams数组之外,服务器状态还维持着一个dirty计数器,以及一个lastsave属性:

  • dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)。
  • lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间。

        当服务器成功执行一个数据库修改命令之后,程序就会对dirty计数器进行更新:命令修改了多少次数据库,dirty计数器的值就增加多少。

struct redisServer{ 
	//....
	//修改计数器 
	long long dirty;
	//上一次执行保存的时间 
	time_t lastsave;
	//....
};

        Redis的服务器周期性操作函数servercron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查 save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令。

         程序会遍历并检查saveparams数组中的所有保存条件,只要有任意一个条件被满足,那么服务器就会执行BGSAVE命令。

AOF持久化

        除了RDB持久化功能之外,Redis 还提供了AOF(Append Only File)持久化功能。与RDB持久化通过保存数据库中的键值对来记录数据库状态不同,AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态的。

        当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof buf缓冲区的末尾。

AOF持久化策略

    有三种方式如下:appendfsync选项的默认值为everysec。

  • appendfsync always :收到写命令就立即写入磁盘,最慢,但是保证完全的持久化
  • appendfsync everysec:每秒钟写入磁盘一次,在性能和持久化方面做了很好的折中
  • appendfsync no:完全依赖操作系统,性能最好,持久化没保证

AOF文件的载入与数据还原

       因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。详细步骤如下:

  1. 创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。
  2. 从AOF文件中分析并读取出一条写命令。
  3. 使用伪客户端执行被读出的写命令。
  4. 一直执行步骤2和步骤3,直到AOF文件中的所有写命令都被处理完毕为止。

AOF的重写

       为了解决AOF文件体积膨胀的问题,Redis 提供了AOF文件重写功能。通过该功能,Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新旧两个AOF文件所保存的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令,体积要小得多。

        首先从数据库中读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这就是AOF重写功能的实现原理。

       Redis 服务器使用单个线程来处理命令请求,在重写AOF文件期间,服务期将无法处理客户端发来的命令请求。所以Redis将AOF重写程序放到子进程里执行,这样做可以同时达到两个目的:

  • 子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求。
  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。

        因为子进程在进行AOF重写期间,服务器进程还需要继续处理命令请求,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致。为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区。

       当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,并执行以下工作:

  • 将AOF重写缓冲区中的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致。
  • 对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两个AOF文件的替换。

参考

Redis设计与实现
Redis实战
Redis官网