Mysql系列第二十五講 mysql如何確保資料不丟失?有幾點值得我們借鑑

2020-10-12 11:00:35

預備知識

  1. mysql內部是使用b+樹的結構將資料儲存在磁碟中,b+樹中節點對應mysql中的頁,mysql和磁碟互動的最小單位為頁,頁預設情況下為16kb,表中的資料記錄儲存在b+樹的葉子節點中,當我們需要修改、刪除、插入資料時,都需要按照頁來對磁碟進行操作。

  2. 磁碟順序寫比隨機寫效率要高很多,通常我們使用的是機械硬碟,機械硬碟寫資料的時候涉及磁碟尋道、磁碟旋轉定址、資料寫入的時間,耗時比較長,如果是順序寫,省去了尋道和磁碟旋轉的時間,效率會高几個數量級。

  3. 記憶體中資料讀寫操作比磁碟中資料讀寫操作速度高好多個數量級。

mysql確保資料不丟失原理分析

我們來思考一下,下面這條語句的執行過程是什麼樣的:

start transaction;
update t_user set name = '易兮' where user_id = 666;
commit;

按照正常的思路,通常過程如下:

  1. 找到user_id=666這條記錄所在的頁p1,將p1從磁碟載入到記憶體中

  2. 在記憶體中對p1中user_id=666這條記錄資訊進行修改

  3. mysql收到commit指令

  4. 將p1頁寫入磁碟

  5. 給使用者端返回更新成功

上面過程可以確保資料被持久化到了磁碟中。

我們將需求改一下,如下:

start transaction;
update t_user set name = '易兮' where user_id = 666;
update t_user set name = 'javacode2018' where user_id = 888;
commit;

來看一下處理過程:

  1. 找到user_id=666這條記錄所在的頁p1,將p1從磁碟載入到記憶體中

  2. 在記憶體中對p1中user_id=666這條記錄資訊進行修改

  3. 找到user_id=888這條記錄所在的頁p2,將p2從磁碟載入到記憶體中

  4. 在記憶體中對p2中user_id=888這條記錄資訊進行修改

  5. mysql收到commit指令

  6. 將p1頁寫入磁碟

  7. 將p2頁寫入磁碟

  8. 給使用者端返回更新成功

上面過程我們看有什麼問題

  1. 假如6成功之後,mysql宕機了,此時p1修改已寫入磁碟,但是p2的修改還未寫入磁碟,最終導致user_id=666的記錄被修改成功了,user_id=888的資料被修改失敗了,資料是有問題的

  2. 上面p1和p2可能位於磁碟的不同位置,涉及到磁碟隨機寫的問題,導致整個過程耗時也比較長

上面問題可以歸納為2點:無法確保資料可靠性、隨機寫導致耗時比較長。

關於上面問題,我們看一下mysql是如何優化的,mysql內部引入了一個redo log,這是一個檔案,對於上面2條更新操作,mysql實現如下:

mysql內部有個redo log buffer,是記憶體中一塊區域,我們將其理解為陣列結構,向redo log檔案中寫資料時,會先將內容寫入redo log buffer中,後續會將這個buffer中的內容寫入磁碟中的redo log檔案,這個個redo log buffer是整個mysql中所有連線共用的記憶體區域,可以被重複使用。

  1. mysql收到start transaction後,生成一個全域性的事務編號trx_id,比如trx_id=10

  2. user_id=666這個記錄我們就叫r1,user_id=888這個記錄叫r2

  3. 找到r1記錄所在的資料頁p1,將其從磁碟中載入到記憶體中

  4. 在記憶體中找到r1在p1中的位置,然後對p1進行修改(這個過程可以描述為:將p1中的pos_start1到pos_start2位置的值改為v1),這個過程我們記為rb1(內部包含事務編號trx_id),將rb1放入redo log buffer陣列中,此時p1的資訊在記憶體中被修改了,和磁碟中p1的資料不一樣了

  5. 找到r2記錄所在的資料頁p2,將其從磁碟中載入到記憶體中

  6. 在記憶體中找到r2在p2中的位置,然後對p2進行修改(這個過程可以描述為:將p2中的pos_start1到pos_start2位置的值改為v2),這個過程我們記為rb2(內部包含事務編號trx_id),將rb2放入redo log buffer陣列中,此時p2的資訊在記憶體中被修改了,和磁碟中p2的資料不一樣了

  7. 此時redo log buffer陣列中有2條記錄[rb1,rb2]

  8. mysql收到commit指令

  9. 將redo log buffer陣列中內容寫入到redo log檔案中,寫入的內容:

1.start trx=10;
2.寫入rb1
3.寫入rb2
4.end trx=10;
  1. 返回給使用者端更新成功。

上面過程執行完畢之後,資料是這樣的:

  1. 記憶體中p1、p2頁被修改了,還未同步到磁碟中,此時記憶體中資料頁和磁碟中資料頁是不一致的,此時記憶體中資料頁我們稱為髒頁

  2. 對p1、p2頁修改被持久到磁碟中的redolog檔案中了,不會丟失

認真看一下上面過程中第9步驟,一個成功的事務記錄在redo log中是有start和end的,redo log檔案中如果一個trx_id對應start和end成對出現,說明這個事務執行成功了,如果只有start沒有end說明是有問題的。

那麼對p1、p2頁的修改什麼時候會同步到磁碟中呢?

redo log是mysql中所有連線共用的檔案,對mysql執行insert、delete和上面update的過程類似,都是先在記憶體中修改頁資料,然後將修改過程持久化到redo log所在的磁碟檔案中,然後返回成功。redo log檔案是有大小的,需要重複利用的(redo log有多個,多個之間採用環形結構結合幾個變數來做到重複利用,這塊知識不做說明,有興趣的可以去網上找一下),當redo log滿了,或者系統比較閒的時候,會對redo log檔案中的內容進行處理,處理過程如下:

  1. 讀取redo log資訊,讀取一個完整的trx_id對應的資訊,然後進行處理

  2. 比如讀取到了trx_id=10的完整內容,包含了start end,表示這個事務操作是成功的,然後繼續向下

  3. 判斷p1在記憶體中是否存在,如果存在,則直接將p1資訊寫到p1所在的磁碟中;如果p1在記憶體中不存在,則將p1從磁碟載入到記憶體,通過redo log中的資訊在記憶體中對p1進行修改,然後將其寫到磁碟中

上面的update之後,p1在記憶體中是存在的,並且p1是已經被修改過的,可以直接重新整理到磁碟中。

如果上面的update之後,mysql宕機,然後重新啟動了,p1在記憶體中是不存在的,此時系統會讀取redo log檔案中的內容進行恢復處理。

  1. 將redo log檔案中trx_id=10的佔有的空間標記為已處理,這塊空間會被釋放出來可以重複利用了

  2. 如果第2步讀取到的trx_id對應的內容沒有end,表示這個事務執行到一半失敗了(可能是第9步驟寫到一半宕機了),此時這個記錄是無效的,可以直接跳過不用處理

上面的過程做到了:資料最後一定會被持久化到磁碟中的頁中,不會丟失,做到了可靠性。

並且內部採用了先把頁的修改操作先在記憶體中進行操作,然後再寫入了redo log檔案,此處redo log是按順序寫的,使用到了io的順序寫,效率會非常高,相對於使用者來說響應會更快。

對於將資料頁的變更持久化到磁碟中,此處又採用了非同步的方式去讀取redo log的內容,然後將頁的變更刷到磁碟中,這塊的設計也非常好,非同步刷盤操作!

但是有一種情況,當一個事務commit的時候,剛好發現redo log不夠了,此時會先停下來處理redo log中的內容,然後在進行後續的操作,遇到這種情況時,整個事物響應會稍微慢一些。

mysql中還有一個binlog,在事務操作過程中也會寫binlog,先說一下binlog的作用,binlog中詳細記錄了對資料庫做了什麼操作,算是對資料庫操作的一個流水,這個流水也是相當重要的,主從同步就是使用binlog來實現的,從庫讀取主庫中binlog的資訊,然後在從庫中執行,最後,從庫就和主庫資訊保持同步一致了。還有一些其他系統也可以使用binlog的功能,比如可以通過binlog來實現bi系統中etl的功能,將業務資料抽取到資料倉儲,阿里提供了一個java版本的專案:canal,這個專案可以模擬從庫從主庫讀取binlog的功能,也就是說可以通過java程式來監控資料庫詳細變化的流水,這個大家可以腦洞大開一下,可以做很多事情的,有興趣的朋友可以去研究一下;所以binlog對mysql來說也是相當重要的,我們來看一下系統如何確保redo log 和binlog在一致性的,都寫入成功的。

還是以update為例:

start transaction;
update t_user set name = '路人甲Java' where user_id = 666;
update t_user set name = 'javacode2018' where user_id = 888;
commit;

一個事務中可能有很多操作,這些操作會寫很多binlog紀錄檔,為了加快寫的速度,mysql先把整個過程中產生的binlog紀錄檔先寫到記憶體中的binlog cache快取中,後面再將binlog cache中內容一次性持久化到binlog檔案中。

過程如下:

  1. mysql收到start transaction後,生成一個全域性的事務編號trx_id,比如trx_id=10

  2. user_id=666這個記錄我們就叫r1,user_id=888這個記錄叫r2

  3. 找到r1記錄所在的資料頁p1,將其從磁碟中載入到記憶體中

  4. 在記憶體中對p1進行修改

  5. 將p1修改操作記錄到redo log buffer中

  6. 將p1修改記錄流水記錄到binlog cache中

  7. 找到r2記錄所在的資料頁p2,將其從磁碟中載入到記憶體中

  8. 在記憶體中對p2進行修改

  9. 將p2修改操作記錄到redo log buffer中

  10. 將p2修改記錄流水記錄到binlog cache中

mysql收到commit指令

將redo log buffer攜帶trx_id=10寫入到redo log檔案,持久化到磁碟,這步操作叫做redo log prepare,內容如下

1.start trx=10;
2.寫入rb1
3.寫入rb2
4.prepare trx=10;

注意上面是prepare了,不是之前說的end了。
  1. 將binlog cache攜帶trx_id=10寫入到binlog檔案,持久化到磁碟

  2. 向redo log中寫入一條資料:end trx=10;表示redo log中這個事務完成了,這步操作叫做redo log commit

  3. 返回給使用者端更新成功

我們來分析一下上面過程可能出現的一些情況:

步驟10操作完成後,mysql宕機了

宕機之前,所有修改都位於記憶體中,mysql重新啟動之後,記憶體修改還未同步到磁碟,對磁碟資料沒有影響,所以無影響。

步驟12執行完畢之後,mysql宕機了

此時redo log prepare過程是寫入redo log檔案了,但是binlog寫入失敗了,此時mysql重新啟動之後會讀取redo log進行恢復處理,查詢到trx_id=10的記錄是prepare狀態,會去binlog中查詢trx_id=10的操作在binlog中是否存在,如果不存在,說明binlog寫入失敗了,此時可以將此操作回滾

步驟13執行完畢之後,mysql宕機

此時redo log prepare過程是寫入redo log檔案了,但是binlog寫入失敗了,此時mysql重新啟動之後會讀取redo log進行恢復處理,查詢到trx_id=10的記錄是prepare狀態,會去binlog中查詢trx_id=10的操作在binlog是存在的,然後接著執行上面的步驟14和15.

做一個總結

上面的過程設計比較好的地方,有2點

紀錄檔先行,io順序寫,非同步操作,做到了高效操作

對資料頁,先在記憶體中修改,然後使用io順序寫的方式持久化到redo log檔案;然後非同步去處理redo log,將資料頁的修改持久化到磁碟中,效率非常高,整個過程,其實就是 MySQL 裡經常說到的 WAL 技術,WAL 的全稱是 Write-Ahead Logging,它的關鍵點就是先寫紀錄檔,再寫磁碟。

兩階段提交確保redo log和binlog一致性

為了確保redo log和binlog一致性,此處使用了二階段提交技術,redo log 和binlog的寫分了3步走:

攜帶trx_id,redo log prepare到磁碟

攜帶trx_id,binlog寫入磁碟

攜帶trx_id,redo log commit到磁碟

上面3步驟,可以確保同一個trx_id關聯的redo log 和binlog的可靠性。

關於上面2點優秀的設計,我們平時開發的過程中也可以借鑑,下面舉2個常見的案例來學習一下。

18級大連海事大學在校生
網路工程 & 國際經濟與貿易 雙學位
中山市易兮軟體開發有限公司創始人
易兮科技團隊負責人
Java語言愛好者