上一节提到了 Redis 的持久性,也就是在服务器实例宕机或故障时,拥有再恢复的能力。但是在这个服务器实例宕机恢复期间,是无法接受新的数据请求。对于整体服务而言这是无法容忍的,因此我们可以使用多个服务器实例,在一个实例宕机中断时,另外的服务器实例可以继续对外提供服务,从而不中断业务。Redis 是如何做的呢?Redis 做法是增加冗余副本,将一份数据同时保存在多个实例上。那么如何保存各个实例之间的数据一致性呢?Redis 采用主从库读写分离模式来保证数据副本的一致性。
在 Redis 中提供复制的服务器称为主服务器(master),被主服务器进行复制的服务器称为从服务器(slave )。但是在复制方式上有所区别,主从库之间采用的是读写分离的方式:
为何要采用读写分离,因为如果在不同实例上执行修改操作,要保证实例之间的一致性就必须加锁、实例间的协商等操作,会带来巨额的开销。如果采用读写分离,数据的修改迁移到主库上进行,然后再同步到从库上,就可以达到不使用锁达到数据一致性的效果。
主从复制是如何进行复制的,是一次性全部复制,还是分批一批一批的复制?而且如果复制中网络中断,数据还能保持一致性吗,其内部原理是怎样的?Redis 的复制功能主要有两个操作:
在同步操作中,我们首先来看看全量复制,顾名思义也就是主库将所有的数据传送给从库。因为主从库初始化需要传输全部数据,所以第一次同步其实就是一次全量复制。当启动多个 Redis 实例时,主从库间就可以通过 replicaof
(Redis 5.0 前使用的是 slaveof
)命令形成主从库的关系,在这期间从库就会全量复制当前主库的数据库状态。主要分为三个阶段:
psync
命令,主库根据这个命令参数来启动复制。而 psync
命令包含了主库的 runID
和 复制进度 offset
两个参数:
runID
: 是每个 Redis 实例启动时随机生成的 ID,它是用来唯一标记这个实例。而当主从库第一次复制时,从库不知道主库的 runID
。因此将 runID
设置为 “?”。offset
:第一次设置为 -1,表示第一次复制 主库在接收到 psync 命令后,会利用 FULLRESYNC 响应命令带上两个参数:主库 runID
和主库目前的复制进度 offset
来返回给从库。而从库在收到响应后会记录下主库传递的这两个参数。
bgsave
命令,执行持久化生成 RDB 文件,并将该文件发送给从库。从库在接收到 RDB 文件后,会先清空当前数据库,然后再加载 RDB 文件。
在主库同步从库过程中,我们知道 bgsave
命令系统会 fork 一个子线程来创建 RDB 文件,另外的主线程可以继续处理命令请求。而在同步过程中的新增的写操作,主库会在内存中用专门的 replication buffer
来记录这些写操作。
replication buffer
中的写操作发送给从库,从库再执行这些写操作。到此完成所有的操作数据同步。
结合一个实例来展示三个阶段:
现有两个实例,实例1(ip: 172.16.19.3)和实例 2(ip: 172.16.19.5)。在从实例2上执行以下命令后,实例2 就变成了实例 1 的从实例。执行以下命令后,实例2 就变成了实例1 的从库,并开始复制数据:
replicaof 172.16.19.3 6379
在一次全量复制中,对于主库而言需要完成两个消耗资源的操作:发送 RDB 文件和 repl buffer
文件。所以如果请求的从库数量较多时,虽然主库会 fork 子线程进行生成 RDB 文件,但是从库请求数量过多,也会导致主线程 fork 操作过多,最终也会阻塞主线程的其他正常请求。所以,为了解决这个问题,我们可以通过 “主-从-从”模式来将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。如下图:
具体操作是: 在部署主从集群的时候,手动选择一个从库(比如选择内存资源配置较高的从库)用于级联其他的从库。然后可以再选择一些从库(如三分之一的从库),在这些从库上执行下面命令,让它们和刚才所选的从库建立起主从关系:
replicaof 所选从库的IP 6379
这样,这些级联的从库不用和主库进行交互,而只需要和连接的从库进行写操作同步即可。这样就可以减轻主库的压力了。
在主从库完成第一次的全量复制后,它们会形成一个网络连接,主库会通过这个连接将后续收到的命令操作再同步给从库。这个过程也称作为基于长连接的命令传播。这个长连接可以避免频繁的建立连接开销,后续我们会再提命令传播。
那么为什么需要增量复制呢,因为连接过程中存在着网络连接和阻塞,如果网络连接中断,主从库之间就无法实现命令传播。那如果再次进行全量复制,其开销就有点得不偿失。所以新设计出了增量复制,而与全量复制不相同,增量复制只会把主从库网络断联期间主库收到的命令同步给从库。其中重点就是利用repl_backlog_buffer
缓冲区,上面我们知道,在全量复制时,主库会把写操作命令写入 replication buffer
,与此同时,也会把这些操作命令写入 repl_backlog_buffer
缓冲区中(repl_backlog_buffer
是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置)。如下图:
主库在缓冲区的写位置偏移量就是 master_repl_offset
,从库的读位置偏移量是 slave_repl_offset
。正常情况下两个偏移量基本相等。
接下来我们看看网络连接断开时,主库有可能会收到新的写操作命令,一般而言,master_repl_offset
会大于 slave_repl_offset
。所以当主从库网络连接后,主库只需要将 master_repl_offset
和 slave_repl_offset
中间的命令操作同步给从库。
如上图中的 repl_backlog_buffer
示意图,主库和从库之间相差了put d e
和 put d f
两个操作,在增量复制时,主库只需要将它们同步给从库即可。
我们再来看一下增量复制的流程:
repl_backlog_buffer
是一个环形缓冲区,所以存在这样的情况:在缓冲区写满后,主库会继续写入,这样就会覆盖之前的写操作,那么这有可能就会导致主从库之间的数据不一致。此时,我们可以调整 repl_backlog_size
这个参数,实际情况下可以根据应用调整 repl_backlog_size
的大小。
在上述初始化全量复制结束后,主从库两者的数据库状态达到一致完成了同步,后面两者则处于长连接状态。此时主库只需要一直将自己执行的写命令发送给从库,从库一直接收并执行主库发来的写命令即可保证主从库的数据一致性了。这个时候主从库会互相成为对方的客户端。
当完成了同步之后,主从库就会进入命令传播阶段,这时主库只要一直将自己执行的写命令发送给从库,而从库只要一 直接收并执行主库发来的写命令,就可以保证主从库保持数据操作一致性。
在该阶段,从库默认会以每秒一次的频率向主库发送命令:
REPLCONF ACK <replication_offset>
replication_offset 是从库当前的复制偏移量
REPLCONF ACK 命令的作用有
检测主从库的网络连接状态:当主库超过一秒钟未收到从库发来的 REPLCONF ACK 命令,那么主库就会知道主从库之间连接出现问题。
辅助实现 min-slaves 选项:Redis的min-slaves-to-write
和min-slaves-max-lag
两个选项可以防止主库在不安全的情况下执行写命令。
举个例子,如果我们向主库提供以下设置:
min-slaves-to-write 3
min-slaves-max-lag 10
那么在从库的数量少于3个,或者三个从库的延迟(lag) 值都大于或等于10秒时,主库将拒绝执行写命令,这里的延迟值就是上面提到的INFO replication命令的lag值。
检测命令丢失: 如果因为网络故障,主库传播给从库的写命令在半路丢 失,那么当从库向主库发送REPLCONF ACK命令时,主库将发觉从库当前的复制偏移量少于自己的复制偏移量,然后主库就会根据从库提交的复制偏移量,在复制积压缓冲区(repl_backlog
)里面找到从库缺少的数据,并将这些数据重新发送给从库。
总结就是在传播命令阶段,主库通过向从库传播命令来更新从库的状态,保持主从库一致。而从库则通过向主库发送命令来进行心跳检测,以及命令丢失检测。
Redis 的读写分离可以实现 Redis 的读负载均衡,能够提高 Redis 服务器的并发量,但是在使用 Redis 读写分离时,也需要注意延迟不一致、数据过期问题。
对于命令传播阶段:因为命令传播阶段是异步操作,所以延迟与数据的不一致无法避免。有以下解决方式:
对于非命令传播的其他阶段,可以对 slave-serve-stale-date
设置为 no 。则从节点只能响应 info、slaveof 等少数命令,可以保证对数据的一致性。
数据过期问题已经在Redis 的键管理 中提到过,在单机 Redis 中存在惰性删除和定期删除两种删除策略。而在主从复制场景下,从库不会主动删除数据,主要通过主库控制从库中过期数据的删除。而主库的删除策略都不能保证主库及时对过期数据执行删除操作,所以当客户端通过 Redis 从库读取数据时很容易读取到已经过期的数据。
在没有使用哨兵的读写分离场景下,建议写监控程序进行切换读写分别连接的 Redis 节点。针对于手动进行切换的方式更复杂但是不容易出错。
在使用读写分离前,可以考虑其他方法增加Redis的读负载能力:如尽量优化主节点(减少慢查询、减少持久化等其他情况带来的阻塞等)提高负载能力;使用Redis集群同时提高读负载能力和写负载能力等。如果使用读写分离,可以使用哨兵,使主从节点的故障切换尽可能自动化,并减少对应用程序的侵入。
《Redis 设计与实现》
《Redis 开发与运维》
https://pdai.tech/md/db/nosql-redis/db-redis-x-copy.html
https://time.geekbang.org/column/article/272852
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=59#/detail/pc?id=1782