redis复制过程浅析

复制

  在分布式系统遇到网络分区故障时,往往需要依赖分区容错性来对外提供可用性和一致性服务,这就要求同一份数据在分布式环境中存在数据冗余,并在某部分数据损坏或不可访问后,仍然能够不影响用户的使用。复制便是提供这种分区容错性的常用手段,它要求同一份数据在多台机器各保存一份完整的版本。之前几篇文章介绍了redis单机版本的一些工作原理,本文便开始扩展到分布式环境,简单分析一下redis的多机版本是如何实现复制这一功能的。整个复制过程可分为master和slave两个部分介绍。

slave

  每一个redis进程都有两种身份,一种是master,另一种是slave。master负责接收数据写入,同时有一个或多个slave作为数据备份,一般的业务场景是一个master维护多个slave,slave同时也可以是其他slave的master。master和slave之间是客户端-服务器的形式通信,与单机版本服务器处理客户端的模型一样,也是IO多路复用,读写绑定不同的事件处理器。其中master是slave的客户端,把其他客户端的命令原封不动转发给slave。slave也是master的客户端,定期向master汇报当前的复制偏移量。

  由于复制过程是slave发起的,便先从slave这一端介绍。无论master还是slave,在处理复制这一过程里实际上便是实现了一套通信协议,这个协议涉及master和slave在多个状态之间转换,因此master和slave都相当于状态机。我们只要关注这几个状态的转移,便可以理清整个复制过程。

REDIS_REPL_NONE

  第一个状态是REDIS_REPL_NONE,代表不进行复制,该进程是单机进程。

REDIS_REPL_CONNECT

  第二个状态是REDIS_REPL_CONNECT,这个状态的含义是尝试连接master。REDIS_REPL_NONE与REDIS_REPL_CONNECT之间转移是通过命令slaveof实现的。slaveof no one取消复制,从REDIS_REPL_CONNECT转移到REDIS_REPL_NONE;slaveof ip port开始复制,设置master服务器的ip和port,同时完成从REDIS_REPL_NONE到REDIS_REPL_CONNECT的状态转移。

REDIS_REPL_CONNECTING

  第三个状态是REDIS_REPL_CONNECTING,代表正在连接。redis的定时函数serverCron里,每秒钟会调用一次replicationCron函数,这个函数会检查当进程状态是REDIS_REPL_CONNECTING时,尝试建立与master的socket连接。前面说过,复制过程的master和slave也是沿用了redis单机版的客户端-服务器通信模型,并且在slaveof命令时已然获取了master的ip和port,那么就可以建立socket连接了。由于redis采用单线程Io多路复用模型,注重高性能,不能容忍任何形式的长时间阻塞,因此尝试建立socket的过程也是无阻塞的,如果失败立即返回。socket建立成功后,则正式进入REDIS_REPL_CONNECTING状态,并且给socket绑定读写事件处理器syncWithMaster,这个事件处理器会完成开启数据同步的任务。

REDIS_REPL_RECEIVE_PONG

  第四个状态是REDIS_REPL_RECEIVE_PONG,代表等待PONG到达。由于之后的数据传输非常耗时,同时需要占用大量带宽,这个状态主要是测试master和slave之间的网络是否健康,能否承担起之后的数据传输重任。redis的IO多路复用模型检查master的socket是否可写,可写则调用syncWithMaster函数,当检测到当前状态是REDIS_REPL_CONNECTING时,会往master发送PING,等待PONG到达,此时状态转换为REDIS_REPL_RECEIVE_PONG,并解除写事件处理器的绑定,以防再次因为可写而反复调用syncWithMaster。如果超过一定时间,redis进程状态仍为REDIS_REPL_CONNECTING或REDIS_REPL_RECEIVE_PONG,那么说明网络条件不好,状态退回到REDIS_REPL_CONNECT,关闭socket连接,解除事件处理器绑定,等到在定时函数中再次尝试连接master。

REDIS_REPL_TRANSFER

  第五个状态是REDIS_REPL_TRANSFER,代表开始接受master的数据传输。在规定时间内收到PONG,那么首先会开启部分重同步或者完整重同步。这里先介绍一下部分重同步和完整重同步,以及涉及到的几个技术点。

部分重同步

  对于不是第一次进行复制的redis进程,可能之前已经有master的大部分数据了,只是由于网络故障等原因断开了连接,此时不需要把master的全部数据都传输一遍,只需要传输缺少的部分就可以。redis进程的主要业务就是执行命令,因此维护了一个叫复制缓冲区的东西,每当有命令到达,就把该命令语句追加到复制缓冲区中,同时维护一个只增的复制偏移量,一直往上加命令语句的字节数。复制缓冲区在内部是用在内存的环形列表实现的,其大小一般设为2平均每秒产生的写命令长度总和断线到重连接的平均时间。这个区域越大,则重连接时传输的数据越少,同步越快,同时消耗的内存也会增加。

完整重同步

  如果slave的复制偏移量已经不在复制缓冲区的范围内,就需要进行完整重同步了。完整重同步会传输一个RDB文件到slave上,然后由slave加载。之所以选择RDB文件,是因为它是二进制文件,体积小,便于传输,同时包含了完整的数据。

  slave在REDIS_REPL_RECEIVE_PONG状态下在规定时间内收到PONG,会解除读事件处理器的绑定,因为暂时不再需要了,同时向master发送PSYNC命令PSYNC master_id offset。由于之前可能有master连接过,所以这里可以带上master对应的redis进程的id, offset代表当前slave的复制偏移量。master会判断id是否是自身的id,并且offset是否在复制缓冲区内,如果是则进行部分重同步,向slave发送”+CONTINUE”回复;如果有一个条件不满足,则向slave发送”+FULLRESYNC”回复,进行完整重同步。如果是第一次进行复制,那么slave向master发送PSYNC ? -1,在master返回”+FULLRESYNC”后进行完整重同步。这个过程redis涉及到对socket的读和写,在一般情况下,读写绑定了不同的事件处理器,是异步进行的,但在这里是同步地发出psync命令,然后同步地收取回复。具体的实现就是立即等待单个socket的数据,并设置超时时间。

  如果是完整重同步的情况,收到psync回复后,绑定新的读事件处理器readSyncBulkPayload,这个函数会读取master传来的RDB文件,然后写入到一个临时文件中,状态转换为REDIS_REPL_TRANSFER。如果是部分重同步,就比较简单了,状态直接转换为REDIS_REPL_CONNECTED,绑定读写事件处理器readQueryFromClient和sendReplyToClient。这两个事件处理器就和普通的客户端-服务器形式没有任何区别了,仅仅是用来处理各种redis命令。

REDIS_REPL_CONNECTED

  第六个状态是REDIS_REPL_CONNECTED,代表已连接。这个状态可以在完整重同步完成后由REDIS_REPL_TRANSFER转换得到。当处在REDIS_REPL_TRANSFER状态时,首先会等待master完成写RDB文件,在写完后,会先往slave发送RDB文件的大小,然后开始发送RDB文件。每次读事件发生,slave会以4k为单位读取数据,剩余的数据留到下次再读,避免阻塞其他客户端。同时每写一定的数据到磁盘,都会手动sync一次,避免一次写磁盘用满IO,导致响应缓慢。当判断已读数据达到RDB文件的大小时,解除读事件处理器,清空原来数据库,然后加载RDB文件。这个过程会一直阻塞,直到数据加载成功。然后状态转换为REDIS_REPL_CONNECTED。

  在每次读事件发生时,会更新slave与master的交互时间,如果长时间没有数据到达,那么会在replicationCron函数里停掉这个传输过程,并把状态回退到REDIS_REPL_CONNECT。由于master写RDB文件可能耗时较长,因此master会在replicationCron里发送\0给这个状态的slave,slave收到以后仅仅更新交互时间。同时由于slave在清空数据库或者载入RDB文件时,可能会耗时较久,为了避免master认为slave断开,slave也会定时发送\0到master。

  master会在replicationCron里向所有online的slave发送ping命令,以让slave更新与服务器的交互时间。当处在REDIS_REPL_CONNECTED状态的slave超过一定时间没有收到master的命令,那么便认为连接中断了,会关闭socket,状态回退到REDIS_REPL_CONNECT。

master

  master这端维护多个slave,每个slave对应的client也是一个状态机,其状态与slave的状态也是有一定的对应关系的。

REDIS_REPL_NONE

  这个状态与slave的REDIS_REPL_NONE是完全对应的,代表该客户端不是slave。

REDIS_REPL_ONLINE

  这个状态代表slave就绪,与master的复制关系已经建立。master开始复制的生命周期,是从slave发送psync指令开始的,如果可以进行部分重同步,那么直接跳到这个状态,并且把复制缓冲区中slave缺失的部分发给它。

REDIS_REPL_WAIT_BGSAVE_START

  如果在开启完整重同步后,发现之前已经有后台的写rdb进程在工作,并且这个进程不是为其他slave的复制准备的,那么进入REDIS_REPL_WAIT_BGSAVE_START状态,等待下一次写RDB文件开启。redis会在rdb进程结束的回调函数里把所有处于该状态的slave的状态转换为REDIS_REPL_WAIT_BGSAVE_END。该状态对应slave的REDIS_REPL_TRANSFER状态。

REDIS_REPL_WAIT_BGSAVE_END

  如果在开启完整重同步后,之前并没有rdb进程在工作,那么开始新的写rdb进程,进入REDIS_REPL_WAIT_BGSAVE_END状态,等待rdb进程结束。或者rdb进程为其他slave的复制作准备,那么复制其他slave的输出缓冲区,以共用之后的rdb数据。该状态也对应slave的REDIS_REPL_TRANSFER状态。

REDIS_REPL_SEND_BULK

  当RDB文件写入完毕,会调用回调函数,该函数会遍历所有slave,把状态为REDIS_REPL_WAIT_BGSAVE_END
的slave转换为REDIS_REPL_SEND_BULK,然后重新绑定新的写事件处理器sendBulkToSlave,打开RDB文件,准备传输。该状态同样对应slave的REDIS_REPL_TRANSFER状态。

  IO多路复用模型每次发现slave可写,就调用sendBulkToSlave函数,读取一定的RDB数据,然后写到网络上。每次不能写太多数据,避免阻塞其他客户端。当RDB文件传送完毕后,状态转移到REDIS_REPL_ONLINE。

异步复制

  当复制建立完成,数据完成同步后,之后master的每次写操作都会在slave端回放一遍。redis为了高性能,复制是异步完成的,命令返回的时候slave不一定能把命令执行完。但slave会在replicationCron函数里向master发送命令REPLCONF ACK offset,汇报自己的复制偏移量。master收到以后更新client的复制偏移量,以及交互时间。当超过一定数量的slave的交互时间相隔过长,就会禁止写入新数据。

同步复制

  redis的复制默认是异步的,如果需要同步复制,那么可以用WAIT命令来实现。每当有命令执行时,redis都会记录当前的复制偏移量到客户端状态里。当调用WAIT命令时,用户指定至少多少个replication成功以及超时时间。redis会把客户端加到等待slave响应的队列里,并把客户端状态设置为REDIS_BLOCKED_WAIT。处于该状态的客户端无法处理socket的输入数据,后续命令会在输入缓冲区堆积,直到WAIT之前的命令复制完成或者超时。

  在每次进入IO多路复用的等待事件前,会调用beforeSleep函数,该函数会给所有slave发送REPLCONF GETACK命令,收到该命令的slave会马上发送自己的复制偏移量给master。beforeSleep函数还会检查等待slave响应队列里是否有客户端的有足够多的slave的复制偏移量不少于要求的值,足够的话就解除block,把客户端状态设为REDIS_UNBLOCKED,并让这些客户端开始处理输入缓冲区的命令。

  在serverCron里还会对所有block的客户端进行检查,如果达到超时时间,就解除这些客户端的block,并通知这些客户端超时。