淺談MySQL讀寫分離的坑以及應對的方案

一、主從架構

爲什麼我們要進行讀寫分離?個人覺得還是業務發展到一定的規模,驅動技術架構的改革,讀寫分離可以減輕單臺服務器的壓力,將讀請求和寫請求分流到不同的服務器,分攤單臺服務的負載,提高可用性,提高讀請求的性能。

圖片

上面這個圖是一個基礎的Mysql的主從架構,1主1備3從。這種架構是客戶端主動做的負載均衡,數據庫的連接信息一般是放到客戶端的連接層,也就是說由客戶端來選擇數據庫進行讀寫

圖片

上圖是一個帶proxy的主從架構,客戶端只和proxy進行連接,由proxy根據請求類型和上下文決定請求的分發路由。

兩種架構方案各有什麼特點:

  1. 客戶端直連架構,由於少了一層proxy轉發,所以查詢性能會比較好點兒,架構簡單,遇到問題好排查。但是這種架構,由於要了解後端部署細節,出現主備切換,庫遷移的時候客戶端都會感知到,並且需要調整庫連接信息

  2. 帶proxy的架構,對客戶端比較友好,客戶端不需要了解後端部署細節,連接維護,後端信息維護都由proxy來完成。這樣的架構對後端運維團隊要求比較高,而且proxy本身也要求高可用,所以整體架構相對來說比較複雜

但是不論使用哪種架構,由於主從之間存在延遲,當一個事務更新完成後馬上發起讀請求,如果選擇讀從庫的話,很有可能讀到這個事務更新之前的狀態,我們把這種讀請求叫做過期讀。出現主從延遲的情況有多種,有興趣的同學可以自己瞭解一下,雖然出現主從延遲我們同樣也有應對策略,但是不能100%避免,這些不是我們本次討論的範圍,我們主要討論一下如果出現主從延遲,剛好我們的讀走的都是從庫,我們應該怎麼應對?

首先我把應對的策略總結一下:

  • 強制走主庫

  • sleep方案

  • 判斷主從無延遲

  • 等主庫位點

  • 等GTID方案

接下來基於上述的幾種方案,我們逐個討論一下怎麼實現和有什麼問題。

 

二、主從同步

在開始介紹主從延遲解決方案前先簡單的回顧一下主從的同步

圖片

上圖表示了一個update語句從節點A同步到節點B的完整過程

備庫B和主庫A維護了一個長連接,主庫A內部有一個線程,專門用來服務備庫B的連接。一個事務日誌同步的完整流程是:

  1. 在備庫 B 上通過 change master 命令,設置主庫 A 的 IP、端口、用戶名、密碼,以及要從哪個位置開始請求 binlog,這個位置包含文件名和日誌偏移量。
  2. 在備庫 B 上執行 start slave 命令,這時候備庫會啓動兩個線程,就是圖中的 io_thread 和 sql_thread。
  3. 其中 io_thread 負責與主庫建立連接。
  4. 主庫 A 校驗完用戶名、密碼後,開始按照備庫 B 傳過來的位置,從本地讀取 binlog,發給 B。備庫 B 拿到 binlog 後,寫到本地文件,稱爲中轉日誌(relay log)。
  5. sql_thread 讀取中轉日誌,解析出日誌裏的命令,並執行。

上圖中紅色箭頭,如果用顏色深淺表示併發度的話,顏色越深併發度越高,所以主從延遲時間的長短取決於備庫同步線程執行中轉日誌(圖中的relay log)的快慢。總結一下可能出現主從延遲的原因:

  1. 主庫併發高,TPS大,備庫壓力大執行日誌慢

  2. 大事務,一個事務在主庫執行5s,那麼同樣的到備庫也得執行5s,比如一次性刪除大量的數據,大表DDL等都是大事務

  3. 從庫的並行複製能力,Msyql5.6之前的版本是不支持並行複製的也就是上圖的模型。並行複製也比較複雜,就不在這兒贅述了,大家可以自行復習瞭解一下。

 

三、主從延遲解決方案

1.強制走主庫

這種方案就是要對我們的請求進行分類,通常可以將請求分成兩類:

  • 對於必須要拿到最新結果的請求,可以強制走主庫

  • 對於可以讀到舊數據的請求,可以分配到從庫

這種方案是最簡單的方案,但是這種方案有一個缺點就是,對於所有的請求都不能是過期讀的請求,那麼所有的壓力就又來到了主庫,就得放棄讀寫分離,放棄擴展性

2.sleep方案

sleep方案就是每次查詢從庫之前都先執行一下:select sleep(1),類似這樣的命令,這種方式有兩個問題:

  • 如果主從延遲大於1s,那麼依然讀到的是過期狀態

  • 如果這個請求可能0.5s就能在從庫拿到結果,仍然要等1s

這種方案看起來十分的不靠譜,不專業,但是這種方案確實也有使用的場景。

之前在做項目的時候,有這樣麼一種場景,就是我們先寫主庫,寫完後,發送一個MQ消息,然後消費方接到消息後,調用我們的查詢接口查數據,當然我們也是讀寫分離的模式,就出現了查不到數據的情況,這個時候建議消費方對消息進行一個延遲消費,比如延遲30ms,然後問題就解決了,這種方式類似sleep方案,只不過把sleep放到了調用方

3.判斷主從無延遲方案

1) 命令判斷

show slave status,這個命令是在從庫上執行的,執行的結果裏面有個seconds_behind_master字段,這個字段表示主從延遲多少s,注意單位是秒。所以這種方案就是通過判斷當前這個值是否爲0,如果爲0則直接查詢獲取結果,如果不爲0,則一直等待,直到主從延遲變爲0

因爲這個值是秒級的,但是我們的一些場景下是毫秒級的請求,所以通過這個方式判斷,不是特別精確

2) 對比位點判斷主從無延遲

圖片

上圖是執行一次show slave status 部分結果

  • Master_Log_File和Read_Master_Log_Pos表示讀到的主庫的最新的位點

  • Relay_Master_Log_File和Exec_Master_Log_Pos表示備庫執行的最新的位點

如果Master_Log_File和Relay_Master_Log_File,Read_Master_Log_Pos和Exec_Master_Log_Pos這兩組值完全一致,表示主從之間是沒有延遲的

3) 對比GTID判斷主從無延遲

  • Auto_Position:表示這對主從之間啓用了GTID協議

  • Retrieved_Gtid_Set:表示從庫接收到的所有的GTID的集合

  • Executed_Gtid_Set:表示從庫執行完成的所有的GTID集合

通過比較Retrieved_Gtid_Set和Executed_Gtid_Set集合是否一致,來確定主從是否存在延遲。

可見對比位點和對比GTID集合,比sleep要準確一點兒,在查詢之前都可以先判斷一下是否接收到的日誌都執行完成了,雖然準確度提升了,但是還達不到精確,爲啥這麼說呢?

先回顧一下binlog在一個事物下的狀態

  1. 主庫執行完成,寫入binlog,反饋給客戶端

  2. binlog被從主庫發送到備庫,備庫接收到日誌

  3. 備庫執行binlog

我們上面判斷主備無延遲方案,都是判斷備庫收到的日誌都執行過了,但是從binlog在主備之間的狀態分析,可以看出,還有一部分日誌處於客戶端已經收到提交確認,但是備庫還沒有收到日誌的狀態

圖片

這個時候主庫執行了3個事物,trx1,trx2,trx3,其中

  • trx1,trx2已經傳到從庫,並且從庫已經執行完成

  • trx3主庫已經執行完成,並且已經給客戶端回覆,但是還沒有傳給從庫

這個時候如果在從庫B執行查詢,按照上面我們判斷位點的方式,這個時候主從是沒有延遲的,但是還查不到trx3,嚴格說就是出現了"過期讀"。那麼這個問題有什麼方法可以解決麼?

要解決這個問題,可以引入半同步複製,也就是semi-sync repliacation(參考:https://dev.mysql.com/doc/refman/8.0/en/replication-semisync.html)。

可以通過

show variables like '%rpl_semi_sync_master_enabled%'
show variables like '%rpl_semi_sync_slave_enabled%'

這兩個命令來查看主從是否都開啓了半同步複製。

semi-sync做了這樣的設計:

  1. 事務提交的時候,主庫把binlog發給從庫

  2. 從庫接收到主庫發過來的binlog,給主庫一個ack確認,表示收到了

  3. 主庫收到這個ack確認後,纔給客戶端返回一個事務完成的確認

也就是啓用了semi-sync,表示所有返回給客戶端已經確認完成的事務,從庫都收到了binlog日誌,這樣通過semi-sync配合判斷位點的方式,就可以確定在從庫上的查詢,避免了過期讀的出現。

但是semi-sync配合判斷位點的方式,只適用一主一備的情況,在一主多從的情況下,主庫只要收到一個從庫的ack確認,就給客戶端返回事物執行完成的確認,這個時候在從庫上執行查詢就有兩種情況。

  • 如果查詢剛好是在給主庫響應ack確認的從庫上,那麼可以查詢到正確的數據

  • 但是如果請求落到其他的從庫上,他們可能還沒收到日誌,所以依然可能存在過期讀

其實通過判斷同步位點或者GTID集合的方案,還存在一個潛在的問題,就是業務高峯期,主庫的位點或者GITD集合更新的非常快,那麼兩個位點的判斷一直不相等,很可能出現從庫一直無法響應查詢請求的情況。

上面的兩種方案在靠譜程度和精確性上都差了一點兒,接下來介紹兩種相對靠譜和精確一點兒的方案。

4.等主庫位點

要理解等主庫位點,先介紹一條命令

select master_pos_wait(file, pos[, timeout]);

這條命令執行的邏輯是:

  1. 首先是在從庫執行的

  2. 參數file和pos是主庫的binlog文件名和執行到的位置

  3. timeout參數是非必須,設置爲正整數N,表示這個函數最多等到N秒

這個命令執行結果M可能存在的情況:

  • M>0表示從命令執行開始,到應用完file和pos表示的binlog位置,一共執行了M個事務

  • 如果執行期間,備庫的同步線程發生異常,則返回null

  • 如果等待超過N秒,返回-1

  • 如果剛開始執行的時候,發現已經執行了過了這個pos,則返回0

當一個事務執行完成後,我們要馬上發起一個查詢請求,可以通過下面的步驟實現:

1.當一個事務執行完成後,馬上執行show master status,獲取主庫的File和Position

圖片

2.選擇一個從庫執行查詢

3.在從庫上執行 select master_pos_wait(File,Poistion,1)

4.如果返回的值>=0,則在這個從庫上執行

5.否則回主庫查詢

這裏我們假設,這條查詢請求在從庫上最多等待1s,那麼如果1s內master_pos_wait返回一個大於等於0的數,那麼就能保證在這個從庫上能查到剛執行完的事務的最新的數據。

上述的步驟5是這類方案的兜底方案,因爲從庫的延遲時間不可控,不能無限等待,所以如果超時,就應該放棄,到主庫查詢。

可能有同學會覺得,如果所有的延遲都超過1s,那麼所有的壓力都到了主庫,確實是這樣的,但是按照我們設定的不允許出現過期讀,那麼就只有兩種選擇,要麼超時放棄,要麼轉到主庫,具體選擇哪種,需要我們根據業務進行具體的分析。

5.等GTID方案

如果數據庫開啓的GTID模式,那麼相應的也有等GTID的方案

 select wait_for_executed_gtid_set(gtid_set, 1);

這條命令的邏輯是:

  1. 等待,直到這個庫執行的事務中包含傳入的giid_set集合,返回0

  2. 超時返回1

在前面等待主庫位點的方案中,執行完事務後,需要到主庫執行show master status。從mysql5.7.6開始,允許事務執行完成後,把這個事務執行的GTID返回給客戶端,這樣等待GTIID的方案就減少了一次查詢。

這時等GTID方案的流程就變成這樣:

  1. 事務執行完成後,從返回包解析獲取這個事務的GTID,記爲gtid1

  2. 選定一個從庫執行查詢

  3. 在從庫上執行select wait_for_executed_gtid_set(gtid1,1)

  4. 如果返回0,則在這個從庫上執行查詢

  5. 否則回到主庫查詢

和等待主庫位點方案一樣,最後的兜底方案都是轉到主庫查詢了,需要綜合業務考慮確定方案

上面的事務執行完成後,從返回的包中解析GTID,mysql其實沒有提供對應的命令,可以參考Mysql提供的api(https://dev.mysql.com/doc/c-api/8.0/en/mysql-session-track-get-first.html),在我們的客戶端可以調用這個函數獲取GTID。

四、總結

以上簡單介紹了讀寫分離架構,和出現主從延遲後,如果我們用的讀寫分離的架構,那麼我們應該怎麼處理這種情況,相信在日常我們的主從還是或多或少的存在延遲。上面介紹的幾種方案,有些方案看上去十分不靠譜,有些方案做了一些妥協,但是都有實際的應用場景,需要我們根據自身的業務情況,合理選擇對應的方案。

但話說回來,導致過期讀的本質還是一寫多讀導致的,在實際的應用中,可能有別的不用等待就可以水平擴展的數據庫方案,但這往往都是通過犧牲寫性能獲得的,也就是需要我們在讀性能和寫性能之間做個權衡。

文中有不太嚴謹或者錯誤的地方還望大家多多指正。

-end-

 

作者| 尚有智

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章