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. 讀取兩次寫頁面

MySQL 一旦崩潰绪钥,Redo 日志就要去拯救世界了(MySQL 就是它的世界),Redo 日志拯救世界的方式就是把還沒來得及刷盤的臟頁恢復(fù)到崩潰之前那一刻的狀態(tài)关炼。

雖然 Redo 日志能夠用來恢復(fù)數(shù)據(jù)頁程腹,但這是有前提條件的:數(shù)據(jù)頁必須完好無損的狀態(tài)。

本文我們把系統(tǒng)表空間儒拂、獨(dú)立表空間寸潦、undo 表空間中的頁統(tǒng)稱為數(shù)據(jù)頁。

如果數(shù)據(jù)頁剛寫了一半社痛,MySQL 就戛然而止见转,這個(gè)數(shù)據(jù)頁就損壞了,面對(duì)這種情況褥影,Redo 日志也是巧婦難為無米之炊。

Redo 日志拯救世界之路就要因?yàn)檫@個(gè)問題停滯不前嗎咏雌?

那顯示是不能的凡怎,這就該輪到兩次寫上場(chǎng)了校焦。

兩次寫的官方名字是 double write,它包含內(nèi)存緩沖區(qū)和 dblwr 文件兩個(gè)部分统倒,InnoDB 臟頁刷盤前寨典,都會(huì)先把臟頁寫入內(nèi)存緩沖區(qū),再寫入 dblwr 文件房匆,成功之后才會(huì)把網(wǎng)頁刷盤耸成。

兩次寫通過系統(tǒng)變量 innodb_doublewrite 控制開啟或關(guān)閉,本文內(nèi)容基于該系統(tǒng)變量的默認(rèn)值 ON浴鸿,表示開啟兩次寫井氢。

如果網(wǎng)頁寫入內(nèi)存緩沖區(qū)和 dblwr 文件的程中,MySQL 崩潰了岳链,表空間中對(duì)應(yīng)的數(shù)據(jù)頁還是完整的花竞,下次啟動(dòng)時(shí),不需要用兩次寫頁面修復(fù)這個(gè)數(shù)據(jù)頁掸哑。

如果臟頁刷盤時(shí)约急,MySQL 崩潰了,表空間對(duì)應(yīng)的數(shù)據(jù)頁損壞了苗分,下次啟動(dòng)時(shí)厌蔽,應(yīng)用 Redo 日志到數(shù)據(jù)頁之前,需要用兩次寫頁面修復(fù)這個(gè)數(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 啟動(dòng)過程中奴饮,會(huì)把 *.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 日志叉瘩,需要有個(gè)起點(diǎn),起點(diǎn)就是最后一次 checkpoint 的 lsn(3.1 小節(jié))粘捎。

應(yīng)用 Redo 日志有一個(gè)前提:數(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)入下一個(gè)環(huán)節(jié)。

3.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ù)頁不正確邑狸,只是會(huì)多掃描一點(diǎn) Redo 日志而已懈糯,應(yīng)用 Redo 日志時(shí)會(huì)過濾已經(jīng)刷盤的臟頁對(duì)應(yīng)的 Redo 日志。

3.2 修復(fù)損壞的數(shù)據(jù)頁

把兩次寫文件中的所有數(shù)據(jù)頁都加載到內(nèi)存緩沖區(qū)之后单雾,需要用這些頁來把系統(tǒng)表空間赚哗、獨(dú)立表空間她紫、undo 表空間中損壞的數(shù)據(jù)頁恢復(fù)到正常狀態(tài)。

正常狀態(tài)指的是 MySQL 崩潰之前屿储,數(shù)據(jù)頁最后一次正確的刷新到磁盤的狀態(tài)犁苏。

恢復(fù)數(shù)據(jù)頁的過程是對(duì)兩次寫內(nèi)存緩沖區(qū)中的所有數(shù)據(jù)頁進(jìn)行循環(huán),從兩次寫數(shù)據(jù)頁中讀取表空間 ID扩所、頁號(hào),然后根據(jù)表空間 ID 和頁號(hào)去系統(tǒng)表空間朴乖、獨(dú)立表空間祖屏、undo 表空間中讀取對(duì)應(yīng)的數(shù)據(jù)頁。

讀取到對(duì)應(yīng)的數(shù)據(jù)頁之后买羞,會(huì)根據(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ù)庫(kù)出現(xiàn)了不可描述的錯(cuò)誤,數(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ù)制到對(duì)應(yīng)的數(shù)據(jù)頁中端辱,數(shù)據(jù)頁就會(huì)恢復(fù)到正常狀態(tài)了。

3.2 讀取 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ù)頁的表空間 ID
  • Redo 日志所屬數(shù)據(jù)頁的頁號(hào)
  • Redo 日志數(shù)據(jù)稼跳,這部分只是得到了每一條 Redo 日志在 block body 中的地址盟庞,后面應(yīng)用 Redo 日志到數(shù)據(jù)頁時(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 日志以頁單位組織到一起在旱,存放到以表空間 ID 為 key 的第 1 層 hash value 中荤傲。

第 2 層的 hash key 是頁號(hào),value 是需要應(yīng)用到這個(gè)數(shù)據(jù)頁的 Redo 日志組成的鏈表颈渊。

同一個(gè)數(shù)據(jù)頁的 Redo 日志鏈表以頁號(hào)為 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 步是個(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ī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ù)頁的過程中惰蜜,加載數(shù)據(jù)頁到 buffer pool 中。

保留內(nèi)存大小為:buffer pool 實(shí)例數(shù)量 * 256 個(gè)數(shù)據(jù)頁受神,buffer pool 中的剩余內(nèi)存抛猖,就是第 5 步提到的 max_memory,也就是 hash 表能夠使用的最大內(nèi)存鼻听。

3.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 是頁號(hào)倒戏,hash value 是個(gè) Redo 日志鏈表,鏈表中的每個(gè)元素就是一條需要應(yīng)用的 Redo 日志恐似,按照產(chǎn)生的先后排序。

把每個(gè)數(shù)據(jù)頁的 Redo 日志匯總到一起再去應(yīng)用 Redo 日志葛闷,這樣做的好處是效率高双藕。

在崩潰恢復(fù)過程中忧陪,每個(gè)數(shù)據(jù)頁只需要被加載到 buffer pool 中一次,一個(gè)數(shù)據(jù)頁的 Redo 日志能夠一次性應(yīng)用延蟹,干脆利落阱飘。

應(yīng)用 Redo 日志就是循環(huán)這個(gè)嵌套的 hash 表沥匈,把每一條 Redo 日志都應(yīng)用到數(shù)據(jù)頁中咐熙,主要流程如下:

第 1 步棋恼,從第 1 層 hash 表中取到表空間 ID 和這個(gè) undo 表空間下需要應(yīng)用的 Redo 日志組成的第 2 層 hash 表爪飘。

第 2 步师崎,從第 2 層 hash 表中取到一個(gè)頁號(hào)和該數(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 中纵柿,是一個(gè)異步的批量操作昂儒,有可能會(huì)一次加載多個(gè)數(shù)據(jù)頁渊跋。

也就是說拾酝,把數(shù)據(jù)頁從表空間加載到 buffer pool 中會(huì)觸發(fā)預(yù)讀蒿囤,提前把一批需要應(yīng)用 Redo 日志的數(shù)據(jù)頁一次性加載到 buffer pool 中崇决。

預(yù)讀的數(shù)據(jù)頁,不是隨機(jī)讀取的建邓,而是根據(jù)第 3 步判斷不在 buffer pool 中的數(shù)據(jù)頁的頁號(hào)(記為 page_no)湿痢,計(jì)算出一個(gè)頁號(hào)范圍譬重,把這個(gè)范圍內(nèi)需要應(yīng)用 Redo 日志的數(shù)據(jù)頁臀规,全都加載到 buffer pool 中。

頁號(hào)范圍的起點(diǎn):low_limit = page_no - page % 32玩徊,終點(diǎn):low_limit + 32恩袱。

循環(huán) low_limit ~ low_limit + 32 范圍內(nèi)的頁號(hào)畔塔,只要碰到需要應(yīng)用 Redo 日志的數(shù)據(jù)頁澈吨,就先把頁號(hào)臨時(shí)存放到一個(gè)數(shù)組里谅辣。

循環(huán)結(jié)束后婶恼,把數(shù)組里的頁號(hào)對(duì)應(yīng)的數(shù)據(jù)頁異步批量加載到 buffer pool 中勾邦。

從上面的邏輯可以看到检痰,一次預(yù)讀最多只讀 32 個(gè)數(shù)據(jù)頁铅歼。

第 5 步换可,應(yīng)用 Redo 日志到數(shù)據(jù)頁沾鳄。

根據(jù)第 1 步取到的表空間 ID和第 2 步取到的頁號(hào)译荞,從 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 日志對(duì)應(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ù)頁任洞,對(duì)該數(shù)據(jù)頁應(yīng)用 Redo 日志的過程就完成了交掏。

1 ~ 5 步是個(gè)循環(huán)過程耀销,直到所有 undo 表空間的 Redo 日志都被應(yīng)用到數(shù)據(jù)頁熊尉,循環(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è)置的大邪砥稹(默認(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)嗖僮髅弁伲琈ySQL 就突然崩潰了袁余,截?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)祟惪吹耐砘铮砜臻g 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 表空間中頁號(hào) = 3 的數(shù)據(jù)頁中讀取回滾段戚扳。

每個(gè) undo 表空間可以有 1 ~ 128 個(gè)回滾段帽借,由系統(tǒng)變量 innodb_rollback_segments 控制砍艾,默認(rèn)值為 2.

第 3 步脆荷,從回滾段中讀取 undo slot蜓谋。

回滾段的段頭頁中有 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 段的段頭頁的頁號(hào)俗冻,通過這個(gè)頁號(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)頁中胆描,然后分配一個(gè)新的數(shù)據(jù)頁,創(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)接著往下看率触。

7.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红竭。

7.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)圾另。

7.3 回滾未提交 DML 事務(wù)

未提交的 DDL 事務(wù)和 DML 事務(wù)在源碼中是在不同時(shí)間觸發(fā)的,它的回滾過程和 DDL 事務(wù)一樣雕沉。

事務(wù)回滾的過程比較復(fù)雜集乔,本文我們就不展開說了,后續(xù)會(huì)寫一篇文章專門介紹事務(wù)回滾的過程坡椒。

7.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ù)頁(也就是臟頁)摇庙,用 Redo 日志把這些數(shù)據(jù)頁恢復(fù)到 MySQL 崩潰之前那一刻的狀態(tài),這相當(dāng)于對(duì)臟頁進(jìn)行一次刷盤操作遥缕。在這之前卫袒,需要用兩次寫緩沖區(qū)中的頁把損壞的數(shù)據(jù)頁修復(fù)為正常狀態(tài),然后才能在此基礎(chǔ)上用 Redo 日志恢復(fù)數(shù)據(jù)頁单匣。

  • 清理夕凝、提交、回滾還沒有完成的事務(wù)户秤。

    對(duì)于已完成二階段提交的 PREPARE码秉、COMMIT 2 個(gè)階段的事務(wù),做收尾工作鸡号。

    對(duì)于活躍狀態(tài)的事務(wù)转砖,直接回滾。

    對(duì)于 PREPARE 狀態(tài)的事務(wù)鲸伴,如果事務(wù) XID 已寫入 binlog 日志文件府蔗,提交事務(wù),否則回滾事務(wù)汞窗。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末姓赤,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子仲吏,更是在濱河造成了極大的恐慌不铆,老刑警劉巖,帶你破解...
    沈念sama閱讀 210,914評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蜘矢,死亡現(xiàn)場(chǎng)離奇詭異狂男,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)品腹,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,935評(píng)論 2 383
  • 文/潘曉璐 我一進(jìn)店門岖食,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人舞吭,你說我怎么就攤上這事泡垃∥錾海” “怎么了?”我有些...
    開封第一講書人閱讀 156,531評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵蔑穴,是天一觀的道長(zhǎng)忠寻。 經(jīng)常有香客問我,道長(zhǎng)存和,這世上最難降的妖魔是什么奕剃? 我笑而不...
    開封第一講書人閱讀 56,309評(píng)論 1 282
  • 正文 為了忘掉前任,我火速辦了婚禮捐腿,結(jié)果婚禮上纵朋,老公的妹妹穿的比我還像新娘。我一直安慰自己茄袖,他們只是感情好操软,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,381評(píng)論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著宪祥,像睡著了一般聂薪。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上蝗羊,一...
    開封第一講書人閱讀 49,730評(píng)論 1 289
  • 那天藏澳,我揣著相機(jī)與錄音,去河邊找鬼耀找。 笑死笆载,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的涯呻。 我是一名探鬼主播,決...
    沈念sama閱讀 38,882評(píng)論 3 404
  • 文/蒼蘭香墨 我猛地睜開眼腻要,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼复罐!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起雄家,我...
    開封第一講書人閱讀 37,643評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤效诅,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后趟济,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體乱投,經(jīng)...
    沈念sama閱讀 44,095評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,448評(píng)論 2 325
  • 正文 我和宋清朗相戀三年顷编,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了戚炫。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,566評(píng)論 1 339
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡媳纬,死狀恐怖双肤,靈堂內(nèi)的尸體忽然破棺而出施掏,到底是詐尸還是另有隱情,我是刑警寧澤茅糜,帶...
    沈念sama閱讀 34,253評(píng)論 4 328
  • 正文 年R本政府宣布七芭,位于F島的核電站,受9級(jí)特大地震影響蔑赘,放射性物質(zhì)發(fā)生泄漏狸驳。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,829評(píng)論 3 312
  • 文/蒙蒙 一缩赛、第九天 我趴在偏房一處隱蔽的房頂上張望耙箍。 院中可真熱鬧,春花似錦峦筒、人聲如沸究西。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,715評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽卤材。三九已至,卻和暖如春峦失,著一層夾襖步出監(jiān)牢的瞬間扇丛,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,945評(píng)論 1 264
  • 我被黑心中介騙來泰國(guó)打工尉辑, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留帆精,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,248評(píng)論 2 360
  • 正文 我出身青樓隧魄,卻偏偏與公主長(zhǎng)得像卓练,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子购啄,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,440評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容