楔子
主從同步(主從復制)是 Redis 高可用服務的基石,也是多機運行中最基礎的一個。我們把主要存儲數據的節點叫做主節點(master),把其他通過復制主節點數據的副本節點叫做從節點(slave),如下圖所示:
在 Redis 中一個主節點可以擁有多個從節點,一個從節點也可以是其他從節點的主節點,如下圖所示:
而 Redis 為了保證數據副本的一致性,主從節點之間采用的是讀寫分離的方式。
讀操作:主節點、從節點都可以執行;
寫操作:首先在主節點執行,然后主節點再將寫操作同步給從節點,從節點再執行;
那么問題來了,為什么要采用讀寫分離的方式呢?
可以設想一下,如果不管是主節點還是從節點,都能接收客戶端的寫操作,那么一個直接的問題就是:如果客戶端對同一個數據(例如 k1)前后修改了三次,每一次的修改請求都發送到不同的實例上,在不同的實例上執行,那么這個數據在這三個實例上的副本就不一致了(分別是 v1、v2 和 v3)。在讀取這個數據的時候,就可能讀取到舊的值。
如果我們非要保持這個數據在三個實例上一致,就要涉及到加鎖、實例間協商是否完成修改等一系列操作,但這會帶來巨額的開銷,當然是不太能接受的。而主從節點模式一旦采用了讀寫分離,所有數據的修改只會在主節點上進行,不用協調三個實例。主節點有了最新的數據后,會同步給從節點,這樣主從節點的數據就是一致的。
主從同步
首先說一下開啟主從同步的優點:
性能方面:有了主從同步之后,可以把查詢任務分配給從節點,用主節點來執行寫操作,這樣極大的提高了程序運行的效率,把所有壓力分攤到各個節點上了;
高可用:當有了主從同步之后,當主節點宕機之后,可以很迅速的把從節點提升為主節點,為 Redis 服務器的宕機恢復節省了寶貴的時間;
防止數據丟失:當主節點磁盤壞掉之后,其他從節點還保留着相關的數據,不至於數據全部丟失。
那么下面就來看看如何開啟主從同步,既然是主從,那么意味着至少要有兩個節點。當然可以使用 docker 啟動多個容器來進行模擬,不過我阿里雲上有三台服務器,所以這里就不使用 docker 了。當前我阿里雲上的三台服務器信息如下:
47.94.174.89,主機名:satori,2 核心 8GB 內存
47.93.39.238,主機名:matsuri,2 核心 4GB 內存
47.93.235.147,主機名:aqua,2 核心 4GB 內存
我們之前使用的都是 satori 主機,那么下面就用 satori 主機做主節點,matsuri 主機和 aqua 主機做從節點。當然首先我們要在 matsuri 主機和 aqua 主機上也安裝 Redis,這個過程就不演示了,我們三個節點安裝的都是相同版本的 Redis,配置如下。
# satori 節點
bind 0.0.0.0
requirepass satori
daemonize yes
# matsuri 節點
bind 0.0.0.0
requirepass matsuri
daemonize yes
# aqua 節點
bind 0.0.0.0
requirepass aqua
daemonize yes
下面我們啟動三個節點的 Redis 來試一下,首先 Redis 開啟主從同步非常簡單,只需要在從節點的配置文件中指定 slaveof <master_ip> <master_port>
即可,當然如果主節點設置了密碼,那么從節點還需要指定 masterauth <master的密碼>
。
輸入 info replication 即可查看狀態,其中 role 表示節點的身份,connected_slaves 表示對應的從節點數量。顯然這三個節點都是主節點,原因是我們在啟動 matsuri 節點個 aqua 節點的時候只配置了 IP、密碼、是否后台啟動,並沒有設置主從相關的參數,所以它們啟動之后都是主節點。
然后下面我們在命令行中進行設置,我們先以 aqua 節點為例:
一旦執行了slaveof(由於 slave 這個單詞存在歧視,所以 Redis 中也可以使用 replicaof,是等價的),那么這台 Redis 主節點就變成了其它主節點的從節點,然后從節點上的數據會被清空,主節點將自身的數據副本同步給從節點。盡管 aqua 這個節點設置了 name 這個 key,但是當它執行了 slaveof 之后就變成了從節點,所以自身的數據就沒清空了,而主節點又沒有 name 這個 key,所以最后 get name 的結果為 nil。
然后我們將 matsuri 節點也設置為 satori 節點的從節點:
最后再來看看主節點 master 的狀態:
以上我們就實現了主從同步,我們在主節點設置 key,然后看看在從節點上能不能獲取得到。
# matsuri 節點是從節點,此時沒有 name 這個 key
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379>
# 我們在主節點 satori 上設置
127.0.0.1:6379> set name kagura_nana
OK
127.0.0.1:6379>
# 再回到從節點 matsuri 上查看,發現 key 已經成功地被設置了
127.0.0.1:6379> get name
"kagura_nana"
127.0.0.1:6379>
# aqua 節點也是同理,主節點會將數據同步到所有的從節點
127.0.0.1:6379> get name
"kagura_nana"
127.0.0.1:6379>
因此一旦成為從節點之后,自身的數據就會被清空,然后同步主節點的數據,因為此時已經和指定的 master 建立聯系了。
# 如果繼續連接,會提示我們已經連接到指定的 master 了
127.0.0.1:6379> slaveof 47.94.174.89 6379
OK Already connected to specified master
127.0.0.1:6379>
但是問題來了, 如果我們在從節點上寫數據,會不會同步到主節點上面呢?
# 在 matsuri 節點上寫數據
127.0.0.1:6379> set name kagura_mea
(error) READONLY You can't write against a read only replica.
127.0.0.1:6379> # 答案是根本不允許在從節點上進行寫操作
一旦成為從節點,那么它就不能寫入數據了,只能老老實實地從主節點備份數據。所以在默認情況下,處於復制模式的主節點既可以執行寫操作也可以執行讀操作,而從節點則只能執行讀操作。如果想讓從節點也支持寫操作,那么設置 config set replica-read-only no
即可,或者在配置文件中修改,便可以使從節點也開啟寫操作,但是需要注意以下幾點:
在從節點上寫的數據不會同步到主節點;
在進行完整數據同步時,從節點數據會被清空;
但是一般來說,我們都不會讓從節點執行寫操作,而是按照 Redis 的默認策略,采用主從節點讀寫分離的方式。
讀操作:主節點和從節點都可以執行
寫操作:只能主節點執行,然后主節點將寫操作同步給從節點,然后從節點再執行、從而讓數據和主節點保持一致
那么問題來了,問啥要采用讀寫分離的方式呢?可以試想一下,如果不管主節點還是從節點都能接收寫操作(將 replica-read-only 設為 yes),那么一個直接的問題,以我們上面的三個節點為例:如果客戶端對同一個 key 修改了三次,每一次的修改請求都發到不同的實例節點上,那么數據在多個節點之間就不一致了,而在讀取的時候就有可能讀取到舊的值。
如果我們非要讓這個數據在三個節點上保持一致,就要涉及到加鎖、實例間協商是否完成修改等一系列操作,但這會帶來巨額的開銷,當然是不太能接受的。而主從節點模式一旦采用了讀寫分離,所有數據的修改只會在主節點上進行,不用協調三個節點。主節點有了最新的數據后,會同步給從節點,這樣,主從節點的數據就是一致的。
所以我們看到實現主從同步還是很簡單的,就是指定一個 slaveof,可以在配置文件中指定,可以在啟動時通過命令行的方式指定,還可以在進入控制台的時候設置。當然如果主節點設置了密碼,那么從節點還要指定 masterauth,整體沒什么難度。那么如何關閉主從同步呢?
那么如何關閉主從同步呢?關閉主從同步有兩種方式:第一種是通過 slave <other_master_ip> <other_master_port>,將該從節點指向其它的主節點,但是該機器依舊是 slave;另一種方式是通過 slaveof no one,這種方式就是關閉主從同步,然后該機器也會由 slave 變成 master。
我們測試一下:
主從同步是如何工作的?
我們總說 Redis 具有高可靠性,主要從兩方面考量:一是數據盡量少丟失、而是服務盡量少中斷。AOF 和 RDB 保證了前者,而對於后者,Redis 的做法是增加副本冗余量,將一份數據同時保存在多個實例上。即使有一個實例出現了故障,需要過一段時間才能恢復,其他實例也可以對外提供服務,不會影響業務使用。
那么問題來了,主從同步是如何完成的呢?主節點數據是一次性傳給從節點,還是分批同步?要是主從節點間的網絡斷連了,數據還能保持一致嗎?下面就來聊聊主從同步的原理,以及應對網絡中斷風險的方案。
我們先來看看主從節點間的第一次同步是如何進行的,這也是 Redis 實例建立主從節點模式后的規定動作。
注:我們這里說主節點和從節點實際上不是特別恰當,因為我們指的不是機器或者說節點本身,而是指節點上的 redis-server 進程,所以更准確的說法應該叫主庫和從庫。不過這些都無所謂了, 不影響理解就行。
主從節點之間如何建立第一次同步?
當我們啟動多個 Redis 實例的時候,它們相互之間就可以通過 slaveof(Redis 5.0 開始使用 replicaof)命令形成主節點和從節點的關系,以我們之前的 matsuri 節點為例:
127.0.0.1:6379> slaveof 47.94.174.89 6379
# 如果 master 設置了密碼,還需要指定密碼
127.0.0.1:6379> config set masterauth satori
一旦連接成功,那么 "從節點" 會從 "主節點" 上拉取數據,而這個過程可以分為三個階段。
第一階段是主從節點之間建立連接、協商同步的過程,主要是為全量復制做准備。在這一步,從節點和主節點建立連接之后會告訴主節點即將進行同步,主節點確認回復后,那么之間的同步就可以開始了。
具體來說,從節點給主節點發送 psync 命令,表示要進行數據同步,主節點根據這個命令的參數來啟動復制,psync 命令包含了主節點的 runID 和復制進度 offset 這兩個參數。
runID:每個 Redis 實例啟動時都會自動生成的一個隨機 ID,用來唯一標記這個實例。當從節點和主節點第一次復制時,因為不知道主節點的 runID,所以將 runID 設為 "?"
offset:設置為 -1,表示第一次復制
主節點收到 psync 命令后,會用 FULLRESYNC 響應命令帶上兩個參數:主節點 runID 和主節點目前的復制進度 offset,返回給從節點。從節點收到響應后,會記錄下這兩個參數。但這里有個地方需要注意,FULLRESYNC 表示此次復制(第一次)采用的是全量復制,也就是說主節點會把當前數據全部復制給從節點。
在第二階段,通過生成的 RDB 文件,主節點將全部的數據都同步給從節點,從節點完成加載。
具體做法是,主節點執行 bgsave 命令,生成 RDB 文件,接着將文件發給從節點。從節點接收到 RDB 文件后,會先清空當前數據庫,然后加載 RDB 文件。這是因為從節點在通過 replicaof(slaveof)命令開始和主節點同步前,可能保存了其他數據。為了避免之前數據的影響,從節點需要先把當前數據庫清空。
而在主節點將數據同步給從節點的過程中,主節點不會被阻塞,仍然可以正常接收請求,否則 Redis 的服務就被中斷了。但是這些請求中的寫操作並沒有記錄到剛剛生成的 RDB 文件中,為了保證主從節點的數據一致性,主節點會在內存中用專門的 replication buffer,記錄 RDB 文件生成后收到的所有寫操作。
小問題:我們說過 AOF 記錄的操作命令更全,相比於 RDB 丟失的數據更少,那么這里為啥使用 RDB 不用 AOF 呢?原因很簡單,AOF 記錄的具體的寫命令,RDB 記錄的是經過壓縮的二進制數據,占用內存更小,更緊湊。如果是設置到備份、傳輸,那么顯然使用 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 中,而除了 replication buffer 之外,這些命令同時還會被寫入到 repl_backlog_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 之間的命令操作同步給從節點就行。就像剛剛示意圖的中間部分,主節點和從節點之間相差了 "set k4 v4" 這個操作,在增量復制時,主節點只需要把它們同步給從節點,就行了。
在網絡斷連階段,主節點可能會收到新的寫操作命令,所以,一般來說,master_repl_offset 會大於 slave_repl_offset。此時,主節點只用把 master_repl_offset 和 slave_repl_offset 之間的命令操作同步給從節點就行。
但是有一點需要注意,repl_backlog_buffer 是一個環形緩沖區,所以在緩沖區滿后,主節點會繼續寫入,此時就會覆蓋掉之前的操作。比如圖中的 "set k9 v9" 就將最開始的 "set k1 v1" 給覆蓋掉了,因此如果從節點讀取的速度比較慢,那么就有可能造成從節點還未讀取的操作被主節點新寫的操作給覆蓋掉了,從而導致主從節點之間的數據不一致。
因此我們要想辦法避免這一情況,一般而言,我們可以調整 repl-backlog-size 這個參數,這個參數就是用來控制 repl_backlog_buffer 這個環形緩沖區的空間大小的。但設置為多大才合適呢?首先有一個計算公式:" 緩沖空間大小 = 主節點寫入命令速度 * 操作大小 - 主從節點間網絡傳輸命令速度 * 操作大小 ",所以我們需要根據實際情況下計算的結果進行設置。只不過在實際應用中,考慮到可能存在一些突發的請求壓力,我們通常需要把這個緩沖空間擴大一倍,即 " repl-backlog-size = 根據公司計算得到的緩沖空間大小 * 2 ",這就是 repl-backlog-size 的最終值。
舉個例子,如果主節點每秒寫入 2000 個操作,每個操作的大小為 2KB,網絡每秒能傳輸 1000 個操作,那么,有 1000 個操作需要緩沖起來,這就至少需要 2MB 的緩沖空間。否則,新寫的命令就會覆蓋掉舊操作了。但我們說為了應對可能的突發壓力,我們需要將結果乘以 2,因此最終應該把 repl-backlog-size 設為 4MB。
這樣一來,增量復制時主從節點的數據不一致風險就降低了。但如果並發請求量非常大,連兩倍的緩沖空間都存不下新操作請求的話,此時主從節點數據仍然可能不一致。針對這種情況,一方面,我們可以根據 Redis 所在服務器的內存資源再適當增加 repl-backlog-size 值,比如說設置成緩沖空間大小的 4 倍,另一方面,你可以考慮使用切片集群來分擔單個主節點的請求壓力。關於切片集群,我們后面再聊。
所以 repl-backlog-size 這個參數非常重要,因為如果滿了導致操作被覆蓋,就意味着要想主從節點之間數據要想保持一致的話,只能選擇全量復制。因此之前網上流傳一個段子,如果一個人跟你說他們公司業務量多大,技術多么牛,那么你可以問問它 repl-backlog-size 設置的多少,如果是默認值(1M),那么基本可以認為這個人要么在業務量上吹牛皮,要么公司沒有技術牛人。
到這里可以再回顧一下增量復制的流程:
最后再補充一點,從前面的內容我們可以得知,在第一次主從復制的時候,會先產生一個 RDB 文件,再把 RDB 文件發送給從節點。但如果主節點是非固態硬盤的時候,系統的 I/O 操作是非常高的,為了緩解這個問題,Redis 2.8.18 中新增了無盤復制功能,無盤復制功能不會在本地創建 RDB 文件,而是會派生出一個子進程,然后由子進程通過 Socket 的方式,直接將 RDB 文件寫入到從節點,這樣主節點就可以在不創建RDB文件的情況下,完成與從節點的數據同步。要使用該功能,只需把配置項 repl-diskless-sync 的值設置為 yes 即可,它默認配置值為 no。
replication buffer 和 repl_backlog_buffer 的區別
關於 replication buffer 和 repl_backlog_buffer 的區別,可能有人不是很清楚,這里解釋一下。
總的來說,replication buffer 是主從節點在進行全量復制時,主節點上用於和從節點連接的客戶端的 buffer,而 repl_backlog_buffer 是為了支持從節點增量復制,主節點上用於持續保存寫操作的一塊專用 buffer。
Redis 主從節點在進行復制時,當主節點要把全量復制期間的寫操作命令發給從節點,主節點會先創建一個客戶端,用來連接從節點,然后通過這個客戶端,把寫操作命令發給從節點。在內存中,主節點上的客戶端就會對應一個 buffer,這個 buffer 就被稱為 replication buffer。Redis 通過 client_buffer 配置項來控制這個 buffer 的大小。主節點會為每個從節點建立一個客戶端,所以 replication buffer 不是共享的,而是每個從庫都有一個對應的客戶端。
repl_backlog_buffer 是一塊專用 buffer,在 Redis 服務器啟動后,開始一直接收寫操作命令,這是所有從節點共享的。主節點和從節點會各自記錄自己的復制進度,所以不同的從節點在進行恢復時,會把自己的復制進度(slave_repl_offset)發給主節點,主節點就可以和它獨立同步。
小結
這次我們一起了解了 Redis 的主從節點同步的基本原理,總結來說有三種模式:全量復制、基於長連接的命令傳播,以及增量復制。
全量復制雖然耗時,但是對於從節點來說,如果是第一次同步,全量復制是無法避免的,所以有一個小建議:"一個 Redis 實例的數據庫不要太大",一個實例大小在幾 GB 左右是比較合適的,這樣可以減少 RDB 文件生成、傳輸和重新加載的開銷。另外,為了避免多個從節點同時和主節點進行全量復制,給主節點造成過大的同步壓力,我們也可以采用 "主 - 從 - 從" 這一級聯模式,來緩解主節點的壓力。
長連接復制是主從節點正常運行后的常規同步階段,在這個階段中,主從節點之間通過命令傳播實現同步。不過,這期間如果遇到了網絡斷連,增量復制就派上用場了。因此再次建議留意一下 repl-backlog-size 這個配置參數,如果它配置得過小,在增量復制階段,可能會導致從節點的復制進度趕不上主節點,進而導致從節點重新進行全量復制。所以,通過調大這個參數,可以減少從節點在網絡斷連時全量復制的風險。
不過,主從節點模式使用讀寫分離雖然避免了同時寫多個實例帶來的數據不一致問題,但是還面臨主節點故障的潛在風險。主節點故障了從節點該怎么辦,數據還能保持一致嗎,Redis 還能正常提供服務嗎?估計有人猜到了,會通過哨兵機制選出一個新的主節點,那么后續我們就來具體聊聊主節點故障后,保證服務可靠性的解決方案,也就是所謂的哨兵機制。