Redis 复制
使用主从复制
- 运行 Master
调整 Master 内存中保存的缓冲积压部分(replication backlog),以便执行部分重同步。1
2
3# 缓冲区越大,可断开连接再重连执行部分重同步的时间越长,缓冲区会在每次连接时分配。
repl-backlog-size 1mb
repl-backlog-ttl 3600 - 运行 Slave
先在配置文件中设置 Master 和 logfile 路径再运行1
2slaveof 172.16.205.141 6379
logfile "/usr/redis/log/slave.log" - 级联复制(从从复制)
之前是所有 Slave 连到一个 Master 上,这是一种中心化的办法,对 Master 的负担较大,事实上我们完全可以不全部连到 Master 上,而是 Master->Slave1->Slave2 这样传递。
实现级联复制也较简单,只用修改 Slave2 配置文件的slaveof
属性即可。 - Master write,Slave read
通过程序(客户端)实现数据的读写分离,即在程序中判断请求是读是写,让 Master 负责处理写请求,Slave 负责处理读请求;通过扩展 Slave 处理更多的并发请求,减轻 Master 端的负载。
只读 Slave
Redis2.6 之后,Redis 支持只读模式,可以使用slave-read-only
配置来控制这个行为。
只读模式下的 slave 将会拒绝所有写入命令,因此实践中不可能由于某种出错而将数据写入 slave 。但这并不意味着该特性旨在将一个 slave 实例暴露到 Internet ,或者更广泛地说,将之暴露在存在不可信客户端的网络,因为像 DEBUG 或者 CONFIG 这样的管理员命令仍在启用。但是,在 redis.conf 文件中使用 rename-command 指令可以禁用上述管理员命令以提高只读实例的安全性。
同步复制和异步复制
Redis 使用默认的异步复制,其特点是低延迟和高性能,不会影响 Redis 主线程的响应效率。
- Redis 复制在 master 侧是非阻塞的。这意味着 master 在一个或多个 slave 进行初次同步或者是部分重同步时,可以继续处理查询请求。
- 复制在 slave 侧大部分也是非阻塞的。当 slave 进行初次同步时,它可以使用旧数据集处理查询请求,假设你在 redis.conf 中配置了让 Redis 这样做的话。否则,你可以配置如果复制流断开, Redis slave 会返回一个 error 给客户端。但是,在初次同步之后,旧数据集必须被删除,同时加载新的数据集。 slave 在这个短暂的时间窗口内(如果数据集很大,会持续较长时间),会阻塞到来的连接请求。自 Redis 4.0 开始,可以配置 Redis 使删除旧数据集的操作在另一个不同的线程中进行,但是,加载新数据集的操作依然需要在主线程中进行并且会阻塞 slave 。
Redis 虽然声称是单线程模型,但是很多功能仍然是采用多线程实现的。
什么时候触发复制
- 当一个 Master 和一个 Slave 实例连接正常时,Master 通过向 Slave 发送命令流来增量同步自身数据集的改变情况,包括客户端的写入、key 的过期等;
- Master 与 Slave 之间因为网络问题或宕机,之后 Slave 重新连上 Master 时会尝试进行部分重同步,即只获取在断开连接期间内丢失的命令流;
为此,slave 会记住旧 master 的旧 replication ID 和复制偏移量,因此即使询问旧的 replication ID,其也可以将部分复制缓冲提供给连接的 slave 。 - 当无法进行部分重同步时,Slave 会请求进行全量重同步。Master 需要创建所有数据的快照,将之发送给 Slave,之后在数据集发生更改时持续发送命令流到 Slave。
主从复制原理
当用户往 Master 端写入数据时,通过Redis Sync
机制将数据文件发送至 Slave,Slave 也会执行相同的操作确保数据一致。
- 同一个 Master 可以拥有多个 Slaves。Master 下的 Slave 还可以接受同一架构中其它 Slave 的链接与同步请求,实现数据的级联复制,即 Master->Slave->Slave 模式;
repl-diskless-sync-delay
参数可以延迟启动数据传输,目的可以在第一个 slave 就绪后,等待更多的 slave 就绪。
主从复制最好配置成级联复制,因为这样更容易解决单点问题,避免Master承受过大的复制压力。 - Master 以非阻塞的方式同步数据至 slave,这将意味着 Master 会继续处理一个或多个 slave 的读写请求;
- Slave 端同步数据也可以修改为非阻塞的方式,当 slave 在执行新的同步时,它仍可以用旧的数据信息来提供查询;否则,当 slave 与 master 失去联系时,slave 会返回一个错误给客户端;
- 主从复制可以做到读写分离,保证了可扩展性,即多个 slave 专门提供只读查询与数据的冗余,Master 端专门提供写操作;
- 通过配置禁用 Master 数据持久化机制,将其数据持久化操作交给 Slaves 完成,避免在 Master 中要有独立的进程来完成此操作。
- Redis 主从复制的性能问题,为了主从复制的速度和连接的稳定性,Slave 和 Master 最好在同一个局域网内。
标识同步进程:
- 每个 Master 都有一个
Replication ID
:这是一个较大的伪随机字符串,标记了一个给定的数据集。 - 每个 Master 持有一个偏移量
offset
,Master 将自己产生的复制流发送给 slave 时,发送多少个字节的数据,自身的偏移量就会增加多少,目的是当有新的操作修改自己的数据集时,它可以以此更新 Slave 的状态。即使没有 Slave 连接到 Master,offset 也会自增,所以基本上每一对<Replication ID, offset>
都会标识一个 Master 数据集的确切版本。 - Slave 也维护了一个复制偏移量
offset
,代表从库同步的字节数,从库每收到主节点传来的 N 个字节数据时,从库的 offset 增加 N。
Master 和 Slave 的offset
总是不断增大,这也是判断主从数据是否同步的标志,若主从的 offset 相同则表示数据同步量,不通则表示数据不同步。
复制积压缓冲区
主节点(master)响应写命令时,不但会把命名发送给从节点,还会写入复制积压缓冲区,用于复制命令丢失的数据补救。
Slave 连接中断时主节点仍然可以响应命令,但因复制连接中断命令无法发送给 Slave。之后,当 Slave 重启并触发部分复制时,Master 可以将复制积压缓冲区的内容同步给 Slave,从而提高复制效率;
部分重同步过程:
- 当 Slave 连接到 Master,发送一个
PSYNC
命令表明自己记录的旧的 MasterReplication ID
和它们至今为止处理的偏移量offset
; - Master 仅发送 Slave 所需的增量部分的命令流,即上次同步偏移量
offset
之后执行的写命令; - 但是如果 master 的缓冲区中没有足够的命令积压缓冲记录,或者如果 slave 引用了不再知道的历史记录(replication ID),则会转而进行一个全量重同步:在这种情况下, slave 会得到一个完整的数据集副本,从头开始。
全量同步(完整重同步):
- Slave 向 Master 发送
PSYNC
命令; - Master 执行
BGSAVE
命令,开启一个后台进程用于生成一个 RDB 文件; - 同时它开始缓冲所有从客户端接收到的新的写入命令;
- 当后台保存完成时, master 将数据集文件传输给 slave, slave 将之保存在磁盘上,然后加载文件到内存;
- 再然后 master 会将所有缓冲的写命令发给 slave,这个过程以指令流的形式完成并且和 Redis 协议本身的格式相同。
可以通过
telnet
连接到 Redis 服务器上然后发送SYNC
命令来模拟这个过程,但是因为SYNC
功能有限(比如不支持部分重同步),现在的版本用PSYNC
作为代替。
正常情况下,全量同步会先在磁盘上创建一个 RDB 文件,传输时将其加载进内存,然后 Slave 对此进行数据的同步,如果磁盘性能很低,这个过程压力会比较大,Redis 2.8.18
之后支持直接传输 RDB 文件,可以使用repl-diskless-sync
配置参数配置。
全量同步完成以后,在此后的时间里主从维护着心跳检查来确认对方是否在线,每隔一段时间(默认 10 秒,通过repl-ping-slave-period
参数指定)主节点向从节点发送 PING 命令判断从节点是否在线,而从节点每秒 1 次向主节点发送 REPLCONF ACK 命令,命令格式为:REPLCONF ACK {offset}
,其中 offset 指的是从节点保存的复制偏移量,作用是:
- 向主节点报告自己复制进度,主节点会对比复制偏移量向从节点发送未同步的命令;
- 判断主节点是否在线。
主从复制执行过程 - Slave怎么与Master建立连接
1、Slave Redis实例上配置slaveof xxx
,表示将成为另一台Redis实例的从服务器,启动 Slave时,需要设置当前节点的Master信息,并开始主从同步过程;
代码位置:replication.c/slaveofCommand()
1 | // 进入连接状态(重点) |
2、上边设置复制信息成功后,Redis服务器会有一个cron任务(serverCron
)定时判断需要进行同步操作,向Master建立连接,也就是一个握手的过程;
代码位置:replication.c/replicationCron()
1 | if (server.repl_state == REPL_STATE_CONNECT) { |
serverCron是Redis的主事件循环,负责超多的任务,包括过期key处理、rehash、备份RDB文件、AOF重写等等。
3、确定连接后,接下来,cron任务里还有比较关键的一项是确定复制方案,
会先向 Master 发送一个 PSYNC Command,Master会返回复制方案,也就是下面的全量、增量及不支持这3种情况:
代码位置:replication.c/syncWithMaster()
replication.c/slaveTryPartialResynchronization()
1 | // 向主服务器发送 PSYNC 命令 |
注意PSYNC命令的两个参数:
- 主库的runID:每个Redis实例启动时都会自动生成的一个随机ID,用来唯一标识这个实例。
当从库和主库第一次复制时,因为不知道主库的runID,因此会将runID设为”?”。 - 复制进度offset:设为-1表示第一次复制。
4、Master接收到命令后需要判断需要全量同步还是部分同步
这部分代码在replication.c/syncCommand()
中,接下来我们再讨论主节点如何判断同步方式及同步的流程。
主从复制执行过程 - Master如何处理PSYNC命令
1、无论是第一次连接还是重新连接,Master 都会启动一个后台进程(fork),将数据快照保存到数据文件中,同时 Master 会记录所有修改数据的命令并缓存在数据文件中(持久化),Master会将文件内容加载到内存中,等之后回传给Slave(复制);
2、Master端与Slave端完成握手后,需要判断是需要进行全量还是增量复制(也就是上面的返回+FULLRESYNC
还是+CONTINUE
处理Slave的PSYNC
命令的代码位置:replication.c/syncCommand()
判断是否需要执行全量复制的代码位置:replication.c/masterTryPartialResynchronization()
判断执行全量复制的条件如下代码所示:
1 | // 检查 master id 是否和 runid 一致,只有一致的情况下才考虑执行psync |
3、如果是部分复制
Master会向Slave发送 backlog 中从 offset 到 backlog 尾部之间的数据
代码:replication.c/addReplyReplicationBacklog()
部分复制在3.0版本和之后的版本中的实现有比较大的差异。
在3.0时,部分复制发生在Slave向Master发送PSYNC命令时。
1 | void syncCommand(redisClient *c) { |
3.0后,在每次命令执行完之后,还会触发命令传播:
1 | void processInputBufferAndReplicate(client *c) { |
所谓命令传播,就是当Master节点每处理完一个命令都会把命令广播给所有的子节点,而每个子节点接收到Master的广播过来的命令后,会在处理完之后继续广播给自己的子节点。
命令传播也是异步的操作,即Master节点处理完客户端的命令之后会立马向客户端返回结果,而不会一直等待所有的子节点都确认完成操作后再返回以保证Redis高效的性能。
4、什么时候会改为采用全量复制
上面的增量复制中,我们看到Redis实际上是将repl_backlog中的内容复制给了Slave,backlog是一块内存缓冲区(默认大小为1M),每次处理完命令之后,先写入缓冲区repl_backlog, 然后再发送给Slave。
如果一个Slave断连了一段时间,重启后Master可以将这块缓冲区内的内容复制给Slave,但是如果断连的时间比较长,也有可能会触发全量复制,因为缓冲区能保存的命令有限,只能至多保存的命令长度为repl_backlog_length,如果某个子节点落后当前最新命令的长度大于了repl_backlog_length,那么就会触发全量复制。
5、如果是全量复制
这种情况下,Master并不会直接将RDB文件传给Slave,而是先发给Slave+FULLRESYNC
,;
代码:replication.c/masterTryPartialResynchronization()
的末尾
什么时候Master会将RDB文件传给Slave呢?如果当前已经有可用的RDB文件,则直接将RDB文件传输给Slave;如果当前RDB正在备份过程中,Master会在每次RDB文件备份完毕后执行一次传输任务。replication.c/syncCommand()
末尾Master判断RDB当前的备份状态,设置标识表示当前RDB文件是否可用于复制,如果可以复制则会在之后的主事件循环中触发文件的发送:
1 | void syncCommand(redisClient *c) { |
主事件循环中发RDB文件的代码如下:
1 | int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { |
接下来的调用包括:replication.c/backgroundSaveDoneHandler()
replication.c/updateSlavesWaitingBgsave()
replication.c/sendBulkToSlave()
全量同步的大致流程如此,主要分为以下几步:
- Master节点开启子进程进行RDB文件生成
- Master节点将RDB文件发送给Slave节点
- Slave节点清空内存中的所有数据并删除之前的RDB文件
- Slave节点使用从Master接收的RDB文件恢复数据到内存中
需要注意的是,这个过程中的每一步都是耗时的IO操作,所以大部分时候Redis都是尽可能采用增量复制,而不是全量复制。
下面再来讨论Master如何发送及Slave如何接收这份数据。
主从复制执行过程 - Master如何发送及Slave如何接收复制数据
1、如果是全量复制
Slave和Master刚开始握手完毕后,会注册一个readSyncBulkPayload
处理器,用于读取从Master发送过来的RDB文件。
2、Slave 将数据文件保存到磁盘上,然后再加载到内存中;
从库接收到RDB文件后,会先清空当前数据库,然后加载RDB文件,这是因为从库在开始和主库同步前可能保存了其他数据,为了避免之前数据的影响,从库需要先把当前数据库清空。
3、同步过程中主库产生的新数据也要同步给从库
主库同步数据给从库的过程中,主库不会被阻塞,仍然可以正常接收请求(否则Redis服务不就中断了?),但是这些请求中的写操作并没有记录到刚刚生成的RDB文件中,为了保证主从库的数据一致性,主库会在内存中用专门的replication buffer(代码中对应repl_backlog_buffer)记录RDB文件生成后收到的所有写操作。repl_backlog_buffer
是一个环形缓冲区,主库会记录自己写到的位置,而从库则会记录自己已经读到的位置,可以使用repl_backlog_size
来配置这个缓冲区的大小,如果配得过小,可能会导致增量复制阶段从库复制进度赶不上主库,进而导致从库重新进行全量复制。
在Master端定义的offset是master_repl_offset
,在Slave端定义的offset是slave_repl_offset
,正常情况下这两个偏移量是基本相等的。
增量同步期间,从库在发送psync的同时,会把自己当前的slave_repl_offset发给主库,主库判断自己的master_repl_offset和slave_repl_offset之间的差距,如果断连了,master_repl_offset可能会超过slave_repl_offset,那么将这超过的部分发给slave就可以恢复同步了。
主从复制存在的问题
主从库间网络断了怎么办?
Redis2.8之前,如果主从同步过程中出现了网络闪断,那么主从是会重新进行一次全量复制的,开销非常大。
Redis2.8之后,网络闪断后,主从会采取增量复制,将闪断期间的命令发给从库。
宕机恢复
因为 slave 顶多只负责处理读请求,slave 挂掉不会造成数据丢失的问题。
slave 宕机的情况下,应该要求客户端具有一定的熔断恢复能力,并且能在重启后快速恢复:
- 恢复正常后重新连接;
- Master 收到 Slave 的连接后,第一次同步时,主节点做一次 bgsave,并同时将后续修改操作记录到内存 buffer;
- Master 将其完整的 rdb 数据文件全量发送给 Slave;
- Slave 接收完成后将 rdb 镜像文件加载到内存,加载完成后,再通知 Master 将期间修改的操作记录同步到 Slave 节点进行重放就完成了同步过程;
- 如果 Master 同时收到多个 Slave 发来的同步请求,Master 只会在后台启动一个进程保存数据文件,然后将其发送给所有的 Slave,确保 Slave 正常。
主从复制无法应对 Master 挂掉的情况,实际上这种方案只能尽量保证数据不会丢失,不能保证服务的高可用性,为此,需要引入 Redis 的 Sentinel 机制。
客户端可以使用 WAIT
命令来请求同步复制某些特定的数据。但是,WAIT 命令只能确保在其他 Redis 实例中有指定数量的已确认的副本:在故障转移期间,由于不同原因的故障转移或是由于 Redis 持久性的实际配置,故障转移期间确认的写入操作可能仍然会丢失。
是否可以关闭持久化
作为复制方案中的一环,可以考虑关闭 Master 或 Slave 的持久化功能,但是并不建议关掉它们,因为:
- 如果关闭 Master 的持久化:重启(重启功能可以由一些只能运维工具来保证,比如 K8S)的 Master 将从一个空数据集开始,如果一个 Slave 试图与它同步,那么这个 Slave 也会被清空。
- 如果关闭 Slave 的持久化:重启的 Slave 需要从 Master 全量同步数据。
正如前所述,关闭了持久化并配置了自动重启的 Master 是危险的——会导致整个集群的数据全部被清空。
如果 Sentinel 集群用于需要高可用的场景、且 Master 被关闭掉了持久化功能,也是非常危险的:
- 如果重启比较慢,Sentinel 的故障迁移机制重新选主,一个 Slave 会上升为 Master;
- 如果重启得足够快,Sentinel 没有探测到故障,此时 Master 数据被清空了,而 Slave 仍从 Master 同步数据,这将引起上边提到的故障模式——数据将丢失。
因此,如果考虑磁盘性能过慢会导致延迟、关掉了持久化,那么自动重启进程这项应该被禁用。
如何保证主从数据的一致性 - 数据丢失窗口的存在
由于 Redis 使用异步复制,无法保证Slave和Master的实时一致性,因此总会有一个数据丢失窗口。
那在什么情况下,从库会滞后执行同步命令呢?
- 一方面,主从库间的网络可能会有传输延迟,所以从库不能及时地收到主库发送的命令,从库上执行同步命令的时间就会被延后。
- 另一方面,即使从库及时收到了主库的命令,但是,也可能会因为正在处理其它复杂度高的命令(例如集合操作命令)而阻塞。此时,从库需要处理完当前的命令,才能执行主库发送的命令操作,这就会造成主从数据不一致。而在主库命令被滞后处理的这段时间内,主库本身可能又执行了新的写操作。这样一来,主从库间的数据不一致程度就会进一步加剧。
因为异步复制的本质,Redis主从复制无法完全避免数据的丢失,除了尽量保证网络连接状况良好外,还可以写一些监控程序来监控主从库间的复制进度,原理是实时给Redis实例发info replication
命令得到master_repl_offset
和slave_repl_offset
这两个进度信息,计算这二者的差值即可得到主从复制进度的实时程度,如果某个从库进度差值大于我们预设的一个阈值,我们可以让客户端不再和这个从库连接进行数据读取,从而减少读到不一致数据的情况。
这个阈值当然不能设置得过低,否则可能导致所有从库都连不上了。
既然无法避免,那么只能退一步、控制影响范围了,Redis 可以保证:
- Redis slave 每秒钟都会 ping master,确认已处理的复制流的数量。
- Redis master 会记得上一次从每个 slave 都收到 ping 的时间。
- 用户可以配置一个最小的 slave 数量,使得它滞后 <= 最大秒数。
- 如果至少有 N 个 slave ,并且滞后小于 M 秒,则写入将被接受。如果条件不满足,master 将会回复一个 error 并且写入将不被接受。
这些条件是通过min-slaves-to-write
和min-slaves-max-lag
这两个配置来实现的:
min-slaves-to-write
:最少有n个slave的连接还是健康的情况下才能提供服务,至于怎么判断连接是否健康,需要看下面一个配置;min-slaves-max-lag
:判断连接健康的最大延迟时间,slave每次PING Master时Master都会记录该Slave 最后一次PING的时间,如果最后一次PING成功的时间距今比较长了,就说明该Slave的连接状态很有可能已经出问题了。
对于给定的写入来说,虽然不能保证绝对实时的一致性,但至少数据丢失的时间窗限制在给定的秒数内。
1 | # It is possible for a master to stop accepting writes if there are less than |
过期的 key 问题
由于复制的异步特性,对 key 设置过期时间和写入操作很容易导致 race condition 及导致数据集不一致,比如:
1 | (1) sadd x 1 |
在 Master 上,命令(3)是在过期前执行的,而 Slave 上可能因为延后导致命令(3)执行前 x 就已经过期了,此时 x 是没有过期时间的(ttl x 得到-1 表示不过期),这就导致了数据的不一致。
set 命令不会出现这个问题,因为 set 会将过期时间给覆盖成-1。当然情况比较复杂,也有可能是我没有想到。
为了保证针对过期的 key 的复制能够正确工作,Redis 提供如下保证:
- slave 不会让 key 过期,而是等待 master 让 key 过期。当一个 master 让一个 key 到期(或由于 LRU 算法将之驱逐)时,它会合成一个 DEL 命令并传输到所有的 slave。一旦一个 slave 被提升为一个 master ,它将开始独立地过期 key,而不需要任何旧 master 的帮助。
- 但是,由于这是 master 驱动的 key 过期行为,master 无法及时提供 DEL 命令,所以有时候 slave 的内存中仍然可能存在在逻辑上已经过期的 key 。为了处理这个问题,slave 使用它的逻辑时钟以报告只有在不违反数据集的一致性的读取操作(从主机的新命令到达)中才存在 key。用这种方法,slave 避免报告逻辑过期的 key 仍然存在。在实际应用中,使用 slave 程序进行缩放的 HTML 碎片缓存,将避免返回已经比期望的时间更早的数据项。
- 在 Lua 脚本执行期间,不执行任何 key 过期操作。当一个 Lua 脚本运行时,从概念上讲,master 中的时间是被冻结的,这样脚本运行的时候,一个给定的键要么存在要么不存在。这可以防止 key 在脚本中间过期,保证将相同的脚本发送到 slave ,从而在二者的数据集中产生相同的效果。
QA
AOF日志更全,为什么主从同步不使用AOF而是RDB呢?
网络传输效率:RDB直接存储数据,而不是命令,数据量更小,传输更快。
恢复效率:因为使用AOF恢复数据库的话是需要将AOF中记录的命令再执行一次的,这个效率远不如直接将RDB中的数据直接加载到内存里。
主从切换过程中,客户端能正常进行请求吗?
主库故障后从库仍能正常接收读请求,但主库挂掉了所以无法处理写请求。
如果实现应用程序不感知服务器的中断?
- 客户端可以缓存写请求,因为使用Redis的场景同步写请求比较少,且一般都不会在应用程序的关键路径上,所以在不能立刻执行写请求的情况下,客户端完全可以先把请求缓存起来,给应用程序返回一个确认即可。
- 另外,主从切换后,客户端要能及时和新主库重新建立连接。