MySQL 崩潰恢復(fù)過程分析
天有不測風(fēng)云,數(shù)據(jù)庫有旦夕禍福。
前面寫 Redo 日志的文章介紹過,數(shù)據(jù)庫正常運行時,Redo 日志就是個累贅。
現(xiàn)在,終于到了 Redo 日志揚(yáng)眉吐氣,大顯身手的時候了。
本文我們一起來看看,MySQL 在崩潰恢復(fù)過程中都干了哪些事情,Redo 日志又是怎么大顯身手的。
本文介紹的崩潰恢復(fù)過程,包含 server 層和 InnoDB,不涉及其它存儲引擎,內(nèi)容基于 MySQL 8.0.29 源碼。
正文
1、概述
MySQL 崩潰也是一次關(guān)閉過程,只是比正常關(guān)閉著急了一些。
正常關(guān)閉時,MySQL 會做一系列收尾工作,例如:清理 undo 日志、合并 change buffer 緩沖區(qū)等操作。
具體會進(jìn)行哪些收尾工作,取決于系統(tǒng)變量 innodb_fast_shutdown 的配置。
崩潰直接就是戛然而止,撂挑子不干了,還沒來得及進(jìn)行的那些收尾工作怎么辦?
那就只能等待下次啟動的時候再干了,這就是本文要介紹的崩潰恢復(fù)過程。
2、讀取兩次寫頁面
MySQL 一旦崩潰,Redo 日志就要去拯救世界了(MySQL 就是它的世界),Redo 日志拯救世界的方式就是把還沒來得及刷盤的臟頁恢復(fù)到崩潰之前那一刻的狀態(tài)。
雖然 Redo 日志能夠用來恢復(fù)數(shù)據(jù)頁,但這是有前提條件的:數(shù)據(jù)頁必須完好無損的狀態(tài)。
本文我們把系統(tǒng)表空間、獨立表空間、undo 表空間中的頁統(tǒng)稱為數(shù)據(jù)頁。
如果數(shù)據(jù)頁剛寫了一半,MySQL 就戛然而止,這個數(shù)據(jù)頁就損壞了,面對這種情況,Redo 日志也是巧婦難為無米之炊。
Redo 日志拯救世界之路就要因為這個問題停滯不前嗎?
那顯示是不能的,這就該輪到兩次寫上場了。
兩次寫?的官方名字是 double write?,它包含內(nèi)存緩沖區(qū)?和 dblwr 文件兩個部分,InnoDB 臟頁刷盤前,都會先把臟頁寫入內(nèi)存緩沖區(qū),再寫入 dblwr 文件,成功之后才會把臟頁刷盤。
兩次寫通過系統(tǒng)變量 innodb_doublewrite? 控制開啟或關(guān)閉,本文內(nèi)容基于該系統(tǒng)變量的默認(rèn)值 ON,表示開啟兩次寫。
如果臟頁寫入內(nèi)存緩沖區(qū)和 dblwr 文件的程中,MySQL 崩潰了,表空間中對應(yīng)的數(shù)據(jù)頁還是完整的,下次啟動時,不需要用兩次寫頁面修復(fù)這個數(shù)據(jù)頁。
如果臟頁刷盤時,MySQL 崩潰了,表空間對應(yīng)的數(shù)據(jù)頁損壞了,下次啟動時,應(yīng)用 Redo 日志到數(shù)據(jù)頁之前?,需要用兩次寫頁面修復(fù)這個數(shù)據(jù)頁。
dblwr 文件 默認(rèn)位于 MySQL 數(shù)據(jù)目錄下:
[csch@csch /usr/local/mysql_8_0_29/data] ls -l | grep dblwr
-rw-r----- 1 csch staff 192K 8 27 12:04 #ib_16384_0.dblwr
-rw-r----- 1 csch staff 8.2M 8 1 16:29 #ib_16384_1.dblwr
MySQL 啟動過程中,會把 *.dblwr 文件中的所有兩次寫頁面加載到兩次寫內(nèi)存緩沖區(qū),并用內(nèi)存緩沖區(qū)中的兩次寫頁面修復(fù)損壞的數(shù)據(jù)頁,然后再應(yīng)用 Redo 日志到數(shù)據(jù)頁。
3、恢復(fù)數(shù)據(jù)頁
應(yīng)用 Redo 日志到數(shù)據(jù)頁(3.4 小節(jié)),需要先讀取 Redo 日志(3.3 小節(jié))。
讀取日志 Redo 日志,需要有個起點,起點就是最后一次 checkpoint 的 lsn(3.1 小節(jié))。
應(yīng)用 Redo 日志有一個前提:數(shù)據(jù)頁必須是完好無損的。要保證數(shù)據(jù)頁的完整性,應(yīng)用 Redo 日志之前需要修復(fù)損壞的數(shù)據(jù)頁(3.2 小節(jié))。
修復(fù)損壞數(shù)據(jù)頁只需要保證在應(yīng)用 Redo 日志之前就行了,之所以安排在 3.2 小節(jié),是遵循了源碼中的順序。
了解本節(jié)安排內(nèi)容順序的邏輯,有助于理解應(yīng)用 Redo 日志恢復(fù)數(shù)據(jù)頁的過程,接下來我們正式進(jìn)入下一個環(huán)節(jié)。
(1)找到 last_checkpoint_lsn
讀取 Redo 日志之前,必須先確定一個起點,這個起點就是 InnoDB 最后一次 checkpoint 操作的 lsn,也就是 last_checkpoint_lsn。
每個 Redo 日志文件的前 4 個 block 都是保留空間,不會用來寫 Redo 日志,last_checkpoint_lsn? 和其它 checkpoint 信息一起,位于第 1 個 Redo 日志文件的第 2、4 個 block 中。
Redo 日志文件中每個 block 的大小為 512 字節(jié)。
InnoDB 每次進(jìn)行 checkpoint 操作時,都會把 checkpoint_no 加 1,用于標(biāo)識一次 checkpoint 操作。
然后把本次 checkpoint 信息寫入 Redo 日志文件的第 2 或第 4 個 block 中。具體寫入哪個 block,取決于 checkpoint_no。
如果 checkpoint_no 是奇數(shù),checkpoint 信息寫入第 4 個 block。
如果 checkpoint_no 是偶數(shù),checkpoint 信息寫入第 2 個 block。
確定讀取 Redo 日志的起點時,從第 2、4 個 block 中讀取較大的那個 last_checkpoint_lsn 作為起點。
為什么 checkpoint 信息要存儲到 2 個 block 中?
這是一個用于保證 checkpoint 信息安全性的簡單好用的方法,因為每次 checkpoint 只會往其中一個 block 寫入信息。
萬一就在某次寫 checkpoint 信息的過程中 MySQL 崩潰了,有可能導(dǎo)致正在寫入的這個 block 中的 checkpoint 信息不正確。
這種情況下,另一個 block 中的 checkpoint 信息肯定是正確的了,因為它里面的信息是上一次正常寫入的。
能夠用這種冗余方式來保證 checkpoint block 的安全性,基于一個前提:last_checkpoint_lsn 不需要那么精確。
last_checkpoint_lsn 比實際需要應(yīng)用 Redo 日志起點處的 lsn 小是沒關(guān)系的,不會造成數(shù)據(jù)頁不正確,只是會多掃描一點 Redo 日志而已,應(yīng)用 Redo 日志時會過濾已經(jīng)刷盤的臟頁對應(yīng)的 Redo 日志。
(2)修復(fù)損壞的數(shù)據(jù)頁
把兩次寫文件中的所有數(shù)據(jù)頁都加載到內(nèi)存緩沖區(qū)之后,需要用這些頁來把系統(tǒng)表空間、獨立表空間、undo 表空間中損壞的數(shù)據(jù)頁恢復(fù)到正常狀態(tài)。
正常狀態(tài)指的是 MySQL 崩潰之前,數(shù)據(jù)頁最后一次正確的刷新到磁盤的狀態(tài)。
恢復(fù)數(shù)據(jù)頁的過程是對兩次寫內(nèi)存緩沖區(qū)中的所有數(shù)據(jù)頁進(jìn)行循環(huán),從兩次寫數(shù)據(jù)頁中讀取表空間 ID、頁號,然后根據(jù)表空間 ID 和頁號去系統(tǒng)表空間、獨立表空間、undo 表空間中讀取對應(yīng)的數(shù)據(jù)頁。
讀取到對應(yīng)的數(shù)據(jù)頁之后,會根據(jù)其 File Header、File Trailer 中的一些字段判斷數(shù)據(jù)頁是不是已經(jīng)損壞了:
首先,從 File Header 中讀取 FILE_PAGE_LSN 字段,如果 FILE_PAGE_LSN 字段值大于當(dāng)前系統(tǒng)已經(jīng)生成的 Redo 日志的最大 LSN,說明數(shù)據(jù)庫出現(xiàn)了不可描述的錯誤,數(shù)據(jù)頁已經(jīng)損壞。
然后,從 File Header 中讀取 FILE_PAGE_SPACE_OR_CHECKSUM 字段值,從 File Trailer 的前 4 字節(jié)中讀取 checksum。
如果 FILE_PAGE_SPACE_OR_CHECKSUM 字段值和 File Trailer checksum 不一樣,說明數(shù)據(jù)頁已經(jīng)損壞。
一旦出現(xiàn)了上面 2 種情況中的 1 種,把兩次寫數(shù)據(jù)頁的內(nèi)容復(fù)制到對應(yīng)的數(shù)據(jù)頁中,數(shù)據(jù)頁就會恢復(fù)到正常狀態(tài)了。
(3)讀取 Redo 日志
前面確定了讀取 Redo 日志的起點 last_checkpoint_lsn,接下來就該讀取 Redo 日志了,主要流程如下:
?第 1 步,InnoDB 會以 64K? 為單位,從 Redo 日志文件讀取日志到 log buffer 中。
64K = 4 * innodb_page_size,所以,每次從 Redo 日志文件讀取的數(shù)據(jù)量取決于系統(tǒng)變量 innodb_page_size。
第 2 步,已經(jīng)讀取到 log buffer 中的 block,利用 block header 和 block tailer 中的信息對 block 進(jìn)行完整性檢驗之后,把 block body 信息拷貝到另一個緩沖區(qū) parsing buffer。
parsing buffer 是一個 2M 的固定大小緩沖區(qū),用于存放即將要被解析的 Redo 日志。
Redo 日志每個 block 的大小為 512 字節(jié),block header 為 12 字節(jié),block trailer 為 4 字節(jié)。
從 log buffer 的每個 block 中拷貝到 parsing buffer 的 block body 大小就是 512-12-4 = 496 字節(jié),也就是每個 block 中存放的 Redo 日志數(shù)據(jù)部分。
第 3 步,解析 parsing buffer 中的 Redo 日志。
這一步解析 Redo 日志,實際上只是個預(yù)處理操作,并不會完整的解析每一條 Redo 日志,而是只會解析每一條 Redo 日志中的頭信息以及數(shù)據(jù)地址,包括以 4 個部分:
- Redo 日志類型。
- Redo 日志所屬數(shù)據(jù)頁的表空間 ID。
- Redo 日志所屬數(shù)據(jù)頁的頁號。
- Redo 日志數(shù)據(jù),這部分只是得到了每一條 Redo 日志在 block body 中的地址,后面應(yīng)用 Redo 日志到數(shù)據(jù)頁時會用到。
第 4 步,把第 3 步解析出來的每一條 Redo 日志的 4 個部分都拷貝到 hash 表中。
這個 hash 表是個嵌套結(jié)構(gòu),第 1 層 hash key 是表空間 ID,value 也是個 hash 結(jié)構(gòu),也就是第 2 層。
同一個表空間的 Redo 日志以頁單位組織到一起,存放到以表空間 ID 為 key 的第 1 層 hash value 中。
第 2 層的 hash key 是頁號,value 是需要應(yīng)用到這個數(shù)據(jù)頁的 Redo 日志組成的鏈表。
同一個數(shù)據(jù)頁的 Redo 日志鏈表以頁號為 key,放在第 2 層 hash value 中。
鏈表中的 Redo 日志按照產(chǎn)生的先后順序排列,第 1 條就是要應(yīng)用的這些 Redo 日志中最早產(chǎn)生的那條。
第 5 步,應(yīng)用 Redo 日志到數(shù)據(jù)頁。
如果第 4 步進(jìn)行的過程中,Redo 日志數(shù)據(jù)拷貝到 hash 表之后,導(dǎo)致 hash 表占用的空間大于 max_memory,那么需要應(yīng)用 Redo 日志到數(shù)據(jù)頁,應(yīng)用完成之后,清空 hash 表,為下一批 Redo 日志數(shù)據(jù)騰出空間。
這里的 max_memory 表示 hash 表能夠使用的最大內(nèi)存空間。
1 ~ 5 步是個循環(huán)執(zhí)行過程,經(jīng)過 N 輪循環(huán)之后,hash 表中有非常大的可能性還存在著最后一批 Redo 日志,因為占用空間??小于等于?
? max_memory 而只能在那里苦苦等待著被應(yīng)用到 Redo 日志,這個工作就要等待第 6 步去干了。
第 6 步,收尾工作。
1 ~ 5 步循環(huán)結(jié)束之后,收尾工作就把 hash 表中剩下的 Redo 日志應(yīng)用到數(shù)據(jù)頁,這是崩潰過程中最后一次應(yīng)用 Redo 日志。
前面都沒有提到過存放 Redo 日志的 hash 表在哪里,能使用多大內(nèi)存,不知道你有沒有好奇過?
這個 hash 表并不會單獨申請一大塊內(nèi)存,而是借用了 buffer pool 中的內(nèi)存。
因為在崩潰恢復(fù)過程中,進(jìn)行到讀取 Redo 日志階段時,buffer pool 還沒有真正開始用,所以可以先借來給 hash 表用一下。
不過 hash 表并不能使用 buffer pool 的全部內(nèi)存,而是需要保留一部分內(nèi)存,用于應(yīng)用 Redo 日志到數(shù)據(jù)頁的過程中,加載數(shù)據(jù)頁到 buffer pool 中。
保留內(nèi)存大小為:buffer pool 實例數(shù)量 * 256 個數(shù)據(jù)頁?,buffer pool 中的剩余內(nèi)存,就是第 5 步提到的 max_memory,也就是 hash 表能夠使用的最大內(nèi)存。
(4)應(yīng)用 Redo 日志
前面介紹讀取 Redo 日志,為了流程的完整性,有 2 個步驟已經(jīng)涉及到應(yīng)用 Redo 日志了。這里要介紹的是應(yīng)用 Redo 日志的過程,會比上一小節(jié)深入一些。
讀取 Redo 日志階段,已經(jīng)把所有需要應(yīng)用的 Redo 日志都進(jìn)行過預(yù)處理,并拷貝到 hash 表了。
存放 Redo 日志的 hash 表是一個嵌套結(jié)構(gòu):
- 第 1 層的 hash key 是表空間 ID,hash value 還是一個 hash 表。
- 第 2 層的 hash key 是頁號,hash value 是個 Redo 日志鏈表,鏈表中的每個元素就是一條需要應(yīng)用的 Redo 日志,按照產(chǎn)生的先后排序。
把每個數(shù)據(jù)頁的 Redo 日志匯總到一起再去應(yīng)用 Redo 日志,這樣做的好處是效率高。
在崩潰恢復(fù)過程中,每個數(shù)據(jù)頁只需要被加載到 buffer pool 中一次,一個數(shù)據(jù)頁的 Redo 日志能夠一次性應(yīng)用,干脆利落。
應(yīng)用 Redo 日志就是循環(huán)這個嵌套的 hash 表,把每一條 Redo 日志都應(yīng)用到數(shù)據(jù)頁中,主要流程如下:
第 1 步,從第 1 層 hash 表中取到表空間 ID 和這個 undo 表空間下需要應(yīng)用的 Redo 日志組成的第 2 層 hash 表。
第 2 步,從第 2 層 hash 表中取到一個頁號和該數(shù)據(jù)頁中需要應(yīng)用的 Redo 日志鏈表。
第 3 步,判斷當(dāng)前循環(huán)的數(shù)據(jù)頁是不是已經(jīng)加載到 buffer pool 中了。
如果當(dāng)前頁沒有加載到 buffer pool 中,進(jìn)入第 4 步。
如果當(dāng)前頁已經(jīng)加載到 buffer pool 中,進(jìn)入第 5 步。
第 4 步,把不在 buffer pool 中的數(shù)據(jù)頁加載到 buffer pool 中。
加載數(shù)據(jù)頁到 buffer pool 中,是一個異步的批量操作,有可能會一次加載多個數(shù)據(jù)頁。
也就是說,把數(shù)據(jù)頁從表空間加載到 buffer pool 中會觸發(fā)預(yù)讀,提前把一批需要應(yīng)用 Redo 日志的數(shù)據(jù)頁一次性加載到 buffer pool 中。
預(yù)讀的數(shù)據(jù)頁,不是隨機(jī)讀取的,而是根據(jù)第 3 步判斷不在 buffer pool 中的數(shù)據(jù)頁的頁號(記為 page_no),計算出一個頁號范圍,把這個范圍內(nèi)需要應(yīng)用 Redo 日志的數(shù)據(jù)頁,全都加載到 buffer pool 中。
頁號范圍的起點:low_limit = page_no - page % 32,終點:low_limit + 32。
循環(huán) low_limit ~ low_limit + 32 范圍內(nèi)的頁號,只要碰到需要應(yīng)用 Redo 日志的數(shù)據(jù)頁,就先把頁號臨時存放到一個數(shù)組里。
循環(huán)結(jié)束后,把數(shù)組里的頁號對應(yīng)的數(shù)據(jù)頁異步批量加載到 buffer pool 中。
從上面的邏輯可以看到,一次預(yù)讀最多只讀 32 個數(shù)據(jù)頁。
第 5 步,應(yīng)用 Redo 日志到數(shù)據(jù)頁。
根據(jù)第 1 步取到的表空間 ID和第 2 步取到的頁號,從 hash 表中獲取該數(shù)據(jù)頁需要應(yīng)用的 Redo 日志鏈表。
從數(shù)據(jù)頁的 File Header 中讀取 FILE_PAGE_LSN,循環(huán) Redo 日志鏈表中的每一條日志,判斷該日志的 start_lsn 是否大于等于 FILE_PAGE_LSN。
如果 start_lsn < FILE_PAGE_LSN,說明該 Redo 日志對應(yīng)的操作修改的數(shù)據(jù)頁,在 MySQL 崩潰之前就已經(jīng)刷盤,該 Redo 日志就不需要應(yīng)用到數(shù)據(jù)頁了。
如果 start_lsn >= FILE_PAGE_LSN,說明該 Redo 日志需要應(yīng)用到數(shù)據(jù)頁。
然后,根據(jù) Redo 日志類型,調(diào)用不同的方法解析 Redo 日志,直接修改 buffer pool 中的數(shù)據(jù)頁,對該數(shù)據(jù)頁應(yīng)用 Redo 日志的過程就完成了。
1 ~ 5 步是個循環(huán)過程,直到所有 undo 表空間的 Redo 日志都被應(yīng)用到數(shù)據(jù)頁,循環(huán)過程結(jié)束。
4、刪除 undo 表空間
MySQL 運行過程中,如果有大事務(wù)往 undo 表空間中寫入大量 undo 日志,undo 表空間會變大。
在早期版本中,undo 表空間變大之后,就不能再縮回去了。
現(xiàn)在,如果系統(tǒng)變量 innodb_undo_log_truncate 設(shè)置為 on,當(dāng) undo 表空間增長到 innodb_max_undo_log_size 設(shè)置的大?。J(rèn)值為 1G)之后,InnoDB 會把這個 undo 表空間截斷為初始大?。?6M)。
除了通過系統(tǒng)變量控制 undo 表空間自動截斷之外,還可以用下面這個 SQL 手動觸發(fā):
ALTER UNDO TABLESPACE tablespace_name
SET INACTIVE
不管自動還是手動,有可能 InnoDB 正在進(jìn)行 undo 表空間截斷操作,MySQL 就突然崩潰了,截斷表空間操作還沒有完成,那怎么辦?
等到下次啟動的時候,InnoDB 需要把未完成的 undo 表空間截斷操作繼續(xù)完成。
InnoDB 怎么知道哪些 undo 表空間的截斷操作沒有完成?
這就需要用到一個標(biāo)記文件了,InnoDB 對某個 undo 表空間進(jìn)行截斷操作之前,會創(chuàng)建一個對應(yīng)的標(biāo)記文件,文件名是這樣的:undo_表空間編號_trunc.log。
解釋一下表空間的兩個標(biāo)識:表空間編號是給咱們?nèi)祟惪吹?,表空間 ID 是 MySQL 內(nèi)部使用的,這兩者不一樣。
以 undo_001 表空間為例,表空間編號為 1?,InnoDB 對 undo_001 表空間進(jìn)行截斷操作之前,會創(chuàng)建一個 undo_1_trunc.log 文件,如下:
[csch@csch /usr/local/mysql_8_0_29/data] ls -l | grep undo
-rw-r----- 1 csch staff 16M 8 27 12:04 undo_001
-rw-r----- 1 csch staff 16M 8 27 12:04 undo_002
-rw-r--r-- 1 csch staff 16K 6 22 12:36 undo_1_trunc.log
崩潰恢復(fù)過程中,InnoDB 如果發(fā)現(xiàn)某個表空間存在對應(yīng)的 trunc.log 文件,說明這個 undo 表空間在 MySQL 崩潰時正在進(jìn)行截斷操作。
但是,只通過 trunc.log 文件存在這一個條件,并不能確定 undo 表空間截斷操作沒有完成,還要進(jìn)一步判斷。
接著讀取 trunc.log 文件的內(nèi)容,把讀到的內(nèi)容轉(zhuǎn)換成數(shù)字,判斷這個數(shù)字是不是等于 76845412。
76845412 是什么?稍候介紹。
如果等于,說明在 MySQL 崩潰之前,undo 表空間截斷操作已經(jīng)完成,只是 trunc.log 文件還沒來得及刪除。此時,直接刪除這個文件就可以了。
如果不等于,說明 MySQL 崩潰時,undo 表空間截斷操作還沒有完成,那就需要繼續(xù)完成。此時,直接刪除 undo 表空間文件。
被刪除的 undo 表空間要等到初始化事務(wù)子系統(tǒng)之后,才會重建,重建過程我們稍后介紹。
舉個例子:啟動過程中發(fā)現(xiàn)了 undo_001 表空間對應(yīng)的 trunc.log 文件,并且文件中存儲的數(shù)字不是 76845412,那就直接刪除 undo_001 表空間。
刪除之后,就只有 undo_1_trunc.log 文件能證明 undo_001 表空間存在過了,就像下面這樣:
[csch@csch /usr/local/mysql_8_0_29/data] ls -l | grep undo
-rw-r----- 1 csch staff 16M 8 27 12:04 undo_002
-rw-r--r-- 1 csch staff 16K 6 22 12:36 undo_1_trunc.log
為什么這里不把 undo 表空間對應(yīng)的 trunc.log 文件一起刪除?
因為 undo 表空間要等到初始化事務(wù)子系統(tǒng)完成之后再重建,而 trunc.log 是 undo 表空間重建的憑證,所以,現(xiàn)在還不能刪除。
接下來我們再看看 trunc.log 文件的創(chuàng)建和寫入過程。
InnoDB 進(jìn)行 undo 表空間截斷操作之前,就會創(chuàng)建 trunc.log 文件(大小為 innodb_page_size 字節(jié)),并把文件內(nèi)容的所有字節(jié)都初始化為 NULL,然后開始進(jìn)行 undo 表空間截斷操作。
操作完成之后,會往 trunc.log 文件中寫入一個被稱為魔數(shù)的數(shù)字:76845412,用于標(biāo)識 undo 表空間截斷操作已經(jīng)完成。
如果魔數(shù)成功寫入 trunc.log 文件,接下來會把 trunc.log 文件刪除,undo 表空間的截斷操作就結(jié)束了。
5、初始化事務(wù)子系統(tǒng)
現(xiàn)在,我們來到了初始化事務(wù)子系統(tǒng)階段。
InnoDB 之所以把初始化事務(wù)子系統(tǒng)安排在刪除 undo 表空間之后,有可能是為了避免讀取要被刪除的 undo 表空間,能夠節(jié)省一點點時間。
刪除還沒有完成截斷操作的 undo 表空間文件之后,剩下的 undo 表空間文件都需要讀取。
從 undo 表空間文件讀取未完成的事務(wù),初始化事務(wù)子系統(tǒng),主要過程如下:
初始化事務(wù)子系統(tǒng)還包含其它操作,不在本文介紹的范圍內(nèi)。
第 1 步,從內(nèi)存中的 undo 表空間對象數(shù)組中讀取 undo 表空間信息。
undo 表空間默認(rèn)為 2 個,最多可以有 127 個。
有了獨立 undo 表空間之后,位于系統(tǒng)表空間中的回滾段就已經(jīng)不再使用了,所以不需要從系統(tǒng)表空間的回滾段中讀取事務(wù)信息。
第 2 步,從 undo 表空間中頁號 = 3 的數(shù)據(jù)頁中讀取回滾段。
每個 undo 表空間可以有 1 ~ 128 個回滾段,由系統(tǒng)變量 innodb_rollback_segments 控制,默認(rèn)值為 2.
第 3 步,從回滾段中讀取 undo slot。
回滾段的段頭頁中有 1024 個 undo slot(4 字節(jié)),每個 undo slot 對應(yīng)一個 undo 段。
如果 undo slot 的值 等于 FIL_NULL,表示這個 undo slot 沒有關(guān)聯(lián)到 undo 段,繼續(xù)執(zhí)行第 3 步,讀取下一個 undo slot。
如果 undo slot 的值 不等于 FIL_NULL,表示這個 undo slot 關(guān)聯(lián)了 undo 段,進(jìn)入第 4 步。
第 4 步,從 undo slot 對應(yīng)的 undo 段中讀取未完成事務(wù)的信息。
此時,undo slot 的值就是 undo 段的段頭頁的頁號,通過這個頁號可以讀取到 undo 段中的事務(wù)信息。
undo slot 關(guān)聯(lián)了 undo 段,說明數(shù)據(jù)庫崩潰時,undo 段中的事務(wù)還沒有完成,事務(wù)狀態(tài)可能是以下 3 種之一:
- TRX_STATE_ACTIVE,表示事務(wù)還沒有進(jìn)入提交階段。
- TRX_STATE_PREPARED,表示事務(wù)已經(jīng)提交了,但是只完成了二階段提交的 PREPARE 階段,還沒有完成 COMMIT 階段。
- TRX_STATE_COMMITTED_IN_MEMORY,表示事務(wù)已經(jīng)完成了二階段提交的 2 個階段,還剩一些收尾工作沒做,這種狀態(tài)的事務(wù)修改的數(shù)據(jù)已經(jīng)可以被其它事務(wù)看見了。
事務(wù)的收尾工作有哪些?清理已提交事務(wù)小節(jié)會介紹。
第 1 ~ 4 步是個循環(huán)的過程,直到讀完所有 undo 表空間中的事務(wù)信息結(jié)束。
6、重建 undo 表空間
對于存在 trunc.log 文件的 undo 表空間,因為之前 undo 表空間文件被刪除了,現(xiàn)在要開始著手重建 undo 表空間了,主要流程如下:
第 1 步,創(chuàng)建 trunc.log 文件,標(biāo)記 undo 表空間重建操作正在進(jìn)行中。
看到這里你可能會奇怪,undo 表空間對應(yīng)的 trunc.log 文件不是沒有刪除嗎?這里為什么又要創(chuàng)建一次?
別急,且往下看。
在創(chuàng)建 undo 表空間對應(yīng)的 trunc.log 文件之前,會先刪除之前舊的 trunc.log 文件,然后創(chuàng)建新的 trunc.log 文件。
新舊 trunc.log 文件名是一樣的,例如:對于 undo_001 表空間來說,新舊 trunc.log 文件名都是 undo_1_trunc.log。
?為什么要刪除舊的 trunc.log 文件再創(chuàng)建新的同名 trunc.log 文件呢?
因為重建? undo 表空間和新建 undo 表空間是同一套邏輯,而新建 undo 表空間之前,該表空間并不存在對應(yīng)的 trunc.log 文件。
為了保持統(tǒng)一的邏輯,所以會先刪除已經(jīng)存在的 trunc.log 文件。
第 2 步,創(chuàng)建 undo 表空間文件,初始大小為 16M,這個大小是硬編碼的。
第 3 步,初始化 undo 表空間,把表空間 ID、各種鏈表信息寫入表空間的 0 號頁中,然后分配一個新的數(shù)據(jù)頁,創(chuàng)建并初始化回滾段,回滾段數(shù)量由系統(tǒng)變量 innodb_rollback_segments 控制。
第 4 步,循環(huán) undo 表空間中的所有回滾段,把每個回滾段中的 1024 個 undo slot 都初始化為 FIL_NULL。
第 5 步,標(biāo)記 undo 表空間重建操作已經(jīng)完成。
InnoDB 會先往 trunc.log 文件中寫入一個魔數(shù) 76845412,表示重建表空間操作已經(jīng)完成。
寫入魔數(shù)成功之后,再把 trunc.log 文件刪除,重建一個 undo 表空間的過程就結(jié)束了。
如果有多個 undo 表空間需要重建,對于每個 undo 表空間都需要進(jìn)行 1 ~ 5 步的流程。
7、處理事務(wù)
在初始化事務(wù)子系統(tǒng)小節(jié),我們介紹過,從 undo 表空間中讀取出來的事務(wù)有 3 種狀態(tài):
- TRX_STATE_ACTIVE。
- TRX_STATE_PREPARED。
- TRX_STATE_COMMITTED_IN_MEMORY。
處理事務(wù)階段對這 3 種狀態(tài)會進(jìn)行不同的處理,請接著往下看。
(1)清理已提交事務(wù)
這里要清理的已提交事務(wù),指的是狀態(tài)為 TRX_STATE_COMMITTED_IN_MEMORY 的事務(wù),包含 DDL 和 DML 事務(wù)。
這種狀態(tài)的事務(wù)已經(jīng)完成二階段提交的 PREPARE 和 COMMIT 階段,是已經(jīng)提交成功的事務(wù),只差最后一點點清理工作,它們修改的數(shù)據(jù)已經(jīng)能被其它事務(wù)看見了。
清理工作主要有幾點:
- 處理 insert undo 段。如果 insert undo 段能被緩存,undo 段會被加入 insert_undo_cached 鏈表尾部,以備重復(fù)使用;如果 insert undo 段不能被緩存,undo 段就會被釋放。
- 把事務(wù)從讀寫事務(wù)鏈表中刪除。
- 把事務(wù)狀態(tài)修改為TRX_STATE_NOT_STARTED。
(2)回滾未提交 DDL 事務(wù)
未提交事務(wù)指的是狀態(tài)為 TRX_STATE_ACTIVE 的事務(wù),也就是活躍事務(wù)。
崩潰恢復(fù)過程中,這種狀態(tài)的事務(wù)是需要直接回滾的。
你可能會有個疑問,DDL 事務(wù)不是不能回滾嗎?
DDL 事務(wù)不能回滾,這只是針對 MySQL 用戶而言,MySQL 內(nèi)部并不會受到這個限制。
我們在使用 MySQL 的過程中,如果在一個 DML 事務(wù)中間執(zhí)行了一條 DDL 語句,會觸發(fā)隱式提交,直接把 DML 事務(wù)提交了。
然后 DDL 會開啟一個新事務(wù),這個新事務(wù)是自動提交的,DDL 執(zhí)行完成之后,事務(wù)就直接提交了,我們是沒有機(jī)會對 DDL 事務(wù)進(jìn)行回滾操作的。
MySQL 沒給我們回滾 DDL 事務(wù)的機(jī)會,但是它自己有這個特權(quán)。
(3)回滾未提交 DML 事務(wù)
未提交的 DDL 事務(wù)和 DML 事務(wù)在源碼中是在不同時間觸發(fā)的,它的回滾過程和 DDL 事務(wù)一樣。
事務(wù)回滾的過程比較復(fù)雜,本文我們就不展開說了,后續(xù)會寫一篇文章專門介紹事務(wù)回滾的過程。
(4)處理 PREPARE 事務(wù)
PREPARE 事務(wù)指的是狀態(tài)為 TRX_STATE_PREPARED 的事務(wù),這種狀態(tài)的事務(wù)比較特殊,在崩潰恢復(fù)過程中,既有可能被提交,也有可能被回滾。
PREPARE 事務(wù)提交還是回滾,取決于這個事務(wù)的 XID 是否已經(jīng)寫入到 binlog 日志文件中。
事務(wù) XID 是以 binlog event 的方式寫入 binlog 日志文件的,event 的名字是 XID_EVENT。
一個事務(wù)只會有一個 XID,也就只會有一個 XID_EVENT 了。
要知道事務(wù)的 XID_EVENT 是否已經(jīng)寫入到 binlog 日志文件,需要先讀取 binlog 日志文件。
從上面的介紹可以看到,處理 PREPARE 事務(wù)依賴于 binlog 日志文件,因此,這部分邏輯是在打開 binlog 日志文件的過程中實現(xiàn)的。
MySQL 在同一時刻只會往一個 binlog 日志文件中寫入 binlog event,在崩潰那一刻,承載寫入 event 的文件是最后一個 binlog 日志文件。
因此,崩潰恢復(fù)過程中,只需要掃描最后一個 binlog 日志文件,找到其中所有的 XID_EVENT, 用于判斷 PREPARE 事務(wù)的 XID_EVENT 是否已經(jīng)寫入 binlog 日志文件。
如果 MySQL 上一次是正常關(guān)閉,啟動過程中,不會存在沒有完成的事務(wù),沒有 PREPARE 事務(wù)需要處理,也就不用掃描最后一個 binlog 日志文件了。
MySQL 怎么知道上一次是不是正常關(guān)閉呢?
每個 binlog 日志文件的第 1 個 EVENT 都是 FORMAT_DESCRIPTION_EVENT,用于描述 binlog 日志文件格式信息,這個 EVENT 中包含一個標(biāo)記 LOG_EVENT_BINLOG_IN_USE_F。
binlog 日志文件創(chuàng)建時,這個標(biāo)記位會被設(shè)置為 1,表示 binlog 日志文件正在被使用。
LOG_EVENT_BINLOG_IN_USE_F 標(biāo)記在 2 種情況下會被清除:
- 切換 binlog 日志文件時,舊 binlog 日志文件的LOG_EVENT_BINLOG_IN_USE_F 標(biāo)記會被清除。
- MySQL 正常關(guān)閉時,正在使用的 binlog 日志文件的LOG_EVENT_BINLOG_IN_USE_F 標(biāo)記會被清除。
如果 MySQL 突然崩潰,來不及把這個標(biāo)記設(shè)置為 0。
那么下次啟動時,MySQL 讀取最后一個 binlog 日志文件的 FORMAT_DESCRIPTION_EVENT 發(fā)現(xiàn) LOG_EVENT_BINLOG_IN_USE_F 標(biāo)記為 1,就會進(jìn)入處理 PREPARE 事務(wù)階段,主要流程如下:
第 1 步,掃描最后一個 binlog 日志文件,讀取 EVENT,找到其中所有的 XID_EVENT,并把讀取到的事務(wù) XID 存放到一個集合中。
第 2 步,InnoDB 循環(huán)讀寫事務(wù)鏈表,每找到一個 PREPARE 事務(wù)都存放到數(shù)組中,最后把數(shù)組返回給 server 層。
第 3 步,讀取 InnoDB 返回的 PREPARE 事務(wù)數(shù)組,判斷事務(wù) XID 是否在第 1 步的事務(wù) XID 集合中。
第 4 步,提交或回滾事務(wù)。
如果事務(wù) XID 在集合中,說明 MySQL 崩潰之前,事務(wù) XID_EVENT 就已經(jīng)寫入 binlog 日志文件了。
XID_EVENT 有可能已經(jīng)同步給從服務(wù)器,從服務(wù)器上可能已經(jīng)重放了這個事務(wù)。
這種情況下,為了保證主從數(shù)據(jù)的一致性,事務(wù)在主服務(wù)器上也需要提交。
如果事務(wù) XID 不在集合中,說明 MySQL 崩潰之前,事務(wù) XID_EVENT 沒有寫入 binlog 日志文件。
XID_EVENT 肯定也就沒有同步給從服務(wù)器了,同樣為了保證主從數(shù)據(jù)的一致性,事務(wù)在主服務(wù)器上也不能提交,而是需要回滾。
3 ~ 4 步是個循環(huán)過程,循環(huán)完 InnoDB 返回的 PREPARE 事務(wù)數(shù)組之后,處理 PREPARE 事務(wù)的過程結(jié)束,崩潰恢復(fù)主要流程也就完成了。
8、總結(jié)
MySQL 崩潰恢復(fù)過程的核心工作有 2 點:
- 對于 MySQL 崩潰之前還沒有刷新到磁盤的數(shù)據(jù)頁(也就是臟頁),用 Redo 日志把這些數(shù)據(jù)頁恢復(fù)到 MySQL 崩潰之前那一刻的狀態(tài),這相當(dāng)于對臟頁進(jìn)行一次刷盤操作。在這之前,需要用兩次寫緩沖區(qū)中的頁把損壞的數(shù)據(jù)頁修復(fù)為正常狀態(tài),然后才能在此基礎(chǔ)上用 Redo 日志恢復(fù)數(shù)據(jù)頁。
清理、提交、回滾還沒有完成的事務(wù)。
對于已完成二階段提交的 PREPARE、COMMIT 2 個階段的事務(wù),做收尾工作。
對于活躍狀態(tài)的事務(wù),直接回滾。
對于 PREPARE 狀態(tài)的事務(wù),如果事務(wù) XID 已寫入 binlog 日志文件,提交事務(wù),否則回滾事務(wù)。
本文轉(zhuǎn)載自微信公眾號「一樹一溪」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系一樹一溪公眾號。