Redis实现之复制(一)
复制
在Redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(replicate)另一个服务器,我们称呼被复制的服务器为主服务器(master),而对主服务器进行复制的服务器则被称为从服务器(slave),如图1-1所示
图1-1 主服务器和从服务器
假设现在有两个Redis服务器,地址分别为127.0.0.1:6379和127.0.0.1:12345,如果我们向服务器127.0.0.1:12345发送以下命令:
127.0.0.1:12345> SLAVEOF 127.0.0.1 6379 OK
那么服务器127.0.0.1:12345将成为127.0.0.1:6379的从服务器,而服务器127.0.0.1:6379则成为127.0.0.1:12345的主服务器。比如说,如果我们向主服务器执行以下命令:
127.0.0.1:6379> SET msg "hello world" OK
又可以在从服务器上获取msg键的值:
127.0.0.1:12345> GET msg "hello world"
另一方面,如果我们在主服务器中删除了键msg:
127.0.0.1:6379> DEL msg (integer) 1
那么不仅主服务器上的msg键会被删除:
127.0.0.1:6379> EXISTS msg (integer) 0
从服务器上的msg键也会被删除:
127.0.0.1:12345> EXISTS msg (integer) 0
旧版复制功能的实现
Redis的复制功能分为同步(sync)和命令传播(command propagate)两个操作:
- 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态
- 命令传播操作则用于在主服务器状态诶修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库状态重新回到一致状态
同步
当客户端向从服务器发送SLAVEOF命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作,也就是,将从服务器的数据库状态更新至主服务器当前所处的数据库状态。从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令来完成,以下是SYNC命令的执行步骤:
- 从服务器向主服务器发送SYNC命令
- 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有命令
- 当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行执行BGSAVE命令时的数据库状态
- 主服务器将记录在缓冲区中的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态
图1-2展示了SYNC命令执行期间,主从服务器的通信过程
图1-2 主从服务器在执行SYNC命令期间的通信过程
表1-1展示了一个主从服务器进行同步的例子
时间 | 主服务器 | 从服务器 |
T0 | 服务器启动 | 服务器启动 |
T1 | 执行 SET k1 v1 |
|
T2 | 执行 SET k2 v2 |
|
T3 | 执行 SET k3 v3 |
|
T4 | 向主服务器发送 SYNC 命令 | |
T5 | 接收到从服务器发来的 SYNC 命令,执行 BGSAVE 命令,创建包含键 k1 、 k2 、 k3 的 RDB 文件,并使用缓冲区记录接下来执行的所有写命令 |
|
T6 | 执行 SET k4 v4 ,并将这个命令记录到缓冲区里面 |
|
T7 | 执行 SET k5 v5 ,并将这个命令记录到缓冲区里面 |
|
T8 | BGSAVE 命令执行完毕,向从服务器发送 RDB 文件 | |
T9 | 接收并载入主服务器发来的 RDB 文件 ,获得 k1 、 k2 、 k3 三个键 |
|
T10 | 向从服务器发送缓冲区中保存的写命令SET k4 v4 和 SET k5 v5 |
接收并执行主服务器发来的两个 SET 命令,得到 k4 和 k5 两个键 |
T11 | 同步完成,现在主从服务器两者的数据库都包含了键k1 、 k2 、 k3 、 k4 和k5 |
同步完成,现在主从服务器两者的数据库都包含了键k1 、 k2 、 k3 、 k4 和k5 |
命令传播
在同步操作执行完毕之后,主从服务器两者的数据库状态将达到一致,但这种一致并不是一成不变的,每当主服务器执行客户端发送的写命令时,主服务器的数据库就有可能会被修改,并导致主从服务器状态不再一致。举个栗子,假设一个主服务器和一个从服务器刚刚完成同步操作,它们的数据库都保存了相同的五个键k1至k5,如图1-3所示
图1-3 处于一致状态的主从服务器
如果这时,客户端向主服务器发送命令DEL k3,那么主服务器在执行完这个DEL命令之后,主从服务器的数据库状态将出现不一致:主服务器的数据库已经不再包含键k3,但这个键却仍然包含在从服务器的数据库中,如图1-4所示
图1-4 处于不一致状态的主从服务器
为了让主从服务器再次回到一致状态,主服务器需要对从服务器执行命令传播操作,主从服务器会将自己的写命令,即是造成主从服务器不一致的那条写命令发送给从服务器执行,当从服务器执行了相同的写命令后,主从服务器将再次回到一致的状态
在上面的例子中,主服务器因为执行了命令DEL k3而导致主从服务器不一致,所以主服务器将向从服务器发送相同的命令DEL k3。当从服务器执行完在这个命令之后,主从服务器再次回到一致状态,现在主从服务器两者的数据库都不再包含键k3了,如图1-5所示
图1-5 主服务器向从服务器发送命令
旧版复制功能的缺陷
在Redis中,从服务器对主服务器的复制可以分为以下两种情况:
- 初次复制:从服务器以前没有复制过任何主服务器,或者从服务器当前要复制的主服务器和上一次复制的主服务器不同
- 断线后重复制:处于命令传播阶段的主从服务器因为网络原因而中断了复制,但从服务器通过自动重连接重新连上了主服务器,并继续复制主服务器
对于初次复制来说,旧版复制功能能够很好的完成任务,但对于断线后重复制来说,旧版复制功能虽然也能让主从服务器重新回到一致状态,但效率非常低。要理解这一情况,请看表1-2展示的断线后重复值例子
时间 | 主服务器 | 从服务器 |
T0 | 主从服务器完成同步 | 主从服务器完成同步 |
T1 | 执行并传播SET k1 v1 | 执行主服务器传来的SET k1 v1 |
T2 | 执行并传播SET k2 v2 | 执行主服务器传来的SET k2 v2 |
…… | …… | …… |
T10085 | 执行并传播SET kl0085 v10085 | 执行主服务器传来的SET kl0085 v10085 |
T10086 | 执行并传播SET kl0086 v10086 | 执行主服务器传来的SET k10086 v10086 |
T10087 | 主从服务器连接断开 | 主从服务器连接断开 |
T10088 | 执行 SET kl0087 v10087 | 断线中,尝试重新连接主服务器 |
T10089 | 执行 SET k10088 v1008 | 断线中,尝试重新连接主服务器 |
T10090 | 执行 SET kl0089 v10089 | 断线中,尝试重新连接主服务器 |
T10091 | 主从服务器重新连接 | 主从服务器重新连接 |
T10092 | 向主服务器发送SYNC命令 | |
T10093 | 接收到从服务器发来的SYNC命令,执行BGSAVE命令,创建包含键k1至键k10089的RDB文件,并使用缓冲区记录接下来执行的所有写命令 | |
T10094 | BGSAVE命令执行完毕向从服务器发送RDB文件 | |
T10095 | 接收并载入主服务器发来的RDB文件,获得键k1至键kl0089 | |
T10096 | 因为在BGSAVE命令执行期间,主从务器没有执行任何写命令,所以跳过发送缓冲区包含的写命令这一步 | |
T10097 | 主从服务器再次完成同步 | 主从服务器再次完成同步 |
在时间T10091,从服务器终于重新连接上主服务器,因为这时候主从服务器的状态已经不再一致,所以从服务器将向主服务器发送SYNC命令,而主服务器会将包含键k1至键k10089的RDB文件发送给从服务器,从服务器通过接收和载入这个RDB文件来将自己的数据库更新至主服务器数据库当前所处的状态
虽然再次发送SYNC命令可以让主从服务器重新回到一致状态,但如果我们仔细研究这个断线重复过程,就会发现传送RDB文件这一步实际上并不是非做不可的:
- 主从服务器在时间T0只时间T10086中一直处于一致状态,这两个服务器保存的数据大部分是相同的
- 从服务器想要将自己更新至主服务器当前所处的状态,真正需要的是主从服务器连接中断期间,主服务器新添加了k10087、k10088、k10089三个键的数据
- 可惜的是,旧版复制功能并没有利用以上例举的两点,而是继续让主服务器生成RDB文件传输给从服务器,但对于RDB文件中从k1至k10086的数据其实是没必要的
上面给出的例子可能有一点理想化,因为在主从服务器断线期间,主服务器执行的写命令可能会有成百上千之多,而不仅仅是两三个写命令。但总的来说,主从服务器断开的时间越短,主服务器在断线期间执行的写命令就越少,而执行少量写命令所产生的数据量通常比整个数据库的数据量要少的多,在这种情况下,为了让从服务器补足一小部分缺失的数据,却要让主从服务器重新执行一次SYNC,这种做法无疑是非常低效的
SYNC命令是非常消耗资源的,因为每次执行SYNC命令,主从服务器需要执行一下操作:
- 主服务器需要执行BGSAVE命令来生成RDB文件,这个生成操作会耗费主服务器大量的CPU、内存和磁盘I/O资源
- 主服务器需要将自己生成的RDB文件发送给从服务器,这个发送操作会耗费主从服务器大量的网络资源(带宽和流量),并对主服务器响应命令请求的时间产生影响
- 接收到RDB文件的从服务器需要载入主服务器发来的RDB文件,并且在载入期间,从服务器会因为阻塞而没办法处理命令请求
SYNC是一个如此消耗资源的命令,所以Redis最好在真需要的时候才需要执行SYNC命令
新版复制功能的实现
为了解决旧版复制功能在处理断线重复制情况时的低效问题,Redis从2.8版本开始,使用PSYNC命令代替SYNC命令来执行复制时的同步操作。PSYNC命令具有完整同步和部分同步两种模式:
- 其中完整重同步用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,它们都是通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步
- 而部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态
PSYNC命令的部分重同步模式解决了旧版复制功能在处理断线后重复值时出现的低效情况,表1-3展示了如何使用PSYNC命令高效地处理上一节断线后的情况
时间 | 主服务器 | 从服务器 |
T0 | 主从服务器完成同步 | 主从服务器完成同步 |
T1 | 执行并传播SET k1 v1 | 执行主服务器传来的SET k1 v1 |
T2 | 执行并传播SET k2 v2 | 执行主服务器传来的SET k2 v2 |
…… | …… | …… |
T10085 | 执行并传播SET kl0085 v10085 | 执行主服务器传来的SET kl0085 v10085 |
T10086 | 执行并传播SET kl0086 v10086 | 执行主服务器传来的SET kl0086 v10086 |
T10087 | 主从服务器连接断开 | 主从服务器连接断开 |
T10088 | 执行 SET k10087 v10087 | 断线中,尝试重新连接主服务器 |
T10089 | 执行 SET k10088 v10088 | 断线中,尝试重新连接主服务器 |
T10090 | 执行 SET k10089 v10089 | 断线中,尝试重新连接主服务器 |
T10091 | 主从服务器重新连接 | 主从服务器重新连接 |
T10092 | 向主服务器发送PSYNC命令 | |
T10093 | 向从服务器返回+CONTINUE回复,表示执行部分重同步 | |
T10094 | 接收+COTINUE回复,准备执行部分重同步 | |
T10095 | 向从服务器发送SET kl0087 v10087、SET k10088 V10088、 SET k10089 v10089三个命令 | |
T10096 | 接收并执行主服务器传来的三个SET命令 | |
T10097 | 主从服务器再次完成同步 | 主从服务器再次完成同步 |
对比一下SYNC和PSYNC命令处理断线重复制的方法,不难看出,虽然SYNC命令和PSYNC命令都可以让断线的主从服务器重新回到一致的状态,但执行部分重同步所需的资源比起执行SYNC命令所需的资源要少得多,完成同步的速度也快得多。执行SYNC命令需要生成、传送和载入整个RDB文件,而部分重同步只需要将从服务器缺少的写命令发送给从服务器就可以了。图1-6展示了主从服务器在执行部分重同步时的通信过程
图1-6 主从服务器执行部分重同步的过程
部分重同步的实现
在了解了PSYNC命令的由来,以及部分重同步的工作方式止之后,我们来看一下部分重同步的实现细节。部分重同步由以下三个部分构成:
- 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量
- 主服务器的复制积压缓冲区(replication backlog)
- 服务器的运行ID(run ID)
复制偏移量
执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:
- 主服务器每次向从服务器传播N个字节的数据时,就将自己的复制偏移量的值加上N
- 从服务器每次收到主服务器传播来的N个字节的数据时,就将自己的复制偏移量的值加上N
如图1-7所示的例子中,主从服务器的复制偏移量的值都为10086
图1-7 拥有相同偏移量的主服务器和它的三个从服务器
如果这时主服务器向三个从服务器传播长度为33字节的数据,那么主服务器的复制偏移量将更新为10086+33=10119,而三个从服务器在接收到主服务器传播的数据之后,也会将复制偏移量更新为10119,如图1-8所示
图1-8 更新偏移量之后的主从服务器
通过对比主从服务器的复制偏移量,程序可以很容易地知道主从服务器是否处于一致状态:
- 如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的
- 相反,如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态
考虑以下这个例子,假设如图1-7所示,主从服务器当前的复制偏移量都为10086,但时就在主服务器向从服务器传播长度为33字节的数据之前,从服务器A断线了,那么主服务器传播的数据将只有从服务器B和C能收到,在这之后,主服务器、从服务器B和C三个服务器的复制偏移量都将更新为10119,而断线的从服务器A的复制偏移量仍然停留在10086,这说明从服务器A与主服务器并不一致,如题1-9所示
图1-9 因为断线而处于不一致状态的从服务器A
假设从服务器A在断线之后就立即重新连接上主服务器,并且成功,那么接下来,从服务器向主服务器发送PSYNC命令,报告从服务器A当前的复制偏移量为10086,那么这时,主服务器应该对从服务器执行完整重同步还是部分重同步呢?如果执行部分重同步的话,主服务器又如何补偿从服务器A在断线期间丢失的那部分数据呢?答案还是和复制积压缓冲区有关
复制积压缓冲区
复制积压缓冲区是有主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为1MB。当主服务器进行命令传播时,它不仅将命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区中,如图1-10所示
图1-10 主服务器向复制积压缓冲区和所有从服务器传播写命令数据
因为,主服务器的复制积压缓冲区会保存着一部分最近传播的写命令,并且复制积压缓冲区会为队列的每个字节记录相应的复制偏移量,就像表1-4展示的那样
偏移量 | …… | 10087 | 10088 | 10089 | 10090 | 10091 | 10092 | 10093 | 10094 | 10095 | 10096 | 10097 | …… |
字节至 | …… | '*' | '3' | '\r' | '\n' | '$' | '3' | '\r' | '\n' | 'S' | 'E' | 'T' | …… |
当从服务器重新连上主服务器时,从服务器通过PSYNC命令将自己的复制偏移量offset发送给主服务器,主服务器会根据这个复制偏移量来决定对从服务器执行何种同步操作:
- 如果offset偏移量之后的数据(也即是偏移量offset+1开始的数据)仍然存在于复制积压缓冲区中,那么主服务器将对从服务器执行部分同步操作
- 相反,如果offset偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器将对从服务器执行完整重同步操作
回到之前图1-9展示的断线后重连接例子:
- 当从服务器A断线之后,它立即重新连接主服务器,并向主服务器发送PSYNC命令,报告自己的复制偏移量为10086
- 主服务器收到从服务器发来的PSYNC命令以及偏移量10086之后,主服务器将检查偏移量10086之后的数据是否存在于复制积压缓冲中,结果发现这些数据仍然存在,于是主服务器向从服务器发送+CONTINUE回复,表示数据同步将以部分重同步模式来进行
- 接着主服务器会将复制积压缓冲区10086偏移量之后的所有数据(偏移量为10087至10119)都发送给从服务器
- 从服务器只要接收到这33字节的缺失数据,就可以回到与主服务器一致的状态,如图1-11所示
图1-11 主服务器向从服务器发送缺失的数据
Redis为复制积压缓冲区设置的默认大小为1MB,如果主服务器需要执行大量写命令,又或者主从服务器断线后重连接所需的时间比较长,那么这个大小也许并不合适。如果复制积压缓冲区的大小设置得不恰当,那么PSYNC命令的复制重同步模式就不能正常发挥作用,因此,正确估算和设置复制积压缓冲区的大小非常重要。复制积压缓冲区的最小大小可以根据公式second*write_size_per_second来估算:
- 其中second为从服务器断线后重新连接上主服务器所需的平均时间(以秒计算)
- 而write_size_per_second则是主服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和)
例如,如果主服务器平均每秒产生1 MB的写数据,而从服务器断线之后平均要5秒才能重新连接上主服务器,那么复制积压缓冲区的大小就不能低于5MB。为了安全起见,可以将复制积压缓冲区的大小设为2*second*write_size_per_second,这样可以保证绝大部分断线情况都能用部分重同步来处理。至于复制积压缓冲区大小的修改方法,可以参考配置文件中关于repl-backlog-size选项的说明
服务器运行ID
除了复制偏移量和复制积压缓冲区之外,实现部分重同步还需要用到服务器运行ID(run ID):
- 每个Redis服务器,不论主服务器还是从服务,都会有自己的运行ID
- 运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成,例如53b9b28df8042fdc9ab5e3fcbbbabff1d5dce2b3
当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器则会将这个运行ID保存起来
当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行ID:
- 如果从服务器保存的运行ID和当前连接的主服务器的运行ID相同,那么说明从服务器断线之前复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作
- 相反地,如果从服务器保存的运行ID和当前连接的主服务器的运行ID并不相同,那么说明从服务器断线之前复制的主服务器并不是当前连接的这个主服务器,主服务器将对从服务器执行完整重同步操作