MySQL Binlog 技術原理和業務應用案例分析

愛奇藝技術產品團隊發表於2020-07-15

導語


MySQL Binlog用於記錄使用者對資料庫操作的結構化查詢語言(Structured Query Language,SQL)語句資訊。是MySQL資料庫的二進位制日誌,可以使用mysqlbin命令檢視二進位制日誌的內容。愛奇藝在會員訂單系統使用到了 MySQL Binlog,用來實現訂單事件驅動。在使用Binlog 後在簡化系統設計的同時幫助系統提升了可用性和資料一致性。

本文將從實際應用角度出發理解 MySQL中的相關技術原理,從技術原理和工作實踐相結合,幫助大家以及在相關設計中存在的潛在問題,希望能給大家有所幫助和啟發,共同進步。

作者介紹:作者帆叔目前主要負責愛奇藝會員交易系統的技術和架構工作,專注非同步程式設計、服務治理、程式碼重構等領域,熱愛技術,樂於分享。

背 景

Binlog 是 MySQL 中一個很重要的日誌,主要用於 MySQL 主從間的資料同步複製。正是因為 Binlog 的這項功用,它也被用於 MySQL 向其它型別資料庫同步資料,以及業務流程的事件驅動設計。透過研究分析,我們發現使用 MySQL Binlog 實現事件驅動設計並沒有想象中那麼簡單,所以接下來帶大家瞭解 MySQL 的 Binlog、Redo Log、資料更新內部流程,並透過對這些技術原理的介紹,來分析對業務流程可能造成的問題,以及如何避免這些問題。希望透過本文的解析,能夠幫助大家瞭解到 MySQL 的一些原理,從而幫助大家能夠更順利地使用 MySQL 這個流行的資料庫技術。

基於 Binlog 的事件驅動

首先介紹一下會員訂單系統的設計,訂單系統直接向 MQ 傳送訊息,透過非同步訊息驅動後續業務流程,以實現訊息驅動的設計。大致的業務流程示意圖如下:

MySQL Binlog 技術原理和業務應用案例分析

 圖1:直接傳送訊息的訂單事件驅動 

這種設計需要保證資料庫操作和訊息操作的資料一致性,即資料儲存和訊息傳送要全部成功或者全部失敗。顯然在資料儲存前和事務中進行訊息傳送都是不合適的。我們是在資料更新操作後,資料庫事務外傳送訊息。如果資料儲存成功,但訊息傳送失敗,支付系統需要重新通知(上圖步驟1),直至通知成功。這種設計雖然實現了功能和對可用性的基本要求,但存在如下缺點:
1. 業務系統直接依賴訊息中介軟體
訊息中介軟體的故障,不僅會影響支付通知的處理也可能影響業務系統上的其它介面。
2. 業務系統必須實現可靠的重試
不論是請求發起方還是請求接收方都必須實現可靠重試才能實現最大努力通知的目標。
3. 重試間隔增大會造成業務延遲
隨著重試次數增加,每次重試的間隔通常也越來越大,這成為 Exponential Backoff(指數級退避)。這種設計能夠讓請求接收方的故障處理更加從容,避免因密集重試造成請求接收方服務難以恢復。但這樣做可能會使請求接收方在恢復服務之後很長時間後才處理完積壓的訊息,從而造成業務延遲。我們可以採用類似 Hystrix 的自適應設計,在請求接收方服務恢復後回到到正常的請求速率。但這樣的設計顯然會複雜許多。
為了解決上述問題,簡化技術架構,我們採用事件表的設計思想,將訂單表作為事件表。透過訂閱訂單表的 Binlog,生成訂單事件,驅動後續業務流程。在系統架構上,業務系統不用直接依賴訊息中介軟體,只需專注資料庫操作。而透過引入一個接收 Binlog 的獨立的系統,將 MySQL 資料變化轉換成業務事件驅動後續流程。具體流程如下:

MySQL Binlog 技術原理和業務應用案例分析

圖2:基於 Binlog 的訂單事件驅動 

暗 藏 問 題

上文提到,雖然基於 Binlog 的訂單事件驅動設計存在諸多優點,但後來發現其實暗藏問題。經過實驗,我們發現偶爾會有訂單履約延遲的現象。

在正常流程中,訂單履約服務收到訂單支付事件後,會檢查訂單狀態,如果此時訂單狀態為已支付,則進行履約流程的處理。但對於有履約延遲的訂單,訂單履約服務收到此訂單的支付事件後,查詢資料庫發現此訂單並非支付狀態。經過調查,我們排除了資料併發覆蓋問題,並且訂單狀態查詢是發生在主庫上,也不存在主從同步延遲問題。

那究竟是什麼原因導致業務系統收到根據 Binlog 生成的訂單支付事件後,再查詢主庫得到的訂單資料卻是未支付狀態的?

對於此問題的原因我們先放下不談,先來看看 MySQL 在更新資料時的內部原理。

MySQL 資料更新相關原理

本節將向大家介紹 MySQL 資料更新相關原理,以及在這一過程中最重要的兩種日誌:Redo Log 和 Binlog。
>>>> Redo Log 和 Binlog
先來介紹 Redo Log 和 Binary Log(Binlog):
· Redo LogRedo Log 是 InnoDB 儲存引擎提供的一種物理日誌結構,用來描述對底層資料頁操作的具體內容,主要用於實現 crash-safe,並提升磁碟操作效率。
· BinlogBinlog 是 MySQL 本身提供的一種邏輯日誌,和具體儲存引擎無關,描述的是資料庫所執行的 SQL 語句或資料變更情況,主要用於資料複製。
InnoDB 引入 Redo Log 的目的在於實現 crash-safe 和提升資料更新效率。如果 InnoDB 每次資料寫操作都要直接持久化到磁碟上的資料頁中,那樣會大量增加磁碟隨機 IO 次數。引入 Redo Log 後,在對資料寫操作時,會將部分隨機 IO 寫變為順序寫。因為磁碟的順序 IO 效率遠高於隨機 IO,因此引入 Redo Log 機制有助於提升更新資料時的效能(如何實現 crash-safe 將在下一節介紹)。
下面的表格說明了兩種日誌的作用和它們的不同:


Redo Log

Binlog

日誌型別

物理日誌,即資料頁中的真實二級制資料,恢復速度快

邏輯日誌,SQL 語句 (statement) 或資料邏輯變化 (row),恢復速度慢

儲存格式

基於 InnoDB 資料頁格式進行儲存

SQL 語句或資料變化內容

用途

重做資料頁

資料複製

層級

InnoDB 儲存引擎層

MySQL Server 層

記錄方式

迴圈寫

追加寫

這時問題來了,現在 MySQL 中存在了兩種日誌結構:Redo Log 和 Binlog。雖然它們的結構和功能有所不同,但卻記錄著相同的資料。如何保證這兩種日誌資料的一致性,以及如何實現 crash-safe 呢?這就引出了兩階段提交設計。

>>>> 兩階段提交

兩階段提交不是 Redo Log 或 InnoDB 中的設計,而是 MySQL 伺服器的設計(但通常說到兩階段提交時都和 Redo Log 放在一起)。因為 MySQL 採用外掛化的儲存引擎設計,事務提交時,伺服器本身和儲存引擎都需要提交資料。所以從 MySQL 伺服器角度看,其本身就面臨著分散式事務問題。
為解決此問題,MySQL 引入了兩階段提交。在兩階段提交過程中,Redo Log 會有兩次操作:Prepare 和 Commit。而 Binlog 寫操作則夾在 Redo Log 的 Prepare 和 Commit 操作之間。我們可以設想一下不同失敗場景下兩階段提交的設計是如何保證資料一致的:
1. Redo Log Prepare 成功,在寫 Binlog 前崩潰:在故障恢復後事務就會回滾。這樣 Redo Log 和 Binlog 的內容還是一致的。這種情況比較簡單,比較複雜的是下一種情況,即在寫 Binlog 和 Redo Log Commit 中間崩潰時,MySQL 是如何處理的?
2. 在寫 Binlog 之後,但 Redo Log 還沒有 Commit 之前崩潰

  • 如果 Redo Log 有 Commit 標識,說明 Redo Log 其實已經 Commit 成功。這時直接提交事務;
  • 如果 Redo Log 沒有 Commit 標識,則使用 XID(事務 ID)查詢 Binlog 相應日誌,並檢查日誌的完整。如果 Binlog 是完整的,則提交事務,否則回滾;

如何判斷 Binlog 是否完整?簡單來說 Statement 格式的 Binlog 最後有 Commit,或 Row 格式的 Binlog 有 XID Event,那 Binlog 就是完整的。

>>>> MySQL 資料更新流程

接下來看一下 MySQL 執行器和 InnoDB 儲存引擎在執行簡單 update 語句 update t set n = n + 1 where id = 2 時的流程(因為此例只執行單條更新語句,所以其自身就是一個事務)。

  1. 執行器先找引擎取 ID=2 這一行。ID 是主鍵,引擎直接用樹搜尋找到這一行。如果 ID=2 這一行所在的資料頁本來就在記憶體中,就直接返回給執行器;否則,需要先從磁碟讀入記憶體,然後再返回。
  2. 執行器拿到引擎給的行資料,把這個值加上1,比如原來是 N,現在就是 N+1,得到新的一行資料,再呼叫引擎介面寫入這行新資料。
  3. 引擎將這行新資料更新到記憶體中。然後將對記憶體資料頁的更新內容記錄在 Redo Log Buffer 中(這裡不詳細介紹 Redo Log Buffer。只需知道對 Redo Log 的操作並不會直接寫在檔案上,而是先記錄在記憶體中,然後在特定時刻才會寫入磁碟)。此時完成了資料更新操作。
  4. 接下來要進行事務提交的操作。事務提交時,Redo Log 被標記為 Prepare 狀態。通常此時,Redo Log 會從 Buffer 寫入磁碟(innodb_flush_log_at_trx_commit,值為1時,每次提交事務 Redo Log 都會寫入磁碟)。然後 InnoDB 告知執行器執行完成,可以提交事務。
  5. 執行器生成本次操作的 Binlog,並把 Binlog 寫入磁碟。
  6. 執行器呼叫引擎的提交事務介面,引擎把剛剛寫入的 Redo Log 改成提交 Commit 狀態,更新完成。

MySQL Binlog 技術原理和業務應用案例分析

圖中描述了 update 語句執行過程中 MySQL 執行器、InnoDB,以及 Binlog、Redo Log 互動過程(圖中深綠底色的是 MySQL 執行器負責的階段,淺綠底色是 InnoDB 負責的階段)

問題解析

從上面對 MySQL 原理的介紹我們得知,寫 Binlog 發生在事務提交階段,但是 MySQL 因為在 Server 層和儲存引擎層都引入了不同的日誌結構,從而引入了兩階段提交。Binlog 的寫入發生在儲存引擎真正提交事務之前,這導致理論上透過 Binlog 同步資料的系統(MySQL 從庫、其它資料庫或業務系統)有可能早於 MySQL 主庫使最新提交的資料生效。

所以上面提到的訂單履約服務在收到基於 Binlog 的訂單支付事件後卻查到相應訂單是未支付的,原因很可能是訂單履約服務在查詢資料時,訂單支付資料更新操作在 MySQL 內部尚未徹底完成事務的提交。

我們透過開發驗證程式重現了這一現象。驗證程式接收到事務提交完成後的完整 Binlog 時會再次在 MySQL 主庫上查詢對應的記錄,結果會有一定概覽獲得事務提交前的資料。

另外經過了解,也有同行反映遇到過從庫早於主庫看到資料提交的問題。

問題的解決方法

在瞭解問題背後的原因之後,我們需要思考如何解決此問題。目前解決此問題有兩個方法:重試和直接使用 Binlog 資料。

重試這種做法簡單粗暴,既然問題原因是 Binlog 早於事務提交,那等一下再重試查詢自然就解決了。但在實踐中,需要考慮重試的實現方法、以及是否會因為重試過多甚至無限重試導致服務異常。對於重試的實現,可使用的方法有執行緒 Sleep 大法和訊息重投等方式。執行緒 Sleep 大法通常是不被推薦的,因為它會導致執行緒利用率降低,甚至導致服務無法響應。但考慮到本次問題出現機率較低,我們認為執行緒 Sleep 大法是可以使用的,並且此方式簡單易行,可用於問題的快速修復。

第二種重試方式是訊息重投,比如 RocketMQ 中 Consumer 返回 ConsumeConcurrentlyStatus.RECONSUME_LATER 即可觸發訊息重投。但這種重試方法成本較前一種方法高,另外重試間隔也相對較大,對時間敏感的業務影響也較大,因此是否採用此方法需從業務和技術兩個角度綜合考慮。

除了考慮用何種方式重試,還要考慮 ABA 問題,即狀態變化按照 A->B->A 的方式進行。業務系統期待的狀態是 B,但實際可能沒辦法再變成 B 了。因此在用重試解決此問題之前,需要先排除業務系統存在 ABA 問題的可能。對於狀態 ABA 問題,可用狀態機等方式解決,這裡不再展開討論。

除了重試,另一種方法就是直接使用 Binlog。因為 Binlog (row 格式) 直接反映了資料的變化情況,其中可以記錄事務提交涉及到的完整資料,因此可直接用作業務處理。這樣還可以降低資料庫 QPS。如果是新設計的系統,我認為這樣做法比較理想。但對於已有系統,這種方式改動可能較大,是否採用需權衡成本和收益。

招聘資訊

愛奇藝會員開發團隊誠招 Java 資深工程師/技術專家。會員業務是愛奇藝核心業務之一,我們致力於透過技術手段服務核心業務,研發通用化、高可用的業務系統,同時我們也需要擅長如資料庫、服務治理、MQ 等技術的人才。歡迎感興趣的同學傳送簡歷至:luodi@qiyi.com(郵件標題請註明:會員開發)

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69945252/viewspace-2704743/,如需轉載,請註明出處,否則將追究法律責任。

相關文章