前言
開門見山,面對這樣一個問題,你將如何作答?
1千萬,2千萬,或者上億條數據?具體的答案不重要,當然肯定也不會是一個固定的數目,今天我們就一起來探討探討這個問題。
InnoDB是一種兼顧了高可靠性和高性能的通用存儲引擎,它擁有諸多功能和特性,體系結構和工作原理也比較複雜。真要講明白說透徹,不是一兩篇博文能夠實現的,也不是今天的重點。
所以,本文不涉及太多的原理性知識,咱們就針對開頭提出的問題,通過熟悉一些基本的概念和利用工具來驗證,對這個問題做到心中有數。
文件結構
我們知道,InnoDB引擎是支持事務的,所以表裏的數據肯定都是存儲在磁盤上的。如果在test數據庫下創建兩個表:t1和t2,那麼在相應的數據目錄下就會發現兩個文件。
[root@localhost test]# ls
db.opt t1.frm t1.ibd t2.frm t2.ibd
[root@localhost test]# pwd
/var/lib/mysql/test
其中,frm文件是表結構信息,ibd文件是表中的數據。
表結構信息包含MySQL表的元數據(例如表定義)的文件,比如表名、表有多少列、列的數據類型啥的,不重要,我們先不管;
ibd文件存儲的是表中的數據,比如數據行和索引。這個文件比較重要,它是今天我們的重點研究對象。
我們說,MySQL表裏的數據都是存放在磁盤上的。那麼在磁盤上,最小單元是扇區,每個扇區可以存放512個字節的數據;操作系統中最小單元是塊(block),最小單位是4kb。
在Windows系統中,我們可以通過fsutil fsinfo ntfsinfo c:
來查看。
C:\Windows\system32>fsutil fsinfo ntfsinfo c:
NTFS 卷序列號: 0x78f40b2cf40aec66
NTFS 版本: 3.1
LFS 版本: 2.0
扇區數量: 0x000000001bcb6fff
簇總數: 0x0000000003796dff
可用簇: 0x0000000000a63a03
保留總數: 0x00000000000017c3
每個扇區字節數: 512
每個物理扇區字節數: 4096
每個簇字節數: 4096
每個 FileRecord 段字節數: 1024
每個 FileRecord 段簇數: 0
在Linux系統上,可以通過以下兩個命令查看,這取決於文件系統的格式。
xfs_growfs /dev/mapper/centos-root | grep bsize
tune2fs -l /dev/mapper/centos-root | grep Block
我們拉回來接着說MySQL,InnoDB存儲引擎它也是有最小存儲單位的,叫做頁(Page),默認大小是16kb。
我們新創建一個表 t3,裏面任何數據都沒有,我們來看它的ibd文件。
[root@localhost test]# ll
總用量 18579600
-rw-r-----. 1 mysql mysql 67 11月 30 20:59 db.opt
-rw-r-----. 1 mysql mysql 12756 12月 7 21:10 t1.frm
-rw-r-----. 1 mysql mysql 13077839872 12月 7 21:37 t1.ibd
-rw-r-----. 1 mysql mysql 8608 12月 7 21:43 t2.frm
-rw-r-----. 1 mysql mysql 5947523072 12月 7 21:52 t2.ibd
-rw-r-----. 1 mysql mysql 12756 12月 8 21:02 t3.frm
-rw-r-----. 1 mysql mysql 98304 12月 8 21:02 t3.ibd
不僅是t3,我們看到,任何表的ibd文件大小,它永遠是16k的整數倍。理解這個事非常重要,MySQL從磁盤加載數據是按照頁來讀取的,即便你查詢一條數據,它也會讀取一頁16k的數據出來。
聚簇索引
數據庫表中的數據都是存儲在頁裏的,那麼這一個頁可以存放多少條記錄呢?
這取決於一行記錄的大小是多少,假如一行數據大小是1k,那麼理論上一頁就可以放16條數據。
當然,查詢數據的時候,MySQL也不能把所有的頁都遍歷一遍,所以就有了索引,InnoDB存儲引擎用B+樹的方式來構建索引。
聚簇索引就是按照每張表的主鍵構造一顆B+樹,葉子節點存放的是整行記錄數據,在非葉子節點上存放的是鍵值以及指向數據頁的指針,同時每個數據頁之間都通過一個雙向鏈表來進行鏈接。
如上圖所示,就是一顆聚簇索引樹的大致結構。它先將數據記錄按照主鍵排序,放在不同的頁中,下面一行是數據頁。上面的非葉子節點,存放主鍵值和一個指向頁的指針。
當我們通過主鍵來查詢的時候,比如id=6
的條件,就是通過這顆B+樹來查找數據的過程。它先找到根頁面(page offset=3),然後通過二分查找,定位到id=6
的數據在指針爲5的頁上。然後進一步的去page offset=5的頁面上加載數據。
在這裏,我們需要理解兩件事:
上圖中B+樹的根節點(page offset=3),是固定不會變化的。只要表創建了聚簇索引,它的根節點
頁號就被記錄到某個地方了。還有一點,B+樹索引本身並不能直接找到具體的一條記錄,只能知道該記錄在哪個頁上,數據庫會把頁載入到內存,再通過二分查找定位到具體的記錄。
現在我們知道了InnoDB存儲引擎最小存儲單元是頁,在B+樹索引結構裏,頁可以放一行一行的數據(葉子節點),也可以放主鍵+指針(非葉子節點)。
上面已經說過,假如一行數據大小是1k,那麼理論上一頁就可以放16條數據。那一頁可以放多少主鍵+指針呢?
假如我們的主鍵id爲bigint類型,長度爲8字節,而指針大小在InnoDB源碼中設置爲6字節。這樣算下來就是 16384 / 14 = 1170,就是說一個頁上可以存放1170個指針。
一個指針指向一個存放記錄的頁,一個頁裏可以放16條數據,那麼一顆高度爲2的B+樹就可以存放 1170 * 16=18720 條數據。同理,高度爲3的B+樹,就可以存放 1170 * 1170 * 16 = 21902400 條記錄。
理論上就是這樣,在InnoDB存儲引擎中,B+樹的高度一般爲2-4層,就可以滿足千萬級數據的存儲。查找數據的時候,一次頁的查找代表一次IO,那我們通過主鍵索引查詢的時候,其實最多隻需要2-4次IO就可以了。
那麼,實際上到底是不是這樣呢?我們接着往下看。
頁的類型
在開始驗證之前,我們不僅需要了解頁,還需要知道,在InnoDB引擎中,頁並不是只有一種。常見的頁類型有:
- 數據頁,B-tree Node;
- undo頁,undo Log Page;
- 系統頁,System Page;
- 事務數據頁,Transaction system Page;
- 插入緩衝位圖頁,Insert Buffer Bitmap;
- 插入緩衝空閒列表頁,Insert Buffer Free List;
- 未壓縮的二進制大對象頁,Uncompressed BLOB Page;
在這裏我們重點來看 B-tree Node,我們的索引和數據就放在這種頁上。既然有不同的頁類型,我們怎麼知道當前的頁屬於什麼頁呢?
那麼我們就需要大概瞭解下數據頁的結構,數據頁由七個部分組成,每個部分都有不同的含義。
- File Header,文件頭,固定38字節;
- Page Header,頁頭,固定56字節;
- Infimum + supremum,固定26字節;
- User Records,用戶記錄,即行記錄,大小不固定;
- Free Space,空閒空間,大小不固定;
- Page Directort,頁目錄,大小不固定;
- File Trailer,文件結尾信息,固定8字節。
其中,File Header用來記錄頁的一些頭信息,共佔用38個字節。在這個頭信息裏,我們可以獲取到該頁在表空間裏的偏移值和這個數據頁的類型。
接下來是Page Header,它記錄的是數據頁的狀態信息,共佔用56個字節。在這一部分,我們可以獲取到兩個重要的信息:該頁中記錄的數量和當前頁在索引樹的層級,其中0x00代表葉子節點,比如聚簇索引中的葉子節點放的就是整行數據,它總是在第0層。
驗證
前面我們已經說過,ibd文件就是表數據文件。這個文件會隨着數據庫表裏數據的增長而增長,不過它始終會是16k的整數倍。裏面就是一個個的頁,那我們就可以一個一個頁的來解析,通過文件頭可以判斷它是什麼頁,找到 B-tree Node,就可以看到裏面的 Page Level,它的值+1,就代表了當前B+樹的高度。
我們現在就來重新創建一個表,爲了使這個表中的數據一行大小爲1k,我們設置幾個char(255)的字段即可。
CREATE TABLE `t5` (
`id` bigint(8) NOT NULL,
`c1` char(245) NOT NULL DEFAULT '1',
`c2` char(255) NOT NULL DEFAULT '1',
`c3` char(255) NOT NULL DEFAULT '1',
`c4` char(255) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
然後筆者寫了一個存儲過程,用來批量插入數據用的,爲了加快批量插入的速度,筆者還修改了innodb_flush_log_at_trx_commit=0
,切記生產環境可不要這樣玩。
BEGIN
DECLARE i int DEFAULT 0;
select ifnull(max(id),0) into i from t5;
set i = i+1;
WHILE i <= 100000 DO
insert into t5(id)value(i);
set i = i+1;
END WHILE;
END
innodbPageInfo.jar
是筆者用Java代碼寫的一個工具類,用來輸出ibd文件中,頁的信息。
-path 後面是文件的路徑,-v 是否顯示頁的詳情信息,0是 1否。
上面我們創建了t5這張表,一條數據還沒有的情況下,我們看一下這個ibd文件的信息。
[root@localhost innodbInfo]# java -jar innodbPageInfo.jar -path /var/lib/mysql/test/t5.ibd -v 0
page offset 00000000,page type <File Space Header>
page offset 00000001,page type <Insert Buffer Bitmap>
page offset 00000002,page type <File Segment inode>
page offset 00000003,page type <B-tree Node>,page level <0000>
page offset 00000000,page type <Freshly Allocated Page>
page offset 00000000,page type <Freshly Allocated Page>
數據頁總記錄數:0
Total number of page: 6
Insert Buffer Bitmap: 1
File Segment inode: 1
B-tree Node: 1
File Space Header: 1
Freshly Allocated Page: 2
[root@localhost innodbInfo]#
t5表現在沒有任何數據,它的ibd文件大小是98304,也就是說一共有6個頁。其中第四個頁(page offset 3)是數據頁,page level等於0,代表該頁爲葉子節點。因爲目前還沒有數據,可以認爲B+樹的索引只有1層。
我們接着插入10條數據,這個page level還是爲0,B+樹的高度還是1,這是因爲一個頁大約能存放16條大小爲1k的數據。
page offset 00000003,page type <B-tree Node>,page level <0000>
數據頁總記錄數:10
Total number of page: 6
當我們插入15條數據的時候,一個頁就放不下了,原本爲新分配的頁(Freshly Allocated Page)就會變成數據頁,原來的根頁面(page offset=3)就會升級成存儲目錄項的頁。offset 04 和 05就變成了葉子節點的數據頁,所以現在整個B+樹的高度爲2。
page offset 00000003,page type <B-tree Node>,page level <0001>
page offset 00000004,page type <B-tree Node>,page level <0000>
page offset 00000005,page type <B-tree Node>,page level <0000>
數據頁總記錄數:15
Total number of page: 6
繼續插入10000條數據,我們再來看一下B+樹高的情況。當然現在信息比較多了,我們把輸出結果寫到文件裏。
java -jar innodbPageInfo.jar -path /var/lib/mysql/test/t5.ibd -v 0 > t5.txt
截取部分結果如下:
[root@localhost innodbInfo]# vim t5.txt
page offset 00000003,page type <B-tree Node>,page level <0001>
page offset 00000004,page type <B-tree Node>,page level <0000>
page offset 00000005,page type <B-tree Node>,page level <0000>
page offset 00000000,page type <Freshly Allocated Page>
數據頁總記錄數:10000
Total number of page: 1216
B-tree Node: 716
可以看到,1萬條1k大小的記錄,一共用了716個數據頁,根頁面顯示的樹高還是2層。
前面我們計算過,2層的B+樹理論上可以存放18000條左右,筆者測試大約13000條數據左右,B+樹就會成爲3層了。
page offset 00000003,page type <B-tree Node>,page level <0002>
數據頁總記錄數:13000
Total number of page: 1472
B-tree Node: 933
原因也不難理解,因爲每個頁不可能只放數據本身。
首先每個頁都有一些固定的格式,比如文件頭部、頁面頭部、文件尾部這些,我們的數據放在用戶記錄
這部分裏的;
其次,用戶記錄也不只放數據行,每個數據行還有一些其他標記,比如是否刪除、最小記錄、記錄數、在堆中的位置信息、記錄的類型、下一條記錄的相對位置等等;
另外,MySQL參考手冊中也有說到,InnoDB會保留頁的1/16空閒,以便將來插入或者更新索引使用,如果主鍵id不是順序插入的,那可能還不是1/16,會佔用更多的空閒空間。
總之,我們理解一個頁不會全放數據就行了。所以,實測跟理論上不一致也是完全正常的,因爲上面的理論沒有排除這些項。
接着來,我們再插入1000萬條數據,現在ibd文件已經達到11GB。
page offset 00000003,page type <B-tree Node>,page level <0002>
數據頁總記錄數:10000000
Total number of page: 725760
B-tree Node: 715059
我們看到,1千萬條數據,數據頁已經有71萬個,B+樹的高度還是3層,這也就是說幾萬條數據和一千萬條數據的查詢效率基本上是一樣的。
比如我們現在根據主鍵ID查詢一條數據,select * from t5 where id = 6548215;
,查詢時間顯示用了0.010秒。
什麼時候會到4層呢?大概在1300萬左右,B+樹就會增加樹高到4層。
什麼時候會到5層呢?筆者沒測試出來,因爲插入到5000萬條數據的時候,ibd數據文件大小已經55G了,虛擬機已經空間不足了。。
page offset 00000003,page type <B-tree Node>,page level <0003>
數據頁總記錄數:50000000
B-tree Node: 3575286
即便是5000萬條數據,我們通過主鍵ID查詢,查詢時間也是毫秒級的。
理論上要達到十億 - 百億行數據,樹高才能到5層。如果有小夥伴用這種方法,測試出來5層高的數據,歡迎在評論區留言,讓我看看。
另外,朋友們有沒有意識到一個問題?其實影響B+樹樹高的因素,不僅是數據行,還有主鍵ID的長度。我們上面的測試中,ID的類型是bigint(8),在其他字段長度均不變的情況下,我們把ID的類型改爲int(4),相同的樹高就會容納更多的數據,因爲它單個頁能承載的指針數變多了。
CREATE TABLE `t6` (
`id` int(4) NOT NULL,
`c1` char(245) NOT NULL DEFAULT '1',
`c2` char(255) NOT NULL DEFAULT '1',
`c3` char(255) NOT NULL DEFAULT '1',
`c4` char(255) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
針對t6這張表,我們插入16000條數據,然後輸出一下頁面信息。
page offset 00000003,page type <B-tree Node>,page level <0001>
數據頁總記錄數:16000
B-tree Node: 1145
我們來看,如果按照主鍵ID類型bigint(8)來測試,13000條數據的時候,樹高就已經是3了,現在改爲int(4),16000條數據,樹高依然還是2層。儘管數據頁(B-tree Node)數量還是那麼多,變化並不大,但是它不影響樹高。
ok,看到這裏,相信朋友們對開頭提出的問題已經有自己的答案了,如果你也跟着試一遍,理解可能會更加深入。
看到這,還有道經典的面試題:爲什麼MySQL的索引要使用B+樹而不是其它樹形結構?比如B樹?
簡單來說,其中有一個原因就是B+樹的高度比較穩定,因爲它的非葉子節點不會保存數據,只保存鍵值和指針的情況下,一個頁能承載大量的數據。你想啊,B樹它的非葉子節點也會保存數據的,同樣的一行數據大小是1kb,那麼它一頁最多也只能保存16個指針,在大量數據的情況下,樹高就會速度膨脹,導致IO次數就會很多,查詢就會變得很慢。
源碼地址
本文的innodbPageInfo.jar
代碼是筆者參考 MySQL技術內幕(InnoDB存儲引擎)
一書中的工具包,書裏作者是用Python寫的,所以筆者在這裏用Java重新實現了一遍。
Java版本的源碼我放在GitHub上了:https://github.com/taoxun/innodbPageInfo
已經打完包的Jar版本,也可以下載:https://pan.baidu.com/s/1IZVJRNUk_bPESp5zoQwOvA 提取碼:5rnz。
朋友們可以拿這個工具看一看,自己認爲較大的表,它的B+樹索引到底有幾層?
參考資料:
姜承堯:《MySQL技術內幕:InnoDB存儲引擎》
天涯淚小武:https://tianyalei.blog.csdn.net/article/details/100015840
飄揚的紅領巾:https://www.cnblogs.com/leefreeman/p/8315844.html
MySQL官方參考手冊:https://dev.mysql.com/doc/refman/5.7/en/