InnoDB一個支持事務安全的存儲引擎,同時也是mysql的默認存儲引擎。本文主要從數據結構的角度,詳細介紹InnoDB行記錄格式和數據頁的實現原理,從底層看清InnoDB存儲引擎。
本文主要內容是根據掘金小冊《從根兒上理解 MySQL》整理而來。如想詳細了解,建議購買掘金小冊閱讀。
InnoDB簡介
大家都知道mysql中數據是存儲在物理磁盤上的,而真正的數據處理又是在內存中執行的。由於磁盤的讀寫速度非常慢,如果每次操作都對磁盤進行頻繁讀寫的話,那么性能一定非常差。為了上述問題,InnoDB將數據划分為若干頁,以頁作為磁盤與內存交互的基本單位,一般頁的大小為16KB。這樣的話,一次性至少讀取1頁數據到內存中或者將1頁數據寫入磁盤。通過減少內存與磁盤的交互次數,從而提升性能。
其實,這本質上就是一種典型的緩存設計思想,一般緩存的設計基本都是從時間維度
或者空間維度
進行考量的:
時間維度
:如果一條數據正在在被使用,那么在接下來一段時間內大概率還會再被使用。可以認為熱點數據緩存
都屬於這種思路的實現。空間維度
:如果一條數據正在在被使用,那么存儲在它附近的數據大概率也會很快被使用。InnoDB的數據頁
和操作系統的頁緩存
則是這種思路的體現。
InnoDB行格式
mysql是以記錄(一行數據)為單位向數據表中插入數據的,這些記錄在磁盤上的存放方式稱為行格式
。mysql支持4種不同類型的行格式:Compact
、Redundant
(比較老,本文就不具體介紹了)、Dynamic
、Compressed
。
我們可以在創建或修改表的語句中指定行格式:
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名稱
ALTER TABLE 表名 ROW_FORMAT=行格式名稱
比如,我們要創建一個行格式為Compact
,字符集為ascii
的數據表record_format_demo
,sql如下:
mysql> CREATE TABLE record_format_demo (
-> c1 VARCHAR(10),
-> c2 VARCHAR(10) NOT NULL,
-> c3 CHAR(10),
-> c4 VARCHAR(10)
-> ) CHARSET=ascii ROW_FORMAT=COMPACT;
Query OK, 0 rows affected (0.03 sec)
假設我們向record_format_demo
表中插入了2行數據:
mysql> SELECT * FROM record_format_demo;
+------+-----+------+------+
| c1 | c2 | c3 | c4 |
+------+-----+------+------+
| aaaa | bbb | cc | d |
| eeee | fff | NULL | NULL |
+------+-----+------+------+
2 rows in set (0.00 sec)
COMPACT行格式
從上圖可以看出,一條完整的記錄包含記錄的額外信息
和記錄的真實數據
兩大部分。
記錄的額外信息
記錄的額外信息主要包含3類:變長字段長度列表
、NULL值列表
和記錄頭信息
。
變長字段長度列表
mysql中支持一些變長數據類型(比如VARCHAR(M)
、TEXT
等),它們存儲數據占用的存儲空間不是固定的,而是會隨着存儲內容的變化而變化。為了准確描述這種數據,這種變長字段占用的存儲空間要同時包含:
- 真正的數據內容
- 占用的字節數
在Compact行格式中,把所有變長字段的真實數據占用的字節長度都存放在記錄的開頭部位,從而形成一個變長字段長度列表,各變長字段數據占用的字節數按照列的順序逆序
存放。
我們以record_format_demo
第一行數據為例。由於c1
、c2
和c4
都是變成數據類型(VARCHAR(10)
),因此要將這3列值得長度保存在記錄的開頭處。
另外需要注意的一點是,變長字段長度列表中只存儲值為 非NULL 的列內容占用的長度,值為 NULL 的列的長度是不儲存的。也就是說對於第二條記錄來說,因為c4列的值為NULL,所以第二條記錄的變長字段長度列表只需要存儲c1和c2列的長度即可。
NULL值列表
對於可為NULL的列,為了節約存儲空間,mysql不會將NULL
值保存在記錄的真實數據
部分。而是會將其保存在記錄的額外信息
里面的NULL值列表
中。
具體的做法是先統計表中允許存儲NULL
值的列,然后將每個允許存儲NULL
值的列對應一個二進制位(1:值為NULL
,0:值不為NULL
)用來表示是否存儲NULL
值,並按照逆序排列。MySQL規定NULL值列表必須用整數個字節的位表示,如果使用的二進制位個數不是整數個字節,則在字節的高位補0。
對應record_format_demo
表中,c1
、c3
、c4
都是允許存儲NULL值的。前兩條記錄在填充了NULL
值列表后的示意圖就是這樣:
記錄頭信息
記錄頭信息是由固定的5個字節(40位)組成, 不同的位代表不同的含義:
暫時不詳細展開。
記錄的真實數據
記錄的真實數據除了包含各列具體的數據外,還會自動添加一些隱藏列數據。
列名 | 是否必須 | 占用空間 | 描述 |
---|---|---|---|
row_id | 否 | 6字節 | 行ID,唯一標識一條記錄 |
transaction_id | 是 | 6字節 | 事務ID |
roll_pointer | 是 | 7字節 | 回滾指針 |
實際上這幾個列的真正名稱其實是:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR,為了美觀才寫成了row_id、transaction_id和roll_pointer。
只有當數據庫沒有定義主鍵
或者唯一鍵
時,隱藏列row_id
才會存在,並且將其作為數據表主鍵
。
因為表record_format_demo
並沒有定義主鍵,所以MySQL服務器會為每條記錄增加上述的3個列。現在看一下加上記錄的真實數據
的兩個記錄的數據結構:
CHAR(M)列的存儲格式
對於 CHAR(M) 類型的列來說,當列采用的是定長字符集時,該列占用的字節數不會被加到變長字段長度列表,而如果采用變長字符集時,該列占用的字節數也會被加到變長字段長度列表。
另外有一點還需要注意,變長字符集的CHAR(M)
類型的列要求至少占用M
個字節,而VARCHAR(M)
卻沒有這個要求。比方說對於使用utf8
字符集的CHAR(10)
的列來說,該列存儲的數據字節長度的范圍是10~30
個字節,即使我們向該列中存儲一個空字符串也會占用10
個字節。
行溢出數據
VARCHAR(M)最多能存儲的數據
MySQL對一條記錄占用的最大存儲空間是有限制的,除了BLOB
或者TEXT
類型的列之外,其他所有的列(不包括隱藏列和記錄頭信息)占用的字節長度加起來不能超過65535個字節。可以不嚴謹的認為,mysql一行記錄占用的存儲空間不能超過65535個字節。這個65535個字節除了列本身的數據之外,還包括一些其他的數據(storage overhead),比如說我們為了存儲一個VARCHAR(M)類型的列,其實需要占用3部分存儲空間:
- 真實數據
- 真實數據占用字節的長度
- NULL值標識,如果該列有NOT NULL屬性則可以沒有這部分存儲空間
假設varchar_size_demo
只有一個VARCHAR
類型的字段,那么該字段最大占用的65532個字節。因為真實數據的長度可能占用2個字節,NULL值標識
需要占用1個字節。如果該VARCHAR
類型的列沒有NOT NULL
屬性,那最多只能存儲65532
個字節的數據。如果該列是ascii
字符集,對應的最大字符數最大為65532
;如果是utf8
字符集,則對應的最大字符數為21844
。
記錄中的數據太多產生的溢出
我們以ascii字符集下的varchar_size_demo
表為例,插入一條記錄:
mysql> CREATE TABLE varchar_size_demo(
-> c VARCHAR(65532)
-> ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.01 sec)
mysql> INSERT INTO varchar_size_demo(c) VALUES(REPEAT('a', 65532));
Query OK, 1 row affected (0.00 sec)
mysql中磁盤與內存交互的基本單位是頁,一般為16KB,16384個字節,而一行記錄最大可以占用65535
個字節,這就造成了一頁存不下一行數據的情況。在Compact和Redundant行格式中,對於占用存儲空間非常大的列,在記錄的真實數據處只會存儲該列的一部分數據,把剩余的數據分散存儲在幾個其他的頁中,然后記錄的真實數據處用20個字節存儲指向這些頁的地址,從而可以找到剩余數據所在的頁,如圖所示:
這種在本記錄的真實數據處只會存儲該列的前768個字節的數據和一個指向其他頁的地址,然后把剩下的數據存放到其他頁中的情況就叫做行溢出
,存儲超出768字節的那些頁面也被稱為溢出頁
。
行溢出的臨界點
MySQL中規定一個頁中至少存放兩行記錄。以上邊的varchar_size_demo
表為例,它只有一個列c
,我們往這個表中插入兩條記錄,每條記錄最少插入多少字節的數據才會行溢出的現象呢?這得分析一下頁中的空間都是如何利用的。
- 每個頁除了存放我們的記錄以外,也需要存儲一些額外的信息,大概132個字節。
- 每個記錄需要的額外信息是27字節。
假設一個列中存儲的數據字節數為n,如要要保證該列不發生溢出,則需要滿足:
132 + 2×(27 + n) < 16384
結果是n < 8099
。也就是說如果一個列中存儲的數據小於8099個字節,那么該列就不會成為溢出列。如果表中有多個列,那么這個值更小。
Dynamic和Compressed行格式
mysql中默認的行格式就是Dynamic
。Dynamic
和Compressed
行格式和Compact
行格式很像,只是在處理行溢出
數據上有差異。Dynamic
和Compressed
行格式不會在記錄的真實數據
出存放前768個字節,而是將所有字節都存儲在其它頁面中。Compressed
行格式會采用壓縮算法對頁面進行壓縮,以節省空間。
InnoDB數據頁結構
我們已經知道頁是InnoDB管理存儲空間的基本單位,一個頁的大小一般是16KB。InnoDB為了不同的目的設計了許多不同類型的頁,我們這里主要關注存儲數據記錄
的頁,官方稱為索引頁
。由於還沒介紹索引,暫且我們先稱為數據頁
吧。
數據頁結構的快速瀏覽
數據頁在結構上可以划分為多個部分,不同的部分有不同的功能,如下圖所示:
一個InnoDB數據頁被划分為了7個部分,下面大概描述一下這7個部分內容。
名稱 | 中文名 | 占用空間大小 | 簡單描述 |
---|---|---|---|
File Header | 文件頭部 | 38字節 | 頁的一些通用信息 |
Page Header | 頁面頭部 | 56字節 | 數據頁專有的一些信息 |
Infimum + Supremum | 最小記錄和最大記錄 | 26字節 | 兩個虛擬的行記錄 |
User Records | 用戶記錄 | 不確定 | 實際存儲的行記錄內容 |
Free Space | 空閑空間 | 不確定 | 頁中尚未使用的空間 |
Page Directory | 頁面目錄 | 不確定 | 頁中的某些記錄的相對位置 |
File Trailer | 文件尾部 | 8字節 | 校驗頁是否完整 |
記錄在頁中的存儲
用戶自己的存儲的數據會按照對應的行格式
存在User Records
中。實際上,新生成的頁面是沒有User Records
的,只有當我們第一次插入數據時,才會從Free Space
划一個記錄大小的空間給User Records
。當Free Space
用完之后,就意味着當前的數據頁也使用完了。
為了能夠將User Records
講清楚,我們先得理解前面提到的記錄頭信息
。
理解記錄頭信息
先簡單介紹一下記錄頭信息各屬性描述:
名稱 | 大小(單位:bit) | 描述 |
---|---|---|
預留位1 | 1 | 沒有使用 |
預留位2 | 1 | 沒有使用 |
delete_mask | 1 | 標記該記錄是否被刪除 |
min_rec_mask | 1 | B+樹的每層非葉子節點中的最小記錄都會添加該標記 |
n_owned | 4 | 表示當前記錄擁有的記錄數 |
heap_no | 13 | 表示當前記錄在記錄堆的位置信息 |
record_type | 3 | 表示當前記錄的類型,0表示普通記錄,1表示B+樹非葉子節點記錄,2表示最小記錄,3表示最大記錄 |
next_record | 16 | 表示下一條記錄的相對位置 |
接下來以page_demo
表為例,並插入一些數據,詳細介紹記錄頭信息。
mysql> CREATE TABLE page_demo(
-> c1 INT,
-> c2 INT,
-> c3 VARCHAR(10000),
-> PRIMARY KEY (c1)
-> ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.03 sec)
mysql> INSERT INTO page_demo VALUES(1, 100, 'aaaa'), (2, 200, 'bbbb'), (3, 300, 'cccc'), (4, 400, 'dddd');
Query OK, 4 rows affected (0.00 sec)
Records: 4 Duplicates: 0 Warnings: 0
這4條記錄在InnoDB中的行格式如下(只展示記錄頭和真實數據),列中數據均用十進制表示:
我們對照着這個圖來重點介紹幾個屬性的詳細信息:
delete_mask
:標記着當前記錄是否被刪除,0表示未刪除,1表示刪除。未刪除的記錄不會立即從磁盤上移除,而是先打上刪除標記,所有被刪除的記錄會組成一個垃圾鏈表
。之后新插入的記錄可能會重用垃圾鏈表
占用的空間,因此垃圾鏈表占用的存儲空間也被成為可重用空間
。heap_no
:表示當前記錄在本頁中的位置,比如上邊4條記錄在本頁中的位置分別是2、3、4、5
。實際上,InnoDB會自動為每頁加上兩條虛擬記錄,一條是最小記錄
,另一條是最大記錄
。這兩條記錄的構造十分簡單,都是由5字節大小的記錄頭信息
和8字節大小的固定部分
(其實內容就是infimum或者supremum)組成的。這兩條記錄被單獨放在Infimum + Supremum
的部分。
從圖中我們可以看出來,最小記錄和最大記錄的heap_no
值分別是0和1,也就是說它們的位置最靠前。next_record
:表示從當前記錄的真實數據到下一條記錄的真實數據的地址偏移量。可以簡單理解為是一個單向鏈表,最小記錄的下一個是第一條記錄,最后一條記錄的下一個是最大記錄。為了更加形象的展示,我們可以用箭頭來替代一下next_record中的地址偏移量:
從圖中也能看出來,用戶記錄實際上按照主鍵大小正序排序行成一個單向鏈表。如果從中刪除掉一條記錄,這個鏈表也是會跟着變化的,比如我們把第2條記錄刪掉:
- 第2條記錄並沒有從存儲空間中移除,而是把該條記錄的
delete_mask
值設置為1。 - 第2條記錄的
next_record
值變為了0,意味着該記錄沒有下一條記錄了。 - 第1條記錄的
next_record
指向了第3條記錄。
- 第2條記錄並沒有從存儲空間中移除,而是把該條記錄的
Page Directory(頁目錄)
我們已經知道,記錄在頁中按照主鍵大小正序串聯成了一個單鏈表。如果我們要根據主鍵查找具體的某條記錄應該怎么辦,簡單的方式是根據鏈表進行遍歷。但是在數據量比較大的情況下,這種方式顯然效率太差了。因此mysql使用了Page Directory(頁目錄)
來解決這個問題。Page Directory(頁目錄)
大致的原理如下:
- 將所有正常的記錄(包括最大和最小記錄,不包括標記為已刪除的記錄)划分為幾個組。怎么划分先不關注。
- 每個組的最后一條記錄(也就是組內最大的那條記錄)的頭信息中的n_owned屬性表示該組內共有幾條記錄。
- 將每個組的最后一條記錄的地址偏移量單獨提取出來按順序存儲到靠近頁尾部的地方,這個地方就是所謂的
Page Directory
。
mysql規定對於最小記錄所在的分組只能有 1 條記錄,最大記錄所在的分組擁有的記錄條數只能在 1-8 條之間,剩下的分組中記錄的條數范圍只能在是 4-8 條之間。
比方說現在的page_demo
表中正常的記錄共有18條,InnoDB會把它們分成5組,第一組中只有一個最小記錄,如下所示:
通過Page Directory
在一個數據頁中查找指定主鍵值的記錄的過程分為兩步:
- 通過二分法確定該記錄所在的槽,並找到該槽所在分組中主鍵值最小的那條記錄。
- 通過記錄的
next_record
屬性遍歷該槽所在的組中的各個記錄。
對於鏈表的查詢性能優化,思想上基本上都是通過
二分法
實現的。上面介紹的Page Directory
,跳躍表
和查找樹
都是如此。
Page Header(頁面頭部)
Page Header
專門用來存儲數據頁相關的各種狀態信息,比如本頁中已經存儲了多少條記錄,第一條記錄的地址是什么,頁目錄中存儲了多少個槽等等。固定占用56個字節,各部分字節屬性含義如下:
名稱 | 占用空間大小 | 描述 |
---|---|---|
PAGE_N_DIR_SLOTS | 2字節 | 在頁目錄中的槽數量 |
PAGE_HEAP_TOP | 2字節 | 還未使用的空間最小地址,也就是說從該地址之后就是Free Space |
PAGE_N_HEAP | 2字節 | 本頁中的記錄的數量(包括最小和最大記錄以及標記為刪除的記錄) |
PAGE_FREE | 2字節 | 第一個已經標記為刪除的記錄地址(各個已刪除的記錄通過next_record也會組成一個單鏈表,這個單鏈表中的記錄可以被重新利用) |
PAGE_GARBAGE | 2字節 | 已刪除記錄占用的字節數 |
PAGE_LAST_INSERT | 2字節 | 最后插入記錄的位置 |
PAGE_DIRECTION | 2字節 | 最后一條記錄插入的方向 |
PAGE_N_DIRECTION | 2字節 | 一個方向連續插入的記錄數量,如果最后一條記錄的插入方向改變了的話,這個狀態的值會被清零重新統計。 |
PAGE_N_RECS | 2字節 | 該頁中記錄的數量(不包括最小和最大記錄以及被標記為刪除的記錄) |
PAGE_MAX_TRX_ID | 8字節 | 修改當前頁的最大事務ID,該值僅在二級索引中定義 |
PAGE_LEVEL | 2字節 | 當前頁在B+樹中所處的層級 |
PAGE_INDEX_ID | 8字節 | 索引ID,表示當前頁屬於哪個索引 |
PAGE_BTR_SEG_LEAF | 10字節 | B+樹葉子段的頭部信息,僅在B+樹的Root頁定義 |
PAGE_BTR_SEG_TOP | 10字節 | B+樹非葉子段的頭部信息,僅在B+樹的Root頁定義 |
這里只是羅列出來,暫時不需要全部理解。
File Header(文件頭部)
File Header
是用來描述各種頁都適用的一些通用信息的,由以下內容組成:
名稱 | 占用空間大小 | 描述 |
---|---|---|
FIL_PAGE_SPACE_OR_CHKSUM | 4字節 | 頁的校驗和(checksum值) |
FIL_PAGE_OFFSET | 4字節 | 頁號 |
FIL_PAGE_PREV | 4字節 | 上一個頁的頁號 |
FIL_PAGE_NEXT | 4字節 | 下一個頁的頁號 |
FIL_PAGE_LSN | 8字節 | 頁面被最后修改時對應的日志序列位置(英文名是:Log Sequence Number) |
FIL_PAGE_TYPE | 2字節 | 該頁的類型 |
FIL_PAGE_FILE_FLUSH_LSN | 8字節 | 僅在系統表空間的一個頁中定義,代表文件至少被刷新到了對應的LSN值 |
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 4字節 | 頁屬於哪個表空間 |
這里只是羅列出來,暫時不需要全部理解。我們重點關注一下幾個屬性:
FIL_PAGE_SPACE_OR_CHKSUM
當前頁面的校驗和(checksum)。對於一個很長的字節串來說,我們可以通過某種算法來計算一個比較短的值來代表這個很長的字節串,這個比較短的值就稱為校驗和
。通過校驗和
可以大幅度提升字符串等值比較的效率。FIL_PAGE_OFFSET
每一個頁都有一個唯一的頁號,InnoDB
通過頁號來可以定位一個頁。FIL_PAGE_TYPE
代表當前頁的類型,我們前邊說過,InnoDB為了不同的目的而把頁分為不同的類型。類型名稱 十六進制 描述 FIL_PAGE_TYPE_ALLOCATED 0x0000 最新分配,還沒使用 FIL_PAGE_TYPE_ALLOCATED 0x0000 最新分配,還沒使用 FIL_PAGE_UNDO_LOG 0x0002 Undo日志頁 FIL_PAGE_INODE 0x0003 段信息節點 FIL_PAGE_IBUF_FREE_LIST 0x0004 Insert Buffer空閑列表 FIL_PAGE_IBUF_BITMAP 0x0005 Insert Buffer位圖 FIL_PAGE_TYPE_SYS 0x0006 系統頁 FIL_PAGE_TYPE_TRX_SYS 0x0007 事務系統數據 FIL_PAGE_TYPE_FSP_HDR 0x0008 表空間頭部信息 FIL_PAGE_TYPE_XDES 0x0009 擴展描述頁 FIL_PAGE_TYPE_BLOB 0x000A 溢出頁 FIL_PAGE_INDEX 0x45BF 索引頁,也就是我們所說的數據頁 FIL_PAGE_PREV
和FIL_PAGE_NEXT
表示本頁的上一個和下一個頁的頁號,各個頁通過FIL_PAGE_PREV
和FIL_PAGE_NEXT
形成雙向鏈表。
File Trailer
mysql中內存和磁盤的基本交互單位是頁。如果內存中頁被修改了,那么某個時刻一定會將內存頁同步到磁盤中。如果在同步的過程中,系統出現問題,就可能導致磁盤中的頁數據沒能完全同步,也就是發生了臟頁
的情況。為了避免發生這種問題,mysql在每個頁的尾部加上了File Trailer
來校驗頁的完整性。File Trailer
由8個字節組成:
- 前4個字節代表頁的校驗和
這個部分是和File Header中的校驗和相對應的。簡單理解,就是File Header
和File Trailer
都有校驗和,如果兩者一致則表示數據頁是完整的。否則,則表示數據頁是臟頁
。 - 后4個字節代表頁面被最后修改時對應的日志序列位置(LSN)
這個部分也是為了校驗頁的完整性的,暫不詳細了解。
原創不易,覺得文章寫得不錯的小伙伴,點個贊👍 鼓勵一下吧~