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