Redis设计与实现总结——多机数据库的实现

复制

在Redis中用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(repliacte)另一个服务器,被复制的服务器称为主服务器(master),而对服务器进行复制的服务器被称为从服务器(salve)。
复制功能分为同步(sync)和命令传播(command propagate)两个操作:

  • 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。(从服务器主动向主服务器请求数据)
  • 命令传播操作用于在主服务器的数据库状态被修改,导致主从服务器数据库状态出现不一致时,让主服务器的数据库重新回到一致状态。

redis旧版复制

  • 同步过程:
    • 主服务器接收到从服务器发来的SYNC命令,执行BGSAVE命令,创建RDB文件,并使用缓冲区记录接下来执行的所有写命令。
    • 从服务器接收并载入主服务器发来的RDB文件。
    • 主服务器接着发送缓冲区的写命令到从服务器。
    • 从服务器接收命令。
  • 命令传播:
    每当主服务器执行写命令时,主服务器的数据库状态就可能被修改,并导致主从服务器不一致。为了再次回到一致状态,主服务器需要对从服务器执行命令传播操作: 主服务器会将自己执行的写命令发送给从服务器执行,当从服务器执行了相同的写命令后,主从服务器再次回到一致状态。

从服务器初次复制主服务器或者从服务器当前要复制的主服务器和上一次不一样时,RDB文件会完整的传输。在处于命令传播阶段的主从服务器因为网络原因而中断了复制,再次连接上时会重头开始复制。但是第二种情况的效率非常低,很多已经复制过的数据需要再次进行复制。这就是旧版复制功能的缺陷。
新版复制功能为了解决重复复制的问题,提出了一个PSYNC命令代替之前的SYNC命令。完整的复制与上面的第一种情况初次复制是一样的,部分重同步则用于处理断线后的情况: 断线再连接后,主服务器只发送断线期间的写命令到从服务器。
部分重同步的实现是通过复制偏移量:

  • 主服务器每次向从服务器转播N个字节的数据时,就将自己的复制偏移量的值+N
  • 从服务器每次收到主服务器传播来的N个字节数据时,就将自己的复制偏移量的值+N

通过对比主从服务器的复制偏移量,程序可以很容易地知道主从服务器是否处于一致状态:

  • 如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的
  • 相反,如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态

复制积压缓冲区是一个由主服务器维护的固定长度,先进先出队列,默认大小为1MB。当主从断开连接,再次连接时,从服务器会通过PSYNC将自己的复制偏移量offset发送给主服务器:

  • 如果offset偏移量之后的数据存在于复制积压缓冲区,那么主服务器将对从服务器执行部分重同步操作。
  • 如果offset偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器会对从服务器执行完整重同步操作。

在命令传播阶段,从服务器默认会以每秒一次的频率,祥主服务器发送命令REPLICONF ACK <replication_offset>, 其中replication_offset是当前从服务器的复制偏移量, 这个心跳检测的作用如下:

  • 检测主从服务器的网络状态:如果主服务器超过一秒钟没收到从服务器发送的REPLICONF ACK命令,那么主服务器就知道主从服务器之间的连接出现问题了。
  • 辅助实现min-slaves选项:Redis的min-slaves-to-writemin-slaves-max-log两个选项可以防止主服务器在不安全的情况下执行写命令。
  • 检测命令丢失:如果因为网络故障,主服务器传播给从服务器的写命令半路丢失,那么从服务器发送的偏移量就会小于主服务器的偏移量,这时候主服务器会从复制积压缓冲区中重新把命令发送给从服务器。(2.8版本之前没有这个功能,所以会出现丢失的情况)

Sentinel

Sentinel(哨岗,哨兵)是Redsi的高可用性(high availability)解决方案:由一个或多个Sentinel实例(instance)组成的Sentinel系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线的主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。另外Sentinel还会继续监视已下线的服务器,并在它重新上时,将它设置为新的主服务器的从服务器(降级)。
启动Sentinel可以使用命令: redis-sentinel /path/to/your/sentinel.confredis-server /path/to/your/sentinel.conf --sentinel, 启动时需要执行一下步骤:

  • 初始化服务器: Sentinel本质上是一个运行在特殊模式下的Redis服务器,启动初始换与原来有所不同。
  • 将普通Redis服务器使用的代码替换成Sentinel专用代码:初始换Sentinel可以执行的命令,替换之前的默认命令。
  • 初始化Sentinel状态:初始化sentinel.c/sentinelState结构,这个结构保存了服务器中所有Sentinel相关的状态。
  • 根据跟定的配置文件,初始化Sentinel的监视主服务器列表:Sentinel状态中的masters字典记录了所有被Sentinel监视的主服务器的相关信息,其中字典的键是被监视主服务器的名字;而字典的值则是被监视主服务器对应的sentinel.c/sentinelRedisInstance结构。
  • 创建连向主服务器的网络连接: 最后一步是创建连向被监视主服务器的网络连接,Sentinel将成为主服务器的客户端,可以向主服务器发送命令,并从命令回复中获取相关的信息。对于每个被Sentinel监视的主服务器来说,Sentinel会创建两个连向主服务器的异步网络连接:
    • 一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复。
    • 另一个是订阅连接,这个链接专门用于订阅主服务器的__sentinel__:hello频道。

为什么有两个连接?
在Redis目前的发布与订阅功能中,被发送的信息不回保存在Redis服务器里,如果发送信息时,接收信息的客户端不在线或者断线,那么这个客户端就会丢失这条信息。为了不丢失任何信息,必须专门用一个订阅连接来接收该频道的信息(原理?)。另外除了订阅频道,Sentinel还必须向主服务器发送命令,以此来与主服务器进行通信,所以Sentinel还必须向主服务器创建命令连接。

Sentinel网络拓扑

Sentinel与主服务器,从服务器及其他Sentinel之间都是彼此连接的:

  • 首先Sentinel默认每10秒一次向主服务器发送INFO命令,Sentinel可以得到主服务器信息以及主服务器的从服务器信息;
  • Sentinel会更新自己的主服务器和从服务器信息,还会创建连接到从服务器的命令连接和订阅连接。
  • Sentinel还会默认每2秒一次通过命令连接向所有被监视的主服务器和从服务器发送命令,这条命令会向服务器的__sentinel__:hello频道发送一条信息
  • 由于Sentinel订阅了主服务器和从服务器的消息,所以所有订阅的Sentinel都会收到上面的信息,接收消息的Sentinel就会感知到发消息的Sentinel存在,并记录到sentinels属性中(可以实现自动发现功能)

故障处理

检测主观下线状态

默认情况下Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器,从服务器,其他Sentinel等)发送PING命令, 并通过实例返回的PING命令回复判断是否在线。由于每个Sentinel设置的下线时间标准可能不一样,所以会出现不同的Sentinel认为服务器的状态不一致,所以这种情况称为主观下线状态。

检测客观下线状态

当Sentinel从其他Sentinel那里接收的足够数量的已下线判断之后,Sentinel就会认为将主服务器判定为客观下线状态,并对主服务器执行故障转移操作。

选举领头Sentinel

当主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头的Sentinel,并由领头Sentinel对下线服务器执行故障转移。
选举策略是每个检测到主服务器下线的Sentinel都向其他Sentinel发送想要成为领头的命令,收到命令的Sentinel会将发送命令的Sentinel设置为局部领头,如果一个Sentinel被半数以上的Sentinel设置为局部领头,它就胜出,否则会进行再次选举。

故障转移

选举出领头Sentinel后,领头Sentinel将对已下线的主服务器执行故障转移操作:

  • 在已下线服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器: 选择优先级高,复制偏移量大的从服务器,使用命令SLAVE of one使其变为主服务器。
  • 让已下线主服务器属下的所有从服务器改为复制新的主服务器: 领头Sentinel向其他从服务器发送SLAVEOF命令。
  • 将已下线的主服务器设置为心的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器。

集群

Redis集群是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。

节点与槽

Redis集群通常由多个节点(node)组成,开始每个节点都是图例的,它们都处于一个只包含自己的集群中,当要组建一个真正可工作的集群,我们必须将节点连接起来,构成一个包含多个节点的集群。使用CLUSTER MEET <ip> <port>命令来完成。另外Redis服务器启动时也可以根据cluster-enabled配置选项来判断是否开启集群模式。节点信息保存在cluster.h/clusterNode结构中,clusterNode结构保存了一个节点的当前状态,比如节点的创建时间,节点的名等;clusterNodelink属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区; 每个节点都保存着一个clusterState结构,这个结构记录了当前节点的视角下,集群目前所处的状态,例如机器是在线还是下线,集群包含多少节点等。
Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384(=2048*8)个槽(slot),数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。当数据库中的16384个槽有节点在处理时,集群处于一个上线状态(ok);相反地,如果数据库中任何一个槽没有得到处理,那么集群处于下线状态(fail)。
槽指派信息记录在clusterNode.slots[16384/8]属性中, numslots记录了节点负责处理的槽的数量。Redis以0为起始索引,16383为终止索引,对slots数组中的16384个二进制位进行编号,并根据索引i上的二进制位来判断节点是否负责处理槽i:

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

节点会把自己处理的槽信息发送给其他集群中的其他节点,因此集群中的每个节点都会知道数据库中16384个槽分别被指派给了集群中哪些节点。
clusterState结构中的slots[16384]数组则更上面的正好反过来,它记录了每个槽是由哪个节点在管理的。之所以会有这两种结构是为了在查找节点管理了哪些槽和槽由哪个节点管理的复杂度都降低了。

集群中的执行命令

当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽(使用crc16(key)&16383算法得出槽位置),并检查这个槽是否指派给了自己(clusterState.slots[i]是否为自己):

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

节点与单机服务器在数据库方面的区别是,节点只能使用0号数据库,而单机Redis服务器则没有这一限制。
节点还会使用clusterState结构中的slots_to_keys跳跃表来保存槽和键之间的关系,主要目的是方便节点对属于某个或某些槽的所有数据库键进行批量操作。

重新分片

Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目的节点),并且相关槽所属的键值对也会从源节点移动到目的节点。这个过程可以在线进行,在重新分片过程中,集群不需要下线,并且源节点和目的节点都可以继续处理命令请求。
Redis的重新分片操作是由Redis的集群管理软件redis-trib负责执行的。迁移过程如下:
redis-trib
在执行第四步迁移的过程中,如果客户端向源节点发送一个与数据库键有关的命令,那么:

  • 源节点先在自己数据库里查找指定的键,如果找到就直接执行客户端发送的命令.
  • 如果没找到,那么这个键可能已经被迁移到了目标节点,源节点向客户端返回一个ASK错误,指引客户端转向正在导入槽的目的节点,并再次发送之前想要执行的命令。
    当客户端接收到ASK错误并转向正在执行导入槽节点时,客户端会先向节点发送一个ASKING命令,然后才重新发送想要执行的命令。ASKING命令会打开发送客户端的REDIS_ASKING标识。
    一般情况下如果客户端向节点发送一个关于槽i的命令,如果节点没有这个槽,那么就会返回MOVED,但是如果节点的clusterState.importing_slots_from[i]显示节点正在导入槽i,并且发送命令的客户端带有REDIS_ASKING(通过ASKING命令打开)标识,那么节点将执行这个关于槽i的命令一次

关于ASK错误与MOVED错误的区别:

  • MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点,客户端收到关于槽i的MOVED错误后,每次遇到槽i请求是,都可以直接将命令发送至MOVED错误所指向的节点。
  • ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施, 不会影响后面命令的发送。

复制与故障转移

Redis集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。设置从节点的命令:CLUSTER REPLICATE <node_id>
集群中的每个节点都会定期地祥集群中其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记位疑似下线(probable fail, PFAIL)。如果一个集群里,半数以上负责处理槽的主节点都将某个主节点X报告为疑似下线,那么这个主节点X将被标记为已下线(FAIL), 将主节点X标记为已下线的节点会向集群广播一条关于主节点X的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点X标记为下线。
当一个从节点发现自己正在复制的主节点进入了已下线状态,从节点将开始对下线主节点进行故障转移,下面是故障转移执行的步骤:

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

总结

前面主要讲了Redis在多机数据库下的功能特性,其中复制是实现数据备份,数据可靠性的保证。Sentinel实现高可用性的保证。在3.0版本之前的分布式方案都是自己实现的,然后利用Sentinel进行监控。后来Redis自己实现了集群方案,可以用其默认的集群方案来代替之前的自己实现方案。他们之间是相辅相成的,根据自己的需要进行选择。

参考

  1. Redis集群方案应该怎么做?
  2. 如何部署高可用的Redis集群架构