Redis 主從複製的原理及演進

語言: CN / TW / HK

本文作者:百度基礎架構部工程師,王鈺

Redis 的主從複製經歷了多次演進,本文將從最基本的原理和實現講起,並層層遞進,逐步呈現 Redis 主從複製的演進歷史。大家將瞭解到 Redis 主從複製的原理,以及各個改進版本解決了什麼問題,並最終看清 Redis 7.0 主從複製原理的全貌。

什麼是主從複製?

在數據庫語境下,複製(replication)就是將數據從一個數據庫複製到另一個數據庫中。主從複製,是將數據庫分為主節點和從節點,主節點源源不斷地將數據複製給從節點,保證主從節點中存有相同的數據。

有了主從複製,數據可以有多份副本,這帶來了多種好處:

第一,提升數據庫系統的請求處理能力。單個節點能夠支撐的讀流量有限,部署多個節點,並構成主從關係,用主從複製保持主從節點數據一致,如此主從節點可以一起提供服務。

第二,提升整個系統的可用性。因為從節點中有主節點數據的副本,當主節點宕機後,可以立刻提升其中一個從節點為主節點,繼續提供服務。

1.png

Redis 主從複製原理

實現主從複製,直觀的思路是產生一份主節點數據的快照發送給從節點,並以此做為基準,隨後將快照時刻之後的增量數據發送給從節點,如此就能保證主從數據的一致。總體來看,主從複製一般包含全量數據同步、增量同步兩個階段。

在 Redis 的主從複製實現中,包含兩個類似階段:全量數據同步和命令傳播。

  • 全量數據同步:主節點產生一份全量數據的快照,即 RDB 文件,並將此快照發送給從節點。且從產生快照時刻起,記錄新接收到的寫命令。當快照發送完成後,將累積的寫命令發送給從節點,從節點執行這些寫命令。此時基準已經建立完成,主從節點間數據已經大體一致。
  • 命令傳播:全量數據同步完成後,主節點將執行過的寫命令源源不斷地發送給從節點,從節點執行這些命令,保證主從節點中數據有相同的變更,如此保證主從數據持續一致。

下圖中給出了 Redis 主從複製的整個過程:

2.png 1. 主從關係建立後,從節點向主節點發送一個 SYNC 命令請求進行主從同步。 1. 主節點收到 SYNC 命令後,執行 fork 創建一個子進程,子進程中將所有的數據按特定編碼存儲到 RDB(Redis Database) 文件中,這就產生了數據庫的快照。 1. 主節點將此快照發送給從節點,從節點接收並載入快照。 1. 主節點接着將生成快照、發送快照期間積壓的寫命令發送給從節點,從節點接收這些命令並執行,命令執行後,從節點中的數據也就有了同樣的變更。 1. 此後,主節點源源不斷地新執行的寫命令同步到從節點,從節點執行傳播來的命令。如此,主從數據保持一致。需要説明的是,命令傳播存在時延的,所以任意時刻,不能保證主從節點間數據完全一致。

以上就是 Redis 主從複製的基本原理,很簡單很容易理解,Redis 最初就採用這種方案,但這種方案存在一些問題:fork 耗時過長,阻塞主進程執行fork 時,需要拷貝大量的內存頁表,這是一個耗時較多的操作,尤其當內存使用量較大的時候。組內同學曾做過測試,內存佔用 10GB 時,fork 需要消耗 100 多毫秒。fork 的時候主進程阻塞 100 多毫秒,這對 Redis 而言,實在太長了。另外fork 之後,如果主庫中有不少的寫入,那麼由於寫時複製機制,會額外消耗不少的內存,還會增大響應時間。主從間網絡閃斷會觸發全量同步假如主從之間的網絡出現了故障,連接意外斷開,主節點無法繼續傳播命令至該從節點。之後網絡恢復,從節點重新連接上主節點後,主節點不能再繼續傳播新接收到的命令了,因為從節點已經漏掉了一些命令。此時,從節點需要從頭再來,再次執行全部的同步過程,而這要付出很高的代價。網絡閃斷是常發生的事情,閃斷期間主節點中可能只寫入了比較少的數據,但就因為這很少的一部分數據,需要讓從節點進行一次代價高昂的全量同步。這種做法是非常低效的,該如何解決這問題呢?下一節 Redis 部分重同步給你答案。

Redis 部分重同步

網絡短暫斷開後,從節點需要重新同步,這很浪費資源,很不環保。從節點為什麼需要重新同步呢?因為主從斷開期間有部分命令沒有同步到從節點上去。如果忽略這些命令繼續傳播後續的命令,則會導致數據的錯亂,因為丟失掉的命令是不能忽略的。為什麼不將那些命令保存下來呢?這樣當從節點重新連接後,就可以將斷連期間的命令補充給它了,這樣就不需要重新全量同步了。Redis 2.8 版本後,引入了部分同步。它在主節點中維護了一個複製積壓緩衝區,命令一方面會傳播到從節點,另外還會記錄在這個緩衝區中。保存所有的命令是不必要的,Redis 中使用了一個環形的緩衝區,這樣就可以只保留最近的一些命令了。

3.png

命令是保存下來了,但從節點重新連接後,主節點該從什麼地方開始給從節點發送命令呢?如果能給所有命令編一個號,則從節點只需要告訴主節點自己最後收到的命令的編號,主節點就知道該從什麼位置發送命令了。Redis 的實現中是對字節進行編號,這個編號在 Redis 的語境中叫做複製偏移量。 有了部分同步後,主從複製的流程變成了下面這樣:

4.png

主從複製的時候不再使用SYNC命令,而是使用PSYNC,意思的Partial SYNC,部分同步。PSYNC的語法如下:

`PSYNC <master id> <replication offset>`

命令中的兩個參數,一個是主節點的編號,一個是複製偏移量。每個Redis節點都有一個 40 字節的編號,PSYNC 命令中攜帶的編號是期望進行同步的主節點的編號。複製偏移量則表示當前從節點想要從什麼地方開始部分同步。如果是第一次進行主從複製,自然是不知道主節點的編號,複製偏移量也無意義,此時使用 PSYNC ? -1 來進行全量同步。另外,如果從節點指定的複製偏移量不在主節點的複製積壓緩衝區的範圍內,部分同步會失敗,會轉向全量同步。有了部分同步,網絡閃斷後就可以避免全量同步了。但是因為主節點只能保留最近的部分命令,保存多少取決於複製積壓緩衝區的大小。如果從節點斷開時間過長,或者斷開期間主節點新執行的寫命令足夠多,漏掉的命令就無法全部保存到複製積壓緩衝區中了。加大複製積壓緩衝區可以儘可能多地避免全量同步,但這同時會造成額外的內存消耗。部分同步消耗了部分內存來保存最近執行的寫命令,避免閃斷後的全同步,這是很直觀、很容易想象的解決方案。這種方案很好,是否還存在其他問題呢?考慮以下問題:假如從節點重啟了怎麼辦?部分同步依賴主節點的編號和複製偏移量,從節點在初次同步的時候會獲取到主節點的編號,並在之後的同步中不斷調整複製偏移量,這些信息都存儲在內存中。當從節點意外重啟後,儘管本地存有 RDB 或 AOF 文件,還是需要進行一次全量同步。但實際上完全可以載入本地數據,並執行部分同步即可。假如主從切換了怎麼辦?假如主節點意外宕機,外圍監控組件執行了主從切換。此時其他從節點對應的主節點就變化了,從節點中記錄的主節點編號就匹配不上新的主節點了,此時會進行一次全量同步。但實際上所有的從節點在主從切換之前同步進度應該是差不多的,而且新提升的從節點包含的數據應該最全,切主後所有從節點都執行一次全量同步,這實在不合理。

5.png 以上問題如何解決,請繼續往後看。

同源增量同步 從節點重啟後丟失了原主節點編號和複製偏移量,這導致重啟後需要全量同步,這很好辦,把這些信息存下來就可以了。主從切換後,主節點信息變化了,導致從節點需要全量同步,這也容易解決,只需能確認新主節點上的數據是從原主節點複製來的,那就可以繼續從新的主節點上進行復制。Redis 4.0 以後,對 PSYNC 進行了改進,提出了同源增量複製的解決方案,該方案解決了前面提到的兩個問題。從節點重啟後,需要跟主節點全量同步,這本質上是因為從節點丟失了主節點的編號信息,在 Redis 4.0 後,主節點的編號信息被寫入到 RDB 中持久化保存。切主後,從節點需要和新主節點全量同步,本質原因是新的主節點不認原主節點的編號。從節點發送 PSYNC <原主節點編號> <複製偏移量> 給新的主節點,如果新的主節點能夠認識 <原主節點編號>,並明白自己的數據就是從該節點複製來的。那麼新的主節點就應該清楚它和該從節點師出同門,應該接受部分同步。如何才能識別呢,只需要讓從節點在切換為主節點時,將自己之前的主節點的編號記錄下來即可。Redis 4.0 以後,主從切換後,新的主節點會將先前的主節點記錄下來,觀察 info replication 的結果,可以可以看到 master_replid 和 master_replid2 兩個編號,前者是當前主節點的編號,後者為先前主節點的編號:

```` 127.0.0.1:6379> slaveof no one OK 127.0.0.1:6379> info replication

Replication

role:master ... master_replid:b34aff08d983991b3feb4567a2ac0308984a892a master_replid2:a3f2428d31e096a99d87affa6cc787cceb6128a2 master_repl_offset:38599 second_repl_offset:38600 ... repl_backlog_histlen:5180` ```

Redis 中目前值保留了兩個主節點編號,但完全可以實現一個鏈表,將過往的主節點的編號信息都記錄下來,這樣就可以追溯的更遠了。這樣以來,如果一個從節點斷開後,執行了多次主從切換,該從節重新連接後,依然可以識別出它們的數據是同源的。但 Redis 沒有這麼做,這是因為沒有必要,因為就算數據是同源的,但複製積壓緩衝區中保存的數據是有限的,多次主從切換後,複製積壓緩衝區中保存的命令已經無法滿足部分同步了。有了同源增量複製後,主節點切換後,其他從節點可以基於新的主節點繼續增量同步。此時,主從複製看起來已經不存在太大的問題了。但做 Redis 的那幫傢伙,總挖空心思想着能不能再做些優化。下面我將描述 Redis 主從複製的一些優化策略。\ 無盤全量同步和無盤加載\ Redis 執行全量複製,需要生成當前數據庫的一份快照,具體做法是執行 fork 創建子進程,子進程遍歷所有數據並編碼後寫入 RDB 文件中。RDB 生成後,在主進程中,會讀取此文件併發送給從節點。讀寫磁盤上的 RDB 文件是比較耗資源的,在主進程中執行勢必會導致 Redis 的響應時間變長。因此一個優化方案是 dump 後直接將數據直接發送數據給從節點,不需要將數據先寫入到 RDB 。Redis 6.0 中實現了這種無盤全量同步和無盤加載的策略。採用無盤全量同步,避免了對磁盤的操作,但也有缺點。一般情況下,在子進程中直接使用網絡發送數據,這比在子進程中生成 RDB 要慢,這意味着子進程需要存活的時間相對較長。子進程存在的時間越長,寫時複製造成的影響就越大,進而導致消耗的內存會更多。在全量複製時候,從節點一般是先接收 RDB 將其存在本地,接收完成後再載入 RDB。同樣地,從節點也可以直接載入主節點發來的數據,避免將其存入本地的 RDB 文件中,而後再從磁盤加載。\ 共享主從複製緩衝區\ 在主節點的視角中,從節點就是一個客户端,從節點發送了 PSYNC 命令後,主節點就要與它們完成全量同步,並不斷地把寫命令同步給從節點。Redis 的每個客户端連接上存在一個發送緩衝區。主節點執行了寫命令後,就會將命令內容寫入到各個連接的發送緩衝區中。發送緩衝區存儲的是待傳播的命令,這意味着多個發送緩衝區中的內容其實是相同的。而且,這些命令還在複製積壓緩衝區中存了一份呢。這就造成了大量的內存浪費,尤其是存在很多從節點的時候。

6.png 在 Redis 7.0 中,我們團隊的同學提出並實現了共享主從複製緩衝區的方案解決了這個問題。該方案讓發送緩衝區與複製積壓緩衝區共享,避免了數據的重複,可有效節省內存。\

總結

本文回顧並總結了 Redis 主從複製的演化過程,並解釋了各次演化所解決的問題。最後,描述了對 Redis 主從複製進行優化的一些策略。

下面是對全文的總結:

  1. 宏觀來看 Redis 的主從複製分為全量同步和命令傳播兩個階段。主節點先發送快照給從節點,然後源源不斷地將命令傳播給從節點,以此保證主從數據的一致。
  2. Redis 2.8 之前的主從複製存在閃斷後需要重新全量同步的問題,Redis 2.8 引入了複製積壓緩衝區解決了這一問題。
  3. 在 Redis 4.0 中,同源增量複製的策略被提出,解決了主從切換後從節點需要全量同步的問題。至此,Redis 的主從複製整體上已經比較完善了。
  4. Redis 6.0 中,為進一步優化主從複製的性能,無盤同步和加載被提出,避免全量同步時讀寫磁盤,提高主從同步的速度。
  5. 在 Redis 7.0 rc1 中,採用了共享主從複製緩衝區的策略,降低了主從複製帶來的內存開銷。

希望本文能幫助大家回顧 Redis 主從複製的原理,並對其建立更加深刻的印象。