Redis的主從數據一致性
我們學習了 AOF 和 RDB,如果 Redis 發生了宕機,它們可以分別通過回放日誌和重新讀入 RDB 文件的方式恢複數據,從而保證盡量少丟失數據,提升可靠性。不過,即使用了這兩種方法,也依然存在服務不可用的問題。比如說,我們在實際使用時只運行了一個 Redis 實例,那麼,如果這個實例宕機了,它在恢復期間,是無法服務新來的數據存取請求的。
我們知道Redis 具有高可靠性,又是什麼意思呢?其實,這裡有兩層含義:一是數據盡量少丟失,二是服務盡量少中斷。AOF 和 RDB 保證了前者,而對於後者,Redis 的做法就是增加副本冗餘量,將一份數據同時保存在多個實例上。即使有一個實例出現了故障,需要過一段時間才能恢復,其他實例也可以對外提供服務,不會影響業務使用。
實際上,Redis 提供了主從庫模式,以保證數據副本的一致,主從庫之間採用的是讀寫分離的方式。
讀操作:主庫、從庫都可以接收;
寫操作:首先到主庫執行,然後,主庫將寫操作同步給從庫。

為什麼要採用讀寫分離的方式呢?
如果在上圖中,不管是主庫還是從庫,都能接收客戶端的寫操作,那麼,一個直接的問題就是:如果客戶端對同一個數據(例如 k1)前後修改了三次,每一次的修改請求都發送到不同的實例上,在不同的實例上執行,那麼,這個數據在這三個實例上的副本就不一致了(分別是 v1、v2 和 v3)。在讀取這個數據的時候,就可能讀取到舊的值。
如果我們非要保持這個數據在三個實例上一致,就要涉及到加鎖、實例間協商是否完成修改等一系列操作,但這會帶來巨額的開銷,當然是不太能接受的。而主從庫模式一旦採用了讀寫分離,所有數據的修改只會在主庫上進行,不用協調三個實例。主庫有了最新的數據後,會同步給從庫,這樣,主從庫的數據就是一致的。
那麼,主從庫同步是如何完成的呢?主庫數據是一次性傳給從庫,還是分批同步?要是主從庫間的網路斷連了,數據還能保持一致嗎?
我們先來看看主從庫間的第一次同步是如何進行的,這也是 Redis 實例建立主從庫模式後的規定動作。
主從庫間如何進行第一次同步?
當我們啟動多個 Redis 實例的時候,它們相互之間就可以通過 replicaof(Redis 5.0 之前使用 slaveof)命令形成主庫和從庫的關係,之後會按照三個階段完成數據的第一次同步。
例如,現在有實例 1(ip:172.16.19.3)和實例 2(ip:172.16.19.5),我們在實例 2 上執行以下這個命令後,實例 2 就變成了實例 1 的從庫,並從實例 1 上複製數據:
replicaof 172.16.19.3 6379
第一階段是主從庫間建立連接、協商同步的過程,主要是為全量複製做準備。在這一步,從庫和主庫建立起連接,並告訴主庫即將進行同步,主庫確認回復後,主從庫間就可以開始同步了。具體來說,從庫給主庫發送 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 中的修改操作發給從庫,從庫再重新執行這些操作。這樣一來,主從庫就實現同步了。
主從級聯模式分擔全量複製時的主庫壓力
通過分析主從庫間第一次數據同步的過程,你可以看到,一次全量複製中,對於主庫來說,需要完成兩個耗時的操作:生成 RDB 文件和傳輸 RDB 文件。
如果從庫數量很多,而且都要和主庫進行全量複製的話,就會導致主庫忙於 fork 子進程生成 RDB 文件,進行數據全量同步。fork 這個操作會阻塞主執行緒處理正常請求,從而導致主庫響應應用程式的請求速度變慢。此外,傳輸 RDB 文件也會佔用主庫的網路頻寬,同樣會給主庫的資源使用帶來壓力。那麼,有沒有好的解決方法可以分擔主庫壓力呢?
其實是有的,這就是「主 - 從 - 從」模式。
主從庫模式中,所有的從庫都是和主庫連接,所有的全量複製也都是和主庫進行的。現在,我們可以通過「主 - 從 - 從」模式將主庫生成 RDB 和傳輸 RDB 的壓力,以級聯的方式分散到從庫上。
簡單來說,我們在部署主從集群的時候,可以手動選擇一個從庫(比如選擇記憶體資源配置較高的從庫),用於級聯其他的從庫。然後,我們可以再選擇一些從庫(例如三分之一的從庫),在這些從庫上執行如下命令,讓它們和剛才所選的從庫,建立起主從關係。
replicaof 所選從庫的IP 6379
這樣一來,這些從庫就會知道,在進行同步時,不用再和主庫進行交互了,只要和級聯的從庫進行寫操作同步就行了,這就可以減輕主庫上的壓力,如下圖所示:

到這裡,我們了解了主從庫間通過全量複製實現數據同步的過程,以及通過「主 - 從 - 從」模式分擔主庫壓力的方式。那麼,一旦主從庫完成了全量複製,它們之間就會一直維護一個網路連接,主庫會通過這個連接將後續陸續收到的命令操作再同步給從庫,這個過程也稱為基於長連接的命令傳播,可以避免頻繁建立連接的開銷。
聽上去好像很簡單,但不可忽視的是,這個過程中存在著風險點,最常見的就是網路斷連或阻塞。如果網路斷連,主從庫之間就無法進行命令傳播了,從庫的數據自然也就沒辦法和主庫保持一致了,客戶端就可能從從庫讀到舊數據。
主從庫間網路斷了怎麼辦?
在 Redis 2.8 之前,如果主從庫在命令傳播時出現了網路閃斷,那麼,從庫就會和主庫重新進行一次全量複製,開銷非常大。
從 Redis 2.8 開始,網路斷了之後,主從庫會採用增量複製的方式繼續同步。聽名字大概就可以猜到它和全量複製的不同:全量複製是同步所有數據,而增量複製只會把主從庫網路斷連期間主庫收到的命令,同步給從庫。
那麼,增量複製時,主從庫之間具體是怎麼保持同步的呢?這裡的奧妙就在於 repl_backlog_buffer 這個緩衝區。我們先來看下它是如何用於增量命令的同步的。
當主從庫斷連後,主庫會把斷連期間收到的寫操作命令,這時候 replication buffer不存在的,會把這些操作命令也寫入 repl_backlog_buffer 這個緩衝區。
這裡解釋一下repl_backlog_buffer和replication buffer概念:
1、repl_backlog_buffer:就是上面我解釋到的,它是為了從庫斷開之後,如何找到主從差異數據而設計的環形緩衝區,從而避免全量同步帶來的性能開銷。如果從庫斷開時間太久,repl_backlog_buffer環形緩衝區被主庫的寫命令覆蓋了,那麼從庫連上主庫後只能乖乖地進行一次全量同步,所以repl_backlog_buffer配置盡量大一些,可以降低主從斷開後全量同步的概率。而在repl_backlog_buffer中找主從差異的數據後,如何發給從庫呢?這就用到了replication buffer。
2、replication buffer:Redis和客戶端通訊也好,和從庫通訊也好,Redis都需要給分配一個 記憶體buffer進行數據交互,客戶端是一個client,從庫也是一個client,我們每個client連上Redis後,Redis都會分配一個client buffer,所有數據交互都是通過這個buffer進行的:Redis先把數據寫到這個buffer中,然後再把buffer中的數據發到client socket中再通過網路發送出去,這樣就完成了數據交互。所以主從在增量同步時,從庫作為一個client,也會分配一個buffer,只不過這個buffer專門用來傳播用戶的寫命令到從庫,保證主從數據一致,我們通常把它叫做replication buffer。
repl_backlog_buffer 是一個環形緩衝區,主庫會記錄自己寫到的位置,從庫則會記錄自己已經讀到的位置。
剛開始的時候,主庫和從庫的寫讀位置在一起,這算是它們的起始位置。隨著主庫不斷接收新的寫操作,它在緩衝區中的寫位置會逐步偏離起始位置,我們通常用偏移量來衡量這個偏移距離的大小,對主庫來說,對應的偏移量就是 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_backlog_buffer 是一個環形緩衝區,所以在緩衝區寫滿後,主庫會繼續寫入,此時,就會覆蓋掉之前寫入的操作。如果從庫的讀取速度比較慢,就有可能導致從庫還未讀取的操作被主庫新寫的操作覆蓋了,這會導致主從庫間的數據不一致。
因此,我們要想辦法避免這一情況,一般而言,我們可以調整 repl_backlog_size 這個參數。這個參數和所需的緩衝空間大小有關。緩衝空間的計算公式是:緩衝空間大小 = 主庫寫入命令速度 * 操作大小 - 主從庫間網路傳輸命令速度 * 操作大小。在實際應用中,考慮到可能存在一些突發的請求壓力,我們通常需要把這個緩衝空間擴大一倍,即 repl_backlog_size = 緩衝空間大小 * 2,這也就是 repl_backlog_size 的最終值。
舉個例子,如果主庫每秒寫入 2000 個操作,每個操作的大小為 2KB,網路每秒能傳輸 1000 個操作,那麼,有 1000 個操作需要緩衝起來,這就至少需要 2MB 的緩衝空間。否則,新寫的命令就會覆蓋掉舊操作了。為了應對可能的突發壓力,我們最終把 repl_backlog_size 設為 4MB。
這樣一來,增量複製時主從庫的數據不一致風險就降低了。不過,如果並發請求量非常大,連兩倍的緩衝空間都存不下新操作請求的話,此時,主從庫數據仍然可能不一致。
針對這種情況,一方面,你可以根據 Redis 所在伺服器的記憶體資源再適當增加 repl_backlog_size 值,比如說設置成緩衝空間大小的 4 倍,另一方面,你可以考慮使用切片集群來分擔單個主庫的請求壓力。
拓展問題:
AOF 記錄的操作命令更全,相比於 RDB 丟失的數據更少。那麼,為什麼主從庫間的複製不使用 AOF 呢?
1、RDB文件內容是經過壓縮的二進位數據(不同數據類型數據做了針對性優化),文件很小。而AOF文件記錄的是每一次寫操作的命令,寫操作越多文件會變得很大,其中還包括很多對同一個key的多次冗餘操作。在主從全量數據同步時,傳輸RDB文件可以盡量降低對主庫機器網路頻寬的消耗,從庫在載入RDB文件時,一是文件小,讀取整個文件的速度會很快,二是因為RDB文件存儲的都是二進位數據,從庫直接按照RDB協議解析還原數據即可,速度會非常快,而AOF需要依次重放每個寫命令,這個過程會經歷冗長的處理邏輯,恢復速度相比RDB會慢得多,所以使用RDB進行主從全量同步的成本最低。
2、假設要使用AOF做全量同步,意味著必須打開AOF功能,打開AOF就要選擇文件刷盤的策略,選擇不當會嚴重影響Redis性能。而RDB只有在需要定時備份和主從全量同步數據時才會觸發生成一次快照。而在很多丟失數據不敏感的業務場景,其實是不需要開啟AOF的。

