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

導語

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 Log :Redo Log 是 InnoDB 存儲引擎提供的一種物理日誌結構,用來描述對底層數據頁操作的具體內容,主要用於實現 crash-safe,並提升磁盤操作效率。
  • Binlog :Binlog 是 MySQL 本身提供的一種邏輯日誌,和具體存儲引擎無關,描述的是數據庫所執行的 SQL 語句或數據變更情況,主要用於數據複製。

InnoDB 引入 Redo Log 的目的在於實現 crash-safe 和提升數據更新效率。如果 InnoDB 每次數據寫操作都要直接持久化到磁盤上的數據頁中,那樣會大量增加磁盤隨機 IO 次數。引入 Redo Log 後,在對數據寫操作時,會將部分隨機 IO 寫變為順序寫。因為磁盤的順序 IO 效率遠高於隨機 IO,因此引入 Redo Log 機制有助於提升更新數據時的性能(如何實現 crash-safe 將在下一節介紹)。

下面的表格說明了兩種日誌的作用和它們的不同:

Redo LogBinlog日誌類型物理日誌,即數據頁中的真實二級制數據,恢復速度快邏輯日誌,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。如果是新設計的系統,我認為這樣做法比較理想。但對於已有系統,這種方式改動可能較大,是否採用需權衡成本和收益。


分享到:


相關文章: