mysql儲存引擎InnoDB詳解,從底層看清InnoDB資料結構

六點半起床發表於2020-08-06

InnoDB一個支援事務安全的儲存引擎,同時也是mysql的預設儲存引擎。本文主要從資料結構的角度,詳細介紹InnoDB行記錄格式和資料頁的實現原理,從底層看清InnoDB儲存引擎。

本文主要內容是根據掘金小冊《從根兒上理解 MySQL》整理而來。如想詳細瞭解,建議購買掘金小冊閱讀。

InnoDB簡介

大家都知道mysql中資料是儲存在物理磁碟上的,而真正的資料處理又是在記憶體中執行的。由於磁碟的讀寫速度非常慢,如果每次操作都對磁碟進行頻繁讀寫的話,那麼效能一定非常差。為了上述問題,InnoDB將資料劃分為若干頁,以頁作為磁碟與記憶體互動的基本單位,一般頁的大小為16KB。這樣的話,一次性至少讀取1頁資料到記憶體中或者將1頁資料寫入磁碟。通過減少記憶體與磁碟的互動次數,從而提升效能。

其實,這本質上就是一種典型的快取設計思想,一般快取的設計基本都是從時間維度或者空間維度進行考量的:

  1. 時間維度:如果一條資料正在在被使用,那麼在接下來一段時間內大概率還會再被使用。可以認為熱點資料快取都屬於這種思路的實現。
  2. 空間維度:如果一條資料正在在被使用,那麼儲存在它附近的資料大概率也會很快被使用。InnoDB的資料頁作業系統的頁快取則是這種思路的體現。

InnoDB行格式

mysql是以記錄(一行資料)為單位向資料表中插入資料的,這些記錄在磁碟上的存放方式稱為行格式。mysql支援4種不同型別的行格式:CompactRedundant(比較老,本文就不具體介紹了)、DynamicCompressed。 我們可以在建立或修改表的語句中指定行格式:

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等),它們儲存資料佔用的儲存空間不是固定的,而是會隨著儲存內容的變化而變化。為了準確描述這種資料,這種變長欄位佔用的儲存空間要同時包含:

  1. 真正的資料內容
  2. 佔用的位元組數

在Compact行格式中,把所有變長欄位的真實資料佔用的位元組長度都存放在記錄的開頭部位,從而形成一個變長欄位長度列表,各變長欄位資料佔用的位元組數按照列的順序逆序存放

我們以record_format_demo第一行資料為例。由於c1c2c4都是變成資料型別(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表中,c1c3c4都是允許儲存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部分儲存空間:

  1. 真實資料
  2. 真實資料佔用位元組的長度
  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,我們往這個表中插入兩條記錄,每條記錄最少插入多少位元組的資料才會行溢位的現象呢?這得分析一下頁中的空間都是如何利用的。

  1. 每個頁除了存放我們的記錄以外,也需要儲存一些額外的資訊,大概132個位元組。
  2. 每個記錄需要的額外資訊是27位元組。

假設一個列中儲存的資料位元組數為n,如要要保證該列不發生溢位,則需要滿足:

132 + 2×(27 + n) < 16384

結果是n < 8099也就是說如果一個列中儲存的資料小於8099個位元組,那麼該列就不會成為溢位列。如果表中有多個列,那麼這個值更小。

Dynamic和Compressed行格式

mysql中預設的行格式就是DynamicDynamicCompressed行格式和Compact行格式很像,只是在處理行溢位資料上有差異。DynamicCompressed行格式不會在記錄的真實資料出存放前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條記錄。

Page Directory(頁目錄)

我們已經知道,記錄在頁中按照主鍵大小正序串聯成了一個單連結串列。如果我們要根據主鍵查詢具體的某條記錄應該怎麼辦,簡單的方式是根據連結串列進行遍歷。但是在資料量比較大的情況下,這種方式顯然效率太差了。因此mysql使用了Page Directory(頁目錄)來解決這個問題。Page Directory(頁目錄)大致的原理如下:

  1. 將所有正常的記錄(包括最大和最小記錄,不包括標記為已刪除的記錄)劃分為幾個組。怎麼劃分先不關注。
  2. 每個組的最後一條記錄(也就是組內最大的那條記錄)的頭資訊中的n_owned屬性表示該組內共有幾條記錄。
  3. 將每個組的最後一條記錄的地址偏移量單獨提取出來按順序儲存到靠近頁尾部的地方,這個地方就是所謂的Page Directory

mysql規定對於最小記錄所在的分組只能有 1 條記錄,最大記錄所在的分組擁有的記錄條數只能在 1-8 條之間,剩下的分組中記錄的條數範圍只能在是 4-8 條之間。 比方說現在的page_demo表中正常的記錄共有18條,InnoDB會把它們分成5組,第一組中只有一個最小記錄,如下所示:

通過Page Directory在一個資料頁中查詢指定主鍵值的記錄的過程分為兩步:

  1. 通過二分法確定該記錄所在的槽,並找到該槽所在分組中主鍵值最小的那條記錄。
  2. 通過記錄的next_record屬性遍歷該槽所在的組中的各個記錄。

對於連結串列的查詢效能優化,思想上基本上都是通過二分法實現的。上面介紹的Page Directory跳躍表查詢樹都是如此。

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位元組 頁屬於哪個表空間

這裡只是羅列出來,暫時不需要全部理解。我們重點關注一下幾個屬性:

  1. FIL_PAGE_SPACE_OR_CHKSUM 當前頁面的校驗和(checksum)。對於一個很長的位元組串來說,我們可以通過某種演算法來計算一個比較短的值來代表這個很長的位元組串,這個比較短的值就稱為校驗和。通過校驗和可以大幅度提升字串等值比較的效率。
  2. FIL_PAGE_OFFSET 每一個頁都有一個唯一的頁號,InnoDB通過頁號來可以定位一個頁。
  3. 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|索引頁,也就是我們所說的資料頁|
  4. FIL_PAGE_PREV和FIL_PAGE_NEXT 表示本頁的上一個和下一個頁的頁號,各個頁通過FIL_PAGE_PREV和FIL_PAGE_NEXT形成雙向連結串列。

File Trailer

mysql中記憶體和磁碟的基本互動單位是頁。如果記憶體中頁被修改了,那麼某個時刻一定會將記憶體頁同步到磁碟中。如果在同步的過程中,系統出現問題,就可能導致磁碟中的頁資料沒能完全同步,也就是發生了髒頁的情況。為了避免發生這種問題,mysql在每個頁的尾部加上了File Trailer來校驗頁的完整性。File Trailer由8個位元組組成:

  1. 前4個位元組代表頁的校驗和 這個部分是和File Header中的校驗和相對應的。簡單理解,就是File HeaderFile Trailer都有校驗和,如果兩者一致則表示資料頁是完整的。否則,則表示資料頁是髒頁
  2. 後4個位元組代表頁面被最後修改時對應的日誌序列位置(LSN) 這個部分也是為了校驗頁的完整性的,暫不詳細瞭解。

原創不易,覺得文章寫得不錯的小夥伴,點個贊? 鼓勵一下吧~

相關文章