|
|
& j, y5 Q0 {$ X+ r; I5 U4 w
<p>上一节提到了 Redis 的持久性,也就是在服务器实例宕机或故障时,拥有再恢复的能力。但是在这个服务器实例宕机恢复期间,是无法接受新的数据请求。对于整体服务而言这是无法容忍的,因此我们可以使用多个服务器实例,在一个实例宕机中断时,另外的服务器实例可以继续对外提供服务,从而不中断业务。Redis 是如何做的呢?Redis 做法是<strong>增加冗余副本</strong>,<strong>将一份数据同时保存在多个实例</strong>上。那么如何保存各个实例之间的数据一致性呢?Redis 采用<strong>主从库读写分离模式</strong>来保证数据副本的一致性。</p>2 P4 z Q" h: Z+ H, ]- C3 W- \
<h2 id="一主从复制介绍">一、主从复制介绍</h2>9 ~" P. e- B2 u/ _
<p>在 Redis 中提供复制的服务器称为主服务器(master),被主服务器进行复制的服务器称为从服务器(slave )。但是在复制方式上有所区别,主从库之间采用的是读写分离的方式:</p>$ y0 `/ } d9 o$ w" x
<ul>& t J7 _; Y3 C, x* \/ X
<li><strong>读操作</strong>:主库和从库都可接收</li>
& A1 J7 |5 o7 @0 m) Y<li><strong>写操作</strong>:写操作先在主库中执行,然后再将操作同步给从库</li>
8 v; O9 y; r' V' A. f& h8 x</ul>. Z c, f$ A1 i1 m, i
<p><img src="https://img2022.cnblogs.com/blog/1707576/202202/1707576-20220211213252722-390582213.jpg" ></p>
: ], c9 I/ Z2 Y6 U q<p>为何要采用读写分离,因为如果在不同实例上执行修改操作,要保证实例之间的一致性就必须加锁、实例间的协商等操作,会带来巨额的开销。如果采用读写分离,数据的修改迁移到主库上进行,然后再同步到从库上,就可以达到不使用锁达到数据一致性的效果。</p>- A& g3 ]- K% H
<h2 id="二主从复制原理">二、主从复制原理</h2>- D1 m# |: \5 r7 f; }
<p>主从复制是如何进行复制的,是一次性全部复制,还是分批一批一批的复制?而且如果复制中网络中断,数据还能保持一致性吗,其内部原理是怎样的?Redis 的复制功能主要有两个操作:</p>
+ g4 r; C, f* ]2 M7 L' m<ul>
, m# o) n9 V+ q v7 L- F6 c2 P2 Q0 t<li>/ U( h; H& Y: A8 J7 [+ u( F- I ^8 Z
<p><strong>同步(sync)</strong>:同步操作是将从库的数据状态更新至主库当前所处的状态,主要有全量复制和增量复制两种</p>) \) h5 j% ]' J7 \
</li>; d b& H. C) B8 T; N
<li>
& }& F( K9 T8 d/ m5 c! v<p><strong>命令传播(command propagate)</strong>:命令传播是作用在主库的数据库状态被修改后,主从库之间的数据库状态出现不一致,让主从服务器的数据库状态重新回到一致性。</p>
! o" m% {0 Y" Q: J</li>8 B' r5 e1 g0 E; V( S
</ul>
k ]7 `8 E$ _5 R& d6 j( ~<h3 id="21-同步操作">2.1 同步操作</h3>3 n1 G& u) ^) p- G& w b, f0 w
<h4 id="211-全量复制">2.1.1 全量复制</h4>
# A* k0 [, z+ v4 \<p>在同步操作中,我们首先来看看全量复制,顾名思义也就是主库将所有的数据传送给从库。因为主从库初始化需要传输全部数据,所以第一次同步其实就是一次全量复制。当启动多个 Redis 实例时,主从库间就可以通过 <code>replicaof</code>(Redis 5.0 前使用的是 <code>slaveof</code>)命令形成主从库的关系,在这期间从库就会全量复制当前主库的数据库状态。主要分为三个阶段:</p>- w( n2 N2 u6 y8 o4 L0 N
<ol>
+ |! s0 `2 y( ~<li>& o, I: I3 i! `! o& w' S
<p><strong>主从库建立连接</strong>:主要是为全量复制做准备,在这一步,从库和主库建立起连接,告知主库进行数据同步,待主库确认回复后,主从库之间可以开始同步。主要的操作是从库给主库发送 <code>psync</code> 命令,主库根据这个命令参数来启动复制。而 <code>psync</code> 命令包含了主库的 <code>runID</code> 和 复制进度 <code>offset</code>两个参数:</p>, N$ t6 m$ ]5 s3 q$ x; \( b4 w
<ul>) W2 A1 r7 e, S3 ?# V- D& [$ H
<li><strong><code>runID</code></strong>: 是每个 Redis 实例启动时随机生成的 ID,它是用来唯一标记这个实例。而当主从库第一次复制时,从库不知道主库的 <code>runID</code> 。因此将 <code>runID</code> 设置为 “?”。</li>
+ @; v) U8 @& E- m<li><strong><code>offset</code></strong>:第一次设置为 -1,表示第一次复制</li>
9 Y2 s& k6 ~+ ~5 h* Y; j! i' n</ul>
1 k+ X& s- q; F: S" i! \<p>主库在接收到 psync 命令后,会利用 FULLRESYNC 响应命令带上两个参数:主库 <code>runID</code>和主库目前的复制进度 <code>offset</code> 来返回给从库。而从库在收到响应后会记录下主库传递的这两个参数。</p>8 d) h9 ^/ Z' B, Z4 I
</li>5 _- g+ ?2 w; R+ u- p/ M" H
<li>6 n# m! y$ @& N+ @) V8 w' S
<p><strong>主库将所有数据同步给从库</strong>:数据同步给从库后,从库接收到数据后,在本地完成数据加载。这个过程主要依赖于内存快照生成的 RDB 文件。</p>! k u6 i5 y' ~
<p>内部具体的流程是,主库先执行 <code>bgsave</code> 命令,执行持久化生成 RDB 文件,并将该文件发送给从库。从库在接收到 RDB 文件后,会先清空当前数据库,然后再加载 RDB 文件。</p>+ u) d6 T% O* [8 x
<p>在主库同步从库过程中,我们知道 <code>bgsave</code>命令系统会 fork 一个子线程来创建 RDB 文件,另外的主线程可以继续处理命令请求。而在同步过程中的新增的写操作,主库会在内存中用专门的 <code>replication buffer</code> 来记录这些写操作。</p>" ^( a& k# m/ F* O( K) M( _* A
</li>
+ h( N' r7 x& u$ h8 n% X1 a, W/ |" p; ]<li>
0 z! S+ L$ J+ k% r- Q<p><strong>主库将新接收的写操作再发送给从库</strong>:当主库完成 RDB 文件发送后,就会将 <code>replication buffer</code> 中的写操作发送给从库,从库再执行这些写操作。到此完成所有的操作数据同步。</p>1 j) `, H( c! U! K
</li>/ p7 h9 B1 V8 q; W3 u0 z
</ol>+ `* R4 R- F! x4 k8 ]( U
<p>结合一个实例来展示三个阶段:</p>% f+ u. c/ h T( P- k& s
<p>现有两个实例,实例1(ip: 172.16.19.3)和实例 2(ip: 172.16.19.5)。在从实例2上执行以下命令后,实例2 就变成了实例 1 的从实例。执行以下命令后,实例2 就变成了实例1 的从库,并开始复制数据:</p>4 k3 O) {3 T# c) N
<pre><code class="language-sh">replicaof 172.16.19.3 6379
$ f2 a/ Y4 \; u R</code></pre>; a% f, m. g' K7 ]2 L
<p><img src="https://img2022.cnblogs.com/blog/1707576/202202/1707576-20220211213311291-1707870632.jpg" ></p>
?4 k/ Z5 H6 R% @<h4 id="212-主从从模式复制">2.1.2 主从从模式复制</h4>- I6 u7 T; T9 x
<p>在一次全量复制中,对于主库而言需要完成两个消耗资源的操作:发送 RDB 文件和 <code>repl buffer</code> 文件。所以如果请求的从库数量较多时,虽然主库会 fork 子线程进行生成 RDB 文件,但是从库请求数量过多,也会导致主线程 fork 操作过多,最终也会阻塞主线程的其他正常请求。所以,为了解决这个问题,我们可以<strong>通过 “主-从-从”模式来将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上</strong>。如下图:</p>
2 h9 N' c' g7 v9 ^$ i3 B2 `# B, e<p><img src="https://img2022.cnblogs.com/blog/1707576/202202/1707576-20220211213330954-674647319.jpg" ></p>+ m# Q" I( o ~+ Y; `
<p>具体操作是: 在部署主从集群的时候,手动选择一个从库(比如选择内存资源配置较高的从库)用于级联其他的从库。然后可以再选择一些从库(如三分之一的从库),在这些从库上执行下面命令,让它们和刚才所选的从库建立起主从关系:</p>
; r6 O# L! {4 u A2 m<pre><code class="language-sh">replicaof 所选从库的IP 63795 a. M$ c2 P! V( w n3 n
</code></pre>3 ?9 |" B j8 N8 }) B& F. I
<p>这样,这些级联的从库不用和主库进行交互,而只需要和连接的从库进行写操作同步即可。这样就可以减轻主库的压力了。</p>" l- s2 J: [& H6 ~, @
<h4 id="213-增量复制">2.1.3 增量复制</h4>5 ~$ S7 I* @" O7 r! l
<p>在主从库完成第一次的全量复制后,它们会形成一个网络连接,主库会通过这个连接将后续收到的命令操作再同步给从库。这个过程也称作为<strong>基于长连接的命令传播</strong>。这个长连接可以避免频繁的建立连接开销,后续我们会再提命令传播。</p>
2 N: F" L0 j# L# }& \# O<p>那么为什么需要增量复制呢,因为连接过程中存在着<strong>网络连接和阻塞</strong>,如果网络连接中断,主从库之间就无法实现命令传播。那如果再次进行全量复制,其开销就有点得不偿失。所以新设计出了增量复制,而与全量复制不相同,增量复制只会把主从库网络断联期间主库收到的命令同步给从库。其中重点就是利用<code>repl_backlog_buffer</code> 缓冲区,上面我们知道,在全量复制时,主库会把写操作命令写入 <code>replication buffer</code> ,与此同时,也会把这些操作命令写入 <code>repl_backlog_buffer</code> 缓冲区中(<code>repl_backlog_buffer</code> 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置)。如下图:</p>$ l9 W0 ?# _1 [- h
<p><img src="https://img2022.cnblogs.com/blog/1707576/202202/1707576-20220211213421342-676422844.jpg" ></p>
1 I! x" U! Z( |<p>主库在缓冲区的写位置偏移量就是 <code>master_repl_offset</code>,从库的读位置偏移量是 <code>slave_repl_offset</code>。正常情况下两个偏移量基本相等。</p>
9 p7 j1 V& x1 I9 M S3 r<p>接下来我们看看网络连接断开时,主库有可能会收到新的写操作命令,一般而言,<code>master_repl_offset</code>会大于 <code>slave_repl_offset</code>。所以当主从库网络连接后,主库只需要将 <code>master_repl_offset</code> 和 <code>slave_repl_offset</code> 中间的命令操作同步给从库。</p>: v* g' q9 l/ H$ f, b7 S
<p>如上图中的 <code>repl_backlog_buffer</code>示意图,主库和从库之间相差了<code>put d e</code>和 <code>put d f</code> 两个操作,在增量复制时,主库只需要将它们同步给从库即可。</p>
# y/ b) o! {( C L' R' |( f<p>我们再来看一下增量复制的流程:</p>8 N6 |( k- `% L$ `1 W! j
<p><img src="https://img2022.cnblogs.com/blog/1707576/202202/1707576-20220211213439896-844044503.jpg" ></p>1 l: {5 U1 }& m
<p><code>repl_backlog_buffer</code> 是一个环形缓冲区,所以存在这样的情况:在缓冲区写满后,主库会继续写入,这样就会覆盖之前的写操作,那么这有可能就会导致主从库之间的数据不一致。此时,我们可以调整 <code>repl_backlog_size</code> 这个参数,实际情况下可以根据应用调整 <code>repl_backlog_size</code> 的大小。</p>
# {" F m, C3 D! V! z% ]6 J0 u<h3 id="22-命令传播">2.2 命令传播</h3>) H, P- s7 i9 S6 O: G, o
<p>在上述初始化全量复制结束后,主从库两者的数据库状态达到一致完成了同步,后面两者则处于长连接状态。此时主库只需要一直将自己执行的写命令发送给从库,从库一直接收并执行主库发来的写命令即可保证主从库的数据一致性了。这个时候主从库会互相成为对方的客户端。</p>
; {- ?% F8 Q: W G; J' l<h4 id="221-主库的命令传播">2.2.1 主库的命令传播</h4># a( {& Z! z5 ~+ B# H3 z _
<p>当完成了同步之后,主从库就会进入命令传播阶段,这时主库只要一直将自己执行的写命令发送给从库,而从库只要一 直接收并执行主库发来的写命令,就可以保证主从库保持数据操作一致性。</p>
/ {7 x& m; f: a; f) B<h4 id="222-从库的心跳检测">2.2.2 从库的心跳检测</h4>+ Z6 A5 x4 l1 m6 u4 b# n
<p>在该阶段,从库默认会以每秒一次的频率向主库发送命令:</p>0 ^% X' g! B$ L) A
<pre><code class="language-sh">REPLCONF ACK <replication_offset>
2 r1 G; s4 B1 g! d</code></pre>
$ m% L8 n' l- W$ H* w9 S2 x! x<ul>4 w1 h: S" b( Q4 x) l
<li>
6 `. Y1 s. X; x$ o# W<p>replication_offset 是从库当前的复制偏移量</p>
+ Q" G! X+ z- X1 u$ z</li>+ \8 l/ C$ l" [7 W, Y
<li>
" S6 e( B4 w9 u% d. w5 C. M# B<p>REPLCONF ACK 命令的作用有</p>
/ b5 _% E9 }5 s9 I8 D<ul>
9 C# e7 s8 `* P<li>2 V$ P, ]. B( z4 i
<p>检测主从库的网络连接状态:当主库超过一秒钟未收到从库发来的 REPLCONF ACK 命令,那么主库就会知道主从库之间连接出现问题。</p>/ }- z5 r7 a" B+ c" R
</li>2 y! K$ S( I3 C. M E/ t& X
<li>
8 ]$ S2 }+ G3 U A2 k<p>辅助实现 min-slaves 选项:Redis的<code>min-slaves-to-write</code>和<code>min-slaves-max-lag</code>两个选项可以防止主库在不安全的情况下执行写命令。</p>
8 z) }4 X5 P4 E<ul>
. D3 A! N! d7 F<li>" R) z$ R3 o9 r E4 ^9 J
<p>举个例子,如果我们向主库提供以下设置:</p>
5 A8 L% U z/ K; K6 z<pre><code class="language-sh">min-slaves-to-write 3
- y$ m+ @9 H4 @0 M5 @min-slaves-max-lag 100 q* X, T. G- j) W. X5 h: ~
</code></pre>
* }3 J" A, Q" K* U<p>那么在从库的数量少于3个,或者三个从库的延迟(lag) 值都大于或等于10秒时,主库将拒绝执行写命令,这里的延迟值就是上面提到的INFO replication命令的lag值。</p>
' U+ }/ ~7 h1 i3 H</li>
_# Y8 e2 i3 p6 {0 Y( M0 ~. |: m* p</ul># |/ @$ t! c+ p) N5 V- ~( ]
</li>
0 V4 ` S4 M1 F" U0 z5 s R<li>
6 ~. s* e( u, l: D) o9 o<p>检测命令丢失: 如果因为网络故障,主库传播给从库的写命令在半路丢 失,那么<strong>当从库向主库发送REPLCONF ACK命令时,主库将发觉从库当前的复制偏移量少于自己的复制偏移量</strong>,然后主库就会根据从库提交的复制偏移量,在复制积压缓冲区(<code>repl_backlog</code>)里面找到从库缺少的数据,并将这些数据重新发送给从库。</p>
$ ~) Z& L0 F9 v+ U. w2 R</li>
7 T8 h$ n. U7 z# N1 @5 o</ul>! r: r9 D) v# H) D' `. M2 P- `
</li>
7 ]. ^5 j+ W8 N9 i3 ], l</ul>
5 ?% ]$ g2 c- ^# ?<p>总结就是在传播命令阶段,主库通过向从库传播命令来更新从库的状态,保持主从库一致。而从库则通过向主库发送命令来进行心跳检测,以及命令丢失检测。</p>
4 `8 e/ X8 `8 ~3 F5 ^' @" `<h2 id="三主从复制的面试题">三、主从复制的面试题</h2>
& P1 y( {- h ^3 t% C, A, C<h3 id="31-aof-记录的操作命令更全相比-rdb-丢失的数据更少那么为什么主从库间的复制不使用-aof-">3.1 AOF 记录的操作命令更全,相比 RDB 丢失的数据更少,那么为什么主从库间的复制不使用 AOF ?</h3>
' ]/ ?. \& f: |- p7 V2 k( Y<ul>2 m0 ~5 {! F. q$ i& V! u
<li><strong>RDB 文件是以压缩二进制的方式存储,文件小</strong>,所以在从库加载 RDB 文件时,速度会很快。而 AOF 需要依次重放每个写命令,过程相对 RDB 文件的方式要慢的多。</li>) G3 I! w( {, N" s5 @- ^& L5 ~
<li>如果使用AOF 做全量复制,打开 AOF 功能需要选择文件刷盘的方式,选择不当会严重影响 Redis 的性能。而 RDB 持久化方式只需要定时备份和主从全量复制数据时才会触发生成一次快照。在大多数丢失数据不敏感的业务场景,其实是不需要开启 AOF 的。</li>
: _# l5 k# i _3 x8 \</ul>
& J& G$ g1 {$ q: _8 V* ~<h3 id="32-如何处理读写分离中的问题">3.2 如何处理读写分离中的问题</h3>
4 }3 q) U; N6 E% U& \- F) ^2 D<p>Redis 的读写分离可以实现 Redis 的读负载均衡,能够提高 Redis 服务器的并发量,但是在使用 Redis 读写分离时,也需要注意延迟不一致、数据过期问题。</p>$ l4 T; T) Y0 j) |1 Q7 y
<h4 id="321-延迟与不一致问题">3.2.1 延迟与不一致问题</h4>
/ g- N) x, B& s8 Q. @' F<p>对于命令传播阶段:因为命令传播阶段是异步操作,所以延迟与数据的不一致无法避免。有以下解决方式:</p>
1 d) o: Z9 u6 `9 A+ Y<ul>
0 k4 d3 X# n! a<li>若应用对数据不一致的接受程度较低,可以优化中从节点之间的网络环境、使用集群同时扩展写负载和读负载、监控主从节点延迟(offset)判断,若从节点延迟过大,则通知应用不再通过该从节点读取数据</li>
y( b- m4 u) R4 Y" J) [' z</ul>
% u9 O0 Y3 [% [<p>对于非命令传播的其他阶段,可以对 <code>slave-serve-stale-date</code> 设置为 no 。则从节点只能响应 info、slaveof 等少数命令,可以保证对数据的一致性。</p>8 z/ ~. h5 S/ L
<h4 id="322-数据过期问题">3.2.2 数据过期问题</h4>
. R: m# @; H9 u: J<p>数据过期问题已经在<a href="https://www.cnblogs.com/EthanWong/p/15866054.html"><em>Redis 的键管理</em></a> 中提到过,在单机 Redis 中存在惰性删除和定期删除两种删除策略。而在主从复制场景下,从库不会主动删除数据,主要通过主库控制从库中过期数据的删除。而主库的删除策略都不能保证主库及时对过期数据执行删除操作,所以当客户端通过 Redis 从库读取数据时很容易读取到已经过期的数据。</p>5 ]( F, [3 Q! t$ O( C, J
<h4 id="323-故障切换问题">3.2.3 故障切换问题</h4>9 s3 _$ k d/ \
<p>在没有使用哨兵的读写分离场景下,建议写监控程序进行切换读写分别连接的 Redis 节点。针对于手动进行切换的方式更复杂但是不容易出错。</p>6 K6 I# M' a! M. r- _7 Q
<h4 id="总结">总结</h4>
2 s4 Z5 p; j0 y' @, {<blockquote>
+ v7 f3 H' b, Q$ [' [<p>在使用读写分离前,可以考虑其他方法增加Redis的读负载能力:如尽量优化主节点(减少慢查询、减少持久化等其他情况带来的阻塞等)提高负载能力;使用Redis集群同时提高读负载能力和写负载能力等。如果使用读写分离,可以使用哨兵,使主从节点的故障切换尽可能自动化,并减少对应用程序的侵入。</p>
* ?/ }- |$ y3 M</blockquote>
; o9 u, i. \: B. E; F/ `4 A<h2 id="参考资料">参考资料</h2>' O& E* l5 ~0 c8 Q
<p>《Redis 设计与实现》</p>. R1 u, L8 X8 \, m" w
<p>《Redis 开发与运维》</p>9 H( t5 \& K& E2 F: Y
<p><a href="https://pdai.tech/md/db/nosql-redis/db-redis-x-copy.html">https://pdai.tech/md/db/nosql-redis/db-redis-x-copy.html</a></p>! N; i$ v' u9 z x
<p><a href="https://time.geekbang.org/column/article/272852">https://time.geekbang.org/column/article/272852</a></p>! r. _ W" h) g5 u, g
<p><a href="https://kaiwu.lagou.com/course/courseInfo.htm?courseId=59#/detail/pc?id=1782">https://kaiwu.lagou.com/course/courseInfo.htm?courseId=59#/detail/pc?id=1782</a></p>
( `& N# I0 U% \. h% u# \
4 _( `1 {2 k4 y) X: @/ K, ^% Y1 A6 m |
|