MySQL:InnoDB一棵B+樹可以存放多少行數據?

前言

開門見山,面對這樣一個問題,你將如何作答?

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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章