MySQL 的 crash-safe 原理解析

vivo網際網路技術發表於2020-05-25

本文首發於 vivo網際網路技術 微信公眾號 
連結: https://mp.weixin.qq.com/s/5i9wmJs4_Er7RaYfNnETyA
作者:xieweipeng

MySQL作為當下最流行的開源關係型資料庫,有一個很關鍵和基本的能力,就是必須能夠保證資料不會丟。那麼在這個能力背後,MySQL是如何設計才能保證不管在什麼時間崩潰,恢復後都能保證資料不會丟呢?有哪些關鍵技術支撐了這個能力?本文將為我們一一揭曉。

一、前言

MySQL 保證資料不會丟的能力主要體現在兩方面:

  1. 能夠恢復到任何時間點的狀態;

  2. 能夠保證MySQL在任何時間段突然奔潰,重啟後之前提交的記錄都不會丟失;

對於第一點將MySQL恢復到任何時間點的狀態,相信很多人都知道,只要保留有足夠的binlog,就能透過重跑binlog來實現。

對於第二點的能力,也就是本文標題所講的 crash-safe。即在 InnoDB 儲存引擎中,事務提交過程中任何階段,MySQL突然奔潰,重啟後都能保證事務的完整性,已提交的資料不會丟失,未提交完整的資料會自動進行回滾。這個能力依賴的就是redo log和unod log兩個日誌。

因為crash-safe主要體現在事務執行過程中突然奔潰,重啟後能保證事務完整性,所以在講解具體原理之前,先了解下MySQL事務執行有哪些關鍵階段,後面才能依據這幾個階段來進行解析。下面以一條更新語句的執行流程為例,話不多說,直接上圖:

從上圖可以清晰地看出一條更新語句在MySQL中是怎麼執行的,簡單進行總結一下:

  1. 從記憶體中找出這條資料記錄,對其進行更新;

  2. 將對資料頁的更改記錄到redo log中;

  3. 將邏輯操作記錄到binlog中;

  4. 對於記憶體中的資料和日誌,都是由後臺執行緒,當觸發到落盤規則後再非同步進行刷盤;

上面演示了一條更新語句的詳細執行過程,接下來我們們透過解答問題,帶著問題來剖析這個crash-safe的設計原理。

二、WAL機制

問題:為什麼不直接更改磁碟中的資料,而要在記憶體中更改,然後還需要寫日誌,最後再落盤這麼複雜?

這個問題相信很多同學都能猜出來,MySQL更改資料的時候,之所以不直接寫磁碟檔案中的資料,最主要就是效能問題。因為直接寫磁碟檔案是隨機寫,開銷大效能低,沒辦法滿足MySQL的效能要求。所以才會設計成先在記憶體中對資料進行更改,再非同步落盤。但是記憶體總是不可靠,萬一斷電重啟,還沒來得及落盤的記憶體資料就會丟失,所以還需要加上寫日誌這個步驟,萬一斷電重啟,還能透過日誌中的記錄進行恢復。

寫日誌雖然也是寫磁碟,但是它是順序寫,相比隨機寫開銷更小,能提升語句執行的效能(針對順序寫為什麼比隨機寫更快,可以比喻為你有一個本子,按照順序一頁一頁寫肯定比寫一個字都要找到對應頁寫快得多)。

這個技術就是大多數儲存系統基本都會用的 WAL(Write Ahead Log)技術,也稱為日誌先行的技術,指的是對資料檔案進行修改前,必須將修改先記錄日誌。保證了資料一致性和永續性,並且提升語句執行效能。

三、核心日誌模組

問題:更新SQL語句執行流程中,總共需要寫3個日誌,這3個是不是都需要,能不能進行簡化?

更新SQL執行過程中,總共涉及 MySQL日誌模組其中的三個核心日誌,分別是redo log(重做日誌)、undo log(回滾日誌)、binlog(歸檔日誌)。這裡提前預告,crash-safe的能力主要依賴的就是這三大日誌。

接下來,針對每個日誌將單獨介紹各自的作用,然後再來評估是否能簡化掉。

1、重做日誌 redo log

redo log也稱為事務日誌,由InnoDB儲存引擎層產生。記錄的是資料庫中每個頁的修改,而不是某一行或某幾行修改成怎樣,可以用來恢復提交後的物理資料頁(恢復資料頁,且只能恢復到最後一次提交的位置,因為修改會覆蓋之前的)。

前面提到的WAL技術,redo log就是WAL的典型應用,MySQL在有事務提交對資料進行更改時,只會在記憶體中修改對應的資料頁和記錄redo log日誌,完成後即表示事務提交成功,至於磁碟資料檔案的更新則由後臺執行緒非同步處理。由於redo log的加入,保證了MySQL資料一致性和永續性(即使資料刷盤之前MySQL奔潰了,重啟後仍然能透過redo log裡的更改記錄進行重放,重新刷盤),此外還能提升語句的執行效能(寫redo log是順序寫,相比於更新資料檔案的隨機寫,日誌的寫入開銷更小,能顯著提升語句的執行效能,提高併發量),由此可見redo log是必不可少的。

redo log是固定大小的,所以只能迴圈寫,從頭開始寫,寫到末尾就又回到開頭,相當於一個環形。當日志寫滿了,就需要對舊的記錄進行擦除,但在擦除之前,需要確保這些要被擦除記錄對應在記憶體中的資料頁都已經刷到磁碟中了。在redo log滿了到擦除舊記錄騰出新空間這段期間,是不能再接收新的更新請求,所以有可能會導致MySQL卡頓。(所以針對併發量大的系統,適當設定redo log的檔案大小非常重要!!!)

2、回滾日誌 undo log

undo log顧名思義,主要就是提供了回滾的作用,但其還有另一個主要作用,就是多個行版本控制(MVCC),保證事務的原子性。在資料修改的流程中,會記錄一條與當前操作相反的邏輯日誌到undo log中(可以認為當delete一條記錄時,undo log中會記錄一條對應的insert記錄,反之亦然,當update一條記錄時,它記錄一條對應相反的update記錄),如果因為某些原因導致事務異常失敗了,可以藉助該undo log進行回滾,保證事務的完整性,所以undo log也必不可少。

3、歸檔日誌 binlog

binlog在MySQL的server層產生,不屬於任何引擎,主要記錄使用者對資料庫操作的SQL語句(除了查詢語句)。之所以將binlog稱為歸檔日誌,是因為binlog不會像redo log一樣擦掉之前的記錄迴圈寫,而是一直記錄(超過有效期才會被清理),如果超過單日誌的最大值(預設1G,可以透過變數 max_binlog_size 設定),則會新起一個檔案繼續記錄。但由於日誌可能是基於事務來記錄的(如InnoDB表型別),而事務是絕對不可能也不應該跨檔案記錄的,如果正好binlog日誌檔案達到了最大值但事務還沒有提交則不會切換新的檔案記錄,而是繼續增大日誌,所以 max_binlog_size 指定的值和實際的binlog日誌大小不一定相等。

正是由於binlog有歸檔的作用,所以binlog主要用作主從同步和資料庫基於時間點的還原。

那麼回到剛才的問題,binlog可以簡化掉嗎?這裡需要分場景來看:

  1. 如果是主從模式下,binlog是必須的,因為從庫的資料同步依賴的就是binlog;

  2. 如果是單機模式,並且不考慮資料庫基於時間點的還原,binlog就不是必須,因為有redo log就可以保證crash-safe能力了;但如果萬一需要回滾到某個時間點的狀態,這時候就無能為力,所以建議binlog還是一直開啟;

根據上面對三個日誌的詳解,我們可以對這個問題進行解答:在主從模式下,三個日誌都是必須的;在單機模式下,binlog可以視情況而定,保險起見最好開啟。

四、兩階段提交

問題:為什麼redo log要分兩步寫,中間再穿插寫binlog呢?

從上面可以看出,因為redo log影響主庫的資料,binlog影響從庫的資料,所以redo log和binlog必須保持一致才能保證主從資料一致,這是前提。

相信很多有過開發經驗的同學都知道分散式事務,這裡的redo log和binlog其實就是很典型的分散式事務場景,因為兩者本身就是兩個獨立的個體,要想保持一致,就必須使用分散式事務的解決方案來處理。而將redo log分成了兩步,其實就是使用了兩階段提交協議(Two-phase Commit,2PC)。

下面對更新語句的執行流程進行簡化,看一下MySQL的兩階段提交是如何實現的:

從圖中可看出,事務的提交過程有兩個階段,就是將redo log的寫入拆成了兩個步驟:prepare和commit,中間再穿插寫入binlog。

如果這時候你很疑惑,為什麼一定要用兩階段提交呢,如果不用兩階段提交會出現什麼情況,比如先寫redo log,再寫binlog或者先寫binlog,再寫redo log不行嗎?下面我們用反證法來進行論證。

我們繼續用update T set c=c+1 where id=2這個例子,假設id=2這一條資料的c初始值為0。那麼在redo log寫完,binlog還沒有寫完的時候,MySQL程式異常重啟。由於redo log已經寫完了,系統重啟後會透過redo log將資料恢復回來,所以恢復後這一行c的值是1。但是由於binlog沒寫完就crash了,這時候binlog里面就沒有記錄這個語句。因此,不管是現在的從庫還是之後透過這份binlog還原臨時庫都沒有這一次更新,c的值還是0,與原庫的值不同。

同理,如果先寫binlog,再寫redo log,中途系統crash了,也會導致主從不一致,這裡就不再詳述。

所以將redo log分成兩步寫,即兩階段提交,才能保證redo log和binlog內容一致,從而保證主從資料一致。

兩階段提交雖然能夠保證單事務兩個日誌的內容一致,但在多事務的情況下,卻不能保證兩者的提交順序一致,比如下面這個例子,假設現在有3個事務同時提交:

T1 (--prepare--binlog---------------------commit)
T2 (-----prepare-----binlog----commit)
T3 (--------prepare-------binlog------commit)

解析:

    redo log prepare的順序:T1 --》T2 --》T3

    binlog的寫入順序:T1 --》 T2 --》T3

    redo log commit的順序:T2 --》 T3 --》T1

結論:由於binlog寫入的順序和redo log提交結束的順序不一致,導致binlog和redo log所記錄的事務提交結束的順序不一樣,最終導致的結果就是主從資料不一致。

因此,在兩階段提交的流程基礎上,還需要加一個鎖來保證提交的原子性,從而保證多事務的情況下,兩個日誌的提交順序一致。所以在早期的MySQL版本中,透過使用prepare_commit_mutex鎖來保證事務提交的順序,在一個事務獲取到鎖時才能進入prepare,一直到commit結束才能釋放鎖,下個事務才可以繼續進行prepare操作。透過加鎖雖然完美地解決了順序一致性的問題,但在併發量較大的時候,就會導致對鎖的爭用,效能不佳。除了鎖的爭用會影響到效能之外,還有一個對效能影響更大的點,就是每個事務提交都會進行兩次fsync(寫磁碟),一次是redo log落盤,另一次是binlog落盤。大家都知道,寫磁碟是昂貴的操作,對於普通磁碟,每秒的QPS大概也就是幾百。

五、組提交

問題:針對透過在兩階段提交中加鎖控制事務提交順序這種實現方式遇到的效能瓶頸問題,有沒有更好的解決方案呢?

答案自然是有的,在MySQL 5.6 就引入了binlog組提交,即BLGC(Binary Log Group Commit)。binlog組提交的基本思想是,引入佇列機制保證InnoDB commit順序與binlog落盤順序一致,並將事務分組,組內的binlog刷盤動作交給一個事務進行,實現組提交目的。具體如圖:

 

第一階段(prepare階段):

持有prepare_commit_mutex,並且write/fsync redo log到磁碟,設定為prepared狀態,完成後就釋放prepare_commit_mutex,binlog不作任何操作。

第二個階段(commit階段):這裡拆分成了三步,每一步的任務分配給一個專門的執行緒處理:

  1. Flush Stage(寫入binlog快取)

    ① 持有Lock_log mutex [leader持有,follower等待]

    ② 獲取佇列中的一組binlog(佇列中的所有事務)

    ③ 寫入binlog快取

  2. Sync Stage(將binlog落盤)

    ①釋放Lock_log mutex,持有Lock_sync mutex[leader持有,follower等待]

    ②將一組binlog落盤(fsync動作,最耗時,假設sync_binlog為1)。

  3. Commit Stage(InnoDB commit,清楚undo資訊)

    ①釋放Lock_sync mutex,持有Lock_commit mutex[leader持有,follower等待]

    ② 遍歷佇列中的事務,逐一進行InnoDB commit

    ③ 釋放Lock_commit mutex

每個Stage都有自己的佇列,佇列中的第一個事務稱為leader,其他事務稱為follower,leader控制著follower的行為。每個佇列各自有mutex保護,佇列之間是順序的。只有flush完成後,才能進入到sync階段的佇列中;sync完成後,才能進入到commit階段的佇列中。但是這三個階段的作業是可以同時併發執行的,即當一組事務在進行commit階段時,其他新事務可以進行flush階段,實現了真正意義上的組提交,大幅度降低磁碟的IOPS消耗。

針對組提交為什麼比兩階段提交加鎖效能更好,簡單做個總結:組提交雖然在每個佇列中仍然保留了prepare_commit_mutex鎖,但是鎖的粒度變小了,變成了原來兩階段提交的1/4,所以鎖的爭用性也會大大降低;另外,組提交是批次刷盤,相比之前的單條記錄都要刷盤,能大幅度降低磁碟的IO消耗。

六、資料恢復流程

問題:假設事務提交過程中,MySQL程式突然奔潰,重啟後是怎麼保證資料不丟失的?

下圖就是MySQL重啟後,提供服務前會先做的事 -- 恢復資料的流程:

對上圖進行簡單描述就是:奔潰重啟後會檢查redo log中是完整並且處於prepare狀態的事務,然後根據XID(事務ID),從binlog中找到對應的事務,如果找不到,則回滾;找到並且事務完整則重新commit redo log,完成事務的提交。

下面我們根據事務提交流程,在不同的階段時刻,看看MySQL突然奔潰後,按照上述流程是如何恢復資料的。

  1. 時刻A(剛在記憶體中更改完資料頁,還沒有開始寫redo log的時候奔潰):

    因為記憶體中的髒頁還沒刷盤,也沒有寫redo log和binlog,即這個事務還沒有開始提交,所以奔潰恢復跟該事務沒有關係;

  2. 時刻B(正在寫redo log或者已經寫完redo log並且落盤後,處於prepare狀態,還沒有開始寫binlog的時候奔潰):

    恢復後會判斷redo log的事務是不是完整的,如果不是則根據undo log回滾;如果是完整的並且是prepare狀態,則進一步判斷對應的事務binlog是不是完整的,如果不完整則一樣根據undo log進行回滾;

  3. 時刻C(正在寫binlog或者已經寫完binlog並且落盤了,還沒有開始commit redo log的時候奔潰):

    恢復後會跟時刻B一樣,先檢查redo log中是完整並且處於prepare狀態的事務,然後判斷對應的事務binlog是不是完整的,如果不完整則一樣根據undo log回滾,完整則重新commit redo log;

  4. 時刻D(正在commit redo log或者事務已經提交完的時候,還沒有反饋成功給客戶端的時候奔潰):

    恢復後跟時刻C基本一樣,都會對照redo log和binlog的事務完整性,來確認是回滾還是重新提交。

七、總結

至此對MySQL 的crash-safe原理細節就基本講完了,簡單回顧一下:

  1. 首先簡單介紹了WAL日誌先行技術,包括它的定義、流程和作用。WAL是大部分資料庫系統實現一致性和永續性的通用設計模式。;

  2. 接著對MySQL的日誌模組,redo log、undo log、binlog、兩階段提交和組提交都進行了詳細介紹;

  3. 最後講解了資料恢復流程,並從不同時刻加以驗證。

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

相關文章