MySQL是基於磁盤進行數據存儲的關系型數據庫, 所有的數據、索引等數據均以磁盤文件的方式存儲, 在有需要時載入內存讀取。 為了加快數據查詢的效率, 通常會在一些字段上添加索引, 但是許多文檔都會告訴我們, 不要添加太多的索引, 索引不要太長, 使用數字或者空字符串來代替NULL值, 為什么會有這些建議? 這些建議又是否正確? 答案都能夠從MySQL數據的物理存儲方式中找到。
1. InnoDB文件格式
由於InnoDB是MySQL使用最為廣泛的存儲引擎, 所以本篇博文基於InnoDB存儲引擎來討論其數據存儲方式。
當我們創建一個table時, InnoDB會創建三個文件。 一個是表結構定義文件, 另一個為數據實際存儲文件, 並且所有的索引也將存放在這個文件中。 最后一個文件保存該table所制定的字符集。

2. InnoDB行記錄格式
當我們使用SQL查詢一條或者是多條數據時, 數據將會以一行一行的方式返回, 而實際上數據在文件中也的確是使用行記錄的方式進行存儲的。
不同的InnoDB引擎版本可能有着不同的行記錄格式來存放數據, 可以說, 行記錄格式的變更將會直接影響到InnoDB的查詢以及DML效率。 在MySQL 5.7版本中, 如果對某個table執行:
SHOW TABLE STATUS LIKE "table_name" \G;
將會得到該table的一系列信息, 在這里, 我們只需要知道Row_format的值即可, 5.7將會返回Dynamic。
在官網上給出了不同格式的行記錄格式之間的差別, 詳細內容見官方文檔:
https://dev.mysql.com/doc/refman/5.7/en/innodb-row-format.html
在這里我們只需要知道Dynamic行記錄格式在存儲可變字符(Varchar)時, 與Compact行記錄格式有着同樣的表現即可。

Compact行記錄格式將以上圖的方式保存在文件中, 需要注意的是, 如果一個table中沒有任何的varchar類型, 那么變長字段長度列表將為空。
Compact行記錄格式的首部是一個非NULL變長字段長度列表, 並且是按照列的順序逆序放置的, 其長度表現為:
- 若列的長度小於255字節, 用1字節表示
- 若列的長度大於255字節, 用2字節表示
變長字段的長度最大不會超過2字節, 這是因為MySQL中VARCAHR類型的最大長度限制為65535。 變長字段之后的第二個部分為NULL標識位, 該位指示了該行數據中是否存在NULL值, 有則用1表示, 本質上是一個bitmap。
下面用一個實際的例子來具體分析Compact行記錄格式的實際存儲。
-- 創建database CREATE SCHEMA `coco` DEFAULT CHARACTER SET latin1 ; -- 創建table CREATE TABLE one ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(10), nickname VARCHAR(10), PRIMARY KEY (id), KEY (nickname) ) ENGINE=InnoDB CHARSET=LATIN1; -- 插入代表性數據 INSERT INTO one (name, nickname) VALUES ("a", "AAA"); INSERT INTO one (name, nickname) VALUES ("b", "BBB"); INSERT INTO one (name, nickname) VALUES ("c", NULL); INSERT INTO one (name, nickname) VALUES ("d", "DDD"); INSERT INTO one (name, nickname) VALUES ("e", ""); INSERT INTO one (name, nickname) VALUES ("f", "FFF");
而后在/var/lib/mysql/coco中即可找到該表的.ibd文件了, 使用hexdump -C one.ibd對其進行16進制的數據解析並查看。 由於數據太長, 所以僅截取部分數據:
0000c070 73 75 70 72 65 6d 75 6d 03 01 00 00 00 10 00 1d |supremum........| 0000c080 80 00 00 01 00 00 00 08 d1 29 bd 00 00 01 35 01 |.........)....5.| 0000c090 10 61 41 41 41 03 01 00 00 00 18 00 1c 80 00 00 |.aAAA...........| 0000c0a0 02 00 00 00 08 d1 29 bd 00 00 01 35 01 1d 62 42 |......)....5..bB| 0000c0b0 42 42 01 02 00 00 20 00 1a 80 00 00 03 00 00 00 |BB.... .........| 0000c0c0 08 d1 29 bd 00 00 01 35 01 2a 63 03 01 00 00 00 |..)....5.*c.....| 0000c0d0 28 00 1d 80 00 00 04 00 00 00 08 d1 29 bd 00 00 |(...........)...| 0000c0e0 01 35 01 37 64 44 44 44 00 01 00 00 00 30 00 1a |.5.7dDDD.....0..| 0000c0f0 80 00 00 05 00 00 00 08 d1 29 bd 00 00 01 35 01 |.........)....5.| 0000c100 44 65 03 01 00 00 00 38 ff 66 80 00 00 06 00 00 |De.....8.f......| 0000c110 00 08 d1 29 bd 00 00 01 35 01 51 66 46 46 46 00 |...)....5.QfFFF.|
實際存儲數據從0000c078開始, 使用Compact行記錄格式對其進行整理:
03 01 /* 變長字段長度列表, 逆序, 第一行varchar數據為('a', 'AAA') */ 00 /* NULL標識位, 該值表示該行未有NULL值的列 */ 00 00 10 00 1d /* 記錄頭(Record Header)信息, 固定長度為5字節 */ 80 00 00 01 /* Row ID, 這里即為該行數據的主鍵值(paimary key),長度為4 */ 00 00 00 08 d1 29 /* Transaction ID, 即事務ID, 默認為6字節 */ bd 00 00 01 35 01 10 /* 回滾指針, 默認為7字節 */ 61 /* 列1數據'a' */ 41 41 41 /* 列2數據'AAA' */
第2行數據與第1行數據大同小異, 值得關注的是包含有NULL值以及空值的行, 即第3行和第5行, 首先來看第3行數據:
01 /* 由於該行中只有一列數據類型為varchar,並且非NULL, 所以列表長度為1 */ 02 /* 02轉換為2進制結果為10, 表示第二列數據為NULL(注意是逆序) */ 00 00 20 00 1a /* 記錄頭(Record Header)信息, 固定長度為5字節 */ 80 00 00 03 /* 第3行數據的主鍵id */ 00 00 00 08 d1 29 /* Transaction ID, 即事務ID, 默認為6字節 */ bd 00 00 01 35 01 2a /* 回滾指針, 默認為7字節 */ 63 /* 列1數據'c' */
可以非常明顯的看到, NULL值並沒有在文件中進行存儲, 而是僅使用NULL標識位來標記某一列是否為NULL。 所以說, NULL值不會占據任何的物理存儲空間, 相反, varchar類型的NULL值還會少占用變長字段長度列表空間。
再來看空字符串所在的第5行數據:
00 01 /* 表示第2列的varchar長度為0 */ 00 /* 該行沒有NULL值的列 */ 00 00 30 00 1a /* 記錄頭(Record Header)信息, 固定長度為5字節 */ 80 00 00 05 /* 第5行數據的主鍵id */ 00 00 00 08 d1 29 /* Transaction ID, 即事務ID, 默認為6字節 */ bd 00 00 01 35 01 44 /* 回滾指針, 默認為7字節 */ 65 /* 列1數據'e' */
可以看到, 空字符串和NULL值一樣, 也不占用任何的磁盤存儲空間。 只不過與NULL值不同的是, 在首部的變長字符長度列表中仍然占據存儲空間, 但是值為0。
3. 數據的聚集索引組織方式
有些人將聚集索引(Cluster Index)理解成為主鍵, 或者是主鍵索引, 這是不准確的。 聚集索引並不是一種索引結構, 而是一種數據的組織方式, 用唯一且不為空的主鍵來對所有的數據進行組織。 主鍵, 是最為常見的聚集索引對外表現的形式。
聚集索引最大的特點就在於數據在邏輯上是一定是連續的, 但是在物理是並不一定連續。 比如我們常見的自增主鍵, 當我們對查詢語句不做任何處理時, 默認就是按照主鍵的遞增順序返回的。
而輔助索引, 或者是二級索引, 是由程序員人為的在某些列上所添加的索引。 輔助索引所代表的數據在邏輯上不一定連續, 物理存儲上也不一定連續。
MySQL使用B+Tree來組織數據和索引(關於B+Tree的詳細內容, 可見下方傳送門), 在非葉子節點中保存着索引和指針, 在葉子節點保存着數據。 情況又分兩種:
- 聚集索引的葉子節點保存着實際的數據,即一行完整的數據
- 輔助索引的葉子節點保存着該行數據的主鍵ID
那些有趣的數據結構與算法(04)–B-Tree與B+Tree
也就是說, 假設聚集索引和輔助索引的B+Tree樹高均為3的話, 使用主鍵查詢需要3次邏輯I/O。 而使用輔助索引則需要6次邏輯I/O才能找到該行數據。

還記得在上面的Compact行記錄格式中的行記錄頭, 也就是Record Header信息嗎? Record Header的最后兩個字節表示下一行數據的偏移量, 其實這個就是B+Tree中的指針。 例如第一行的起始位置為c078, Record Header最后兩個字節為001d, 加起來等於c095, 剛好是第二行的起始位置。
在上面的例子中, 我們創建了這樣的一張表:
CREATE TABLE one ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(10), nickname VARCHAR(10), PRIMARY KEY (id), KEY (nickname) ) ENGINE=InnoDB CHARSET=LATIN1;
其中nickname字段被我們添加了輔助索引, 同樣地, 可以使用.ibd文件來具體對其結構進行分析。 使用hexdump -C one.ibd解析文件並找到輔助索引開始的地方:
00010060 02 00 37 69 6e 66 69 6d 75 6d 00 07 00 0b 00 00 |..7infimum......| 00010070 73 75 70 72 65 6d 75 6d 03 00 00 00 10 00 0e 41 |supremum.......A| 00010080 41 41 80 00 00 01 03 00 00 00 18 00 18 42 42 42 |AA...........BBB| 00010090 80 00 00 02 01 00 00 20 00 19 80 00 00 03 03 00 |....... ........| 000100a0 00 00 28 00 19 44 44 44 80 00 00 04 00 00 00 00 |..(..DDD........| 000100b0 30 ff cc 80 00 00 05 03 00 00 00 38 ff b2 46 46 |0..........8..FF| 000100c0 46 80 00 00 06 00 00 00 00 00 00 00 00 00 00 00 |F...............| 000100d0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
索引數據從00010078的位置開始, 逐行進行分析即可:
03 /* 當前索引字段的長度 */ 00 00 00 10 00 0e /* 不知道是啥 */ 41 41 41 /* 索引值 */ 80 00 00 01 /* 指向的主鍵id */
第2行與第1行基本類似, 現在來看看比較特殊的第3行與第5行。 第3行索引數據內容:
01 00 00 20 00 19 80 00 00 03 /* 指向的主鍵id */
當索引的內容為NULL值時, 輔助索引的文件格式也變得奇怪了起來, 和第一行完全不一樣, 再來看看第5行:
00 /* 當前索引字段的長度 */ 00 00 00 30 ff cc 80 00 00 05 /* 指向的主鍵id */
和正常索引內容基本類似, 空字符串仍然沒有表示, 僅使用了00表示該字段長度為0。
4. 輔助索引葉子節點存儲方式
在MySQL中, 數據管理的最小單元為頁(page), 而並非一行一行的數據。 數據保存在頁中, 當我們使用主鍵查找一行數據時, 其實MySQL並不能直接返回這一行數據, 而是將該行所在的頁載入內存, 然后在內存頁中進行查找。
通常情況下頁大小為16K, 在某些情況下可能會對頁進行壓縮, 使得頁大小為8K或者是4K。 由於B+Tree的特點, 使得每一頁內最少為2行數據, 再少就將退化成鏈表, 顯然出於效率的考量不會讓此種情況出現。 故而一行數據大小至多為16K, 通過該特性, 就可以研究二級索引的葉子節點是什么樣子的了。
CREATE TABLE two ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(10), nickname VARCHAR(8000), PRIMARY KEY (id), KEY (nickname(2000)) ) ENGINE=InnoDB CHARSET=LATIN1; INSERT INTO two SELECT 1, 'a', REPEAT('A', 8000); INSERT INTO two SELECT 2, 'b', NULL; INSERT INTO two SELECT 3, 'c', REPEAT('C', 8000); INSERT INTO two SELECT 4, 'd', NULL; INSERT INTO two SELECT 5, 'e', REPEAT('E', 8000); INSERT INTO two SELECT 6, 'f', REPEAT('F', 8000); INSERT INTO two SELECT 7, 'g', NULL; INSERT INTO two SELECT 8, 'h', REPEAT('H', 8000); INSERT INTO two SELECT 9, 'i', REPEAT('G', 8000); INSERT INTO two SELECT 10, 'i', "";
由於索引長度的限制, 這里僅取nickname的前2000個字符進行索引, 並插入一些具有代表性的數據。 同樣使用hexdump -C two.ibd對索引結構進行分析:
00010070 73 75 70 72 65 6d 75 6d d0 87 00 05 00 10 07 e6 |supremum........| 00010080 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 |AAAAAAAAAAAAAAAA| * 00010850 80 00 00 01 01 00 00 18 07 e6 80 00 00 02 d0 87 |................| 00010860 00 00 00 20 07 e6 43 43 43 43 43 43 43 43 43 43 |... ..CCCCCCCCCC| 00010870 43 43 43 43 43 43 43 43 43 43 43 43 43 43 43 43 |CCCCCCCCCCCCCCCC| * 00011030 43 43 43 43 43 43 80 00 00 03 01 00 00 28 0f c2 |CCCCCC.......(..| 00011040 80 00 00 04 d0 87 00 00 00 30 07 dc 45 45 45 45 |.........0..EEEE| 00011050 45 45 45 45 45 45 45 45 45 45 45 45 45 45 45 45 |EEEEEEEEEEEEEEEE| * 00011810 45 45 45 45 45 45 45 45 45 45 45 45 80 00 00 05 |EEEEEEEEEEEE....| 00011820 d0 87 00 00 00 38 0f c2 46 46 46 46 46 46 46 46 |.....8..FFFFFFFF| 00011830 46 46 46 46 46 46 46 46 46 46 46 46 46 46 46 46 |FFFFFFFFFFFFFFFF| * 00011ff0 46 46 46 46 46 46 46 46 80 00 00 06 01 00 00 40 |FFFFFFFF.......@| 00012000 0f c3 80 00 00 07 d0 87 00 00 00 48 e0 62 48 48 |...........H.bHH| 00012010 48 48 48 48 48 