MySQL 8.0.29 instant DDL 數(shù)據(jù)腐化問題分析
前言
DDL 相對(duì)于數(shù)據(jù)庫的 DML 之類的其他操作,相對(duì)來說是比較耗時(shí)、相對(duì)重型的操作; 因此對(duì)業(yè)務(wù)的影比較嚴(yán)重。MySQL 從5.6版本開始一直在持續(xù)改進(jìn)其DDL性能:引入了 online DDL,inplace DDL,instant DDL 等實(shí)用性極強(qiáng)的功能, DDL 目前對(duì)業(yè)務(wù)的影響持續(xù)降低。
MySQL 8.0.29 引入了 instant add/drop column 功能,支持在任意位置添加 column, drop column 也不需要表數(shù)據(jù)的任何形式的移動(dòng), 只需要修改表的元數(shù)據(jù)就可以完成 add/drop column,所以 instant add/drop column 的操作是輕型操作,速度快,資源需求量少。
ALTER table drop column a, ALGORITHM=INSTANT;
8.0.29 引入了新的alter 算法 INSTANT。
但是這個(gè)新功能目前很不穩(wěn)定,導(dǎo)致的問題比較多; 而且通常都比較嚴(yán)重: 數(shù)據(jù)損壞,或者數(shù)據(jù)庫無法啟動(dòng)等。
本文是分析其中的一個(gè)問題: 對(duì)表進(jìn)行 instant drop 后,進(jìn)行 update ,之后數(shù)據(jù)庫停機(jī),而后數(shù)據(jù)庫無法啟動(dòng)。
為分析這個(gè)問題, 我們會(huì)從 instant add/drop column 在 Innodb 的實(shí)現(xiàn)原理與細(xì)節(jié)方面來闡述這個(gè)數(shù)據(jù)腐化bug的具體原因。
Instant add or drop column的主線邏輯
因?yàn)檫@個(gè)功能的WorkLog無法從官方獲取,所以無法得到準(zhǔn)確的設(shè)計(jì)出發(fā)點(diǎn),通過閱讀相關(guān)代碼,得出要實(shí)現(xiàn)這個(gè)功能,必須要處理以下關(guān)鍵點(diǎn):
- 因?yàn)橐С衷谌我馕恢锰砑?刪除列,同時(shí)不會(huì)更改表數(shù)據(jù)文件,所以表的邏輯定義與row的實(shí)際存儲(chǔ)形式需要映射關(guān)系,不再是所見即所得的一一對(duì)應(yīng)的關(guān)系。即為了實(shí)現(xiàn)這樣功能:
表中列的定義順序與表中行數(shù)據(jù)(row)的存儲(chǔ)順序是不同的。
同時(shí)對(duì)同一個(gè)table可以做多次instant DDL, 所以需要引入版本機(jī)制,在表的數(shù)據(jù)文件中,不同row對(duì)應(yīng)的表定義可能是不同的,需要在row中記住表定義的version。
以上可以認(rèn)為是該功能的設(shè)計(jì)原則與實(shí)現(xiàn)的主線邏輯。
表定義的列順序與row 存儲(chǔ)列順序闡述
在引入這個(gè)功能之前, create table 時(shí)列定義的順序與列在 InnoDB 中存儲(chǔ)的順序是一致的。(這里我們不用考慮 InnoDB 添加系統(tǒng)隱藏列)
Instant add/drop column 要實(shí)現(xiàn)的亮點(diǎn)功能是在表定義的任意位置添加或者減少 column,同時(shí)做這樣的操作的時(shí)候,能夠做到不需要重構(gòu)表數(shù)據(jù)。
我們稱 column 在表定義中出現(xiàn)的順序?yàn)檫壿嬳樞?
而 column 在行數(shù)據(jù)的存儲(chǔ)順序?yàn)槲锢眄樞颉?/p>
要做到修改表定義,而不重構(gòu)表數(shù)據(jù),就必須將邏輯順序與物理順序解耦: 不能再像MySQL 8.0.29之前的版本那樣,邏輯順序與物理順序是完全一致的;而從8.0.29開始通過表的元數(shù)據(jù)保存了邏輯順序與物理順序的映射關(guān)系。這種映射關(guān)系的構(gòu)建與維護(hù)構(gòu)成了 instant add/drop column 的基礎(chǔ).
如下圖簡(jiǎn)單闡述了邏輯/物理順序的關(guān)系。
引入row版本的必要性
對(duì)于同一張表,Instant add/drop DDL可以執(zhí)行多次;每一次執(zhí)行后,邏輯/物理順序的映射關(guān)系就發(fā)生變化;同時(shí) instant add/drop DDL 并不需要做表數(shù)據(jù)的重構(gòu)操作;因此可以得出經(jīng)過多次 instant add/drop DDL,InnoDB存儲(chǔ)的行數(shù)據(jù)與表定義存在多種邏輯/物理順序映射關(guān)系:比如說,在 ibd 文件中,前十行數(shù)據(jù)對(duì)應(yīng)原始的表定義,接下來的十行可能對(duì)應(yīng)著 instant add column 后的數(shù)據(jù),再接下來的十行,可能對(duì)應(yīng)著 instant drop column 后的數(shù)據(jù)。
為了管理這種形式的邏輯/物理,在 InnoDB 中,為每一行實(shí)際存儲(chǔ)的數(shù)據(jù)引入了版本號(hào)的概念:每個(gè)版本號(hào)對(duì)應(yīng)著一個(gè)邏輯/物理映射關(guān)系。
為存儲(chǔ)這個(gè)版本信息,InnoDB 中,row 的信息頭記錄的格式有稍微的變化:
如上圖所示,在row的extra中存儲(chǔ)了其對(duì)應(yīng)的版本號(hào), 同時(shí)在 row header 中有標(biāo)志位指示出了是否存在版本號(hào)信息。
根據(jù)版本號(hào)獲取相應(yīng)的映射關(guān)系,就可以正確的解析行數(shù)據(jù)。
目前版本號(hào)最大支持到64, instant add/drop column 到達(dá)這個(gè)限制后報(bào)錯(cuò);其后如果還需要 instant add/drop column DDL 操作,可能需要做一次能夠觸發(fā) table rebuild 操作才可以。
數(shù)據(jù)腐化問題
由 instant add/drop column 引入了多個(gè)數(shù)據(jù)腐化問題,其中一個(gè)問題可以從:
[PS-8292] MySQL 8.0.29 fails to perform crash recovery - Percona JIRA(https://jira.percona.com/browse/PS-8292) 查看。
這個(gè)問題簡(jiǎn)單來說:在對(duì)表進(jìn)行 instant drop 后,進(jìn)行update操作,之后MySQL server 重啟,在啟動(dòng)階段恢復(fù)之前的 update 操作會(huì)引發(fā) assert 崩潰(debug版本的情況下)。
從代碼上看,這個(gè)bug可能會(huì)造成數(shù)據(jù)的靜默錯(cuò)誤(數(shù)據(jù)完全錯(cuò)亂而且不報(bào)任何錯(cuò)誤),而不僅僅是崩潰這一種現(xiàn)象。
通過對(duì)core文件的簡(jiǎn)單分析,造成該問題的大概原因如下:
在通過redo做恢復(fù)的時(shí)候,字段的邏輯順序與物理存儲(chǔ)順序之間的映射關(guān)系不對(duì)(錯(cuò)位)導(dǎo)致的。在恢復(fù)期間可能會(huì)找不到對(duì)應(yīng)的字段,或者更新了錯(cuò)誤的字段。
原因分析
從原始的問題看,這個(gè)是發(fā)生在 InnoDB 啟動(dòng)恢復(fù)階段。這一階段離不開 redo log的參與。前面介紹 instant add/drop 設(shè)計(jì)要點(diǎn)的時(shí)候,那些列出的要點(diǎn),可以認(rèn)為是在在 DDL 期間的工作以及編碼的基本邏輯;那么在完成 instant DDL 時(shí)候, 在 DML 的時(shí)候也需要將必要的信息寫入 redo log 才能做到 recovery。
- 為支持 instant add/drop column,redo log 記錄的格式發(fā)生了變化,因?yàn)榇abug,導(dǎo)致在解析 redo log 做恢復(fù)的時(shí)候,得到的字段信息錯(cuò)誤,導(dǎo)致數(shù)據(jù)腐化。
- 問題表現(xiàn)出來可能是: 恢復(fù)始終無法執(zhí)行,數(shù)據(jù)庫無法啟動(dòng);還可能是恢復(fù)到錯(cuò)誤的數(shù)據(jù),數(shù)據(jù)庫能夠啟動(dòng)。
因?yàn)?redo log 的種類較多,信息也比較繁雜,這里我們只關(guān)注問題本身中出現(xiàn)的 update 相關(guān)的 redo log ,進(jìn)而較多的關(guān)注 update redo log 與該問題相關(guān)的字段信息。
下圖簡(jiǎn)要的闡述了 update redo log 相關(guān)內(nèi)容:
到這里,可以看到 在MySQL 8.0.29中,update redo log 引入了 instant column 的物理邏輯順序。
下面從 InnoDB 的恢復(fù)流程跟蹤問題發(fā)生的原因,其中主要需要關(guān)注的是恢復(fù)過程中的表(索引)定義。
- 應(yīng)用 redo log 是在數(shù)據(jù)庫啟動(dòng)階段最開始就執(zhí)行,此時(shí)數(shù)據(jù)字典無法打開,獲取不到待恢復(fù)表的定義信息
- 但是此時(shí)需要表的定義信息去解析 redo log 中的相關(guān)數(shù)據(jù)
- 此時(shí)就會(huì)根據(jù)redo log中記錄的長(zhǎng)度信息,以及記錄長(zhǎng)度的順序構(gòu)建臨時(shí)的表定義,此時(shí)僅僅是為了恢復(fù),并不需要精確的表定義,此時(shí)只需要知道field的長(zhǎng)度和位置即可。
- 同時(shí)如果 redo log 中如果有instant DDL 的信息,那么也會(huì)用這些信息去修改臨時(shí)構(gòu)建的表定義:這是問題發(fā)生的初始錯(cuò)誤的地方。
- 恢復(fù)過程中,構(gòu)建出的臨時(shí)表實(shí)際上表中列的邏輯順序,這是符合正常運(yùn)行的需求的。
- 但是實(shí)際上8.0.29中字段長(zhǎng)度的記錄順序是按字段(列)的物理存儲(chǔ)順序?qū)懭氲摹?/li>
- 如果帶有 instant DDL 的信息,那么修改表定義時(shí)就會(huì)按物理順序去修改邏輯順序的表定義,這樣會(huì)修改到非預(yù)期的字段,導(dǎo)致錯(cuò)誤發(fā)生!
Bug重現(xiàn)與解析
CREATE TABLE `tb1` (
`col1` VARCHAR(10) NOT NULL,
`col2` char(13),
`col3` varchar(11),
PRIMARY KEY (`col1`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO tb1 VALUES ('4000','50','100');
--echo # the FIRST INSTANT ALTER
ALTER TABLE tb1 DROP COLUMN col2, LOCK=DEFAULT;
INSERT INTO tb1 VALUES( '4545', '52' );
UPDATE tb1 SET col3 = '46' WHERE col1 = '4545';
--echo # crash and restart 1
--source include/kill_and_restart_mysqld.inc
CHECK TABLE tb1;
DROP TABLE tb1;
以上MySQL MTR 測(cè)例可以重現(xiàn) InnoDB 啟動(dòng)恢復(fù)期間始終 core 的問題。我們從這個(gè)例子出發(fā),結(jié)合上面解釋的 instant drop DDL 代碼行為看看問題是如何一步步發(fā)生的。
- 首先說明一下,在測(cè)例運(yùn)行期間邏輯順序與物理順序的變化。 如下圖所示稍微展示了 table 的邏輯定于與 InnoDB row 存儲(chǔ)的以下細(xì)節(jié)。這里注意的是 被 dropped column 仍然會(huì)以隱藏列的形式存在于表定于中:因?yàn)?drop 之前存在的 row 還是需要這樣信息解析字段。
- 結(jié)合 redo log 的恢復(fù)過程看看問題發(fā)生的第一現(xiàn)場(chǎng)。這里針對(duì)這個(gè)測(cè)例摘取相關(guān) redo log 的部分信息:
- 2.1 按照字段長(zhǎng)度列表(8.0.29中是物理順序?qū)懭氲牧斜恚﹦?chuàng)建的專門用于恢復(fù)的表,類似于: create table dummy_table (d1:10, d2:13, d3:11)
2.2 按照 instant 字段信息修改 dummy 表:按照 physical pos=1 去修改后,結(jié)果類似于:create table dummy_table (d1:10, d2:13[dropped], d3:11)
2.3 期望的正確的表應(yīng)該類似于:create table dummy_table(d1:10, d3:11, d2:13[dropped]);
2.4 Redo log中的Field_no=1, 去恢復(fù)時(shí)期望用到的是 #2.3 的表,但是過程中創(chuàng)建的是#2.2中錯(cuò)誤的表,這樣當(dāng)Field_no=1去恢復(fù)數(shù)據(jù)時(shí),會(huì)錯(cuò)誤的發(fā)現(xiàn)對(duì)應(yīng)的field(column)已經(jīng)dropped, 導(dǎo)致core!
MySQL8.0.30修復(fù)方案
知道了問題發(fā)生的原因,修復(fù)起來就比較簡(jiǎn)單了:
- MySQL 8.0.30的代碼修復(fù)方案
Redo log中字段的長(zhǎng)度列表,按照字段的邏輯順序?qū)懭耄辉侔创鎯?chǔ)順序?qū)懭搿?/p>
在 redo log 的 instant column 信息中也包含了字段的邏輯位置。
Redo log 的記錄本身的版本設(shè)置為 1 ,與8.0.29的版本為 0 ,做出差別。
8.0.30的修復(fù)代碼本身也是不能正確解析8.0.29產(chǎn)生的 redo log ,只是根據(jù)版本號(hào)檢測(cè)出8.0.29 redo log,進(jìn)而報(bào)錯(cuò)防止數(shù)據(jù)進(jìn)一步惡化。實(shí)際上8.0.29的 redo log ,在 instant DDL 后,是不可能正確解析的,因?yàn)闆]有邏輯/物理的映射關(guān)系。
- 修復(fù)的邏輯比較簡(jiǎn)單:
Redo log中字段的長(zhǎng)度列表,按照字段的邏輯順序?qū)懭耄?/p>
保證在恢復(fù)階段構(gòu)建的臨時(shí)表是按正確的邏輯定義順序構(gòu)建的。
- 在redo log 的 instant column 信息中也包含字段的邏輯位置:
保證在更新臨時(shí)表的字段時(shí),按照邏輯順序,不會(huì)出現(xiàn)錯(cuò)誤更新的情況。
下面是MySQL 8.0.30 update redo log 相關(guān)字段信息:
從上圖可以看出,MySQL 8.0.30 redo log 中已經(jīng)不存儲(chǔ)物理位置相關(guān)的信息了,全部是邏輯位置相關(guān)的信息;這樣就和MySQL 8.0.29 redo log 這種有問題的記錄方式是曇花一現(xiàn)了。
附帶的這個(gè)測(cè)例可以重現(xiàn)數(shù)據(jù)的靜默錯(cuò)誤(恢復(fù)過程沒問題, 但是數(shù)據(jù)實(shí)際上錯(cuò)了)
CREATE TABLE `tb2` ( `c1` char(4) NOT NULL, `c2` char(4), `c3` char(4), PRIMARY KEY (`c1`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
begin;
INSERT INTO tb2 VALUES ('1000','2000','3000');
commit;
--echo # the FIRST INSTANT ALTER
ALTER TABLE tb2 add COLUMN c4 char(4) after c1, LOCK=DEFAULT;
INSERT INTO tb2 VALUES ('1001','4001', '2001', '3001');
SELECT * FROM tb2;
UPDATE tb2 set c4='4002' WHERE c1='1001';
--echo # crash and restart 1
--source include/kill_and_restart_mysqld.inc
select * from tb2;
CHECK TABLE tb2;
需要把這個(gè)測(cè)例放到innodb test case suite中。