InnoDB中數據是如何存儲的

寫在前面 > 本文章是學習掘金小冊《MySQL 是怎樣運行的:從根兒上理解 MySQL》 之後整理的,文章大量使用和借鑑了該小冊的內容。另外小冊很不錯,講解十分到位,推薦閱讀。

如果你學習或使用過MySQL,那麼或多或少知道的概念,它是InnoDB管理存儲空間的基本單位,一個頁的大小一般是16KB,而一個中又存儲了多條記錄。這篇文章將從單條記錄,帶你瞭解MySQL中數據存儲的祕密。

InnoDB記錄存儲結構--行格式

我們知道MySQL中真正存儲數據的是存儲引擎,因爲MySQL中存儲的數據一般都是比較多的,內存肯定是無法存儲的,所以數據被存儲在磁盤上。表中的一條一條數據(又:一條一條記錄)在磁盤上是如何存儲的呢?

記錄在磁盤上的存放方式也被稱爲行格式或者記錄格式;目前有4種不同類型的行格式,分別是CompactRedundantDynamicCompressed行格式,下面我們將詳細介紹Compact的結構。

COMPACT行格式

image_1c9g4t114n0j1gkro2r1h8h1d1t16.png-42.4kB

可以看到上圖中包含兩部分:記錄的額外信息,記錄的真實數據。 其中記錄真實數據是真正存儲數據的部分,而記錄額外信息存儲了記錄的額外信息(或者叫元數據),又分爲三塊不同的空間:

  • 邊長字段長度
  • NULL值列表
  • 記錄頭信息
變長字段長度列表

MySQL中的VARCHAR(M)VARBINARY(M)、各種TEXT類型,各種BLOB類型,這些數據類型稱爲變長字段,變長字段中存儲多少字節的數據是不固定的。

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

假如有一條記錄包含兩列變長字段,其中c1列存儲的值爲'eeee',佔用的字節數爲4c2列存儲的值爲'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,效果就是這樣:

image_1c9g8g27b1bdlu7t187emsc46s61.png-19.4kB

以此類推,如果一個表中有9個允許爲NULL,那這個記錄的NULL值列表部分就需要2個字節來表示了。

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

> 另外有一點還需要注意,變長字符集的CHAR(M)類型的列要求至少佔用M個字節,而VARCHAR(M)卻沒有這個要求。比方說對於使用utf8字符集的CHAR(10)的列來說,該列存儲的數據字節長度的範圍是10~30個字節。即使我們向該列中存儲一個空字符串也會佔用10個字節,這是怕將來更新該列的值的字節長度大於原有值的字節長度而小於10個字節時,可以在該記錄處直接更新,而不是在存儲空間中重新分配一個新的記錄空間,導致原有的記錄空間成爲所謂的碎片。

記錄頭信息

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

image_1c9geiglj1ah31meo80ci8n1eli8f.png-29.5kB

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

名稱 大小(單位: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條記錄在本中的位置分別是:2345。是不是少了點啥?是的,怎麼不見heap_no值爲01的記錄呢?

    其實InnoDB自動給每個頁裏邊兒加了兩個記錄,由於這兩個記錄並不是我們自己插入的,所以有時候也稱爲僞記錄或者虛擬記錄。這兩個僞記錄一個代表最小記錄,一個代表最大記錄

    由於這兩條記錄不是我們自己定義的記錄,所以它們並不存放在User Records部分,他們被單獨放在一個稱爲Infimum + Supremum的部分(也就是最小記錄和最大記錄,詳見後面),如圖所示:

    image_1c9qs1mn2t3j1nt344116nk15uf2p.png-119.7kB

    從圖中我們可以看出來,最小記錄和最大記錄的heap_no值分別是01,也就是說它們的位置最靠前。

  • record_type

    這個屬性表示當前記錄的類型,一共有4種類型的記錄,0表示普通記錄,1表示B+樹非葉節點記錄,2表示最小記錄,3表示最大記錄。

  • next_record

    這玩意兒非常重要,它表示從當前記錄的真實數據到下一條記錄的真實數據的地址偏移量。比方說第一條記錄的next_record值爲32,意味着從第一條記錄的真實數據的地址處向後找32個字節便是下一條記錄的真實數據。如果你熟悉數據結構的話,就立即明白了,這其實是個鏈表,可以通過一條記錄找到它的下一條記錄。但是需要注意注意再注意的一點是,下一條記錄指得並不是按照我們插入順序的下一條記錄,而是按照主鍵值由小到大的順序的下一條記錄。而且規定 Infimum記錄(也就是最小記錄) 的下一條記錄就是本頁中主鍵值最小的用戶記錄,而本頁中主鍵值最大的用戶記錄的下一條記錄就是 Supremum記錄(也就是最大記錄),爲了更形象的表示一下這個next_record起到的作用,我們用箭頭來替代一下next_record中的地址偏移量:

    image_1cot1r96210ph1jng1td41ouj85c13.png-120.5kB

> 你會不會覺得next_record這個指針有點兒怪,爲啥要指向記錄頭信息和真實數據之間的位置呢?爲啥不乾脆指向整條記錄的開頭位置,也就是記錄的額外信息開頭的位置呢?因爲這個位置剛剛好,向左讀取就是記錄頭信息,向右讀取就是真實數據。我們前邊還說過變長字段長度列表、NULL值列表中的信息都是逆序存放,這樣可以使記錄中位置靠前的字段和它們對應的字段長度信息在內存中的距離更近,可能會提高高速緩存的命中率。

記錄真實數據

記錄的真實數據除了nameaddress 等這些我們自己定義的列的數據以外,MySQL會爲每個記錄默認的添加一些列(也稱爲隱藏列),具體的列如下:

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

這裏需要提一下InnoDB表對主鍵的生成策略:優先使用用戶自定義主鍵作爲主鍵,如果用戶沒有定義主鍵,則選取一個Unique鍵作爲主鍵,如果表中連Unique鍵都沒有定義的話,則InnoDB會爲表默認添加一個名爲row_id的隱藏列作爲主鍵。所以我們從上表中可以看出:InnoDB存儲引擎會爲每條記錄都添加 transaction_idroll_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個字節!
記錄中的數據太多產生的溢出
  • CompactRedundant行格式中,對於佔用存儲空間非常大的列,在記錄的真實數據處只會存儲該列的一部分數據,把剩餘的數據分散存儲在幾個其他的頁中,然後記錄的真實數據處用20個字節存儲指向這些頁的地址(當然這20個字節中還包括這些分散在其他頁面中的數據的佔用的字節數),從而可以找到剩餘數據所在的頁。
  • 如果某一列中的數據非常多的話,在本記錄的真實數據處只會存儲該列的前768個字節的數據和一個指向其他頁的地址,然後把剩下的數據存放到其他頁中,這個過程也叫做行溢出,存儲超出768字節的那些頁面也被稱爲溢出頁

最後需要注意的是,不只是 VARCHAR(M) 類型的列,其他的 TEXTBLOB 類型的列在存儲數據經常也會發生行溢出

InnoDB數據頁結構

文章開頭簡單提了一下的概念,它是InnoDB管理存儲空間的基本單位,一個頁的大小一般是16KBInnoDB爲了不同的目的而設計了許多種不同類型的,比如存放表空間頭部信息的頁,存放Insert Buffer信息的頁,存放INODE信息的頁,存放undo日誌信息的頁等等等等。今兒個我們聚焦的是那些存放我們表中記錄的那種類型的頁,官方稱這種存放記錄的頁爲索引(INDEX)頁,鑑於我們還沒有了解過索引是個什麼東西,而這些表中的記錄就是我們日常口中所稱的數據,所以目前還是叫這種存放記錄的頁爲數據頁吧。

數據頁代表的這塊16KB大小的存儲空間可以被劃分爲多個部分,不同部分有不同的功能,各個部分如圖所示:

img

在頁的7個組成部分中,我們自己存儲的記錄會按照我們指定的行格式存儲到User Records部分。但是在一開始生成頁的時候,其實並沒有User Records這個部分,每當我們插入一條記錄,都會從Free Space部分,也就是尚未使用的存儲空間中申請一個記錄大小的空間劃分到User Records部分,當Free Space部分的空間全部被User Records部分替代掉之後,也就意味着這個頁使用完了,如果還有新的記錄插入的話,就需要去申請新的頁了,這個過程的圖示如下:

image_1cosvi1in9st476cdqfki1n39m.png-133.8kB

Page Directory(頁目錄)

每個頁都有一個分組的概念,就是將該頁中的數據再分組。它能進一步提高我們在頁內部查找的效率。

對於最小記錄(Infimum)所在的分組只能有 1 條記錄,最大記錄(Supremum)所在的分組擁有的記錄條數只能在 1~8 條之間,剩下的分組中記錄的條數範圍只能在是 4~8 條之間。

每個組的最後一條記錄的地址偏移量單獨提取出來按順序存儲到靠近的尾部的地方,這個地方就是所謂的Page Directory,也就是頁目錄。頁面目錄中的這些地址偏移量被稱爲(英文名:Slot),所以這個頁面目錄就是由組成的。這個東西有什麼用?它可以幫助我們快速查找某條數據。

  • 每個組最後一條記錄中的n_owned值,就代表着這個分組中記錄數量。

image_1couate3jr19gc18gl1cva1fcg34.png-100.8kB

所以在一個數據頁中查找指定主鍵值的記錄的過程分爲兩步:

  1. 通過二分法確定該記錄所在的槽,並找到該槽所在分組中主鍵值最小的那條記錄。
  2. 通過記錄的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_PREVFIL_PAGE_NEXT

    我們前邊強調過,InnoDB都是以頁爲單位存放數據的,有時候我們存放某種類型的數據佔用的空間非常大(比方說一張表中可以有成千上萬條記錄),InnoDB可能不可以一次性爲這麼多數據分配一個非常大的存儲空間,如果分散到多個不連續的頁中存儲的話需要把這些頁關聯起來,FIL_PAGE_PREVFIL_PAGE_NEXT就分別代表本頁的上一個和下一個頁的頁號。這樣通過建立一個雙向鏈表把許許多多的頁就都串聯起來了,而無需這些頁在物理上真正連着。需要注意的是,並不是所有類型的頁都有上一個和下一個頁的屬性,不過我們本集中嘮叨的數據頁(也就是類型爲FIL_PAGE_INDEX的頁)是有這兩個屬性的,所以所有的數據頁其實是一個雙鏈表,就像這樣:

    image_1ca00fhg418pl1f1a1iav1uo3aou9.png-90.9kB

File Trailer

我們知道InnoDB存儲引擎會把數據存儲到磁盤上,但是磁盤速度太慢,需要以爲單位把數據加載到內存中處理,如果該頁中的數據在內存中被修改了,那麼在修改後的某個時間需要把數據同步到磁盤中。但是在同步了一半的時候中斷電了咋辦,這不是莫名尷尬麼?爲了檢測一個頁是否完整(也就是在同步的時候有沒有發生只同步一半的尷尬情況),設計InnoDB的大叔們在每個頁的尾部都加了一個File Trailer部分,這個部分由8個字節組成,可以分成2個小部分:

  • 前4個字節代表頁的校驗和

    這個部分是和File Header中的校驗和相對應的。每當一個頁面在內存中修改了,在同步之前就要把它的校驗和算出來,因爲File Header在頁面的前邊,所以校驗和會被首先同步到磁盤,當完全寫完時,校驗和也會被寫到頁的尾部,如果完全同步成功,則頁的首部和尾部的校驗和應該是一致的。如果寫了一半兒斷電了,那麼在File Header中的校驗和就代表着已經修改過的頁,而在File Trailer中的校驗和代表着原先的頁,二者不同則意味着同步中間出了錯。

  • 後4個字節代表頁面被最後修改時對應的日誌序列位置(LSN)

    這個部分也是爲了校驗頁的完整性的,只不過我們目前還沒說LSN是個什麼意思,所以大家可以先不用管這個屬性。

這個File TrailerFile Header類似,都是所有類型的頁通用的。

最後

內容還是比較多的,如果你是第一次接觸這些東西,看下來難免會有點懵。最後我們總結一下。

數據頁之間通過指針連接組成一個雙向鏈表,數據頁中的記錄會按照主鍵值從小到大的順序組成一個單向鏈表,每個數據頁都會爲存儲在它裏邊兒的記錄生成一個頁目錄,在通過主鍵查找某條記錄的時候可以在頁目錄中使用二分法快速定位到對應的槽,然後再遍歷該槽對應分組中的記錄即可快速找到指定的記錄。頁和記錄的關係示意圖如下:

image_1cov976plf2u1j3g1jp8serjc616.png-87.7kB

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