MySQL物理存儲方式


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 48 48 48 48 48 48 48 48 48 48 48 |HHHHHHHHHHHHHHHH| * 000127d0 48 48 48 48 48 48 48 48 48 48 48 48 48 48 80 00 |HHHHHHHHHHHHHH..| 000127e0 00 08 d0 87 00 00 00 50 f8 24 47 47 47 47 47 47 |.......P.$GGGGGG| 000127f0 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 |GGGGGGGGGGGGGGGG| * 00012fb0 47 47 47 47 47 47 47 47 47 47 80 00 00 09 00 00 |GGGGGGGGGG......| 00012fc0 00 00 58 d0 bb 80 00 00 0a 00 00 00 00 00 00 00 |..X.............| 00012fd0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| 

從上表中可以看到, 索引數據起始點為00010078, 逐行進行分析可以發現, NULL值和空值的表現形式與上一小節分析的基本相同。

NULL值行:

01 00 00 18 07 e6 80 00 00 02 /* 主鍵id */ 01 00 00 28 0f c2 80 00 00 04 /* 主鍵id */ 01 00 00 40 0f c3 80 00 00 07 /* 主鍵id */ 

空字符串行:

00 00 00 00 58 d0 bb 80 00 00 0a /* 主鍵id */ 

所以說, 分析到這里, 我們完全有理由說NULL值要比空值占用更少的物理存儲空間, 包含索引存儲空間。 但是, 這是在我們所定義表結構時允許字段值為NULL的前提下, 當我們顯式的指定IS NOT NULL時, 情況又會不一樣。

CREATE TABLE three ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(10) NOT NULL, nickname VARCHAR(10) NOT NULL, PRIMARY KEY (id), KEY (nickname) ) ENGINE=InnoDB CHARSET=LATIN1; INSERT INTO three (name, nickname) VALUES ("a", "AAA"); INSERT INTO three (name, nickname) VALUES ("b", ""); INSERT INTO three (name, nickname) VALUES ("c", "CCC"); INSERT INTO three (name, nickname) VALUES ("d", "DDD"); INSERT INTO three (name, nickname) VALUES ("e", "EEE"); INSERT INTO three (name, nickname) VALUES ("f", ""); INSERT INTO three (name, nickname) VALUES ("g", "GGG"); 

hexdump -C three.ibd可得:

0000c070 73 75 70 72 65 6d 75 6d 03 01 00 00 10 00 1c 80 |supremum........| 0000c080 00 00 01 00 00 00 08 dd 21 ba 00 00 01 2f 01 10 |........!..../..| 0000c090 61 41 41 41 00 01 00 00 18 00 19 80 00 00 02 00 |aAAA............| 0000c0a0 00 00 08 dd 22 bb 00 00 01 31 01 10 62 03 01 00 |...."....1..b...| 0000c0b0 00 20 00 1c 80 00 00 03 00 00 00 08 dd 25 bd 00 |. ...........%..| 0000c0c0 00 01 35 01 10 63 43 43 43 03 01 00 00 28 00 1c |..5..cCCC....(..| 0000c0d0 80 00 00 04 00 00 00 08 dd 28 bf 00 00 01 36 01 |.........(....6.| 0000c0e0 10 64 44 44 44 03 01 00 00 30 00 1c 80 00 00 05 |.dDDD....0......| 0000c0f0 00 00 00 08 dd 29 c0 00 00 01 37 01 10 65 45 45 |.....)....7..eEE| 0000c100 45 00 01 00 00 38 00 19 80 00 00 06 00 00 00 08 |E....8..........| 0000c110 dd 2a a1 00 00 01 12 01 10 66 03 01 00 00 40 ff |.*.......f....@.| 0000c120 4f 80 00 00 07 00 00 00 08 dd 2b a2 00 00 01 15 |O.........+.....| 0000c130 01 10 67 47 47 47 00 00 00 00 00 00 00 00 00 00 |..gGGG..........| 

數據從c078開始, 同樣進行逐行分析:

第一行數據:

03 01 /* 逆序可變字符長度列表 */ 00 00 10 00 1c /* 記錄頭信息(Record Header), c078+001c=c094 */ 80 00 00 01 /* 主鍵id */ -- Transaction ID + Roll Pointer 61 /* 列1數據 */ 41 41 41 /* 列2數據 */ 

第二行數據

00 01 /* 逆序可變字符長度列表 */ 00 00 18 00 19 /* 記錄頭信息(Record Header), c094+0019=c0ad */ 80 00 00 02 /* 主鍵id */ -- Transaction ID + Roll Pointer 62 /* 列1數據, 列2數據為空值, 故無記錄 */ 

第三行數據:

03 01 /* 逆序可變字符長度列表 */ 00 00 20 00 1c /* 記錄頭信息(Record Header), c0ad+001c=c0c9 */ 80 00 00 03 /* 主鍵id */ -- Transaction ID + Roll Pointer 63 /* 列1數據 */ 43 43 43 /* 列2數據 */ 

可以看到, 將所有的列設置為NOT NULL之后, 存儲內容少了一個NULL標識位, 此時該table的存儲效率要高於最初的表結構。

所以說, 如果想要真正的節省表空間存儲大小, 需要將所有的字段都設置為NOT NULL約束, 否則在存儲時仍然需要NULL標識位來標記哪一列數據為非NULL, 即使所有的列都有數據。

最后, NULL真的比空字符串占用更少的空間嗎? 答案是不一定。 如果在定義表結構時指定了NOT NULL, 那么數據中就不可能出現NULL值, 也就無從比起。 如果在定義表結構時沒有指定NOT NULL, 那么NULL將會比空字符串占用更少的空間。

5. 總結

經過對.ibd文件的分析, 想必對數據以及索引的組織方式有了一個更加清晰的了解, 並且也能夠判斷出各種各樣優化建議到底是否正確了。

使用數字或者是空串來代替NULL值? 沒有必要, 有時還會適得其反, 而且對於添加了二級索引的NULL值, 查詢仍然會使用索引。 正確的做法就是在定義表結構的時候就將NULL值扼殺在搖籃里, 如此一來能夠節省一部分的磁盤空間以及一定程度上的效率提升。

為什么索引不能太多? 因為每添加一個索引, .ibd文件中就需要多維護一個B+Tree索引樹, 如果某一個table中存在10個索引, 那么就需要維護10棵B+Tree, 寫入效率會降低, 並且會浪費磁盤空間。

B+Tree中的指針是用什么實現的? 使用文件偏移量實現, 指向下一行或者是下一個索引的起始位置。

轉載: https://smartkeyerror.com/MySQL-physical-structure


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM