MySQL之行格式、頁結(jié)構(gòu)
前言
關(guān)于為何要了解MySQL的物理實(shí)現(xiàn):
其實(shí)像B+索引,多版本并發(fā)控制(MVCC)等MySQL常問的技術(shù)知識點(diǎn)都是會(huì)對應(yīng)到具體的物理實(shí)現(xiàn)上,如果不了解MySQL到底怎么存儲數(shù)據(jù),不清楚每個(gè)數(shù)據(jù)行中有什么結(jié)構(gòu),不清楚B+樹中的一個(gè)節(jié)點(diǎn)對應(yīng)什么物理結(jié)構(gòu),又怎么算了解了MySQL
從圖開始理解
以下面這段創(chuàng)建代碼為例:
mysql> CREATE TABLE format_demo (
-> c1 VARCHAR(10),
-> c2 VARCHAR NOT NULL,
-> c3 CHAR(10),
-> c4 VARCHAR(10)
)
那么現(xiàn)在這個(gè)表在我們眼中是這樣的:(插入了兩條數(shù)據(jù))
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>
對應(yīng)在磁盤中表又是怎么樣存儲的呢?
行格式
所謂表的構(gòu)成,實(shí)際就是一行行的數(shù)據(jù),所以在磁盤中表是按行數(shù)據(jù)進(jìn)行存儲的。那么在磁盤中是一整個(gè)表的數(shù)據(jù)都連續(xù)放在一起么?顯然不可能,思考數(shù)據(jù)分頁的方法,MySQL也是按照分頁的方式將一個(gè)表的數(shù)據(jù)拆分開存放。以InnoDB來說:
- 將數(shù)據(jù)劃分為若干個(gè)頁,以頁作為磁盤和內(nèi)存之間交互的基本單位,InnoDB中頁的大小一般為 16 KB。也就是在一般情況下,一次最少從磁盤中讀取16KB的內(nèi)容到內(nèi)存中,一次最少把內(nèi)存中的16KB內(nèi)容刷新到磁盤中。
關(guān)于頁的概念先放到這之后再說。
所以現(xiàn)在我們前進(jìn)了一大步,起碼知道磁盤里的表大概長這樣:

再回到行格式上,MySQL中涉及的有這4種:
COMPACT行格式
Redundant行格式
Dynamic行格式
Compressed行格式
主要介紹最重要的COMPACT格式,首先它長這樣:

可以看到MySQL除了記錄用戶提供的信息之外還記錄了相當(dāng)?shù)?strong>額外信息,這些信息可以分為3類:
變長字段長度列表
NULL值列表
記錄頭信息
簡要說明這些額外信息:
變長字段長度列表將真實(shí)數(shù)據(jù)中的每個(gè)非空列按其數(shù)據(jù)字節(jié)長度的逆序存放起來

空間分配:長度不是固定的,取決于有多少數(shù)據(jù)
NULL值列表中儲存了所有沒有設(shè)置NOT NULL的字段(列),并用一個(gè)2進(jìn)制位表示該列的NULL狀態(tài),同時(shí)這個(gè)列表也是按列順序的倒序排列的,也就是如果該行數(shù)據(jù)中對應(yīng)第1,3,4個(gè)字段可以為空(沒有設(shè)置NOT NULL)那么NULL值列表就長這樣:

需要說明的是,MySQL中無論是定長數(shù)據(jù)還是非定長數(shù)據(jù)都可以設(shè)置對NULL的控制,所以如果該列不為空那么該列數(shù)據(jù)的相關(guān)信息就存在變長字段長度列表中。
空間分配:同時(shí)NULL值列表是按整數(shù)倍字節(jié)分配空間的,不足的位置補(bǔ)上0.
記錄頭信息存儲的都是和行數(shù)據(jù)控制相關(guān)的內(nèi)容:

空間分配:固定5個(gè)字節(jié)40個(gè)二進(jìn)制位
每段內(nèi)容記錄信息如下:(不做詳細(xì)解釋了,了解大概即可)
| 名稱 | 大?。˙it) | 描述 |
|---|---|---|
| 預(yù)留位 | 1 | - |
| 預(yù)留位 | 1 | - |
delete_mash |
1 | 刪除標(biāo)記位 |
min_rec_mask |
1 | B+樹的每層非葉子節(jié)點(diǎn)中的最小記錄都會(huì)添加該標(biāo)記 |
n_owned |
4 | 表示當(dāng)前記錄擁有的記錄數(shù) |
heap_no |
13 | 表示當(dāng)前記錄在記錄堆的位置信息 |
record_type |
3 | 表示當(dāng)前記錄的類型,0表示普通記錄,1表示B+樹非葉子節(jié)點(diǎn)記錄,2表示最小記錄,3表示最大記錄 |
next_record |
16 | 表示下一條記錄的相對位置 |
記錄的真實(shí)數(shù)據(jù)
說完記錄中的額外信息,那么記錄里的真實(shí)數(shù)據(jù)真的只有用戶定義的數(shù)據(jù)么?
顯然不是,MySQL會(huì)自動(dòng)為每個(gè)行數(shù)據(jù)增加一些額外的列,例如DB_ROW_ID,DB_TRX_ID, DB_ROW_PTR分別表示數(shù)據(jù)行的行id, 行的事務(wù)id, 指向下一個(gè)版本數(shù)據(jù)行的指針,其中事務(wù)id和指向下一版本的指針實(shí)際都是關(guān)系到MySQL中多版本并發(fā)控制的具體實(shí)現(xiàn)。
所以到目前為止,我們已經(jīng)完整了解了一條數(shù)據(jù)行到底長什么樣:

關(guān)于定長和非定長類型
VARCHAR()和CHAR()類型的大致區(qū)別,在根據(jù)建表時(shí)設(shè)定的不同字符集下(字符在字符集中對應(yīng)占字節(jié)L),傳入類型中的Maxlen會(huì)限定給CHAR(Maxlen)至少分配ML的空間,而VARCHAR(Maxlen)則是完全根據(jù)數(shù)據(jù)具體字符個(gè)數(shù)*來計(jì)算分配。計(jì)算分配的規(guī)則不在這里說明。
頁結(jié)構(gòu)
下面是頁結(jié)構(gòu)的示意圖:

可以在中間找到我們剛剛討論的行數(shù)據(jù)位于User Records中。
上述內(nèi)容簡述功能如下:
| 名稱 | 中文名 | 占用空間 | 描述 |
|---|---|---|---|
File Header |
文件頭部 |
38字節(jié) |
頁的一些通用信息 |
Page Header |
頁面頭部 |
56字節(jié) |
數(shù)據(jù)頁專有的一些信息 |
Infimum + Supremum |
最小記錄和最大記錄 |
26字節(jié) |
兩個(gè)虛擬的行記錄 |
User Records |
用戶記錄 | 不確定 | 實(shí)際存儲的行記錄內(nèi)容 |
Free Space |
空閑空間 | 不確定 | 頁中尚未使用的空間 |
Page Directory |
頁面目錄 | 不確定 | 頁中的某些記錄的相對位置 |
File Trailer |
文件尾部 |
8字節(jié) |
校驗(yàn)頁是否完整 |
其中用戶記錄開始是沒有的,當(dāng)插入行數(shù)據(jù)時(shí)會(huì)使用空閑空間,當(dāng)空閑空間裝滿之后就要申請新的頁了

繼續(xù)說明行記錄在頁中怎么組織又需要使用到之前提到的行中的頭信息
大致如下:
-
heap_no屬性表示當(dāng)前記錄在頁中的位置,用戶插入的數(shù)據(jù)一般從2開始排序,為什么是2呢?因?yàn)?
0,1是頁中固有的最大和最小行記錄,分別指向頁最大和最小數(shù)據(jù)。這里其實(shí)涉及到了MySQLB+樹的物理實(shí)現(xiàn)過程,簡單來說就是頁其實(shí)就是B+樹的一個(gè)物理節(jié)點(diǎn),而B+樹中所有節(jié)點(diǎn)是順序排列的,這個(gè)排列的順序就是按照表的唯一主鍵來進(jìn)行的(一般會(huì)設(shè)置一個(gè)與業(yè)務(wù)無關(guān)的邏輯主鍵,并且自增)。所以這兩個(gè)多余的記錄就可以理解成節(jié)點(diǎn)鏈表中的頭尾節(jié)點(diǎn),用于快速遍歷節(jié)點(diǎn)。 next_record記錄了從當(dāng)前記錄到下一行數(shù)據(jù)的地址偏移量
那么現(xiàn)在頁中的行數(shù)據(jù)就是這樣串起來的:

這里再提供一個(gè)簡圖說明一個(gè)頁是B+樹中的一個(gè)節(jié)點(diǎn)這句話的意義:

頁目錄
所以B+樹中的索引是怎么在頁中實(shí)現(xiàn)的呢?這里就涉及到頁中另外一個(gè)關(guān)鍵的內(nèi)容:頁目錄
頁目錄就是MySQL在頁中可以快速檢索數(shù)據(jù)的保障。如果沒有頁目錄,那么定位到了一個(gè)頁不就只能順序遍歷一次鏈表了。當(dāng)然,頁目錄得以實(shí)現(xiàn)的基礎(chǔ)也是行數(shù)據(jù)是按主鍵進(jìn)行排序存放的。
而目錄的制作過程大致是這樣的:
將所有正常的記錄(包括最大和最小記錄,不包括標(biāo)記為已刪除的記錄)劃分為幾個(gè)組。
每個(gè)組的最后一條記錄(也就是組內(nèi)最大的那條記錄)的頭信息中的
n_owned屬性表示該記錄擁有多少條記錄,也就是該組內(nèi)共有幾條記錄。將每個(gè)組的最后一條記錄的地址偏移量單獨(dú)提取出來按順序存儲到靠近
頁的尾部的地方,這個(gè)地方就是所謂的Page Directory,也就是頁目錄(此時(shí)應(yīng)該返回頭看看頁面各個(gè)部分的圖)。頁面目錄中的這些地址偏移量被稱為槽(英文名:Slot),所以這個(gè)頁面目錄就是由槽組成的。
那么最小只有一個(gè)分組的概況如下:

可以觀察到最小記錄的n_owned值為1,而最大記錄的n_owned值為5。這關(guān)系到每個(gè)分組是如何設(shè)計(jì)的:
對于最小記錄所在的分組只能有 1 條記錄,最大記錄所在的分組擁有的記錄條數(shù)只能在 1~8 條之間,剩下的分組中記錄的條數(shù)范圍只能在是 4~8 條之間。所以分組是按照下邊的步驟進(jìn)行的:
初始情況下一個(gè)數(shù)據(jù)頁里只有最小記錄和最大記錄兩條記錄,它們分屬于兩個(gè)分組。
之后每插入一條記錄,都會(huì)從
頁目錄中找到主鍵值比本記錄的主鍵值大并且差值最小的槽,然后把該槽對應(yīng)的記錄的n_owned值加1,表示本組內(nèi)又添加了一條記錄,直到該組中的記錄數(shù)等于8個(gè)。在一個(gè)組中的記錄數(shù)等于8個(gè)后再插入一條記錄時(shí),會(huì)將組中的記錄拆分成兩個(gè)組,一個(gè)組中4條記錄,另一個(gè)5條記錄。這個(gè)過程會(huì)在
頁目錄中新增一個(gè)槽來記錄這個(gè)新增分組中最大的那條記錄的偏移量。
所以最終一個(gè)有多個(gè)分組的頁面結(jié)構(gòu)就長這樣:

File Header和File Trailer
不出意料Header中保存了這個(gè)頁中的一些信息,而File Trailer中保存了指向下一個(gè)頁的指針,用于串聯(lián)頁面,也就是B+樹。
全文內(nèi)容總結(jié)自:掘金小冊《MySQL是怎樣運(yùn)行的》