深入理解Redis主从原理
深入理解redis主从原理
Redis依据AOF和RDB模式进行数据的持久化,减少数据的丢失,那么Redis如何做到服务减少中断?
Redis的做法和业内这些数据库的做法一致,增加副本冗余量来实现高可用。
现在我们来看下有几种高可用的方法:
- 主从库
- 哨兵模式
- cluster集群
主从库
【基本配置】
对于redis主从,先来看下最基本的配置
1、复制一份redis.conf文件
2、将相关配置修改为如下值:
port 6380
pidfile /var/run/redis_6380.pid # 把pid进程号写入pidfile配置的文件
logfile "6380.log"
dir /usr/local/redis‐5.0.3/data/6380 # 指定数据存放目录
#bind 127.0.0.1(bind绑定的是自己机器网卡的ip,如果有多块网卡可以配多个ip,代表允许客户端通 过机器的哪些网卡ip去访问,内网一般可
以不配置bind,注释掉即可)
3、配置主从复制
replicaof 192.168.0.60 6379 # 从本机6379的redis实例复制数据,Redis 5.0之前使用slaveof
replica‐read‐only yes # 配置从节点只读
4、启动从节点
redis‐server redis.conf
redis的配置还是比较简单的,配置完成后启动,对主节点进行数据插入,登录从节点客户端,查看是否插入成功。
【主从工作原理】
主从库间如何进行第一次同步
当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照以下三个阶段完成数据的第一次同步。
第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。
具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。
- runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。
- offset,此时设为 -1,表示第一次复制。
主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。
这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。
具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。 (需要注意,若是该从库之前归属于其他主库或集群,明确是否进行了数据备份防止数据丢失)
在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。
最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。同步成功后,主从之间会维护一个socket长连接来避免命令传输带来的频繁建立连接开销。
主-从-从模式
通过分析主从库间第一次数据同步的过程,你可以看到,一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成 RDB 文件和传输 RDB 文件。
如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生成 RDB 文件,进行数据全量同步。fork 这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,且主库会为每个从库分配repl buffer,同样会给主库的资源使用带来压力,同时,这也是为什么不建议单台redis不要占用过大的内存,会导致生成的RDB文件过大。那么,有没有好的解决方法可以分担主库压力呢?
就是使用主-从-从模式。
通过该模式,可以将主库的部分压力分担到从库上,由从库和从库间进行全量复制和数据传输。
那么对于repl buffer来说,是否有限制呢?
如果主从在传播命令时,因为某些原因从库处理得非常慢,那么主库上的这个buffer就会持续增长,消耗大量的内存资源,甚至OOM。所以Redis提供了client-output-buffer-limit参数限制这个buffer的大小,如果超过限制,主库会强制断开这个client的连接,也就是说从库处理慢导致主库内存buffer的积压达到限制后,主库会强制断开从库的连接,此时主从复制会中断,中断后如果从库再次发起复制请求,那么此时可能会导致恶性循环,引发复制风暴,这种情况需要格外注意。
那么可能有些同学会思考,既然从库的复制有压力,为何不生成一个一段时间的副本进行留存,待从库需要同步时进行传输,就不需要fork子进程就行bgsave了?
因为生成RDB的时机就是当前从库所拥有的偏移量,待RDB在从库落盘后,repl buffer就会开始传输数据,若新的从库再次过来同步全量数据,拿到之前的偏移量和数据,repl buffer中存储的数据量有限,并不会存储已传输的数据,那么传输过的数据就不会存在于replication buffer中,造成新从库的无法同步全量数据,导致从库数据不完整。
Ps:但是在Redis7.0处理了该同步压力大的问题,多从库同步共享RDB副本,并且为了从库数据复制的完整性,会将第一次从库同步的repl buffer 复制给新同步从库的repl buffer中。
/* To attach this slave, we check that it has at least all the * capabilities of the slave that triggered the current BGSAVE. */ if (ln && ((c->slave_capa & slave->slave_capa) == slave->slave_capa)) { /* Perfect, the server is already registering differences for * another slave. Set the right state, and copy the buffer. * We don't copy buffer if clients don't want. */ if (!(c->flags & CLIENT_REPL_RDBONLY)) copyClientOutputBuffer(c,slave); replicationSetupSlaveForFullResync(c,slave->psync_initial_offset); serverLog(LL_NOTICE,"Waiting for end of BGSAVE for SYNC"); } else if ( ... )
同样,如何该repl buffer的大小过大,可能也会导致复制缓慢,造成阻塞问题。
所以在redis7.0实现了共享复制缓冲区。
在redis7.0之前,每个主库会为每一个从库,client都会分配repl buffer,若repl buffer设置过大,非常损耗主库内存,且存储的内容基本上都是一样的,就是可能存在时间先后导致的内容差异,所以最直观的方式就是在命令传播时将这些命令放在全局复制缓冲区,多从库共享。不同的从库复制不同的数据,同时也避免了ReplicationBacklog的开销。
针对于共享复制缓冲区的原理,我会先在下面将ReplicationBacklog讲完后再继续说明。
replicationbacklog
在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。
从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。听名字大概就可以猜到它和全量复制的不同:全量复制是同步所有数据,而增量复制只会把主从库网络断连期间主库收到的命令,同步给从库。
那么,增量复制时,主从库之间具体是怎么保持同步的呢?这里的奥妙就在于 repl_backlog_buffer 这个缓冲区。我们先来看下它是如何用于增量命令的同步的。
当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。
repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。
replication buffer 对于一主多从来说是相互独立,即redis会为每一个redis client创建一个独立的replication buffer,通过该buffer传输数据,而repl_backlog_buffer则是多个从库共用一个,每一个redis client都会有一个offset,该offset除了在断开重连后会通过psync将自己的offset传递给master,主从还维护了一个心跳机制,从节点会向主节点发送REPLCONF ACK命令,频率是每秒1次,命令格式为:REPLCONF ACK {offset},其中offset指从节点保存的复制偏移量告知master,repl_backlog_buffer通过该offset进行移动。
刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新写操作越多,这个值就会越大。
同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等。
主从库的连接恢复之后,从库首先会给主库发送 psync 命令,并把自己当前的 slave_repl_offset 发给主库,主库会判断自己的 master_repl_offset 和 slave_repl_offset 之间的差距。
在网络断连阶段,主库可能会收到新的写操作命令,所以,一般来说,master_repl_offset 会大于 slave_repl_offset。此时,主库只用把 master_repl_offset 和 slave_repl_offset 之间的命令操作同步给从库就行。
就像刚刚示意图的中间部分,主库和从库之间相差了 put d e 和 put d f 两个操作,在增量复制时,主库只需要把它们同步给从库,就行了。
但是,repl_back_log 是一个环形缓冲区,在缓冲区写满之后继续写入会造成覆盖,如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。
因此,我们要想办法避免这一情况,一般而言,我们可以调整 repl_backlog_size 这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2,这也就是repl_backlog_size 的最终值。但是也存在某些特殊情况,例如并发请求量大,可能两倍都不够用,此时就需要设置更大的冗余量。注意,repl_backlog_size的配置变更会导致该数据的清除,线上尽量勿操作,造成从库的全量复制。
到这里,主从原理大致就讲完了,接下来我们继续说下redis7.0的一些改动。
redis7.0为了更好的利用内存,设计了共享复制缓存区(replication buffer),多个从库及replicationbacklog共享。
注意在redis7.0各从库的缓存区称为OutputBuffer,和上述所讲的replication buffer是一个概念,而现在的replication buffer称为共享缓存区,是原replication buffer的设计升级,是所有从库共享的,那么原来各从库的replication buffer的概念就不存在了,为了避免混淆将原来各从库的replication buffer称为OutputBuffer。
/* Similar with 'clientReplyBlock', it is used for shared buffers between
* all replica clients and ReplicationBacklog. */
typedef struct replBufBlock {
int refcount; /* Number of replicas or repl backlog using. */
long long id; /* The unique incremental number. */
long long repl_offset; /* Start replication offset of the block. */
size_t size, used;
char buf[];
} replBufBlock;
共享复制缓冲区采用链表的表示方法,将其切割为多个16KB的数据块(replBufBlock)。为了维护不同从库的对ReplicationBuffer的使用信息,在replBufBlock中增加了如下字段:
refcount
:block 的引用计数id
:block 的唯一标识,单调递增的数值repl_offset
:block 开始的复制偏移
如上图所示,ReplicationBuffer 是由多个 replBufBlock 组成的链表,当 ReplicatonBacklog 或从库对某个 block 使用时,便对正在使用的 replBufBlock 增加引用计数,可以看到,ReplicatonBacklog 正在使用的 replBufBlock refcount 是 1,从库 A 和 B 正在使用的 replBufBlock refcount 是 2。当从库使用完当前的 replBufBlock(已经将数据发送给从库)时,就会对其 refcount 减 1 而且移动到下一个 replBufBlock,并对其 refcount 加 1。
该设计与实现解决了不仅仅解决了多从库消耗内存过多的问题,同时对于共用RDB副本的buffer拷贝问题,从多数据拷贝到引用信息的改动;对于OutputBuffer的释放,只需要replBufBlock refcount减1,不会有任何的阻塞;对于ReplicatonBacklog大小的变更仅仅是配置的变更,不会清除数据。
对于ReplicationBuffer的裁剪和释放
ReplicationBuffer 不可能无限增长,Redis 会有相应的逻辑对其进行裁剪,简单来说,Redis 会从头访问 replBufBlock 链表,如果发现 replBufBlock refcount 为 0,则会释放它,直到迭代到第一个 replBufBlock refcount 不为 0 才停止。所以想要释放 ReplicationBuffer,只需要减少相应 ReplBufBlock 的 refcount,有以下主要会减少 refcount 的情况:
- 当从库使用完当前的 replBufBlock 会对其 refcount 减 1
- 当从库断开链接时会对正在引用的 replBufBlock refcount 减 1,无论是因为超过 client-output-buffer-limit 导致的断开还是网络原因导致的断开
- 当 ReplicationBacklog 引用的 replBufBlock 数据量超过设置的该值大小时,会对正在引用的 replBufBlock refcount 减 1,以尝试释放内存
Ps:一般而言,设置的从库 client-output-buffer-limit 会远大于 ReplicationBacklog 大小,所以如果有一个慢从库节点,则其引用的 ReplicationBuffer 将大于 ReplicationBacklog,但是为了尽可能的进行部分重同步(PSYNC),这种情况下 ReplicationBacklog 不会尝试释放 replBufBlock,从而隐式地将 ReplicationBacklog 变大,以便尽可能支持部分重同步。
ReplicationBuffer 的释放也需要注意,因为如果一个从库引用的 replBufBlock 过多,它断开时释放的 replBufBlock 可能很多,这会造成堵塞问题,所以我们会限制一次释放的个数,未及时释放的内存在 ServerCron 中渐进式释放。
ReplicatonBacklog 使用 ReplicationBuffer 的问题和解决方案
当从库尝试与主库进行增量重同步时,会发送自己的 repl_offset, 若从库可满足增量重同步条件,则主库会从 ReplicationBacklog 中拷贝从库缺失的数据到从库 OutputBuffer,虽然现在的拷贝变成了仅仅是对特定 replBufBlock 引用计数的改变,在每个 replBufBlock 中记录了该其第一个字节对应的 repl_offset,但如何高效地从数万个 replBufBlock 的链表中找到特定的那个, 仍然一件棘手的事情。
直接从头到位遍历链表查找对应的 replBufBlock 必然会耗费较多时间而堵塞服务。起初,使用了一个链表用于索引固定区间间隔的 replBufBlock,每 1000 个 replBufBlock 记录一个索引信息,当查找 repl_offset 时,会先从索引链表中查起,然后再查找 replBufBlock 链表,类似于跳表的查找实现,如上图所示。但这个方案在极端场景下可能会查找超过千次,有 10 毫秒以上的延迟。最终使用 rax 树实现了对 replBufBlock 固定区间间隔的索引,每 64 个记录一个索引点。一方面,rax 索引占用的内存较少;另一方面,查询效率也是非常高,理论上查找比较次数不会超过 100,耗时在 1 毫秒以内。
以上,所有主从原理的内容基本上都讲完了,欢迎大家阅读指正。本来想同时把哨兵和集群也放到里面,发现内容太多,还是要放慢脚步,在学习总结方面,没有捷径可走,慢即使快。
哨兵和集群的原理机制将在下一篇进行说明。
参考
极客时间-Redis核心技术与实战
blog.devgenius.io/an-in-depth…
公众号-Redis开发运维实战
转载自:https://juejin.cn/post/7124955350804987940