在上一篇文章中,我和你介紹了binlog的基本內容,在一個主備關系中,每個備庫接收主庫的binlog並執行。
正常情況下,只要主庫執行更新生成的所有binlog,都可以傳到備庫並被正確地執行,備庫就能達到跟主庫一致的狀態,這就是最終一致性。
但是,MySQL要提供高可用能力,只有最終一致性是不夠的。為什么這么說呢?今天我就着重和你分析一下。
這里,我再放一次上一篇文章中講到的雙M結構的主備切換流程圖。
主備延遲
主備切換可能是一個主動運維動作,比如軟件升級、主庫所在機器按計划下線等,也可能是被動操作,比如主庫所在機器掉電。
接下來,我們先一起看看主動切換的場景。
在介紹主動切換流程的詳細步驟之前,我要先跟你說明一個概念,即“同步延遲”。與數據同步有關的時間點主要包括以下三個:
-
主庫A執行完成一個事務,寫入binlog,我們把這個時刻記為T1;
-
之后傳給備庫B,我們把備庫B接收完這個binlog的時刻記為T2;
-
備庫B執行完成這個事務,我們把這個時刻記為T3。
所謂主備延遲,就是同一個事務,在備庫執行完成的時間和主庫執行完成的時間之間的差值,也就是T3-T1。
你可以在備庫上執行show slave status命令,它的返回結果里面會顯示seconds_behind_master,用於表示當前備庫延遲了多少秒。
seconds_behind_master的計算方法是這樣的:
-
每個事務的binlog 里面都有一個時間字段,用於記錄主庫上寫入的時間;
-
備庫取出當前正在執行的事務的時間字段的值,計算它與當前系統時間的差值,得到seconds_behind_master。
可以看到,其實seconds_behind_master這個參數計算的就是T3-T1。所以,我們可以用seconds_behind_master來作為主備延遲的值,這個值的時間精度是秒。
你可能會問,如果主備庫機器的系統時間設置不一致,會不會導致主備延遲的值不准?
其實不會的。因為,備庫連接到主庫的時候,會通過執行SELECT UNIX_TIMESTAMP()函數來獲得當前主庫的系統時間。如果這時候發現主庫的系統時間與自己不一致,備庫在執行seconds_behind_master計算的時候會自動扣掉這個差值。
需要說明的是,在網絡正常的時候,日志從主庫傳給備庫所需的時間是很短的,即T2-T1的值是非常小的。也就是說,網絡正常情況下,主備延遲的主要來源是備庫接收完binlog和執行完這個事務之間的時間差。
所以說,主備延遲最直接的表現是,備庫消費中轉日志(relay log)的速度,比主庫生產binlog的速度要慢。接下來,我就和你一起分析下,這可能是由哪些原因導致的。
主備延遲的來源
首先,有些部署條件下,備庫所在機器的性能要比主庫所在的機器性能差。
一般情況下,有人這么部署時的想法是,反正備庫沒有請求,所以可以用差一點兒的機器。或者,他們會把20個主庫放在4台機器上,而把備庫集中在一台機器上。
其實我們都知道,更新請求對IOPS的壓力,在主庫和備庫上是無差別的。所以,做這種部署時,一般都會將備庫設置為“非雙1”的模式。
但實際上,更新過程中也會觸發大量的讀操作。所以,當備庫主機上的多個備庫都在爭搶資源的時候,就可能會導致主備延遲了。
當然,這種部署現在比較少了。因為主備可能發生切換,備庫隨時可能變成主庫,所以主備庫選用相同規格的機器,並且做對稱部署,是現在比較常見的情況。
追問1:但是,做了對稱部署以后,還可能會有延遲。這是為什么呢?
這就是第二種常見的可能了,即備庫的壓力大。一般的想法是,主庫既然提供了寫能力,那么備庫可以提供一些讀能力。或者一些運營后台需要的分析語句,不能影響正常業務,所以只能在備庫上跑。
我真就見過不少這樣的情況。由於主庫直接影響業務,大家使用起來會比較克制,反而忽視了備庫的壓力控制。結果就是,備庫上的查詢耗費了大量的CPU資源,影響了同步速度,造成主備延遲。
這種情況,我們一般可以這么處理:
-
一主多從。除了備庫外,可以多接幾個從庫,讓這些從庫來分擔讀的壓力。
-
通過binlog輸出到外部系統,比如Hadoop這類系統,讓外部系統提供統計類查詢的能力。
其中,一主多從的方式大都會被采用。因為作為數據庫系統,還必須保證有定期全量備份的能力。而從庫,就很適合用來做備份。
備注:這里需要說明一下,從庫和備庫在概念上其實差不多。在我們這個專欄里,為了方便描述,我把會在HA過程中被選成新主庫的,稱為備庫,其他的稱為從庫。
追問2:采用了一主多從,保證備庫的壓力不會超過主庫,還有什么情況可能導致主備延遲嗎?
這就是第三種可能了,即大事務。
大事務這種情況很好理解。因為主庫上必須等事務執行完成才會寫入binlog,再傳給備庫。所以,如果一個主庫上的語句執行10分鍾,那這個事務很可能就會導致從庫延遲10分鍾。
不知道你所在公司的DBA有沒有跟你這么說過:不要一次性地用delete語句刪除太多數據。其實,這就是一個典型的大事務場景。
比如,一些歸檔類的數據,平時沒有注意刪除歷史數據,等到空間快滿了,業務開發人員要一次性地刪掉大量歷史數據。同時,又因為要避免在高峰期操作會影響業務(至少有這個意識還是很不錯的),所以會在晚上執行這些大量數據的刪除操作。
結果,負責的DBA同學半夜就會收到延遲報警。然后,DBA團隊就要求你后續再刪除數據的時候,要控制每個事務刪除的數據量,分成多次刪除。
另一種典型的大事務場景,就是大表DDL。這個場景,我在前面的文章中介紹過。處理方案就是,計划內的DDL,建議使用gh-ost方案(這里,你可以再回顧下第13篇文章《為什么表數據刪掉一半,表文件大小不變?》中的相關內容)。
追問3:如果主庫上也不做大事務了,還有什么原因會導致主備延遲嗎?
造成主備延遲還有一個大方向的原因,就是備庫的並行復制能力。這個話題,我會留在下一篇文章再和你詳細介紹。
其實還是有不少其他情況會導致主備延遲,如果你還碰到過其他場景,歡迎你在評論區給我留言,我來和你一起分析、討論。
由於主備延遲的存在,所以在主備切換的時候,就相應的有不同的策略。
可靠性優先策略
在圖1的雙M結構下,從狀態1到狀態2切換的詳細過程是這樣的:
-
判斷備庫B現在的seconds_behind_master,如果小於某個值(比如5秒)繼續下一步,否則持續重試這一步;
-
把主庫A改成只讀狀態,即把readonly設置為true;
-
判斷備庫B的seconds_behind_master的值,直到這個值變成0為止;
-
把備庫B改成可讀寫狀態,也就是把readonly 設置為false;
-
把業務請求切到備庫B。
這個切換流程,一般是由專門的HA系統來完成的,我們暫時稱之為可靠性優先流程。
備注:圖中的SBM,是seconds_behind_master參數的簡寫。
可以看到,這個切換流程中是有不可用時間的。因為在步驟2之后,主庫A和備庫B都處於readonly狀態,也就是說這時系統處於不可寫狀態,直到步驟5完成后才能恢復。
在這個不可用狀態中,比較耗費時間的是步驟3,可能需要耗費好幾秒的時間。這也是為什么需要在步驟1先做判斷,確保seconds_behind_master的值足夠小。
試想如果一開始主備延遲就長達30分鍾,而不先做判斷直接切換的話,系統的不可用時間就會長達30分鍾,這種情況一般業務都是不可接受的。
當然,系統的不可用時間,是由這個數據可靠性優先的策略決定的。你也可以選擇可用性優先的策略,來把這個不可用時間幾乎降為0。
可用性優先策略
如果我強行把步驟4、5調整到最開始執行,也就是說不等主備數據同步,直接把連接切到備庫B,並且讓備庫B可以讀寫,那么系統幾乎就沒有不可用時間了。
我們把這個切換流程,暫時稱作可用性優先流程。這個切換流程的代價,就是可能出現數據不一致的情況。
接下來,我就和你分享一個可用性優先流程產生數據不一致的例子。假設有一個表 t:
mysql> CREATE TABLE `t` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`c` int(11) unsigned DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(c) values(1),(2),(3);
這個表定義了一個自增主鍵id,初始化數據后,主庫和備庫上都是3行數據。接下來,業務人員要繼續在表t上執行兩條插入語句的命令,依次是:
insert into t(c) values(4);
insert into t(c) values(5);
假設,現在主庫上其他的數據表有大量的更新,導致主備延遲達到5秒。在插入一條c=4的語句后,發起了主備切換。
圖3是可用性優先策略,且binlog_format=mixed時的切換流程和數據結果。
現在,我們一起分析下這個切換流程:
-
步驟2中,主庫A執行完insert語句,插入了一行數據(4,4),之后開始進行主備切換。
-
步驟3中,由於主備之間有5秒的延遲,所以備庫B還沒來得及應用“插入c=4”這個中轉日志,就開始接收客戶端“插入 c=5”的命令。
-
步驟4中,備庫B插入了一行數據(4,5),並且把這個binlog發給主庫A。
-
步驟5中,備庫B執行“插入c=4”這個中轉日志,插入了一行數據(5,4)。而直接在備庫B執行的“插入c=5”這個語句,傳到主庫A,就插入了一行新數據(5,5)。
最后的結果就是,主庫A和備庫B上出現了兩行不一致的數據。可以看到,這個數據不一致,是由可用性優先流程導致的。
那么,如果我還是用可用性優先策略,但設置binlog_format=row,情況又會怎樣呢?
因為row格式在記錄binlog的時候,會記錄新插入的行的所有字段值,所以最后只會有一行不一致。而且,兩邊的主備同步的應用線程會報錯duplicate key error並停止。也就是說,這種情況下,備庫B的(5,4)和主庫A的(5,5)這兩行數據,都不會被對方執行。
圖4中我畫出了詳細過程,你可以自己再分析一下。
從上面的分析中,你可以看到一些結論:
-
使用row格式的binlog時,數據不一致的問題更容易被發現。而使用mixed或者statement格式的binlog時,數據很可能悄悄地就不一致了。如果你過了很久才發現數據不一致的問題,很可能這時的數據不一致已經不可查,或者連帶造成了更多的數據邏輯不一致。
-
主備切換的可用性優先策略會導致數據不一致。因此,大多數情況下,我都建議你使用可靠性優先策略。畢竟對數據服務來說的話,數據的可靠性一般還是要優於可用性的。
但事無絕對,有沒有哪種情況數據的可用性優先級更高呢?
答案是,有的。
我曾經碰到過這樣的一個場景:
- 有一個庫的作用是記錄操作日志。這時候,如果數據不一致可以通過binlog來修補,而這個短暫的不一致也不會引發業務問題。
- 同時,業務系統依賴於這個日志寫入邏輯,如果這個庫不可寫,會導致線上的業務操作無法執行。
這時候,你可能就需要選擇先強行切換,事后再補數據的策略。
當然,事后復盤的時候,我們想到了一個改進措施就是,讓業務邏輯不要依賴於這類日志的寫入。也就是說,日志寫入這個邏輯模塊應該可以降級,比如寫到本地文件,或者寫到另外一個臨時庫里面。
這樣的話,這種場景就又可以使用可靠性優先策略了。
接下來我們再看看,按照可靠性優先的思路,異常切換會是什么效果?
假設,主庫A和備庫B間的主備延遲是30分鍾,這時候主庫A掉電了,HA系統要切換B作為主庫。我們在主動切換的時候,可以等到主備延遲小於5秒的時候再啟動切換,但這時候已經別無選擇了。
采用可靠性優先策略的話,你就必須得等到備庫B的seconds_behind_master=0之后,才能切換。但現在的情況比剛剛更嚴重,並不是系統只讀、不可寫的問題了,而是系統處於完全不可用的狀態。因為,主庫A掉電后,我們的連接還沒有切到備庫B。
你可能會問,那能不能直接切換到備庫B,但是保持B只讀呢?
這樣也不行。
因為,這段時間內,中轉日志還沒有應用完成,如果直接發起主備切換,客戶端查詢看不到之前執行完成的事務,會認為有“數據丟失”。
雖然隨着中轉日志的繼續應用,這些數據會恢復回來,但是對於一些業務來說,查詢到“暫時丟失數據的狀態”也是不能被接受的。
聊到這里你就知道了,在滿足數據可靠性的前提下,MySQL高可用系統的可用性,是依賴於主備延遲的。延遲的時間越小,在主庫故障的時候,服務恢復需要的時間就越短,可用性就越高。
小結
今天這篇文章,我先和你介紹了MySQL高可用系統的基礎,就是主備切換邏輯。緊接着,我又和你討論了幾種會導致主備延遲的情況,以及相應的改進方向。
然后,由於主備延遲的存在,切換策略就有不同的選擇。所以,我又和你一起分析了可靠性優先和可用性優先策略的區別。
在實際的應用中,我更建議使用可靠性優先的策略。畢竟保證數據准確,應該是數據庫服務的底線。在這個基礎上,通過減少主備延遲,提升系統的可用性。
最后,我給你留下一個思考題吧。
一般現在的數據庫運維系統都有備庫延遲監控,其實就是在備庫上執行 show slave status,采集seconds_behind_master的值。
假設,現在你看到你維護的一個備庫,它的延遲監控的圖像類似圖6,是一個45°斜向上的線段,你覺得可能是什么原因導致呢?你又會怎么去確認這個原因呢?
你可以把你的分析寫在評論區,我會在下一篇文章的末尾跟你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一起閱讀。
上期問題時間
上期我留給你的問題是,什么情況下雙M結構會出現循環復制。
一種場景是,在一個主庫更新事務后,用命令set global server_id=x修改了server_id。等日志再傳回來的時候,發現server_id跟自己的server_id不同,就只能執行了。
另一種場景是,有三個節點的時候,如圖7所示,trx1是在節點 B執行的,因此binlog上的server_id就是B,binlog傳給節點 A,然后A和A’搭建了雙M結構,就會出現循環復制。
這種三節點復制的場景,做數據庫遷移的時候會出現。
如果出現了循環復制,可以在A或者A’上,執行如下命令:
stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=(server_id_of_B);
start slave;
這樣這個節點收到日志后就不會再執行。過一段時間后,再執行下面的命令把這個值改回來。
stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=();
start slave;