mysql - InnoDB記錄存儲結構

準備工作

到現在爲止,MySQL對於我們來說還是一個黑盒,我們只負責使用客戶端發送請求並等待服務器返回結果,表中的數據到底存到了哪裏?以什麼格式存放的?MySQL是以什麼方式來訪問的這些數據?這些問題我們統統不知道,對於未知領域的探索向來就是社會主義核心價值觀中的一部分,作爲新一代社會主義接班人,不把它們搞懂怎麼支援祖國建設呢?

我們前邊嘮叨請求處理過程的時候提到過,MySQL服務器上負責對錶中數據的讀取和寫入工作的部分是存儲引擎,而服務器又支持不同類型的存儲引擎,比如InnoDBMyISAMMemory啥的,不同的存儲引擎一般是由不同的人爲實現不同的特性而開發的,真實數據在不同存儲引擎中存放的格式一般是不同的,甚至有的存儲引擎比如Memory都不用磁盤來存儲數據,也就是說關閉服務器後表中的數據就消失了。由於InnoDBMySQL默認的存儲引擎,也是我們最常用到的存儲引擎,我們也沒有那麼多時間去把各個存儲引擎的內部實現都看一遍,所以本集要嘮叨的是使用InnoDB作爲存儲引擎的數據的存儲結構,瞭解了一個存儲引擎的數據存儲結構之後,其他的存儲引擎都是依瓢畫葫蘆,等我們用到了再說哈~

InnoDB頁簡介

InnoDB是一個將表中的數據存儲到磁盤上的存儲引擎,所以即使關機後重啓我們的數據還是存在的。而真正處理數據的過程是發生在內存中的,所以需要把磁盤中的數據加載到內存中,如果是處理寫入或修改請求的話,還需要把內存中的內容刷新到磁盤上。而我們知道讀寫磁盤的速度非常慢,和內存讀寫差了幾個數量級,所以當我們想從表中獲取某些記錄時,InnoDB存儲引擎需要一條一條的把記錄從磁盤上讀出來麼?不,那樣會慢死,InnoDB採取的方式是:將數據劃分爲若干個頁,以頁作爲磁盤和內存之間交互的基本單位,InnoDB中頁的大小一般爲 16KB。也就是在一般情況下,一次最少從磁盤中讀取16KB的內容到內存中,一次最少把內存中的16KB內容刷新到磁盤中。

InnoDB行格式

我們平時是以記錄爲單位來向表中插入數據的,這些記錄在磁盤上的存放方式也被稱爲行格式或者記錄格式。設計InnoDB存儲引擎的大叔們到現在爲止設計了4種不同類型的行格式,分別是CompactRedundantDynamicCompressed行格式,隨着時間的推移,他們肯定會設計出更多的行格式,但是不管怎麼變,在原理上大體都是相同的。

指定行格式的語法

行格式是在我們創建或修改表的語句中指定的

CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名稱
ALTER TABLE 表名 ROW_FORMAT=行格式名稱

比如我們在xiaohaizi數據庫裏創建一個演示用的表record_format_demo,可以這樣指定它的行格式

mysql> USE xiaohaizi;
Database changed
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)
mysql>

可以看到我們剛剛創建的這個表的行格式就是Compact,另外,我們還指定了這個表的字符集爲ascii,因爲ascii字符集只包括空格、標點符號、數字、大小寫字母和一些不可見字符,所以我們的漢字是不能存到這個表裏的。我們現在向這個表中插入兩條記錄:

mysql> INSERT INTO record_format_demo(c1, c2, c3, c4) VALUES('aaaa', 'bbb', 'cc', 'd'), ('eeee', 'fff', NULL, NULL);
Query OK, 2 rows affected (0.02 sec)
Records: 2  Duplicates: 0  Warnings: 0
mysql> 

表中的記錄就是這個樣子的:

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)
mysql>

演示表的內容也填充好了,現在我們就來看看各個行格式下的存儲方式到底有啥不同吧~

COMPACT行格式

廢話不多說,直接看圖:

大家從圖中可以看出來,一條完整的記錄其實可以被分爲記錄的額外信息記錄的真實數據兩大部分,下邊我們詳細看一下這兩部分的組成。

記錄的額外信息

這部分信息是服務器爲了描述這條記錄而不得不額外添加的一些信息,這些額外信息分爲3類,分別是變長字段長度列表NULL值列表記錄頭信息,我們分別看一下。

變長字段長度列表

前邊說過MySQL支持一些變長的數據類型,比如VARCHAR(M)VARBINARY(M)、各種TEXT類型,各種BLOB類型,這些變長的數據類型佔用的存儲空間分爲兩部分:

  1. 真正的數據內容

  2. 佔用的字節數

因爲如果不保存真實數據佔用的字節數的話,MySQL服務器也不知道我們存儲的數據究竟有多長。在Compact行格式中,把所有變長類型的列的長度都存放在記錄的開頭部位形成一個列表,按照列的順序逆序存放,我們再次強調一遍,是逆序存放!我們拿record_format_demo表中的第一條記錄來舉個例子。因爲record_format_demo表的c1c2c4列都是VARCHAR(10)類型的,也就是變長的數據類型,所以這三個列的值的長度都需要保存在記錄開頭處,因爲record_format_demo表中的各個列都使用的是ascii字符集,所以每個字符只需要1個字節來進行編碼,來看一下第一條記錄各列內容的長度:

列名 存儲內容 內容長度(十進制表示) 內容長度(十六進制表示)
c1 'aaaa' 4 0x04
c2 'bbb' 3 0x03
c4 'd' 1 0x01

又因爲這些長度值需要按照列的逆序存放,所以最後變長字段長度列表的字節串用十六進制表示的效果就是(各個字節之間實際上沒有空格,用空格隔開只是方便理解):

01 03 04 

把這個字節串組成的變長字段長度列表填入上邊的示意圖中的效果就是:

由於第一行記錄中c1c2c4列中的字符串都比較短,也就是說內容佔用的字節數比較小,用1個字節就可以表示,但是如果變長列的內容佔用的字節數比較多,可能就需要用2個字節來表示。具體用1個還是2個字節來表示真實數據佔用的字節數,MySQL有它的一套規則,因爲用漢字描述很長的概念很容易變得囉嗦從而讓人迷惑,所以我們用公式來表示一下,首先聲明一下WML的意思:

  1. 假設某個字符集中表示一個字符最多需要使用的字節數爲W,也就是使用SHOW CHARSET語句的結果中的Maxlen列,比方說utf8字符集中的W就是3gbk字符集中的W就是2ascii字符集中的W就是1

  2. 對於變長類型VARCHAR(M)來說,這種類型表示能存儲最多M個字符,所以這個類型能表示的字符串最多佔用的字節數就是M×W

  3. 假設它存儲的字符串佔用的字節數是L

所以確定使用1個字節還是2個字節表示真正字符串佔用的字節數的規則就是這樣:

  • 如果M×W < 256,那麼使用1個字節來表示真正字符串佔用的字節數。

  • 如果M×W >= 256,則分爲兩種情況:

    • 如果L < 128,則用1個字節來表示真正字符串佔用的字節數

    • 如果L >= 128,則用2個字節來表示真正字符串佔用的字節數

小貼士: 也就是說確定使用1個字節還是2個字節來表示真實數據佔用的字節數取決於`W`、`M`和`L`的值,上邊的`c1`、`c2`、`c3`列的`W=1`,`M=10`,所以符合`M×W < 256`,所以使用1字節來表示真正字符串佔用的字節數,其它的情況大家可以自己舉例,我這就不贅述了。

需要注意的一點是,變長字段長度列表中只存儲值爲 非NULL 的列內容佔用的長度,值爲 NULL 的列的長度是不儲存的 。也就是說對於第二條記錄來說,因爲c4列的值爲NULL,所以變長字段長度列表只需要存儲c1c2列的長度即可。其中c1列存儲的值爲'eeee',佔用的字節數爲4c2列存儲的值爲'fff',佔用的字節數爲3,所以變長字段長度列表需2個字節。填充完變長字段長度列表的兩條記錄的對比圖如下:

NULL值列表

我們知道表中的某些列可能存儲NULL值,如果把這些NULL值都放到記錄的真實數據中存儲會很佔地方,所以Compact行格式把這些值爲NULL的列統一管理起來,存儲到NULL值列表中,它的處理過程是這樣的:

  1. 首先統計表中允許存儲NULL的列有哪些。

    我們前邊說過,主鍵列、被NOT NULL修飾的列都是不可以存儲NULL值的,所以在統計的時候不會把這些列算進去。比方說表record_format_demo的3個列c1c3c4都是允許存儲NULL值的,而c2列是被NOT NULL修飾,不允許存儲NULL值。

  2. 如果表中沒有允許存儲 NULL 的列,則 NULL值列表 也不存在了,否則將每個允許存儲NULL的列對應一個二進制位,二進制位按照列的順序逆序排列,二進制位表示的意義如下:

    因爲表record_format_demo有3個值允許爲NULL的列,所以這3個列和二進制位的對應關係就是這樣:

再一次強調,二進制位按照列的順序逆序排列,所以第一個列c1和最後一個二進制位對應。

  • 二進制位的值爲1時,代表該列的值爲NULL

  • 二進制位的值爲0時,代表該列的值不爲NULL

3. MySQL規定NULL值列表必須用整數個字節的位表示,如果使用的二進制位個數不是整數個字節,則在字節的高位補0

record_format_demo只有3個允許爲NULL的列,對應3個二進制位,不足一個字節,所以在字節的高位補0,效果就是這樣:

知道了規則之後,我們再返回頭看錶record_format_demo中的兩條記錄中的NULL值列表應該怎麼儲存。因爲只有c1c3c4這3個列允許存儲NULL值,所以所有記錄的NULL值列表只需要一個字節。

  • 對於第一條記錄來說,c1c3c4這3個列的值都不爲NULL,所以它們對應的二進制位都是0,畫個圖就是這樣:

所以第一條記錄的NULL值列表用十六進制表示就是:0x00

  • 對於第二條記錄來說,c1c3c4這3個列中c3c4的值都爲NULL,所以這3個列對應的二進制位的情況就是:

第一條記錄的NULL值列表用十六進制表示就是:0x06

所以這兩條記錄在填充了NULL值列表後的示意圖就是這樣:

記錄頭信息

除了變長字段長度列表NULL值列表之外,還有一個用於描述記錄的記錄頭信息,它是由固定的5個字節組成。5個字節也就是40個二進制位,不同的位代表不同的意思,如圖:

這些二進制位代表的詳細信息如下表:

名稱 大小(單位: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 表示下一條記錄的相對位置

大家不要被這麼多的屬性和陌生的概念給嚇着,我這裏只是爲了內容的完整性把這些位代表的意思都寫了出來,現在沒必要把它們的意思都記住,記住也沒啥用,現在只需要看一遍混個臉熟,等之後用到這些屬性的時候我們再回過頭來看。

因爲我們並不清楚這些屬性詳細的用法,所以這裏就不分析各個屬性值是怎麼產生的了,之後我們遇到會詳細看的。所以我們現在直接看一下record_format_demo中的兩條記錄的頭信息分別是什麼:

記錄的真實數據

記錄的真實數據除了我們插入的那些列的數據,MySQL會爲每個記錄默認的添加一些列(也稱爲隱藏列),具體的列如下:

列名 是否必須 佔用空間 描述
row_id 6字節 行ID,唯一標識一條記錄
transaction_id 6字節 事務ID
roll_pointer 7字節 回滾指針

需要注意的是,MySQL服務器會爲每條記錄都添加 transaction_id 和 roll_pointer 這兩個列,但是 row_id 只有在表沒有定義主鍵的時候纔會爲記錄添加,相當於MySQL服務器幫我們來添加一個主鍵。這些列的值不用我們操心,MySQL服務器會自己幫我們添加的。

因爲表record_format_demo並沒有定義主鍵,所以MySQL服務器會爲每條記錄增加上述的3個列。現在看一下加上記錄的真實數據的兩個記錄長什麼樣吧:

看這個這個圖的時候我們需要注意幾點:

  1. record_format_demo使用的是ascii字符集,所以0x61616161就表示字符串'aaaa'0x626262就表示字符串'bbb',以此類推。

  2. 注意第1條記錄中c3列的值,它是CHAR(10)類型的,它實際存儲的字符串是:'cc'ascii字符集中的字節表示是'0x6363',雖然表示這個字符串只佔用了2個字節,但整個c3列仍然佔用了10個字節的空間,除真實數據以外的8個字節的統統都用空格字符填充,空格字符在ascii字符集的表示就是0x20

  3. 注意第2條記錄中c3c4列的值都爲NULL,它們被存儲在了前邊的NULL值列表處,在記錄的真實數據處就不再冗餘存儲,從而節省存儲空間。

行溢出數據

VARCHAR(M)最多能存儲的數據

我們知道對於VARCHAR(M)類型的列最多可以佔用65535個字節。其中的M代表該類型最多存儲的字符數量,如果我們使用ascii字符集的話,一個字符就代表一個字節,我們看看VARCHAR(65535)是否可用:

mysql> CREATE TABLE varchar_size_demo(
    ->     c VARCHAR(65535)
    -> ) CHARSET=ascii ROW_FORMAT=Compact;
ERROR 1118 (42000): Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535. This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs
mysql>

從報錯信息裏可以看出,MySQL對一條記錄佔用的最大存儲空間是有限制的,除了BLOB或者TEXT類型的列之外,其他所有的列(不包括隱藏列和記錄頭信息)佔用的字節長度加起來不能超過65535個字節。所以MySQL服務器建議我們把存儲類型改爲TEXT或者BLOB的類型。這個65535個字節除了列本身的數據之外,還包括一些storage overhead,比如說我們爲了存儲一個VARCHAR(M)類型的列,需要佔用3部分存儲空間:

  • 真實數據

  • 真實數據佔用字節的長度

  • NULL值標識,如果該列有NOT NULL屬性則可以沒有這部分存儲空間

如果該VARCHAR類型的列沒有NOT NULL屬性,那最多隻能存儲65532個字節的數據,因爲真實數據的長度需要佔用2個字節,NULL值標識需要佔用1個字節:

mysql> CREATE TABLE varchar_size_demo(
    ->      c VARCHAR(65532)
    -> ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.02 sec)

mysql>

如果VARCHAR類型的列有NOT NULL屬性,那最多隻能存儲65533個字節的數據,因爲真實數據的長度需要佔用2個字節,不需要NULL值標識:

mysql> DROP TABLE varchar_size_demo;
Query OK, 0 rows affected (0.01 sec)

mysql> CREATE TABLE varchar_size_demo(
    ->      c VARCHAR(65533) NOT NULL
    -> ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.02 sec)

mysql>

如果VARCHAR(M)類型的列使用的不是ascii字符集,那會怎麼樣呢?來看一下:

mysql> DROP TABLE varchar_size_demo;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TABLE varchar_size_demo(
    ->       c VARCHAR(65532)
    -> ) CHARSET=gbk ROW_FORMAT=Compact;
ERROR 1074 (42000): Column length too big for column 'c' (max = 32767); use BLOB or TEXT instead
mysql> CREATE TABLE varchar_size_demo(
    ->       c VARCHAR(65532)
    -> ) CHARSET=utf8 ROW_FORMAT=Compact;
ERROR 1074 (42000): Column length too big for column 'c' (max = 21845); use BLOB or TEXT instead
mysql>

從執行結果中可以看出,如果VARCHAR(M)類型的列使用的不是ascii字符集,那M的最大取值取決於該字符集表示一個字符最多需要的字節數。比方說gbk字符集表示一個字符最多需要2個字符,那在該字符集下,M的最大取值就是32767,也就是說最多能存儲32767個字符;utf8字符集表示一個字符最多需要3個字符,那在該字符集下,M的最大取值就是21845,也就是說最多能存儲21845個字符。

記錄中的數據太多產生的溢出

我們以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>

其中的REPEAT('a', 65532)是一個函數調用,它表示生成一個把字符'a'重複65532次的字符串。前邊說過,MySQL中磁盤和內存交互的基本單位是,也就是說MySQL是以爲基本單位來管理存儲空間的,我們的記錄都會被分配到某個中存儲。而一個頁的大小一般是16KB,也就是16384字節,而一個VARCHAR(M)類型的列就最多可以存儲65532個字節,這樣就可能造成一個頁存放不了一條記錄的尷尬情況。

CompactReduntant行格式中,對於佔用存儲空間非常大的列,在記錄的真實數據處只會存儲該列的一部分數據,把剩餘的數據分散存儲在幾個連續的頁中,只在記錄的真實數據處用20個字節存儲指向這些頁的地址,從而可以找到剩餘數據所在的頁,如圖所示:

 

從圖中可以看出來,對於CompactReduntant行格式來說,如果某一列中的數據非常多的話,在本記錄的真實數據處只會存儲該列的前786個字節的數據和一個指向其他頁的地址,然後把剩下的數據存放到其他頁中,這個過程也叫做行溢出。畫一個簡圖就是這樣:

不只是 VARCHAR(M) 類型的列,其他的 TEXTBLOB 類型的列在存儲數據非常多的時候也會發生行溢出

行溢出臨界點

那發生行溢出的臨界點是什麼呢?也就是說在列存儲多少字節的數據時就會發生行溢出

MySQL中規定一個頁中至少存放兩行記錄,至於爲什麼這麼規定我們之後再說,現在看一下這個規定造成的影響。以我們以上邊的varchar_size_demo表爲例,它只有一個列c,我們往這個表中插入兩條記錄,每條記錄最少插入多少字節的數據纔會行溢出的現象呢?這得分析一下頁中的空間都是如何利用的。

  • 每個頁除了存放我們的記錄以外,也需要存儲一些額外的信息,亂七八糟的額外信息加起來需要136個字節的空間(現在只要知道這個數字就好了),其他的空間都可以被用來存儲記錄。

  • 每個記錄需要的額外信息是27字節。

    這27個字節包括下邊這些部分:

    • 2個字節用於存儲真實數據的長度

    • 1個字節用於存儲列是否是NULL值

    • 5個字節大小的頭信息

    • 6個字節的row_id

    • 6個字節的transaction_id

    • 7個字節的roll_pointer

假設一個列中存儲的數據字節數爲n,那麼發生行溢出現象時需要滿足這個式子:

136 + 2×(27 + n) > 16384

求解這個式子得出的解是:n > 8098。也就是說如果一個列中存儲的數據不大於8098個字節,那就不會發生行溢出,否則就會發生行溢出

我們這個只是針對只有一個列的varchar_size_demo表來說的,如果表中有多個列,那上邊的式子又得改一改了,所以重點就是:你不用關注這個臨界點是什麼,只要知道如果我們想一個行中存儲了很大的數據時,可能發生行溢出的現象

Dynamic和Compressed行格式

下邊要介紹兩個比較新的行格式,DynamicCompressed行格式,我現在使用的MySQL版本是5.7,它的默認行格式就是Dynamic,這倆行格式和Compact行格式賊像,只不過在處理行溢出數據時有點兒分歧,它們不會在記錄的真實數據處存儲字符串的前768個字節,而是把所有的字節都存儲到其他頁面中,只在記錄的真實數據處存儲其他頁面的地址,就像這樣:

Compressed行格式和Dynamic不同的一點是,Compressed行格式會把存儲到其他頁面的數據採用壓縮算法進行壓縮,以節省空間。

CHAR(M)列的存儲格式

record_format_demo表的c1c2c4列的類型是VARCHAR(10),而c3列的類型是CHAR(10),我們說在Compact行格式下只會把變成類型的列的長度逆序存到變長字段長度列表中,就像這樣:

但是這只是因爲我們的record_format_demo表採用的是ascii字符集,這個字符集是一個定長字符集,也就是說表示一個字符采用固定的一個字節,如果採用變長的字符集的話,c3列的長度也會被存儲到變長字段長度列表中,比如我們修改一下record_format_demo表的字符集:

mysql> ALTER TABLE record_format_demo MODIFY COLUMN c3 CHAR(10) CHARACTER SET utf8;
Query OK, 2 rows affected (0.02 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql>

修改該列字符集後記錄的變長字段長度列表也發生了變化,如圖:

這就意味着:對於 CHAR(M) 類型的列來說,當列採用的是定長字符集時,該列佔用的字節數不會被加到變長字段長度列表,而如果採用變長字符集時,該列佔用的字節數也會被加到變長字段長度列表

總結

  1. 頁是MySQL中磁盤和內存交互的基本單位,也是MySQL是管理存儲空間的基本單位。

  2. 指定和修改行格式的語法如下:

    CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名稱
    ALTER TABLE 表名 ROW_FORMAT=行格式名稱
    
  3. InnoDB目前定義了4中行格式

    • COMPACT行格式

      具體組成如圖:

    • Dynamic和Compressed行格式
      這兩種行格式類似於COMPACT行格式,只不過在處理行溢出數據時有點兒分歧,它們不會在記錄的真實數據處存儲字符串的前768個字節,而是把所有的字節都存儲到其他頁面中,只在記錄的真實數據處存儲其他頁面的地址。

      另外,Compressed行格式會把存儲在其他頁面中的數據壓縮處理。

  • 一個頁一般是16KB,當記錄中的數據太多,當前頁放不下的時候,會把多餘的數據存儲到其他頁中,這種現象稱爲行溢出

  • 對於 CHAR(M) 類型的列來說,當列採用的是定長字符集時,該列佔用的字節數不會被加到變長字段長度列表,而如果採用變長字符集時,該列佔用的字節數也會被加到變長字段長度列表。

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