天天看點

MySQL · 引擎特性 · InnoDB檔案系統管理

從上層的角度來看,innodb層的檔案,除了redo日志外,基本上具有相當統一的結構,都是固定block大小,普遍使用的btree結構來管理資料。隻是針對不同的block的應用場景會配置設定不同的頁類型。通常預設情況下,每個block的大小為univ_page_size,在不做任何配置時值為16kb,你還可以選擇在安裝執行個體時指定一個塊的block大小。 對于壓縮表,可以在建表時指定block size,但在記憶體中表現的解壓頁依舊為統一的頁大小。

從實體檔案的分類來看,有日志檔案,主系統表空間檔案ibdata,undo tablespace檔案,臨時表空間檔案,使用者表空間。

日志檔案主要用于記錄redo log,innodb采用循環使用的方式,你可以通過參數指定建立檔案的個數和每個檔案的大小。預設情況下,日志是以512位元組的block機關寫入。由于現代檔案系統的block size通常設定到4k,innodb提供了一個選項,可以讓使用者将寫入的redo日志填充到4kb,以避免read-modify-write的現象;而percona server則提供了另外一個選項,支援直接将redo日志的block size修改成指定的值。

ibdata是innodb最重要的系統表空間檔案,它記錄了innodb的核心資訊,包括事務系統資訊,中繼資料資訊,記錄innodb change buffer的btree, 防止資料損壞的double write buffer等等關鍵資訊。我們稍後會展開描述。

undo獨立表空間是一個可選項,通常預設情況下,undo資料是存儲在ibdata中的,但你也可以通過配置選項innodb_undo_tablespaces來将undo 復原段配置設定到不同的檔案中,目前開啟undo tablespace隻能在install階段進行。在主流版本進入5.7時代後,我們建議開啟獨立undo表空間,隻有這樣才能利用到5.7引入的新特效:online undo truncate。

mysql 5.7新開辟了一個臨時表空間,預設的磁盤檔案命名為ibtmp1,所有非壓縮的臨時表都存儲在該表空間中。由于臨時表的本身屬性,該檔案在重新開機時會重新建立。對于雲服務提供商而言,通過ibtmp檔案,可以更好的控制臨時檔案産生的磁盤存儲。

使用者表空間,顧名思義,就是用于自己建立的表空間,通常分為兩類,一類是一個表空間一個檔案,另外一種則是5.7版本引入的所謂general tablespace,在滿足一定限制條件下,可以将多個表建立到同一個檔案中。除此之外,innodb還定義了一些特殊用途的ibd檔案,例如全文索引相關的表檔案。而針對空間資料類型,也建構了不同的資料索引格式r-tree。

為了管理磁盤檔案的讀寫操作,innodb設計了一套檔案io操作接口,提供了同步io和異步io兩種檔案讀寫方式。針對異步io,支援兩種方式:一種是native aio,這需要你在編譯階段加上libaio的dev包,另外一種是simulated aio模式,innodb早期實作了一套系統來模拟異步io,但現在native aio已經很成熟了,并且simulated aio本身存在性能問題,建議生産環境開啟native aio模式。

對于資料讀操作,通常使用者線程觸發的資料塊請求讀是同步讀,如果開啟了資料預讀機制的話,預讀的資料塊則為異步讀,由背景io線程進行。其他背景線程也會觸發資料讀操作,例如purge線程在無效資料清理,會讀undo頁和資料頁;master線程定期做ibuf merge也會讀入資料頁。崩潰恢複階段也可能觸發異步讀來加速recover的速度。

對于資料寫操作,innodb和大部分資料庫系統一樣,都是wal模式,即先寫日志,延遲寫資料頁。事務日志的寫入通常在事務送出時觸發,背景master線程也會每秒做一次redo fsync。資料頁則通常由背景page cleaner線程觸發。但當buffer pool空閑block不夠時,或者沒做checkpoint的lsn age太長時,也會驅動刷髒操作,這兩種場景由使用者線程來觸發。percona server據此做了優化來避免使用者線程參與。mysql5.7也對應做了些不一樣的優化。

除了資料塊操作,還是實體檔案級别的操作,例如truncate, drop table,rename table等ddl操作,innodb需要對這些操作進行協調,目前的解法是通過特殊的flag和計數器的方式來解決。

當檔案讀入記憶體後,我們需要一種統一的方式來對資料進行管理,在啟動執行個體時,innodb會按照instance分區配置設定多個一大塊記憶體(在5.7裡則是按照可配置的chunk size進行記憶體塊劃分),每個chunk又以univ_page_size為機關進行劃分。資料讀入記憶體時,會從buffer pool的free list中配置設定一個空閑block。所有的資料頁都存儲在一個lru連結清單上。修改過的block被加到flush_list上,解壓的資料頁被放到unzip_lru連結清單上。我們可以配置buffer pool為多個instance,以降低對連結清單的競争開銷。

從實體檔案到記憶體管理是一個相對比較龐大的架構,本文将一一為讀者進行分析解讀,以讓讀者對innodb的檔案系統管理有個更加全面的認識。在關鍵的地方本文注明了代碼函數,建議讀者邊參考代碼邊閱讀本文。

本文的代碼部分基于mysql 5.7.11版本,不同的版本函數名或邏輯可能會有所不同。請讀者閱讀本文時盡量選擇該版本的代碼。

本小節主要從檔案的實體結構的角度闡述innodb在最底層如何對實體檔案進行管理,再分别介紹各類檔案的不同結構。

innodb的每個資料檔案都歸屬于一個表空間,不同的表空間使用一個唯一辨別的space id來标記。例如ibdata1, ibdata2...歸屬系統表空間,擁有相同的space id。使用者建立表産生的ibd檔案,則認為是一個獨立的tablespace,隻包含一個檔案。

每個檔案按照固定的page size進行區分,預設情況下,非壓縮表的page size為16kb。而在檔案内部又按照64個page(總共1m)一個extent的方式進行劃分并管理。對于不同的page size,對應的extent大小也不同,對應為:

page size

file space extent size

4 kib

256 pages = 1 mib

8 kib

128 pages = 1 mib

16 kib

64 pages = 1 mib

32 kib

64 pages = 2 mib

64 kib

64 pages = 4 mib

盡管支援更大的page size,但目前還不支援大頁場景下的資料壓縮,原因是這涉及到修改壓縮頁中slot的固定size(其實實作起來也不複雜)。在不做聲明的情況下,下文我們預設使用16kb的page size來闡述檔案的實體結構。

為了管理整個tablespace,除了索引頁外,資料檔案中還包含了多種管理頁,如下圖所示,一個使用者表空間大約包含這些頁來管理檔案,下面會一一進行介紹。

MySQL · 引擎特性 · InnoDB檔案系統管理

首先我們先介紹基于檔案的一個基礎結構,即檔案連結清單。為了管理page,extent這些資料塊,在檔案中記錄了許多的節點以維持具有某些特征的連結清單,例如在在檔案頭維護的inode page連結清單,空閑、用滿以及碎片化的extent連結清單等等。

在innodb裡連結清單頭稱為flst_base_node,大小為flst_base_node_size(16個位元組)。base node維護了連結清單的頭指針和末尾指針,每個節點稱為flst_node,大小為flst_node_size(12個位元組)。相關結構描述如下:

flst_base_node:

macro

bytes

desc

flst_len

4

存儲連結清單的長度

flst_first

6

指向連結清單的第一個節點

flst_last

指向連結清單的最後一個節點

flst_node:

flst_prev

指向目前節點的前一個節點

flst_next

指向目前節點的下一個節點

如上所述,檔案連結清單中使用6個位元組來作為節點指針,指針的内容包括:

fil_addr_page

page no

fil_addr_byte

2

page内的偏移量

該連結清單結構是innodb表空間内管理所有page的基礎結構,下圖先感受下,具體的内容可以繼續往下閱讀。

MySQL · 引擎特性 · InnoDB檔案系統管理

檔案連結清單管理的相關代碼參閱:include/fut0lst.ic, fut/fut0lst.cc

資料檔案的第一個page類型為fil_page_type_fsp_hdr,在建立一個新的表空間時進行初始化(<code>fsp_header_init</code>),該page同時用于跟蹤随後的256個extent(約256mb檔案大小)的空間管理,是以每隔256mb就要建立一個類似的資料頁,類型為fil_page_type_xdes ,xdes page除了檔案頭部外,其他都和fsp_hdr頁具有相同的資料結構,可以稱之為extent描述頁,每個extent占用40個位元組,一個xdes page最多描述256個extent。

fsp_hdr頁的頭部使用fsp_header_size個位元組來記錄檔案的相關資訊,具體的包括:

fsp_space_id

該檔案對應的space id

fsp_not_used

如其名,保留位元組,目前未使用

fsp_size

目前表空間總的page個數,擴充檔案時需要更新該值(<code>fsp_try_extend_data_file_with_pages</code>)

fsp_free_limit

目前尚未初始化的最小page no。從該page往後的都尚未加入到表空間的free list上。

fsp_space_flags

目前表空間的flag資訊,見下文

fsp_frag_n_used

fsp_free_frag連結清單上已被使用的page數,用于快速計算該連結清單上可用空閑page數

fsp_free

16

當一個extent中所有page都未被使用時,放到該連結清單上,可以用于随後的配置設定

fsp_free_frag

free_frag連結清單的base node,通常這樣的extent中的page可能歸屬于不同的segment,用于segment frag array page的配置設定(見下文)

fsp_full_frag

extent中所有的page都被使用掉時,會放到該連結清單上,當有page從該extent釋放時,則移回free_frag連結清單

fsp_seg_id

8

目前檔案中最大segment id + 1,用于段配置設定時的seg id計數器

fsp_seg_inodes_full

已被完全用滿的inode page連結清單

fsp_seg_inodes_free

至少存在一個空閑inode entry的inode page被放到該連結清單上

在檔案頭使用flag(對應上述fsp_space_flags)描述了建立表時的如下關鍵資訊:

fsp_flags_pos_zip_ssize

壓縮頁的block size,如果為0表示非壓縮表

fsp_flags_pos_atomic_blobs

使用的是compressed或者dynamic的行格式

fsp_flags_pos_page_ssize

fsp_flags_pos_data_dir

如果該表空間顯式指定了data_dir,則設定該flag

fsp_flags_pos_shared

是否是共享的表空間,如5.7引入的general tablespace,可以在一個表空間中建立多個表

fsp_flags_pos_temporary

是否是臨時表空間

fsp_flags_pos_encryption

是否是加密的表空間,mysql 5.7.11引入

fsp_flags_pos_unused

未使用的位

除了上述描述資訊外,其他部分的資料結構和xdes page(fil_page_type_xdes)都是相同的,使用連續數組的方式,每個xdes page最多存儲256個xdes entry,每個entry占用40個位元組,描述64個page(即一個extent)。格式如下:

xdes_id

如果該extent歸屬某個segment的話,則記錄其id

xdes_flst_node

12(flst_node_size)

維持extent連結清單的雙向指針節點

xdes_state

該extent的狀态資訊,包括:xdes_free,xdes_free_frag,xdes_full_frag,xdes_fseg,詳解見下文

xdes_bitmap

總共16*8= 128個bit,用2個bit表示extent中的一個page,一個bit表示該page是否是空閑的(xdes_free_bit),另一個保留位,尚未使用(xdes_clean_bit)

xdes_state表示該extent的四種不同狀态:

xdes_free(1)

存在于free連結清單上

xdes_free_frag(2)

存在于free_frag連結清單上

xdes_full_frag(3)

存在于full_frag連結清單上

xdes_fseg(4)

該extent歸屬于id為xdes_id記錄的值的segment。

通過xdes_state資訊,我們隻需要一個flist_node節點就可以維護每個extent的資訊,是處于全局表空間的連結清單上,還是某個btree segment的連結清單上。

第2個page類型為fil_page_ibuf_bitmap,主要用于跟蹤随後的每個page的change buffer資訊,使用4個bit來描述每個page的change buffer資訊。

bits

ibuf_bitmap_free

使用2個bit來描述page的空閑空間範圍:0(0 bytes)、1(512 bytes)、2(1024 bytes)、3(2048 bytes)

ibuf_bitmap_buffered

1

是否有ibuf操作緩存

ibuf_bitmap_ibuf

該page本身是否是ibuf btree的節點

由于bitmap page的空間有限,同樣每隔256個extent page之後,也會在xdes page之後建立一個ibuf bitmap page。

關于change buffer,這裡我們不展開讨論,感興趣的可以閱讀之前的這篇月報:

mysql · 引擎特性 · innodb change buffer介紹

資料檔案的第3個page的類型為fil_page_inode,用于管理資料檔案中的segement,每個索引占用2個segment,分别用于管理葉子節點和非葉子節點。每個inode頁可以存儲fsp_seg_inodes_per_page(預設為85)個記錄。

fseg_inode_page_node

12

inode頁的連結清單節點,記錄前後inode page的位置,basenode記錄在頭page的fsp_seg_inodes_full或者fsp_seg_inodes_free字段。

inode entry 0

192

inode記錄

inode entry 1

……

inode entry 84

每個inode entry的結構如下表所示:

fseg_id

該inode歸屬的segment id,若值為0表示該slot未被使用

fseg_not_full_n_used

fseg_not_full連結清單上被使用的page數量

fseg_free

完全沒有被使用并配置設定給該segment的extent連結清單

fseg_not_full

至少有一個page配置設定給目前segment的extent連結清單,全部用完時,轉移到fseg_full上,全部釋放時,則歸還給目前表空間fsp_free連結清單

fseg_full

配置設定給目前segment且page完全使用完的extent連結清單

fseg_magic_n

magic number

fseg_frag_arr 0

屬于該segment的獨立page。總是先從全局配置設定獨立的page,當填滿32個數組項時,就在每次配置設定時都配置設定一個完整的extent,并在xdes page中将其segment id設定為目前值

fseg_frag_arr 31

總共存儲32個記錄項

從上文我們可以看到,innodb通過inode entry來管理每個segment占用的資料頁,每個segment可以看做一個檔案頁維護單元。inode entry所在的inode page有可能存放滿,是以又通過頭page維護了inode page連結清單。

在ibd的第一個page中還維護了表空間内extent的free、free_frag、full_frag三個extent連結清單;而每個inode entry也維護了對應的free、not_full、full三個extent連結清單。這些連結清單之間存在着轉換關系,以高效的利用資料檔案空間。

當建立一個新的索引時,實際上建構一個新的btree(<code>btr_create</code>),先為非葉子節點segment配置設定一個inode entry,再建立root page,并将該segment的位置記錄到root page中,然後再配置設定leaf segment的inode entry,并記錄到root page中。

當删除某個索引後,該索引占用的空間需要能被重新利用起來。

建立segment

首先每個segment需要從ibd檔案中預留一定的空間(<code>fsp_reserve_free_extents</code>),通常是2個extent。但如果是新建立的表空間,且目前的檔案小于1個extent時,則隻配置設定2個page。

當檔案空間不足時,需要對檔案進行擴充(<code>fsp_try_extend_data_file</code>)。檔案的擴充遵循一定的規則:如果目前小于1個extent,則擴充到1個extent滿;當表空間小于32mb時,每次擴充一個extent;大于32mb時,每次擴充4個extent(<code>fsp_get_pages_to_extend_ibd</code>)。

在預留白間後,讀取檔案頭page并加鎖(<code>fsp_get_space_header</code>),然後開始為其配置設定inode entry(<code>fsp_alloc_seg_inode</code>)。 首先需要找到一個合适的inode page。

我們知道inode page的空間有限,為了管理inode page,在檔案頭存儲了兩個inode page連結清單,一個連結已經用滿的inode page,一個連結尚未用滿的inode page。如果目前inode page的空間使用完了,就需要再配置設定一個inode page,并加入到fsp_seg_inodes_free連結清單上(<code>fsp_alloc_seg_inode_page</code>)。對于獨立表空間,通常一個inode page就足夠了。

當拿到目标inode page後,從該page中找到一個空閑(<code>fsp_seg_inode_page_find_free</code>)未使用的slot(空閑表示其不歸屬任何segment,即fseg_id置為0)

一旦該inode page中的記錄用滿了,就從fsp_seg_inodes_free連結清單上轉移到fsp_seg_inodes_full連結清單。

獲得inode entry後,遞增頭page的fsp_seg_id,作為目前segment的seg id寫入到inode entry中。随後進行一些列的初始化。

在完成inode entry的提取後,就将該inode entry所在inode page的位置及頁内偏移量存儲到其他某個page内(對于btree就是記錄在根節點内,占用10個位元組,包含space id, page no, offset)。

btree的根節點實際上是在建立non-leaf segment時配置設定的,root page被配置設定到該segment的frag array的第一個數組元素中。

segment配置設定入口函數: <code>fseg_create_general</code>

配置設定資料頁

随着btree資料的增長,我們需要為btree的segment配置設定新的page。前面我們已經講過,segment是一個獨立的page管理單元,我們需要将從全局獲得的資料空間納入到segment的管理中。

step 1: 空間擴充

當判定插入索引的操作可能引起分裂時,會進行悲觀插入(<code>btr_cur_pessimistic_insert</code>),在做實際的分裂操作之前,會先對檔案進行擴充,并嘗試預留(tree_height / 16 + 3)個extent,大多數情況下都是3個extent。

這裡有個意外場景:如果目前檔案還不超過一個extent,并且請求的page數小于1/2個extent時,則如果指定page數,保證有2個可用的空閑page,或者配置設定指定的page,而不是以extent為機關進行配置設定。

注意這裡隻是保證有足夠的檔案空間,避免在btree操作時進行檔案extent。如果在這一步擴充了ibd檔案(<code>fsp_try_extend_data_file</code>),新的資料頁并未初始化,也未加入到任何的連結清單中。

在判定是否有足夠的空閑extent時,本身ibd預留的空閑空間也要納入考慮,對于普通使用者表空間是2個extent + file_size * 1%。 這些新擴充的page此時并未進行初始化,也未加入到,在頭page的fsp_free_limit記錄的page no辨別了這類未初始化頁的範圍。

step 2:為segment配置設定page

随後進入索引分裂階段(<code>btr_page_split_and_insert</code>),新page配置設定的上層調用棧:

在傳遞的參數中,有個hint page no,通常是目前需要分裂的page no的前一個(direction = fsp_down)或者後一個page no(direction = fsp_up),其目的是将邏輯上相鄰的節點在實體上也盡量相鄰。

在step 1我們已經保證了實體空間有足夠的資料頁,隻是還沒進行初始化。将page配置設定到目前segment的流程如下(<code>fseg_alloc_free_page_low</code>):

計算目前segment使用的和占用的page數

使用的page數存儲包括fseg_not_full連結清單上使用的page數(存儲在inode entry的fseg_not_full_n_used中) + 已用滿segment的fseg_full連結清單上page數 + 占用的frag array page數量

占用的page數包括fseg_free、fseg_not_full 、fseg_full三個連結清單上的extent + 占用的frag array page數量。

根據hint page擷取對應的xdes entry (<code>xdes_get_descriptor_with_space_hdr</code>)

當滿足如下條件時該hint page可以直接拿走使用:

extent狀态為xdes_fseg,表示屬于一個segment

hint page所在的extent已被配置設定給目前segment(檢查xdes entry的xdes_id)

hint page對應的bit設定為free,表示尚未被占用

傳回hint page

當滿足條件:1. xdes entry目前是空閑狀态(xdes_free);2.該segment中已使用的page數大于其占用的page數的7/8 (fseg_fillfactor);3. 目前segment已經使用了超過32個frag page,即表示其inode中的frag array可能已經用滿。

從表空間配置設定hint page所在的extent (<code>fsp_alloc_free_extent</code>),将其從fsp_free連結清單上移除

設定該extent的狀态為xdes_fseg,寫入seg id,并加入到目前segment的fseg_free連結清單中。

當如下條件時:1. direction != fsp_no_dir,對于btree分裂,要麼fsp_up,要麼fsp_down;2.已使用的空間小于已占用空間的7/8; 3.目前segment已經使用了超過32個frag page

嘗試從segment擷取一個extent(<code>fseg_alloc_free_extent</code>),如果該segment的fseg_free連結清單為空,則需要從表空間配置設定(<code>fsp_alloc_free_extent</code>)一個extent,并加入到目前segment的fseg_free連結清單上

direction為fsp_down時,傳回該extent最後一個page,為fsp_up時傳回該extent的第一個page

xdes entry屬于目前segment且未被用滿,從其中取一個空閑page并傳回

如果該segment占用的page數大于實用的page數,說明該segment還有空閑的page,則依次先看fseg_not_full連結清單上是否有未滿的extent,如果沒有,再看fseg_free連結清單上是否有完全空閑的extent。從其中取一個空閑page并傳回

目前已經實用的page數小于32個page時,則配置設定獨立的page(<code>fsp_alloc_free_page</code>)并加入到該inode的frag array page數組中,然後傳回該block

當上述情況都不滿足時,直接配置設定一個extent(<code>fseg_alloc_free_extent</code>),并從其中取一個page傳回。

上述流程看起來比較複雜,但可以總結為:

對于一個新的segment,總是優先填滿32個frag page數組,之後才會為其配置設定完整的extent,可以利用碎片頁,并避免小表占用太多空間。

盡量獲得hint page;

如果segment上未使用的page太多,則盡量利用segment上的page。

上文提到兩處從表空間為segment配置設定資料頁,一個是配置設定單獨的資料頁,一個是配置設定整個extent

表空間單獨資料頁的配置設定調用函數<code>fsp_alloc_free_page</code>:

如果hint page所在的extent在連結清單xdes_free_frag上,可以直接使用;否則從根據頭page的fsp_free_frag連結清單檢視是否有可用的extent;

未能從上述找到一個可用extent,直接配置設定一個extent,并加入到fsp_free_frag連結清單中。

從獲得的extent中找到描述為空閑(xdes_free_bit)的page。

配置設定該page (<code>fsp_alloc_from_free_frag</code>)

設定page對應的bitmap的xdes_free_bit為false,表示被占用

遞增頭page的fsp_frag_n_used字段

如果該extent被用滿了,就将其從fsp_free_frag移除,并加入到fsp_full_frag連結清單中。同時對頭page的fsp_frag_n_used遞減1個extent(fsp_frag_n_used隻存儲未滿的extent使用的page數量)。

對page内容進行初始化(<code>fsp_page_create</code>)

表空間extent的配置設定函數<code>fsp_alloc_free_extent</code>:

通常先通過頭page看fsp_free連結清單上是否有空閑的extent,如果沒有的話,則将新的extent(例如上述step 1對檔案做擴充産生的新page,從fsp_free_limit算起)加入到fsp_free連結清單上(<code>fsp_fill_free_list</code>):

一次最多加4個extent(<code>fsp_free_add</code>)

如果涉及到xdes page,還需要對xdes page進行初始化;

如果extent中存在類似xdes page這樣的系統管理頁,這個extent被加入到fsp_free_frag連結清單中而不是fsp_free連結清單。

取連結清單上第一個extent為目前使用。

将獲得的extent從fsp_free移除,并傳回對應的xdes entry(<code>xdes_lst_get_descriptor</code>)

回收page

資料頁的回收分為兩種,一種是整個extent的回收,一種是碎片頁的回收。在删除索引頁或者drop索引時都會發生。

當某個資料頁上的資料被删光時,我們需要從其所在segmeng上删除該page(<code>btr_page_free --&gt;fseg_free_page --&gt; fseg_free_page_low</code>),回收的流程也比較簡單:

首先如果是該segment的frag array中的page,将對應的slot設定為fil_null, 并返還給表空間(<code>fsp_free_page</code>):

page在xdes entry中的狀态置為空閑

如果page所在extent處于fsp_full_frag連結清單,則轉移到fsp_free_frag中

如果extent中的page完全被釋放掉了,則釋放該extent(<code>fsp_free_extent</code>),将其轉移到fsp_free連結清單

從函數傳回

如果page所處于的extent目前在該segment的fseg_full連結清單上,則轉移到fseg_not_full連結清單

設定page在xdes entry的bitmap對應的xdes_free_bit為true

如果此時該extent上的page全部被釋放了,将其從fseg_not_full連結清單上移除,并加入到表空間的fsp_free連結清單上(而非segment的fseg_free連結清單)。

釋放segment

當我們删除索引或者表時,需要删除btree(<code>btr_free_if_exists</code>),先删除除了root節點外的其他部分(<code>btr_free_but_not_root</code>),再删除root節點(<code>btr_free_root</code>)

由于資料操作都需要記錄redo,為了避免産生非常大的redo log,leaf segment通過反複調用函數<code>fseg_free_step</code>來釋放其占用的資料頁:

首先找到leaf segment對應的inode entry(<code>fseg_inode_try_get</code>)

然後依次查找inode entry中的fseg_full、或者fseg_not_full、或者fseg_free連結清單,找到一個extent,注意着裡的連結清單元組所指向的位置實際上是描述該extent的xdes entry所在的位置。是以可以快速定位到對應的xdes page及page内偏移量(<code>xdes_lst_get_descriptor</code>)

現在我們可以将這個extent安全的釋放了(<code>fseg_free_extent</code>,見後文)

當反複調用fseg_free_step将所有的extent都釋放後,segment還會最多占用32個碎片頁,也需要依次釋放掉(<code>fseg_free_page_low</code>)

最後,當該inode所占用的page全部釋放時,釋放inode entry:

如果該inode所在的inode page中目前被用滿,則由于我們即将釋放一個slot,需要從fsp_seg_inodes_full轉移到fsp_seg_inodes_free(更新第一個page)

将該inode entry的seg_id清除為0,表示未使用

如果該inode page上全部inode entry都釋放了,就從fsp_seg_inodes_free移除,并删除該page。

non-leaf segment的回收和leaf segment的回收基本類似,但要注意btree的根節點存儲在該segment的frag arrary的第一個元組中,該page暫時不可以釋放(<code>fseg_free_step_not_header</code>)

btree的root page在完成上述步驟後再釋放,此時才能徹底釋放non-leaf segment

ibd檔案中真正建構起使用者資料的結構是btree,在你建立一個表時,已經基于顯式或隐式定義的主鍵建構了一個btree,其葉子節點上記錄了行的全部列資料(加上事務id列及復原段指針列);如果你在表上建立了二級索引,其葉子節點存儲了鍵值加上聚集索引鍵值。本小節我們探讨下組成索引的實體存儲頁結構,這裡預設讨論的是非壓縮頁,我們在下一小節介紹壓縮頁的内容。

每個btree使用兩個segment來管理資料頁,一個管理葉子節點,一個管理非葉子節點,每個segment在inode page中存在一個記錄項,在btree的root page中記錄了兩個segment資訊。

當我們需要打開一張表時,需要從ibdata的資料詞典表中load中繼資料資訊,其中sys_indexes系統表中記錄了表,索引,及索引根頁對應的page no(<code>dict_fld__sys_indexes__page_no</code>),進而找到btree根page,就可以對整個使用者資料btree進行操作。

索引最基本的頁類型為fil_page_index。可以劃分為下面幾個部分。

page header

首先不管任何類型的資料頁都有38個位元組來描述頭資訊(fil_page_data, or page_header),包含如下資訊:

fil_page_space_or_chksum

在mysql4.0之前存儲space id,之後的版本用于存儲checksum

fil_page_offset

目前頁的page no

fil_page_prev

通常用于維護btree同一level的雙向連結清單,指向連結清單的前一個page,沒有的話則值為fil_null

fil_page_next

和fil_page_prev類似,記錄連結清單的下一個page的page no

fil_page_lsn

最近一次修改該page的lsn

fil_page_type

page類型

fil_page_file_flush_lsn

隻用于系統表空間的第一個page,記錄在正常shutdown時安全checkpoint到的點,對于使用者表空間,這個字段通常是空閑的,但在5.7裡,fil_page_compressed類型的資料頁則另有用途。下一小節單獨介紹

fil_page_space_id

存儲page所在的space id

index header

緊随fil_page_data之後的是索引資訊,這部分資訊是索引頁獨有的。

page_n_dir_slots

page directory中的slot個數 (見下文關于page directory的描述)

page_heap_top

指向目前page内已使用的空間的末尾便宜位置,即free space的開始位置

page_n_heap

page内所有記錄個數,包含使用者記錄,系統記錄以及标記删除的記錄,同時當第一個bit設定為1時,表示這個page内是以compact格式存儲的

page_free

指向标記删除的記錄連結清單的第一個記錄

page_garbage

被删除的記錄連結清單上占用的總的位元組數,屬于可回收的垃圾碎片空間

page_last_insert

指向最近一次插入的記錄偏移量,主要用于優化順序插入操作

page_direction

用于訓示目前記錄的插入順序以及是否正在進行順序插入,每次插入時,page_last_insert會和目前記錄進行比較,以确認插入方向,據此進行插入優化

page_n_direction

目前以相同方向的順序插入記錄個數

page_n_recs

page上有效的未被标記删除的使用者記錄個數

page_max_trx_id

最近一次修改該page記錄的事務id,主要用于輔助判斷二級索引記錄的可見性。

page_level

該page所在的btree level,根節點的level最大,葉子節點的level為0

page_index_id

該page歸屬的索引id

segment info

随後20個位元組描述段資訊,僅在btree的root page中被設定,其他page都是未使用的。

page_btr_seg_leaf

10(fseg_header_size)

leaf segment在inode page中的位置

page_btr_seg_top

non-leaf segment在inode page中的位置

10個位元組的inode資訊包括:

fseg_hdr_space

描述該segment的inode page所在的space id (目前的實作來看,感覺有點多餘...)

fseg_hdr_page_no

描述該segment的inode page的page no

fseg_hdr_offset

inode page内的頁内偏移量

通過上述資訊,我們可以找到對應segment在inode page中的描述項,進而可以操作整個segment。

系統記錄

之後是兩個系統記錄,分别用于描述該page上的極小值和極大值,這裡存在兩種存儲方式,分别對應舊的innodb檔案系統,及新的檔案系統(compact page)

rec_n_old_extra_bytes + 1

7

固定值,見infimum_supremum_redundant的注釋

page_old_infimum

"infimum0"

page_old_supremum

9

"supremum0"

compact的系統記錄存儲方式為:

rec_n_new_extra_bytes

5

固定值,見infimum_supremum_compact的注釋

page_new_infimum

page_new_supremum

"supremum",這裡不帶字元0

兩種格式的主要差異在于不同行存儲模式下,單個記錄的描述資訊不同。在實際建立page時,系統記錄的值已經初始化好了,對于老的格式(redundant),對應代碼裡的<code>infimum_supremum_redundant</code>,對于新的格式(compact),對應<code>infimum_supremum_compact</code>。infimum記錄的固定heap no為0,supremum記錄的固定heap no 為1。page上最小的使用者記錄前節點總是指向infimum,page上最大的記錄後節點總是指向supremum記錄。

具體參考索引頁建立函數:<code>page_create_low</code>

使用者記錄

在系統記錄之後就是真正的使用者記錄了,heap no 從2(page_heap_no_user_low)開始算起。注意heap no僅代表實體存儲順序,不代表鍵值順序。

根據不同的類型,使用者記錄可以是非葉子節點的node指針資訊,也可以是隻包含有效資料的葉子節點記錄。而不同的行格式存儲的行記錄也不同,例如在早期版本中使用的redundant格式會被現在的compact格式使用更多的位元組數來描述記錄,例如描述記錄的一些列資訊,在使用compact格式時,可以改為直接從資料詞典擷取。因為redundant屬于漸漸被抛棄的格式,本文的讨論中我們預設使用compact格式。在檔案rem/rem0rec.cc的頭部注釋描述了記錄的實體結構。

每個記錄都存在rec header,描述如下(參閱檔案include/rem0rec.ic)

變長列長度數組

如果列的最大長度為255位元組,使用1byte;否則,0xxxxxxx (one byte, length=0..127), or 1exxxxxxxxxxxxxx (two bytes, length=128..16383, extern storage flag)

sql-null flag

标示值為null的列的bitmap,每個位标示一個列,bitmap的長度取決于索引上可為null的列的個數(dict_index_t::n_nullable),這兩個數組的解析可以參閱函數<code>rec_init_offsets</code>

下面5個位元組(rec_n_new_extra_bytes)描述記錄的額外資訊

....

rec_new_info_bits (4 bits)

目前隻使用了兩個bit,一個用于表示該記錄是否被标記删除(<code>rec_info_deleted_flag</code>),另一個bit(rec_info_min_rec_flag)如果被設定,表示這個記錄是目前level最左邊的page的第一個使用者記錄

rec_new_n_owned (4 bits)

當該值為非0時,表示目前記錄占用page directory裡一個slot,并和前一個slot之間存在這麼多個記錄

rec_new_heap_no (13 bits)

該記錄的heap no

rec_new_status (3 bits)

記錄的類型,包括四種:<code>rec_status_ordinary</code>(葉子節點記錄), <code>rec_status_node_ptr</code>(非葉子節點記錄),<code>rec_status_infimum</code>(infimum系統記錄)以及<code>rec_status_supremum</code>(supremum系統記錄)

rec_next (2bytes)

指向按照鍵值排序的page内下一條記錄資料起點,這裡存儲的是和目前記錄的相對位置偏移量(函數<code>rec_set_next_offs_new</code>)

在記錄頭資訊之後的資料視具體情況有所不同:

對于聚集索引記錄,資料包含了事務id,復原段指針;

對于二級索引記錄,資料包含了二級索引鍵值以及聚集索引鍵值。如果二級索引鍵和聚集索引有重合,則隻保留一份重合的,例如pk (col1, col2),sec key(col2, col3),在二級索引記錄中就隻包含(col2, col3, col1);

對于非葉子節點頁的記錄,聚集索引上包含了其子節點的最小記錄鍵值及對應的page no;二級索引上有所不同,除了二級索引鍵值外,還包含了聚集索引鍵值,再加上page no三部分構成。

free space

這裡指的是一塊完整的未被使用的空間,範圍在頁内最後一個使用者記錄和page directory之間。通常如果空間足夠時,直接從這裡配置設定記錄空間。當判定空閑空間不足時,會做一次page内的重整理,以對碎片空間進行合并。

page directory

為了加快頁内的資料查找,會按照記錄的順序,每隔4~8個數量(page_dir_slot_min_n_owned ~ page_dir_slot_max_n_owned)的使用者記錄,就配置設定一個slot (每個slot占用2個位元組,<code>page_dir_slot_size</code>),存儲記錄的頁内偏移量,可以了解為在頁内建構的一個很小的索引(sparse index)來輔助二分查找。

page directory的slot配置設定是從page末尾(倒數第八個位元組開始)開始逆序配置設定的。在查詢記錄時。先根據page directory 确定記錄所在的範圍,然後在據此進行線性查詢。

增加slot的函數參閱 <code>page_dir_add_slot</code>

頁内記錄二分查找的函數參閱 <code>page_cur_search_with_match_bytes</code>

fil trailer

在每個檔案頁的末尾保留了8個位元組(fil_page_data_end or fil_page_end_lsn_old_chksum),其中4個位元組用于存儲page checksum,這個值需要和page頭部記錄的checksum相比對,否則認為page損壞(<code>buf_page_is_corrupted</code>)

innodb目前存在兩種形式的壓縮頁,一種是transparent page compression,還有一種是傳統的壓縮方式,下文分别進行闡述。

這是mysql5.7新加的一種資料壓縮方式,其原理是利用核心punch hole特性,對于一個16kb的資料頁,在寫檔案之前,除了page頭之外,其他部分進行壓縮,壓縮後留白的地方使用punch hole進行 “打洞”,在磁盤上表現為不占用空間 (但會産生大量的磁盤碎片)。 這種方式相比傳統的壓縮方式具有更好的壓縮比,實作邏輯也更加簡單。

對于這種壓縮方式引入了新的類型<code>fil_page_compressed</code>,在存儲格式上略有不同,主要表現在從fil_page_file_flush_lsn開始的8個位元組被用作記錄壓縮資訊:

fil_page_version

版本,目前為1

fil_page_algorithm_v1

使用的壓縮算法

fil_page_original_type_v1

壓縮前的page類型,解壓後需要恢複回去

fil_page_original_size_v1

未壓縮時去除fil_page_data後的資料長度

fil_page_compress_size_v1

壓縮後的長度

打洞後的page其實際存儲空間需要是磁盤的block size的整數倍。

這裡我們不展開闡述,具體參閱我之前寫的這篇文章:mysql · 社群動态 · innodb page compression

當你建立或修改表,指定<code>row_format=compressed key_block_size=1|2|4|8</code> 時,建立的ibd檔案将以對應的block size進行劃分。例如key_block_size設定為4時,對應block size為4kb。

壓縮頁的格式可以描述如下表所示:

fil_page_header

頁面頭資料,不做壓縮

index field information

索引的列資訊,參閱函數<code>page_zip_fields_encode</code>及<code>page_zip_fields_decode</code>,在崩潰恢複時可以據此恢複出索引資訊

compressed data

壓縮資料,按照heap no排序進入壓縮流,壓縮資料不包含系統列(trx_id, roll_ptr)或外部存儲頁指針

modification log(mlog)

壓縮頁修改日志

空閑空間

external_ptr (optional)

存在外部存儲頁的列記錄指針數組,隻存在聚集索引葉子節點,每個數組元素占20個位元組(<code>btr_extern_field_ref_size</code>),參閱函數<code>page_zip_compress_clust_ext</code>

trx_id, roll_ptr(optional)

隻存在于聚集索引葉子節點,數組元素和其heap no一一對應

node_ptr

隻存在于索引非葉子節點,存儲節點指針數組,每個元素占用4位元組(rec_node_ptr_size)

dense page directory

分兩部分,第一部分是有效記錄,記錄其在解壓頁中的偏移位置,n_owned和delete标記資訊,按照鍵值順序;第二部分是空閑記錄;每個slot占兩個位元組。

在記憶體中通常存在壓縮頁和解壓頁兩份資料。當對資料進行修改時,通常先修改解壓頁,再将dml操作以一種特殊日志的格式記入壓縮頁的mlog中。以減少被修改過程中重壓縮的次數。主要包含這幾種操作:

insert: 向mlog中寫入完整記錄

update:

delete-insert update,将舊記錄的dense slot标記為删除,再寫入完整新記錄

in-place update,直接寫入新更新的記錄

delete: 标記對應的dense slot為删除

頁壓縮參閱函數 <code>page_zip_compress</code>

頁解壓參閱函數 <code>page_zip_decompress</code>

這裡我們将所有非獨立的資料頁統稱為系統資料頁,主要存儲在ibdata中,如下圖所示:

MySQL · 引擎特性 · InnoDB檔案系統管理

ibdata的三個page和普通的使用者表空間一樣,都是用于維護和管理檔案頁。其他page我們下面一一進行介紹。

fsp_ibuf_header_page_no

ibdata的第4個page是change buffer的header page,類型為fil_page_type_sys,主要用于對ibuf btree的page管理。

fsp_ibuf_tree_root_page_no

用于存儲change buffer的根page,change buffer目前存儲于ibdata中,其本質上也是一顆btree,root頁為固定page,也就是ibdata的第5個page。

ibuf header page 和root page聯合起來對ibuf的資料頁進行管理。

首先ibuf btree自己維護了一個空閑page連結清單,連結清單頭記錄在根節點中,偏移量在page_btr_ibuf_free_list處,實際上利用的是普通索引根節點的page_btr_seg_leaf字段。free list上的page類型标示為<code>fil_page_ibuf_free_list</code>

每個ibuf page重用了page_btr_seg_leaf字段,以維護ibuf free list的前後檔案頁節點(page_btr_ibuf_free_list_node)

由于root page中的segment字段已經被重用,是以額外的開辟了一個page,也就是ibdata的第4個page來進行段管理。在其中記錄了ibuf btree的segment header,指向屬于ibuf btree的inode entry。

關于ibuf btree的建構參閱函數 <code>btr_create</code>

fsp_trx_sys_page_no/fsp_first_rseg_page_no

ibdata的第6個page,記錄了innodb重要的事務系統資訊,主要包括:

trx_sys

38

每個資料頁都會保留的檔案頭字段

trx_sys_trx_id_store

持久化的最大事務id,這個值不是實時寫入的,而是256次遞增寫一次

trx_sys_fseg_header

10

指向用來管理事務系統的segment所在的位置

trx_sys_rsegs

128 * 8

用于存儲128個復原段位置,包括space id及page no。每個復原段包含一個檔案segment(<code>trx_rseg_header_create</code>)

以下是page内univ_page_size - 1000的偏移位置

trx_sys_mysql_log_magic_n_fld

magic num ,值為873422344

trx_sys_mysql_log_offset_high

事務送出時會将其binlog位點更新到該page中,這裡記錄了在binlog檔案中偏移量的高位的4位元組

trx_sys_mysql_log_offset_low

同上,記錄偏移量的低4位位元組

trx_sys_mysql_log_name

記錄所在的binlog檔案名

以下是page内univ_page_size - 200 的偏移位置

trx_sys_doublewrite_fseg

包含double write buffer的fseg header

trx_sys_doublewrite_magic

magic num

trx_sys_doublewrite_block1

double write buffer的第一個block(占用一個extent)在ibdata中的開始位置,連續64個page

trx_sys_doublewrite_block2

第二個dblwr block的起始位置

trx_sys_doublewrite_repeat

重複記錄上述三個字段,即magic num, block1, block2,防止發生部分寫時可以恢複

trx_sys_doublewrite_space_id_stored

用于相容老版本,當該字段的值不為trx_sys_doublewrite_space_id_stored_n時,需要重置dblwr中的資料

在5.7版本中,復原段既可以在ibdata中,也可以在獨立undo表空間,或者ibtmp臨時表空間中,一個可能的分布如下圖所示(摘自我之前的這篇文章)。

MySQL · 引擎特性 · InnoDB檔案系統管理

由于是在系統剛啟動時初始化事務系統,是以第0号復原段頭頁總是在ibdata的第7個page中。

事務系統建立參閱函數 <code>trx_sysf_create</code>

innodb最多可以建立128個復原段,每個復原段需要單獨的page來維護其擁有的undo slot,page類型為fil_page_type_sys。描述如下:

trx_rseg

保留的page頭

trx_rseg_max_size

復原段允許使用的最大page數,目前值為ulint_max

trx_rseg_history_size

在history list上的undo page數,這些page需要由purge線程來進行清理和回收

trx_rseg_history

flst_base_node_size(16)

history list的base node

trx_rseg_fseg_header

(fseg_header_size)10

指向目前管理目前復原段的inode entry

trx_rseg_undo_slots

1024 * 4

undo slot數組,共1024個slot,值為fil_null表示未被占用,否則記錄占用該slot的第一個undo page

復原段頭頁的建立參閱函數 <code>trx_rseg_header_create</code>

實際存儲undo記錄的page類型為fil_page_undo_log,undo header結構如下

trx_undo_page_hdr

page 頭

trx_undo_page_type

記錄undo類型,是trx_undo_insert還是trx_undo_update

trx_undo_page_start

事務所寫入的最近的一個undo log在page中的偏移位置

trx_undo_page_free

指向目前undo page中的可用的空閑空間起始偏移量

trx_undo_page_node

連結清單節點,送出後的事務,其擁有的undo頁會加到history list上

undo頁内結構及其與復原段頭頁的關系參閱下圖:

MySQL · 引擎特性 · InnoDB檔案系統管理

關于具體的undo log如何存儲,本文不展開描述,可閱讀我之前的這篇文章:mysql · 引擎特性 · innodb undo log 漫遊

fsp_dict_hdr_page_no

ibdata的第8個page,用來存儲資料詞典表的資訊 (隻有拿到資料詞典表,才能根據其中存儲的表資訊,進一步找到其對應的表空間,以及表的聚集索引所在的page no)

dict_hdr page的結構如下表所示:

dict_hdr

page頭

dict_hdr_row_id

最近被指派的row id,遞增,用于給未定義主鍵的表,作為其隐藏的主鍵鍵值來建構btree

dict_hdr_table_id

目前系統配置設定的最大事務id,每建立一個新表,都賦予一個唯一的table id,然後遞增

dict_hdr_index_id

用于配置設定索引id

dict_hdr_max_space_id

用于配置設定space id

dict_hdr_mix_id_low

dict_hdr_tables

sys_tables系統表的聚集索引root page

dict_hdr_table_ids

sys_table_ids索引的root page

dict_hdr_columns

sys_columns系統表的聚集索引root page

dict_hdr_indexes

sys_indexes系統表的聚集索引root page

dict_hdr_fields

sys_fields系統表的聚集索引root page

dict_hdr頁的建立參閱函數 <code>dict_hdr_create</code>

double write buffer

innodb使用double write buffer來防止資料頁的部分寫問題,在寫一個資料頁之前,總是先寫double write buffer,再寫資料檔案。當崩潰恢複時,如果資料檔案中page損壞,會嘗試從dblwr中恢複。

double write buffer存儲在ibdata中,你可以從事務系統頁(ibdata的第6個page)擷取dblwr所在的位置。總共128個page,劃分為兩個block。由于dblwr在安裝執行個體時已經初始化好了,這兩個block在ibdata中具有固定的位置,page64 ~127 劃屬第一個block,page 128 ~191劃屬第二個block。

在這128個page中,前120個page用于batch flush時的髒頁回寫,另外8個page用于single page flush時的髒頁回寫。

對于大字段,在滿足一定條件時innodb使用外部頁進行存儲。外部存儲頁有三種類型:

fil_page_type_blob:表示非壓縮的外部存儲頁,結構如下圖所示:

MySQL · 引擎特性 · InnoDB檔案系統管理

fil_page_type_zblob:壓縮的外部存儲頁,如果存在多個blob page,則表示第一個

fil_page_type_zblob2:如果存在多個壓縮的blob page,則表示blob鍊随後的page;

結構如下圖所示:

MySQL · 引擎特性 · InnoDB檔案系統管理

而在記錄内隻存儲了20個位元組的指針以指向外部存儲頁,指針描述如下:

btr_extern_space_id

外部存儲頁所在的space id

btr_extern_page_no

第一個外部頁的page no

btr_extern_offset

對于壓縮頁,為12,該偏移量存儲了指向下一個外部頁的的page no;對于非壓縮頁,值為38,指向blob header,如上圖所示

外部頁的寫入參閱函數 <code>btr_store_big_rec_extern_fields</code>

mysql 5.7版本引入了新的資料頁以支援表空間加密及對空間資料類型建立r-tree索引。本文對這種資料頁不做深入讨論,僅僅簡單描述下,後面我們會單獨開兩篇文章分别進行介紹。

資料加密頁

從mysql5.7.11開始innodb支援對單表進行加密,是以引入了新的page類型來支援這一特性,主要加了三種page類型:

fil_page_encrypted:加密的普通資料頁

fil_page_compressed_and_encrypted:資料頁為壓縮頁(transparent page compression) 并且被加密(先壓縮,再加密)

fil_page_encrypted_rtree:gis索引r-tree的資料頁并被加密

對于加密頁,除了資料部分被替換成加密資料外,其他部分和大多數表都是一樣的結構。

加解密的邏輯和transparent compression類似,在寫入檔案前加密(<code>os_file_encrypt_page --&gt; encryption::encrypt</code>),在讀出檔案時解密資料(<code>os_file_io_complete --&gt; encryption::decrypt</code>)

秘鑰資訊存儲在ibd檔案的第一個page中(<code>fsp_header_init --&gt; fsp_header_fill_encryption_info</code>),當執行sql <code>alter instance rotate innodb master key</code>時,會更新每個ibd存儲的秘鑰資訊(<code>fsp_header_rotate_encryption</code>)

預設安裝時,一個新的插件keyring_file被安裝并且預設active,在安裝目錄下,會産生一個新的檔案來存儲秘鑰,位置在$mysql_install_dir/keyring/keyring,你可以通過參數keyring_file_data來指定秘鑰的存放位置和檔案命名。 當你安裝多執行個體時,需要為不同的執行個體指定keyring檔案。

開啟表加密的文法很簡單,在create table或alter table時指定選項encryption=‘y’來開啟,或者encryption=‘n’來關閉加密。

關于innodb表空間加密特性,參閱該commit及官方文檔

r-tree索引頁

在mysql 5.7中引入了新的索引類型r-tree來描述空間資料類型的多元資料結構,這類索引的資料頁類型為<code>fil_page_rtree</code>。

r-tree的相關設計參閱官方wl#6968, wl#6609, wl#6745

mysql5.7引入了臨時表專用的表空間,預設命名為ibtmp1,建立的非壓縮臨時表都存儲在該表空間中。系統重新開機後,ibtmp1會被重新初始化到預設12mb。你可以通過設定參數innodb_temp_data_file_path來修改ibtmp1的預設初始大小,以及是否允許autoextent。預設值為 ”ibtmp1:12m:autoextent“

除了使用者定義的非壓縮臨時表外,第1~32個臨時表專用的復原段也存放在該檔案中(0号復原段總是存放在ibdata中)(<code>trx_sys_create_noredo_rsegs</code>),

關于日志檔案的格式,網上已經有很多的讨論,在之前的系列文章中我也有專門介紹過,本小節主要介紹下mysql5.7新的修改。

首先是checksum算法的改變,目前版本的mysql5.7可以通過參數innodb_log_checksums來開啟或關閉redo checksum,但目前唯一支援的checksum算法是crc32。而在之前老版本中隻支援效率較低的innodb本身的checksum算法。

第二個改變是為redo log引入了版本資訊(wl#8845),存儲在ib_logfile的頭部,從檔案頭開始,描述如下

log_header_format

目前值為1(log_header_format_current),在老版本中這裡的值總是為0

log_header_pad1

新版本未使用

log_header_start_lsn

目前iblogfile的開始lsn

log_header_creator

32

記錄版本資訊,和mysql版本相關,例如在5.7.11中,這裡存儲的是"mysql 5.7.11"(log_header_creator_current)

每次切換到下一個iblogfile時,都會更新該檔案頭資訊(<code>log_group_file_header_flush</code>)

新的版本支援相容老版本(<code>recv_find_max_checkpoint_0</code>),但更新到新版本後,就無法在異常狀态下in-place降級到舊版本了(除非做一次clean的shutdown,并清理掉iblogfile)。

具體實作參閱該commit

本小節我們介紹下磁盤檔案與記憶體資料的中樞,即io子系統。innodb對page的磁盤操作分為讀操作和寫操作。

對于讀操作,在将資料讀入磁盤前,總是為其先預先配置設定好一個block,然後再去磁盤讀取一個新的page,在使用這個page之前,還需要檢查是否有change buffer項,并根據change buffer進行資料變更。讀操作分為兩種場景:普通的讀page及預讀操作,前者為同步讀,後者為異步讀

資料寫操作也分為兩種,一種是batch write,一種是single page write。寫page預設受double write buffer保護,是以對double write buffer的寫磁盤為同步寫,而對資料檔案的寫入為異步寫。

同步讀寫操作通常由使用者線程來完成,而異步讀寫操作則需要背景線程的協同。

舉個簡單的例子,假設我們向磁盤批量寫資料,首先先寫到double write buffer,當dblwr滿了之後,一次性将dblwr中的資料同步刷到ibdata,在確定sync到dblwr後,再将這些page分别異步寫到各自的檔案中。注意這時候dblwr依舊未被清空,新的寫page請求會進入等待。當異步寫page完成後,io helper線程會調用buf_flush_write_complete,将寫入的page從flush list上移除。當dblwr中的page完全寫完後,在函數buf_dblwr_update裡将dblwr清空。這時候才允許新的寫請求進dblwr。

同樣的,對于異步寫操作,也需要io helper線程來檢查page是否完好、merge change buffer等一系列操作。

除了資料頁的寫入,還包括日志異步寫入線程、及ibuf背景線程。

innodb的io背景線程主要包括如下幾類:

io read 線程: 背景讀線程,線程數目通過參數innodb_read_io_threads配置,主要處理innodb 資料檔案異步讀請求,任務隊列為<code>aio::s_reads</code>,任務隊列包含slot數為線程數 * 256(linux 平台),也就是說,每個read線程最多可以pend 256個任務;

io write 線程: 背景寫線程數,線程數目通過參數innodb_write_io_threads配置。主要處理innodb 資料檔案異步寫請求,任務隊列為<code>aio::s_writes</code>,任務隊列包含slot數為線程數 * 256(linux 平台),也就是說,每個read線程最多可以pend 256個任務;

log 線程:寫日志線程。隻有在寫checkpoint資訊時才會發出一次異步寫請求。任務隊列為<code>aio::s_log</code>,共1個segment,包含256個slot;

ibuf 線程:負責讀入change buffer頁的背景線程,任務隊列為<code>aio::s_ibuf</code>,共1個segment,包含256個slot

所有的同步寫操作都是由使用者線程或其他背景線程執行。上述io線程隻負責異步操作。

入口函數:<code>os_aio_func</code>

首先對于同步讀寫請求(os_aio_sync),發起請求的線程直接調用os_file_read_func 或者os_file_write_func 去讀寫檔案 ,然後傳回。

對于異步請求,使用者線程從對應操作類型的任務隊列(aio::select_slot_array)中選取一個slot,将需要讀寫的資訊存儲于其中(aio::reserve_slot):

首先在任務隊列數組中選擇一個segment;這裡根據偏移量來算segment,是以可以盡可能的将相鄰的讀寫請求放到一起,這有利于在io層的合并操作

從該segment範圍内選擇一個空閑的slot,如果沒有則等待;

将對應的檔案讀寫請求資訊指派到slot中,例如寫入的目标檔案,偏移量,資料等;

如果這是一次io寫入操作,且使用native aio時,如果表開啟了transparent compression,則對要寫入的資料頁先進行壓縮并punch hole;如果設定了表空間加密,再對資料頁進行加密;

對于native aio (使用linux自帶的libaio庫),調用函數aio::linux_dispatch,将io請求分發給kernel層。

如果沒有開啟native aio,且沒有設定wakeup later 标記,則會去喚醒io線程(aio::wake_simulated_handler_thread),這是早期libaio還不成熟時,innodb在内部模拟aio實作的邏輯。

tips:編譯native aio需要安裝libaio-dev包,并打開選項srv_use_native_aio

io線程入口函數為<code>io_handler_thread --&gt; fil_aio_wait</code>

首先調用<code>os_aio_handler</code>來擷取請求:

對于native aio,調用函數os_aio_linux_handle 擷取讀寫請求。 io線程會反複以500ms(<code>os_aio_reap_timeout</code>)的逾時時間通過io_getevents确認是否有任務已經完成了(<code>linuxaiohandler::collect()</code>),如果有讀寫任務完成,找到已完成任務的slot後,釋放對應的槽位。

對于simulated aio,調用函數<code>os_aio_simulated_handler</code> 處理讀寫請求,這裡相比native aio要複雜很多

如果這是異步讀隊列,并且<code>os_aio_recommend_sleep_for_read_threads</code>被設定,則暫時不處理,而是等待一會,讓其他線程有機會将更過的io請求發送過來。目前linear readhaed 會使用到該功能。這樣可以得到更好的io合并效果。(<code>simulatedaiohandler::check_pending</code>)

已經完成的slot需要及時被處理(<code>simulatedaiohandler::check_completed</code>,可能由上次的io合并操作完成)

如果有超過2秒未被排程的請求(<code>simulatedaiohandler::select_oldest</code>),則優先選擇最老的slot,防止餓死,否則,找一個檔案讀寫偏移量最小的位置的slot(simulatedaiohandler::select())

沒有任何請求時進入等待狀态

當找到一個未完成的slot時,會嘗試merge相鄰的io請求(<code>simulatedaiohandler::merge()</code>),并将對應的slot加入到simulatedaiohandler::m_slots數組中,最多不超過64個slot

然而在5.7版本裡,合并操作已經被禁止了,全部改成了一個個slot進行讀寫,更新到5.7的使用者一定要注意這個改變,或者改為使用更好的native aio方式

完成io後,釋放slot; 并選擇第一個處理完的slot作為随後優先完成的請求。

從上一步獲得完成io的slot後,調用函數<code>fil_node_complete_io</code>, 遞減<code>node-&gt;n_pending</code>。 對于檔案寫操作,需要加入到fil_system-&gt;unflushed_spaces連結清單上,表示這個檔案修改過了,後續需要被sync到磁盤。

如果設定為o_direct_no_fsync的檔案io模式,則資料檔案無需加入到fil_system_t::unflushed_spaces連結清單上。通常我們即時使用o_direct的方式操作檔案,也需要做一次sync,來保證檔案中繼資料的持久化,但在某些檔案系統下則沒有這個必要,通常隻要檔案的大小這些關鍵中繼資料沒發生變化,可以省略一次fsync。

最後在io完成後,調用<code>buf_page_io_complete</code>,做page corruption檢查、change buffer merge等操作;對于寫操作,需要從flush list上移除block并更新double write buffer;對于lru flush産生的寫操作,還會将其對應的block釋放到free list上;

對于日志檔案操作,調用<code>log_io_complete</code>執行一次fil_flush,并更新記憶體内的checkpoint資訊(<code>log_complete_checkpoint</code>)

由于檔案底層使用pwrite/pread來進行檔案i/o,是以使用者線程對檔案普通的并發i/o操作無需加鎖。但在windows平台下,則需要加鎖進行讀寫。

對相同檔案的io操作通過大量的counter/flag來進行并發控制。

當檔案處于擴充階段時(<code>fil_space_extend</code>),将<code>fil_node_t::being_extended</code>設定為true,避免産生并發extent,或其他關閉檔案或者rename操作等

當正在删除一個表時,會檢查是否有pending的操作(fil_check_pending_operations)

将fil_space_t::stop_new_ops設定為true;

檢查是否有pending的change buffer merge (fil_space_t::n_pending_ops);有則等待

檢查是否有pending的io(fil_node_t::n_pending) 或者pending的檔案flush操作(<code>fil_node_t::n_pending_flushes</code>);有則等待

當truncate一張表時,和drop table類似,也會調用函數<code>fil_check_pending_operations</code>,檢查表上是否有pending的操作,并将<code>fil_space_t::is_being_truncated</code>設定為true

當rename一張表時(<code>fil_rename_tablespace</code>),将檔案的stop_ios标記設定為true,阻止其他線程所有的i/o操作

當進行檔案讀寫操作時,如果是異步讀操作,發現stop_new_ops或者被設定了但is_being_truncated未被設定,會傳回報錯;但依然允許同步讀或異步寫操作(<code>fil_io</code>)

當進行檔案flush操作時,如果發現<code>fil_space_t::stop_new_ops</code>或者<code>fil_space_t::is_being_truncated</code>被設定了,則忽略該檔案的flush操作 (<code>fil_flush_file_spaces</code>)

檔案預讀是一項在ssd普及前普通磁盤上比較常見的技術,通過預讀的方式進行連續io而非帶價高昂的随機io。innodb有兩種預讀方式:随機預讀及線性預讀; facebook另外還實作了一種邏輯預讀的方式

随機預讀

入口函數:<code>buf_read_ahead_random</code>

以64個page為機關(這也是一個extent的大小),目前讀入的page no所在的64個pagno 區域[ (page_no/64)64, (page_no/64) 64 + 64],如果最近被通路的page數超過buf_read_ahead_random_threshold(通常值為13),則将其他page也讀進記憶體。這裡采取異步讀。

随機預讀受參數innodb_random_read_ahead控制

線性預讀

入口函數:<code>buf_read_ahead_linear</code>

所謂線性預讀,就是在讀入一個新的page時,和随機預讀類似的64個連續page範圍内,預設從低到高page no,如果最近連續被通路的page數超過innodb_read_ahead_threshold,則将該extent之後的其他page也讀取進來。

邏輯預讀

由于表可能存在碎片空間,是以很可能對于諸如全表掃描這樣的場景,連續讀取的page并不是實體連續的,線性預讀不能解決這樣的問題,另外一次讀取一個extent對于需要全表掃描的負載并不足夠。是以facebook引入了邏輯預讀。

其大緻思路為,掃描聚集索引,搜集葉子節點号,然後根據葉子節點的page no (可以從非葉子節點擷取)順序異步讀入一定量的page。

由于innodb aio一次隻支援送出一個page讀請求,雖然kernel層本身會做讀請求合并,但那顯然效率不夠高。他們對此做了修改,使innodb可以支援一次送出(io_submit)多個aio請求。

入口函數:<code>row_search_for_mysql --&gt; row_read_ahead_logical</code>

具體參閱這篇博文

或者webscalesql上的幾個commit:

由于現代磁盤通常的block size都是大于512位元組的,例如一般是4096位元組,為了避免 “read-on-write” 問題,在5.7版本裡添加了一個參數innodb_log_write_ahead_size,你可以通過配置該參數,在寫入redo log時,将寫入區域配置到block size對齊的位元組數。

在代碼裡的實作,就是在寫入redo log 檔案之前,為尾部位元組填充0,(參考函數log_write_up_to)

tips:所謂read-on-write問題,就是當修改的位元組不足一個block時,需要将整個block讀進記憶體,修改對應的位置,然後再寫進去;如果我們以block為機關來寫入的話,直接完整覆寫寫入即可。

innodb buffer pool從5.6到5.7版本發生了很大的變化。首先是配置設定方式上不同,其次實作了更好的刷髒效率。對buffer pool上的各個連結清單的管理也更加高效。

在5.7之前的版本中,一個buffer pool instance擁有一個chunk,每個chunk的大小為buffer pool size / instance個數。

而到了5.7版本中,每個instance可能劃分成多個chunk,每個chunk的大小是可定義的,預設為127mb。是以一個buffer pool instance可能包含多個chunk記憶體塊。這麼做的目的是為了實作線上調整buffer pool大小(wl#6117),buffer pool增加或減少必須以chunk為基本機關進行。

在5.7裡有個問題值得關注,即buffer pool size會根據instances * chunk size向上對齊,舉個簡單的例子,假設你配置了64個instance, chunk size為預設128mb,就需要以8gb進行對齊,這意味着如果你配置了9gb的buffer pool,實際使用的會是16gb。是以盡量不要配置太多的buffer pool instance

出于不同的目的,每個buffer pool instance上都維持了多個連結清單,可以根據space id及page no找到對應的instance(<code>buf_pool_get</code>)。

一些關鍵的結構對象及描述如下表所示:

name

buf_pool_t::page_hash

page_hash用于存儲已經或正在讀入記憶體的page。根據快速查找。當不在page hash時,才會去嘗試從檔案讀取

buf_pool_t::lru

lru上維持了所有從磁盤讀入的資料頁,該lru上又在連結清單尾部開始大約3/8處将連結清單劃分為兩部分,新讀入的page被加入到這個位置;當我們設定了innodb_old_blocks_time,若兩次通路page的時間超過該閥值,則将其挪動到lru頭部;這就避免了類似一次性的全表掃描操作導緻buffer pool污染

buf_pool_t::free

存儲了目前空閑可配置設定的block

buf_pool_t::flush_list

存儲了被修改過的page,根據oldest_modification(即載入記憶體後第一次修改該page時的redo lsn)排序

buf_pool_t::flush_rbt

在崩潰恢複階段在flush list上建立的紅黑數,用于将apply redo後的page快速的插入到flush list上,以保證其有序

buf_pool_t::unzip_lru

壓縮表上解壓後的page被存儲到unzip_lru。 buf_block_t::frame存儲解壓後的資料,buf_block_t::page-&gt;zip.data指向原始壓縮資料。

buf_pool_t::zip_free[buf_buddy_sizes_max]

用于管理壓縮頁産生的空閑碎片page。壓縮頁占用的記憶體采用buddy allocator算法進行配置設定。

除了不同的使用者線程會并發操作buffer pool外,還有背景線程也會對buffer pool進行操作。innodb通過讀寫鎖,buf fix計數,io fix标記來進行并發控制。

讀寫并發控制

通常當我們讀取到一個page時,會對其加block s鎖,并遞增buf_page_t::buf_fix_count,直到mtr commit時才會恢複。而如果讀page的目的是為了進行修改,則會加x鎖

當一個page準備flush到磁盤時(<code>buf_flush_page</code>),如果目前page正在被通路,其buf_fix_count不為0時,就忽略flush該page,以減少擷取block上sx lock的昂貴代價。

并發讀控制

當多個線程請求相同的page時,如果page不在記憶體,是否可能引發對同一個page的檔案io ?答案是不會。

從函數<code>buf_page_init_for_read</code>我們可以看到,在準備讀入一個page前,會做如下工作:

配置設定一個空閑block

buf_pool_mutex_enter

持有page_hash x lock

檢查page_hash中是否已被讀入,如果是,表示另外一個線程已經完成了io,則忽略本次io請求,退出

持有block-&gt;mutex,對block進行初始化,并加入到page hash中

設定io fix為buf_io_read

釋放hash lock

将block加入到lru上

持有block s lock

完成io後,釋放s lock

當另外一個線程也想請求相同page時,首先如果看到page hash中已經有對應的block了,說明page已經或正在被讀入buffer pool,如果io_fix為buf_io_read,說明正在進行io,就通過加x鎖的方式做一次sync(<code>buf_wait_for_read</code>),確定io完成。

請求page通常還需要加s或x鎖,而io期間也是持有block x鎖的,如果成功擷取了鎖,說明io肯定完成了。

當buffer pool中的free list不足時,為了擷取一個空閑block,通常會觸發page驅逐操作(<code>buf_lru_free_from_unzip_lru_list</code>)

首先由于壓縮頁在記憶體中可能存在兩份拷貝:壓縮頁和解壓頁;innodb根據最近的io情況和資料解壓技術來判定執行個體是處于io-bound還是cpu-bound(<code>buf_lru_evict_from_unzip_lru</code>)。如果是io-bound的話,就嘗試從unzip_lru上釋放一個block出來(<code>buf_lru_free_from_unzip_lru_list</code>),而壓縮頁依舊儲存在記憶體中。

其次再考慮從buf_pool_t::lru連結清單上釋放block,如果有可替換的page(<code>buf_flush_ready_for_replace</code>)時,則将其釋放掉,并加入到free list上;對于壓縮表,壓縮頁和解壓頁在這裡都會被同時驅逐。

當無法從lru上獲得一個可替換的page時,說明目前buffer pool可能存在大量髒頁,這時候會觸發single page flush(<code>buf_flush_single_page_from_lru</code>),即使用者線程主動去刷一個髒頁并替換掉。這是個慢操作,尤其是如果并發很高的時候,可能觀察到系統的性能急劇下降。在rds mysql中,我們開啟了一個背景線程, 能夠自動根據目前free list的長度來主動做flush,避免使用者線程陷入其中。

除了single page flush外,在mysql 5.7版本裡還引入了多個page cleaner線程,根據一定的啟發式算法,可以定期且高效的的做page flush操作。

本文對此不展開讨論,感興趣的可以閱讀我之前的月報:

mysql · 性能優化· 5.7.6 innodb page flush 優化

mysql · 性能優化· innodb buffer pool flush政策漫談

繼續閱讀