準備工作
到現在爲止,MySQL
對於我們來說還是一個黑盒,我們只負責使用客戶端發送請求並等待服務器返回結果,表中的數據到底存到了哪裏?以什麼格式存放的?MySQL
是以什麼方式來訪問的這些數據?這些問題我們統統不知道,對於未知領域的探索向來就是社會主義核心價值觀中的一部分,作爲新一代社會主義接班人,不把它們搞懂怎麼支援祖國建設呢?
我們前邊嘮叨請求處理過程的時候提到過,MySQL
服務器上負責對錶中數據的讀取和寫入工作的部分是存儲引擎
,而服務器又支持不同類型的存儲引擎,比如InnoDB
、MyISAM
、Memory
啥的,不同的存儲引擎一般是由不同的人爲實現不同的特性而開發的,真實數據在不同存儲引擎中存放的格式一般是不同的,甚至有的存儲引擎比如Memory
都不用磁盤來存儲數據,也就是說關閉服務器後表中的數據就消失了。由於InnoDB
是MySQL
默認的存儲引擎,也是我們最常用到的存儲引擎,我們也沒有那麼多時間去把各個存儲引擎的內部實現都看一遍,所以本集要嘮叨的是使用InnoDB
作爲存儲引擎的數據的存儲結構,瞭解了一個存儲引擎的數據存儲結構之後,其他的存儲引擎都是依瓢畫葫蘆,等我們用到了再說哈~
InnoDB頁簡介
InnoDB
是一個將表中的數據存儲到磁盤上的存儲引擎,所以即使關機後重啓我們的數據還是存在的。而真正處理數據的過程是發生在內存中的,所以需要把磁盤中的數據加載到內存中,如果是處理寫入或修改請求的話,還需要把內存中的內容刷新到磁盤上。而我們知道讀寫磁盤的速度非常慢,和內存讀寫差了幾個數量級,所以當我們想從表中獲取某些記錄時,InnoDB
存儲引擎需要一條一條的把記錄從磁盤上讀出來麼?不,那樣會慢死,InnoDB
採取的方式是:將數據劃分爲若干個頁,以頁作爲磁盤和內存之間交互的基本單位,InnoDB中頁的大小一般爲 16KB。也就是在一般情況下,一次最少從磁盤中讀取16KB的內容到內存中,一次最少把內存中的16KB內容刷新到磁盤中。
InnoDB行格式
我們平時是以記錄爲單位來向表中插入數據的,這些記錄在磁盤上的存放方式也被稱爲行格式
或者記錄格式
。設計InnoDB
存儲引擎的大叔們到現在爲止設計了4種不同類型的行格式
,分別是Compact
、Redundant
、Dynamic
和Compressed
行格式,隨着時間的推移,他們肯定會設計出更多的行格式,但是不管怎麼變,在原理上大體都是相同的。
指定行格式的語法
行格式
是在我們創建或修改表的語句中指定的
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
類型,這些變長的數據類型佔用的存儲空間分爲兩部分:
-
真正的數據內容
-
佔用的字節數
因爲如果不保存真實數據佔用的字節數的話,MySQL服務器也不知道我們存儲的數據究竟有多長。在Compact
行格式中,把所有變長類型的列的長度都存放在記錄的開頭部位形成一個列表,按照列的順序逆序存放,我們再次強調一遍,是逆序存放!我們拿record_format_demo
表中的第一條記錄來舉個例子。因爲record_format_demo
表的c1
、c2
、c4
列都是VARCHAR(10)
類型的,也就是變長的數據類型,所以這三個列的值的長度都需要保存在記錄開頭處,因爲record_format_demo
表中的各個列都使用的是ascii
字符集,所以每個字符只需要1個字節來進行編碼,來看一下第一條記錄各列內容的長度:
列名 | 存儲內容 | 內容長度(十進制表示) | 內容長度(十六進制表示) |
---|---|---|---|
c1 |
'aaaa' |
4 |
0x04 |
c2 |
'bbb' |
3 |
0x03 |
c4 |
'd' |
1 |
0x01 |
又因爲這些長度值需要按照列的逆序存放,所以最後變長字段長度列表
的字節串用十六進制表示的效果就是(各個字節之間實際上沒有空格,用空格隔開只是方便理解):
01 03 04
把這個字節串組成的變長字段長度列表
填入上邊的示意圖中的效果就是:
由於第一行記錄中c1
、c2
、c4
列中的字符串都比較短,也就是說內容佔用的字節數比較小,用1個字節就可以表示,但是如果變長列的內容佔用的字節數比較多,可能就需要用2個字節來表示。具體用1個還是2個字節來表示真實數據佔用的字節數,MySQL
有它的一套規則,因爲用漢字描述很長的概念很容易變得囉嗦從而讓人迷惑,所以我們用公式來表示一下,首先聲明一下W
、M
和L
的意思:
-
假設某個字符集中表示一個字符最多需要使用的字節數爲
W
,也就是使用SHOW CHARSET
語句的結果中的Maxlen
列,比方說utf8
字符集中的W
就是3
,gbk
字符集中的W
就是2
,ascii
字符集中的W
就是1
。 -
對於變長類型
VARCHAR(M)
來說,這種類型表示能存儲最多M
個字符,所以這個類型能表示的字符串最多佔用的字節數就是M×W
, -
假設它存儲的字符串佔用的字節數是
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
,所以變長字段長度列表
只需要存儲c1
和c2
列的長度即可。其中c1
列存儲的值爲'eeee'
,佔用的字節數爲4
,c2
列存儲的值爲'fff'
,佔用的字節數爲3
,所以變長字段長度列表
需2個字節。填充完變長字段長度列表
的兩條記錄的對比圖如下:
NULL值列表
我們知道表中的某些列可能存儲NULL
值,如果把這些NULL值都放到記錄的真實數據
中存儲會很佔地方,所以Compact
行格式把這些值爲NULL
的列統一管理起來,存儲到NULL
值列表中,它的處理過程是這樣的:
-
首先統計表中允許存儲
NULL
的列有哪些。我們前邊說過,主鍵列、被
NOT NULL
修飾的列都是不可以存儲NULL
值的,所以在統計的時候不會把這些列算進去。比方說表record_format_demo
的3個列c1
、c3
、c4
都是允許存儲NULL
值的,而c2
列是被NOT NULL
修飾,不允許存儲NULL
值。 -
如果表中沒有允許存儲 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值列表
應該怎麼儲存。因爲只有c1
、c3
、c4
這3個列允許存儲NULL
值,所以所有記錄的NULL值列表
只需要一個字節。
-
對於第一條記錄來說,
c1
、c3
、c4
這3個列的值都不爲NULL
,所以它們對應的二進制位都是0
,畫個圖就是這樣:
所以第一條記錄的NULL值列表
用十六進制表示就是:0x00
。
-
對於第二條記錄來說,
c1
、c3
、c4
這3個列中c3
和c4
的值都爲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 |
表示當前記錄的類型,
|
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個列。現在看一下加上記錄的真實數據
的兩個記錄長什麼樣吧:
看這個這個圖的時候我們需要注意幾點:
-
表
record_format_demo
使用的是ascii
字符集,所以0x61616161
就表示字符串'aaaa'
,0x626262
就表示字符串'bbb'
,以此類推。 -
注意第1條記錄中
c3
列的值,它是CHAR(10)
類型的,它實際存儲的字符串是:'cc'
,ascii
字符集中的字節表示是'0x6363'
,雖然表示這個字符串只佔用了2個字節,但整個c3
列仍然佔用了10個字節的空間,除真實數據以外的8個字節的統統都用空格字符填充,空格字符在ascii
字符集的表示就是0x20
。 -
注意第2條記錄中
c3
和c4
列的值都爲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
個字節,這樣就可能造成一個頁存放不了一條記錄的尷尬情況。
在Compact
和Reduntant
行格式中,對於佔用存儲空間非常大的列,在記錄的真實數據
處只會存儲該列的一部分數據,把剩餘的數據分散存儲在幾個連續的頁中,只在記錄的真實數據
處用20個字節存儲指向這些頁的地址,從而可以找到剩餘數據所在的頁,如圖所示:
從圖中可以看出來,對於Compact
和Reduntant
行格式來說,如果某一列中的數據非常多的話,在本記錄的真實數據處只會存儲該列的前786
個字節的數據和一個指向其他頁的地址,然後把剩下的數據存放到其他頁中,這個過程也叫做行溢出
。畫一個簡圖就是這樣:
不只是 VARCHAR(M) 類型的列,其他的 TEXT、BLOB 類型的列在存儲數據非常多的時候也會發生行溢出
。
行溢出
臨界點
那發生行溢出
的臨界點是什麼呢?也就是說在列存儲多少字節的數據時就會發生行溢出
?
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行格式
下邊要介紹兩個比較新的行格式,Dynamic
和Compressed
行格式,我現在使用的MySQL版本是5.7
,它的默認行格式就是Dynamic
,這倆行格式和Compact
行格式賊像,只不過在處理行溢出
數據時有點兒分歧,它們不會在記錄的真實數據處存儲字符串的前768
個字節,而是把所有的字節都存儲到其他頁面中,只在記錄的真實數據處存儲其他頁面的地址,就像這樣:
Compressed
行格式和Dynamic
不同的一點是,Compressed
行格式會把存儲到其他頁面的數據採用壓縮算法進行壓縮,以節省空間。
CHAR(M)列的存儲格式
record_format_demo
表的c1
、c2
、c4
列的類型是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) 類型的列來說,當列採用的是定長字符集時,該列佔用的字節數不會被加到變長字段長度列表,而如果採用變長字符集時,該列佔用的字節數也會被加到變長字段長度列表。
總結
-
頁是
MySQL
中磁盤和內存交互的基本單位,也是MySQL
是管理存儲空間的基本單位。 -
指定和修改行格式的語法如下:
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名稱 ALTER TABLE 表名 ROW_FORMAT=行格式名稱
-
InnoDB
目前定義了4中行格式-
COMPACT行格式
具體組成如圖:
-
-
-
Dynamic和Compressed行格式
這兩種行格式類似於COMPACT行格式
,只不過在處理行溢出數據時有點兒分歧,它們不會在記錄的真實數據處存儲字符串的前768個字節,而是把所有的字節都存儲到其他頁面中,只在記錄的真實數據處存儲其他頁面的地址。另外,
Compressed
行格式會把存儲在其他頁面中的數據壓縮處理。
-
-
一個頁一般是
16KB
,當記錄中的數據太多,當前頁放不下的時候,會把多餘的數據存儲到其他頁中,這種現象稱爲行溢出
。 -
對於 CHAR(M) 類型的列來說,當列採用的是定長字符集時,該列佔用的字節數不會被加到變長字段長度列表,而如果採用變長字符集時,該列佔用的字節數也會被加到變長字段長度列表。