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) 這個部分也是為了校驗頁的完整性的,暫不詳細瞭解。
原創不易,覺得文章寫得不錯的小夥伴,點個贊? 鼓勵一下吧~