MySQL 讀後總結 (三)

Shine-x發表於2020-05-07

基本的一主多從結構:

MySQL 讀後總結 (三)

圖中,虛線箭頭表示的是主備關係,也就是A和A’互為主備, 從庫B、C、D指向的是主庫A。一主多從的設定,一般用於讀寫分離,主庫負責所有的寫入和一部分讀,其他的讀請求則由從庫分擔。

如圖2所示為主庫發生故障,主備切換後的結果。

MySQL 讀後總結 (三)

相比於一主一備的切換流程,一主多從結構在切換完成後,A’會成為新的主庫,從庫B、C、D也要改接到A’。正是由於多了從庫B、C、D重新指向的這個過程,所以主備切換的複雜性也相應增加了。

基於位點的主備切換

當我們把節點B設定成節點A’的從庫的時候,需要執行一條change master命令:

CHANGE MASTER TO 
MASTER_HOST=$host_name 
MASTER_PORT=$port 
MASTER_USER=$user_name 
MASTER_PASSWORD=$password 
MASTER_LOG_FILE=$master_log_name 
MASTER_LOG_POS=$master_log_pos  

這條命令有這麼6個引數:

  • MASTER_HOST、MASTER_PORT、MASTER_USER和MASTER_PASSWORD四個引數,分別代表了主庫A’的IP、埠、使用者名稱和密碼。
  • 最後兩個引數MASTER_LOG_FILE和MASTER_LOG_POS表示,要從主庫的master_log_name檔案的master_log_pos這個位置的日誌繼續同步。而這個位置就是我們所說的同步位點,也就是主庫對應的檔名和日誌偏移量。

這裡就有一個問題了,節點B要設定成A’的從庫,就要執行change master命令,就不可避免地要設定位點的這兩個引數,但是這兩個引數到底應該怎麼設定呢?

原來節點B是A的從庫,本地記錄的也是A的位點。但是相同的日誌,A的位點和A’的位點是不同的。因此,從庫B要切換的時候,就需要先經過“找同步位點”這個邏輯。

這個位點很難精確取到,只能取一個大概位置。

考慮到切換過程中不能丟資料,所以在找位點的時候,總是要找一個“稍微往前”的,然後再通過判斷跳過那些在從庫B上已經執行過的事務。

一種取同步位點的方法是這樣的:

  1. 等待新主庫A’把中轉日誌(relay log)全部同步完成;
  2. 在A’上執行show master status命令,得到當前A’上最新的File 和 Position;
  3. 取原主庫A故障的時刻T;
  4. 用mysqlbinlog工具解析A’的File,得到T時刻的位點。
mysqlbinlog File --stop-datetime=T --start-datetime=T

MySQL 讀後總結 (三)

圖中,end_log_pos後面的值“123”,表示的就是A’這個例項,在T時刻寫入新的binlog的位置。然後,我們就可以把123這個值作為$master_log_pos ,用在節點B的change master命令裡。

當然這個值並不精確。為什麼呢?

可以設想有這麼一種情況,假設在T這個時刻,主庫A已經執行完成了一個insert 語句插入了一行資料R,並且已經將binlog傳給了A’和B,然後在傳完的瞬間主庫A的主機就掉電了。

那麼,這時候系統的狀態是這樣的:

  1. 在從庫B上,由於同步了binlog, R這一行已經存在;
  2. 在新主庫A’上, R這一行也已經存在,日誌是寫在123這個位置之後的;
  3. 我們在從庫B上執行change master命令,指向A’的File檔案的123位置,就會把插入R這一行資料的binlog又同步到從庫B去執行。

這時候,從庫B的同步執行緒就會報告 Duplicate entry ‘id_of_R’ for key ‘PRIMARY’ 錯誤,提示出現了主鍵衝突,然後停止同步。

所以,通常情況下,我們在切換任務的時候,要先主動跳過這些錯誤,有兩種常用的方法。

一種做法是,主動跳過一個事務。跳過命令的寫法是:

set global sql_slave_skip_counter=1;
start slave;

因為切換過程中,可能會不止重複執行一個事務,所以需要在從庫B剛開始接到新主庫A’時,持續觀察,每次碰到這些錯誤就停下來,執行一次跳過命令,直到不再出現停下來的情況,以此來跳過可能涉及的所有事務。

另外一種方式是,通過設定slave_skip_errors引數,直接設定跳過指定的錯誤。

在執行主備切換時,有這麼兩類錯誤,是經常會遇到的:

  • 1062錯誤是插入資料時唯一鍵衝突;
  • 1032錯誤是刪除資料時找不到行。

因此,我們可以把slave_skip_errors 設定為 “1032,1062”,這樣中間碰到這兩個錯誤時就直接跳過。

這裡需要注意的是,這種直接跳過指定錯誤的方法,針對的是主備切換時,由於找不到精確的同步位點,所以只能採用這種方法來建立從庫和新主庫的主備關係。

這個背景是,我們很清楚在主備切換過程中,直接跳過1032和1062這兩類錯誤是無損的,所以才可以這麼設定slave_skip_errors引數。等到主備間的同步關係建立完成,並穩定執行一段時間之後,我們還需要把這個引數設定為空,以免之後真的出現了主從資料不一致,也跳過了。

GTID

通過sql_slave_skip_counter跳過事務和通過slave_skip_errors忽略錯誤的方法,雖然都最終可以建立從庫B和新主庫A’的主備關係,但這兩種操作都很複雜,而且容易出錯。所以,MySQL 5.6版本引入了GTID,徹底解決了這個困難。

那麼,GTID到底是什麼意思,又是如何解決找同步位點這個問題呢?

GTID的全稱是Global Transaction Identifier,也就是全域性事務ID,是一個事務在提交的時候生成的,是這個事務的唯一標識。它由兩部分組成,格式是:

GTID=server_uuid:gno

其中:

  • server_uuid是一個例項第一次啟動時自動生成的,是一個全域性唯一的值;
  • gno是一個整數,初始值是1,每次提交事務的時候分配給這個事務,並加1。

在MySQL的官方文件裡,GTID格式是這麼定義的:

GTID=source_id:transaction_id

這裡的source_id就是server_uuid;而後面的這個transaction_id,容易造成誤導,所以改成了gno。為什麼說使用transaction_id容易造成誤解呢?

因為,在MySQL裡面我們說transaction_id就是指事務id,事務id是在事務執行過程中分配的,如果這個事務回滾了,事務id也會遞增,而gno是在事務提交的時候才會分配。

從效果上看,GTID往往是連續的,因此我們用gno來表示更容易理解。

GTID模式的啟動也很簡單,我們只需要在啟動一個MySQL例項的時候,加上引數gtid_mode=on和enforce_gtid_consistency=on就可以了。

在GTID模式下,每個事務都會跟一個GTID一一對應。這個GTID有兩種生成方式,而使用哪種方式取決於session變數gtid_next的值。

  1. 如果gtid_next=automatic,代表使用預設值。這時,MySQL就會把server_uuid:gno分配給這個事務。
    a. 記錄binlog的時候,先記錄一行 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’;
    b. 把這個GTID加入本例項的GTID集合。
  2. 如果gtid_next是一個指定的GTID的值,比如通過set gtid_next=’current_gtid’指定為current_gtid,那麼就有兩種可能:
    a. 如果current_gtid已經存在於例項的GTID集合中,接下來執行的這個事務會直接被系統忽略;
    b. 如果current_gtid沒有存在於例項的GTID集合中,就將這個current_gtid分配給接下來要執行的事務,也就是說系統不需要給這個事務生成新的GTID,因此gno也不用加1。

注意,一個current_gtid只能給一個事務使用。這個事務提交後,如果要執行下一個事務,就要執行set 命令,把gtid_next設定成另外一個gtid或者automatic。

這樣,每個MySQL例項都維護了一個GTID集合,用來對應“這個例項執行過的所有事務”。

用一個簡單的例子你說明GTID的基本用法。

建立一個表t:

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

insert into t values(1,1);

MySQL 讀後總結 (三)

可以看到,事務的BEGIN之前有一條SET @@SESSION.GTID_NEXT命令。這時,如果例項X有從庫,那麼將CREATE TABLE和insert語句的binlog同步過去執行的話,執行事務之前就會先執行這兩個SET命令, 這樣被加入從庫的GTID集合的,就是圖中的這兩個GTID。

假設,現在這個例項X是另外一個例項Y的從庫,並且此時在例項Y上執行了下面這條插入語句:

insert into t values(1,1);

並且,這條語句在例項Y上的GTID是 “aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”。

那麼,例項X作為Y的從庫,就要同步這個事務過來執行,顯然會出現主鍵衝突,導致例項X的同步執行緒停止。這時,應該怎麼處理呢?
可以執行下面的這個語句序列:

set gtid_next='aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10';
begin;
commit;
set gtid_next=automatic;
start slave;

其中,前三條語句的作用,是通過提交一個空事務,把這個GTID加到例項X的GTID集合中。如圖5所示,就是執行完這個空事務之後的show master status的結果。

MySQL 讀後總結 (三)

可以看到例項X的Executed_Gtid_set裡面,已經加入了這個GTID。

這樣,再執行start slave命令讓同步執行緒執行起來的時候,雖然例項X上還是會繼續執行例項Y傳過來的事務,但是由於“aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”已經存在於例項X的GTID集合中了,所以例項X就會直接跳過這個事務,也就不會再出現主鍵衝突的錯誤。

在上面的這個語句序列中,start slave命令之前還有一句set gtid_next=automatic。這句話的作用是“恢復GTID的預設分配行為”,也就是說如果之後有新的事務再執行,就還是按照原來的分配方式,繼續分配gno=3。

基於GTID的主備切換

在GTID模式下,備庫B要設定為新主庫A’的從庫的語法如下:

CHANGE MASTER TO 
MASTER_HOST=$host_name 
MASTER_PORT=$port 
MASTER_USER=$user_name 
MASTER_PASSWORD=$password 
master_auto_position=1 

其中,master_auto_position=1就表示這個主備關係使用的是GTID協議。可以看到,MASTER_LOG_FILE和MASTER_LOG_POS引數已經不需要指定了。

在例項B上執行start slave命令,取binlog的邏輯是這樣的:

  1. 例項B指定主庫A’,基於主備協議建立連線。
  2. 例項B把set_b發給主庫A’。
  3. 例項A’算出set_a與set_b的差集,也就是所有存在於set_a,但是不存在於set_b的GITD的集合,判斷A’本地是否包含了這個差集需要的所有binlog事務。
    a. 如果不包含,表示A’已經把例項B需要的binlog給刪掉了,直接返回錯誤;
    b. 如果確認全部包含,A’從自己的binlog檔案裡面,找出第一個不在set_b的事務,發給B;
  4. 之後就從這個事務開始,往後讀檔案,按順序取binlog發給B去執行。

其實,這個邏輯裡面包含了一個設計思想:在基於GTID的主備關係裡,系統認為只要建立主備關係,就必須保證主庫發給備庫的日誌是完整的。因此,如果例項B需要的日誌已經不存在,A’就拒絕把日誌發給B。

這跟基於位點的主備協議不同。基於位點的協議,是由備庫決定的,備庫指定哪個位點,主庫就發哪個位點,不做日誌的完整性判斷。

再來看看引入GTID後,一主多從的切換場景下,主備切換是如何實現的。

由於不需要找位點了,所以從庫B、C、D只需要分別執行change master命令指向例項A’即可。

其實,嚴謹地說,主備切換不是不需要找位點了,而是找位點這個工作,在例項A’內部就已經自動完成了。但由於這個工作是自動的,所以對HA系統的開發人員來說,非常友好。

之後這個系統就由新主庫A’寫入,主庫A’的自己生成的binlog中的GTID集合格式是:server_uuid_of_A’:1-M。

如果之前從庫B的GTID集合格式是 server_uuid_of_A:1-N, 那麼切換之後GTID集合的格式就變成了server_uuid_of_A:1-N, server_uuid_of_A’:1-M。

當然,主庫A’之前也是A的備庫,因此主庫A’和從庫B的GTID集合是一樣的。

GTID和線上DDL

在之前業務高峰期的慢查詢效能問題時,如果是由於索引缺失引起的效能問題,可以通過線上加索引來解決。但是,考慮到要避免新增索引對主庫效能造成的影響,我們可以先在備庫加索引,然後再切換。

在雙M結構下,備庫執行的DDL語句也會傳給主庫,為了避免傳回後對主庫造成影響,要通過set sql_log_bin=off關掉binlog。

這樣操作的話,資料庫裡面是加了索引,但是binlog並沒有記錄下這一個更新,是不是會導致資料和日誌不一致?

假設,這兩個互為主備關係的庫還是例項X和例項Y,且當前主庫是X,並且都開啟了GTID模式。這時的主備切換流程可以變成下面這樣:

  • 在例項X上執行stop slave。
  • 在例項Y上執行DDL語句。注意,這裡並不需要關閉binlog。
  • 執行完成後,查出這個DDL語句對應的GTID,並記為 server_uuid_of_Y:gno。
  • 到例項X上執行以下語句序列:
    set GTID_NEXT="server_uuid_of_Y:gno";
    begin;
    commit;
    set gtid_next=automatic;
    start slave;

這樣做的目的在於,既可以讓例項Y的更新有binlog記錄,同時也可以確保不會在例項X上執行這條更新。

  • 接下來,執行完主備切換,然後照著上述流程再執行一遍即可。

問題1:在實際工作中,主從備份似乎是mysql用的最多的高可用方案。
但是主從備份這個方案的問題太多:

  1. binlog資料傳輸前,主庫當機,導致提交了的事務資料丟失。
  2. 一主多從,即使採用半同步,也只能保證binlog至少在兩臺機器上,沒有一個機制能夠選出擁有最完整binlog的從庫作為新的主庫。
  3. 主從切換涉及到 人為操作,而不是全自動化的。即使在使用GTID的情況下,也會有binlog被刪除,需要重新做從庫的情況。
  4. 互為主備,如果互為主備的兩個例項全部當機,mysql直接不可用。

mysql應該有更強大更完備的高可用方案(類似於zab協議或者raft協議這種),而在實際環境下,為什麼主從備份用得最多呢?

  • 3 這個應該是可以做到自動化的。
  • 4 概率比較小,其實即使是別的三節點的方案,也架不住掛兩個例項,所以這個不是MySQL主備的鍋。

MySQL到現在,還是提供了很多方案可選的。很多是業務權衡的結果。

  • 比如說,非同步複製,在主庫異常掉電的時候可能會丟資料。
  • 這個大家知道以後,有一些就改成semi-sync了,但是還是有一些就留著非同步複製的模式,因為semi-sync有效能影響(一開始35%,現在好點15%左右,看具體環境),而可能這些業務認為丟一兩行,可以從應用層日誌去補。 就保留了非同步複製模式。

最後,為什麼主從備份用得最多,有一部分歷史原因。多年前MySQL剛要開始火的時候,大家發現這個主備模式好方便,就都用了。
而基於其他協議的方案,都是後來出現的,並且還是陸陸續續出點bug。
涉及到線上服務,大家使用新方案的熱情總是侷限在測試環境的多。

semi-sync也是近幾年才開始穩定並被一些公司開始作為預設配置。

問題2:在GTID模式下,如果一個新的從庫接上主庫,但是需要的binlog已經沒了,要怎麼做?

  1. 如果業務允許主從不一致的情況,那麼可以在主庫上先執行show global variables like ‘gtid_purged’,得到主庫已經刪除的GTID集合,假設是gtid_purged1;然後先在從庫上執行reset master,再執行set global gtid_purged =‘gtid_purged1’;最後執行start slave,就會從主庫現存的binlog開始同步。binlog缺失的那一部分,資料在從庫上就可能會有丟失,造成主從不一致。
  2. 如果需要主從資料一致的話,最好還是通過重新搭建從庫來做。
  3. 如果有其他的從庫保留有全量的binlog的話,可以把新的從庫先接到這個保留了全量binlog的從庫,追上日誌以後,如果有需要,再接回主庫。
  4. 如果binlog有備份的情況,可以先在從庫上應用缺失的binlog,然後再執行start slave。
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章