寫在前面 > 本文章是學習掘金小冊《MySQL 是怎樣運行的:從根兒上理解 MySQL》 之後整理的,文章大量使用和借鑑了該小冊的內容。另外小冊很不錯,講解十分到位,推薦閱讀。
如果你學習或使用過MySQL,那麼或多或少知道頁
的概念,它是InnoDB
管理存儲空間的基本單位,一個頁的大小一般是16KB
,而一個頁
中又存儲了多條記錄。這篇文章將從單條記錄
到頁
,帶你瞭解MySQL中數據存儲的祕密。
InnoDB記錄存儲結構--行格式
我們知道MySQL
中真正存儲數據的是存儲引擎,因爲MySQL
中存儲的數據一般都是比較多的,內存肯定是無法存儲的,所以數據被存儲在磁盤上。表中的一條一條數據(又:一條一條記錄)在磁盤上是如何存儲的呢?
記錄在磁盤上的存放方式也被稱爲行格式
或者記錄格式
;目前有4種不同類型的行格式
,分別是Compact
、Redundant
、Dynamic
和Compressed
行格式,下面我們將詳細介紹Compact
的結構。
COMPACT行格式
可以看到上圖中包含兩部分:記錄的額外信息,記錄的真實數據。 其中記錄真實數據是真正存儲數據的部分,而記錄額外信息存儲了記錄的額外信息(或者叫元數據),又分爲三塊不同的空間:
- 邊長字段長度
- NULL值列表
- 記錄頭信息
變長字段長度列表
MySQL中的VARCHAR(M)
、VARBINARY(M)
、各種TEXT
類型,各種BLOB
類型,這些數據類型稱爲變長字段
,變長字段中存儲多少字節的數據是不固定的。
在Compact
行格式中,把所有變長字段的真實數據佔用的字節長度都存放在記錄的開頭部位,從而形成一個變長字段長度列表,各變長字段數據佔用的字節數按照列的順序逆序存放。
假如有一條記錄包含兩列變長字段,其中c1
列存儲的值爲'eeee'
,佔用的字節數爲4
,c2
列存儲的值爲'fff'
,佔用的字節數爲3
。數字4
可以用1個字節表示,3
也可以用1個字節表示,所以整個變長字段長度列表
共需2個字節。
NULL值列表
我們知道表中的某些列可能存儲NULL
值,如果把這些NULL
值都放到記錄的真實數據
中存儲會很佔地方,所以Compact
行格式把這些值爲NULL
的列統一管理起來,存儲到NULL
值列表中。
如果表中沒有允許存儲 NULL 的列,則 NULL值列表 也不存在了,否則將每個允許存儲NULL
的列對應一個二進制位,二進制位按照列的順序逆序排列,二進制位表示的意義如下:
- 二進制位的值爲
1
時,代表該列的值爲NULL
。 - 二進制位的值爲
0
時,代表該列的值不爲NULL
。
其中,二進制位按照列的順序逆序排列。
MySQL
規定NULL值列表
必須用整數個字節的位表示,如果使用的二進制位個數不是整數個字節,則在字節的高位補0
。
假如有一個表只有3個值允許爲NULL
的列,對應3個二進制位,不足一個字節,所以在字節的高位補0
,效果就是這樣:
以此類推,如果一個表中有9個允許爲NULL
,那這個記錄的NULL
值列表部分就需要2個字節來表示了。
> 注意:對於定長字段 CHAR(M) 類型的列來說,當列採用的是定長字符集(如 ascii
)時,該列佔用的字節數不會被加到變長字段長度列表,而如果採用變長字符集(如 gbk
或 utf8
)時,該列佔用的字節數也會被加到變長字段長度列表。
> 另外有一點還需要注意,變長字符集的CHAR(M)
類型的列要求至少佔用M
個字節,而VARCHAR(M)
卻沒有這個要求。比方說對於使用utf8
字符集的CHAR(10)
的列來說,該列存儲的數據字節長度的範圍是10~30個字節。即使我們向該列中存儲一個空字符串也會佔用10
個字節,這是怕將來更新該列的值的字節長度大於原有值的字節長度而小於10個字節時,可以在該記錄處直接更新,而不是在存儲空間中重新分配一個新的記錄空間,導致原有的記錄空間成爲所謂的碎片。
記錄頭信息
除了變長字段長度列表
、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 |
表示下一條記錄的相對位置 |
-
delete_mask
這個屬性標記着當前記錄是否被刪除,佔用1個二進制位,值爲
0
的時候代表記錄並沒有被刪除,爲1
的時候代表記錄被刪除掉了。啥?被刪除的記錄還在
頁
中麼?是的。這些被刪除的記錄之所以不立即從磁盤上移除,是因爲移除它們之後把其他的記錄在磁盤上重新排列需要性能消耗,所以只是打一個刪除標記而已,所有被刪除掉的記錄都會組成一個所謂的垃圾鏈表
,在這個鏈表中的記錄佔用的空間稱之爲所謂的可重用空間
,之後如果有新記錄插入到表中的話,可能把這些被刪除的記錄佔用的存儲空間覆蓋掉。 -
min_rec_mask
B+樹的每層非葉子節點中的最小記錄都會添加該標記。
-
n_owned
每個組最後一條記錄中的
n_owned
值,就代表着這個分組中記錄數量。後面還會涉及到。 -
heap_no
這個屬性表示當前記錄在本
頁
中的位置,從圖中可以看出來,我們插入的4條記錄在本頁
中的位置分別是:2
、3
、4
、5
。是不是少了點啥?是的,怎麼不見heap_no
值爲0
和1
的記錄呢?其實
InnoDB
自動給每個頁裏邊兒加了兩個記錄,由於這兩個記錄並不是我們自己插入的,所以有時候也稱爲僞記錄
或者虛擬記錄
。這兩個僞記錄一個代表最小記錄
,一個代表最大記錄
。由於這兩條記錄不是我們自己定義的記錄,所以它們並不存放在
頁
的User Records
部分,他們被單獨放在一個稱爲Infimum + Supremum
的部分(也就是最小記錄和最大記錄,詳見後面),如圖所示:從圖中我們可以看出來,最小記錄和最大記錄的
heap_no
值分別是0
和1
,也就是說它們的位置最靠前。 -
record_type
這個屬性表示當前記錄的類型,一共有4種類型的記錄,
0
表示普通記錄,1
表示B+樹非葉節點記錄,2
表示最小記錄,3
表示最大記錄。 -
next_record
這玩意兒非常重要,它表示從當前記錄的真實數據到下一條記錄的真實數據的地址偏移量。比方說第一條記錄的
next_record
值爲32
,意味着從第一條記錄的真實數據的地址處向後找32
個字節便是下一條記錄的真實數據。如果你熟悉數據結構的話,就立即明白了,這其實是個鏈表
,可以通過一條記錄找到它的下一條記錄。但是需要注意注意再注意的一點是,下一條記錄
指得並不是按照我們插入順序的下一條記錄,而是按照主鍵值由小到大的順序的下一條記錄。而且規定 Infimum記錄(也就是最小記錄) 的下一條記錄就是本頁中主鍵值最小的用戶記錄,而本頁中主鍵值最大的用戶記錄的下一條記錄就是 Supremum記錄(也就是最大記錄),爲了更形象的表示一下這個next_record
起到的作用,我們用箭頭來替代一下next_record
中的地址偏移量:
> 你會不會覺得next_record這個指針有點兒怪,爲啥要指向記錄頭信息和真實數據之間的位置呢?爲啥不乾脆指向整條記錄的開頭位置,也就是記錄的額外信息開頭的位置呢?因爲這個位置剛剛好,向左讀取就是記錄頭信息,向右讀取就是真實數據。我們前邊還說過變長字段長度列表、NULL值列表中的信息都是逆序存放,這樣可以使記錄中位置靠前的字段和它們對應的字段長度信息在內存中的距離更近,可能會提高高速緩存的命中率。
記錄真實數據
記錄的真實數據
除了name
、address
等這些我們自己定義的列的數據以外,MySQL
會爲每個記錄默認的添加一些列(也稱爲隱藏列
),具體的列如下:
列名 | 是否必須 | 佔用空間 | 描述 |
---|---|---|---|
DB_ROW_ID |
否 | 6 字節 |
行ID,唯一標識一條記錄 |
DB_TRX_ID |
是 | 6 字節 |
事務ID |
DB_ROLL_PTR |
是 | 7 字節 |
回滾指針 |
這裏需要提一下InnoDB
表對主鍵的生成策略:優先使用用戶自定義主鍵作爲主鍵,如果用戶沒有定義主鍵,則選取一個Unique
鍵作爲主鍵,如果表中連Unique
鍵都沒有定義的話,則InnoDB
會爲表默認添加一個名爲row_id
的隱藏列作爲主鍵。所以我們從上表中可以看出:InnoDB存儲引擎會爲每條記錄都添加 transaction_id 和 roll_pointer 這兩個列,但是 row_id 是可選的。
行溢出數據
VARCHAR(M)最多能存儲的數據
我們知道對於VARCHAR(M)
類型的列最多可以佔用65535
個字節,如果我們使用ascii
字符集的話,一個字符就代表一個字節:
- 如果該
VARCHAR
類型的列沒有NOT NULL
屬性,那最多隻能存儲65532
個字節的數據,因爲真實數據的長度可能佔用2個字節,NULL
值標識需要佔用1個字節。 - 如果
VARCHAR
類型的列有NOT NULL
屬性,那最多隻能存儲65533
個字節的數據,因爲真實數據的長度可能佔用2個字節,不需要NULL
值標識。
如果VARCHAR(M)
類型的列使用的不是ascii
字符集,那M
的最大取值取決於該字符集表示一個字符最多需要的字節數。在列的值允許爲NULL
的情況下,gbk
字符集表示一個字符最多需要2
個字節,那在該字符集下,M
的最大取值就是32766
(也就是:65532/2),也就是說最多能存儲32766
個字符;utf8
字符集表示一個字符最多需要3
個字節,那在該字符集下,M
的最大取值就是21844
,就是說最多能存儲21844
(也就是:65532/3)個字符。
上述所言在列的值允許爲NULL的情況下,gbk字符集下M的最大取值就是32766,utf8字符集下M的最大取值就是
21844,這都是在表中只有一個字段的情況下說的,一定要記住一個行中的所有列(不包括隱藏列和記錄頭信
息)佔用的字節長度加起來不能超過65535個字節!
記錄中的數據太多產生的溢出
- 在
Compact
和Redundant
行格式中,對於佔用存儲空間非常大的列,在記錄的真實數據
處只會存儲該列的一部分數據,把剩餘的數據分散存儲在幾個其他的頁中,然後記錄的真實數據
處用20個字節存儲指向這些頁的地址(當然這20個字節中還包括這些分散在其他頁面中的數據的佔用的字節數),從而可以找到剩餘數據所在的頁。 - 如果某一列中的數據非常多的話,在本記錄的真實數據處只會存儲該列的前
768
個字節的數據和一個指向其他頁的地址,然後把剩下的數據存放到其他頁中,這個過程也叫做行溢出
,存儲超出768
字節的那些頁面也被稱爲溢出頁
。
最後需要注意的是,不只是 VARCHAR(M) 類型的列,其他的 TEXT、BLOB 類型的列在存儲數據經常也會發生行溢出
。
InnoDB數據頁結構
文章開頭簡單提了一下頁
的概念,它是InnoDB
管理存儲空間的基本單位,一個頁的大小一般是16KB
。InnoDB
爲了不同的目的而設計了許多種不同類型的頁
,比如存放表空間頭部信息的頁,存放Insert Buffer
信息的頁,存放INODE
信息的頁,存放undo
日誌信息的頁等等等等。今兒個我們聚焦的是那些存放我們表中記錄的那種類型的頁,官方稱這種存放記錄的頁爲索引(INDEX
)頁,鑑於我們還沒有了解過索引是個什麼東西,而這些表中的記錄就是我們日常口中所稱的數據
,所以目前還是叫這種存放記錄的頁爲數據頁
吧。
數據頁代表的這塊16KB
大小的存儲空間可以被劃分爲多個部分,不同部分有不同的功能,各個部分如圖所示:
在頁的7個組成部分中,我們自己存儲的記錄會按照我們指定的行格式
存儲到User Records
部分。但是在一開始生成頁的時候,其實並沒有User Records
這個部分,每當我們插入一條記錄,都會從Free Space
部分,也就是尚未使用的存儲空間中申請一個記錄大小的空間劃分到User Records
部分,當Free Space
部分的空間全部被User Records
部分替代掉之後,也就意味着這個頁使用完了,如果還有新的記錄插入的話,就需要去申請新的頁了,這個過程的圖示如下:
Page Directory(頁目錄)
每個頁都有一個分組的概念,就是將該頁中的數據再分組。它能進一步提高我們在頁內部查找的效率。
對於最小記錄(Infimum)所在的分組只能有 1 條記錄,最大記錄(Supremum)所在的分組擁有的記錄條數只能在 1~8 條之間,剩下的分組中記錄的條數範圍只能在是 4~8 條之間。
將每個組的最後一條記錄的地址偏移量單獨提取出來按順序存儲到靠近頁
的尾部的地方,這個地方就是所謂的Page Directory
,也就是頁目錄
。頁面目錄中的這些地址偏移量被稱爲槽
(英文名:Slot
),所以這個頁面目錄就是由槽
組成的。這個東西有什麼用?它可以幫助我們快速查找某條數據。
- 每個組最後一條記錄中的
n_owned
值,就代表着這個分組中記錄數量。
所以在一個數據頁中查找指定主鍵值的記錄的過程分爲兩步:
- 通過二分法確定該記錄所在的槽,並找到該槽所在分組中主鍵值最小的那條記錄。
- 通過記錄的
next_record
屬性遍歷該槽所在的組中的各個記錄。
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
針對各種類型的頁都通用,也就是說不同類型的頁都會以File Header
作爲第一個組成部分,它描述了一些針對各種頁都通用的一些信息,比方說這個頁的編號是多少,它的上一個頁、下一個頁是誰等等~ 這個部分佔用固定的38
個字節,是由下邊這些內容組成的:
名稱 | 佔用空間大小 | 描述 |
---|---|---|
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_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_INDEX
,也就是所謂的索引頁
。 -
FIL_PAGE_PREV
和FIL_PAGE_NEXT
我們前邊強調過,
InnoDB
都是以頁爲單位存放數據的,有時候我們存放某種類型的數據佔用的空間非常大(比方說一張表中可以有成千上萬條記錄),InnoDB
可能不可以一次性爲這麼多數據分配一個非常大的存儲空間,如果分散到多個不連續的頁中存儲的話需要把這些頁關聯起來,FIL_PAGE_PREV
和FIL_PAGE_NEXT
就分別代表本頁的上一個和下一個頁的頁號。這樣通過建立一個雙向鏈表把許許多多的頁就都串聯起來了,而無需這些頁在物理上真正連着。需要注意的是,並不是所有類型的頁都有上一個和下一個頁的屬性,不過我們本集中嘮叨的數據頁
(也就是類型爲FIL_PAGE_INDEX
的頁)是有這兩個屬性的,所以所有的數據頁其實是一個雙鏈表,就像這樣:
File Trailer
我們知道InnoDB
存儲引擎會把數據存儲到磁盤上,但是磁盤速度太慢,需要以頁
爲單位把數據加載到內存中處理,如果該頁中的數據在內存中被修改了,那麼在修改後的某個時間需要把數據同步到磁盤中。但是在同步了一半的時候中斷電了咋辦,這不是莫名尷尬麼?爲了檢測一個頁是否完整(也就是在同步的時候有沒有發生只同步一半的尷尬情況),設計InnoDB
的大叔們在每個頁的尾部都加了一個File Trailer
部分,這個部分由8
個字節組成,可以分成2個小部分:
-
前4個字節代表頁的校驗和
這個部分是和
File Header
中的校驗和相對應的。每當一個頁面在內存中修改了,在同步之前就要把它的校驗和算出來,因爲File Header
在頁面的前邊,所以校驗和會被首先同步到磁盤,當完全寫完時,校驗和也會被寫到頁的尾部,如果完全同步成功,則頁的首部和尾部的校驗和應該是一致的。如果寫了一半兒斷電了,那麼在File Header
中的校驗和就代表着已經修改過的頁,而在File Trailer
中的校驗和代表着原先的頁,二者不同則意味着同步中間出了錯。 -
後4個字節代表頁面被最後修改時對應的日誌序列位置(LSN)
這個部分也是爲了校驗頁的完整性的,只不過我們目前還沒說
LSN
是個什麼意思,所以大家可以先不用管這個屬性。
這個File Trailer
與File Header
類似,都是所有類型的頁通用的。
最後
內容還是比較多的,如果你是第一次接觸這些東西,看下來難免會有點懵。最後我們總結一下。
數據頁之間通過指針連接組成一個雙向鏈表
,數據頁中的記錄會按照主鍵值從小到大的順序組成一個單向鏈表
,每個數據頁都會爲存儲在它裏邊兒的記錄生成一個頁目錄
,在通過主鍵查找某條記錄的時候可以在頁目錄
中使用二分法快速定位到對應的槽,然後再遍歷該槽對應分組中的記錄即可快速找到指定的記錄。頁和記錄的關係示意圖如下: