Innodb頁合並和頁分裂


作者:Marco Tusa 、 Sri Sakthivel 譯者:孟維克,知數堂優秀校友 原文鏈接:

  • https://www.percona.com/blog/2017/04/10/innodb-page-merging-and-page-splitting/
  • https://www.percona.com/blog/2020/06/24/mysql-table-fragmentation-beware-of-bulk-insert-with-failure-or-rollback/

InnoDB頁合並和頁分裂

如果您遇到全球少數的MySQL顧問之一,請他審核您的SQL語句和表結構設計,我相信他會告訴您一些有關好的主鍵設計的重要性。特別是對InnoDB,我相信他已經想您解釋了索引合並和頁分裂。這兩個概念與性能密切相關,在設計任意索引(不僅僅是主鍵)時都應該考慮這方面因素。

對您來說,這聽起來可能有點胡言亂語,也許您是對的。這不是一件容易的事情,尤其是在討論內部原理時。這不是您經常要處理的事情,而且通常您根本不想處理它。

但有時這是必要的。如果是這樣,這篇文章就是為您准備的。

在這篇文章中,我想解釋一些InnoDB幕后操作中最不清楚的部分:索引頁創建、頁合並和頁分裂。

在InnoDB中,所有的數據就是一個索引。您可能也聽過,對吧?但這到底是什么意思呢?

表&文件

假設您已經安裝了MySQL,5.7最新版本,您在windmills schema中有一個名為wmills的表。在數據目錄中(通常是/var/lib/mysql/)您會看到它包含有:

data/
  windmills/
      wmills.ibd
      wmills.frm

這是因為參數innodb_file_per_table從MySQL5.6開始已經設置為1。這樣設置,schema中每個表都是一個文件(如果是分區表,則有多個文件)。

這里重要的是名為wmills.ibd的文件。這個文件被分為N個段。每個段都與一個索引相關聯。

盡管文件不會因刪除數據而收縮,段本身會增長或收縮,下一級為區。一個區僅存在一個段中,並且固定尺寸為1MB(在默認頁大小的情況下)。頁是區的下一級,默認大小為16KB。

因此,一個區最多可包含64頁。一個頁可以包含2到N行。一個頁可以容納的行數與行大小有關,這是表結構設計時定義的。InnoDB中有一個規則,至少要在一個頁中容納兩行。因此,行大小限制為8000字節。

如圖所示:

 

InnoDB使用B+樹。

根節點,分支節點和葉子節點

每個頁(葉子節點)包含由主鍵組織的2~N行。樹有專門的頁管理不同的子樹。這些被稱為內部節點(INodes)。

 

enter image description here

這個圖片僅是示例,並不能說明下面的實際輸出。

細節如下:

ROOT NODE #3: 4 records, 68 bytes
 NODE POINTER RECORD ≥ (id=2) → #197
 INTERNAL NODE #197: 464 records, 7888 bytes
 NODE POINTER RECORD ≥ (id=2) → #5
 LEAF NODE #5: 57 records, 7524 bytes
 RECORD: (id=2) → (uuid="884e471c-0e82-11e7-8bf6-08002734ed50", millid=139, kwatts_s=1956, date="2017-05-01", location="For beauty's pattern to succeeding men.Yet do thy", active=1, time="2017-03-21 22:05:45", strrecordtype="Wit")

表結構如下:

CREATE TABLE `wmills` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT,
  `uuid` char(36) COLLATE utf8_bin NOT NULL,
  `millid` smallint(6) NOT NULL,
  `kwatts_s` int(11) NOT NULL,
  `date` date NOT NULL,
  `location` varchar(50) COLLATE utf8_bin DEFAULT NULL,
  `active` tinyint(2) NOT NULL DEFAULT '1',
  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `strrecordtype` char(3) COLLATE utf8_bin NOT NULL,
  PRIMARY KEY (`id`),
  KEY `IDX_millid` (`millid`)
) ENGINE=InnoDB;

所有類型的B+樹都有一個稱為根節點的入口點。我們已經在第3頁找到了它。根頁包含了索引ID、INodes數量等信息。INode頁包含關於頁本身、值的范圍等信息。最后,我們有葉節點,這是我們可以找到數據的地方。在本例中,我們可以看到葉節點5有57條記錄,總共7524字節。這行下面是一條記錄,您可以看到行數據。

這里的概念是,當您在表和行中組織數據時,InnoDB在分支節點、頁和記錄中組織數據。記住InnoDB不能以單行基礎上工作是非常重要的。InnoDB總是在頁上操作。一旦頁被加載,它就會掃描頁以尋找所請求的行/記錄。

現在都清楚了么?讓我們繼續。

頁內部

頁可以是空,也可以是被填充滿(100%)。行記錄由主鍵組織。例如,如果您的表使用自增值,您將有序列ID=1,2,3,4等。

enter image description here

頁還有另一個重要屬性:MERGE_THRESHOLD。這個參數的默認值是頁的50%,它在InnoDB頁合並活動中起着非常重要的作用:

enter image description here

在插入數據時,如果插入的記錄可以容納在該頁內,則按順序填充該頁。

當頁已經滿時,下一條記錄將插入到下一頁:

enter image description here

鑒於B+樹的特點,該結構不僅可以自上而下沿着子樹查找,還可以水平跨葉節點查找。這是因為每個葉節點頁都有一個指向包含序列中下一個記錄的頁的指針。

例如,第5頁指向下一頁第6頁。第6頁指向前一頁(第5頁),並指向下一頁(第7頁)。

鏈表的這種機制允許快速、有序的掃描(例如,范圍掃描)。如之前所述,這是在插入基於自增主鍵的表發生的情況。但是如果我開始刪除值時會發生什么呢?

頁合並

當您刪除一條記錄時,不會實際刪除該記錄,而是將記錄標記為已刪除,並且該記錄使用的空間可回收。

enter image description here

當一個頁刪除足夠多的數據,達到合並閾值(默認是頁大小的50%),InnoDB開始找相鄰的頁(之前和之后的)查看它們是否有機會合並兩個頁,優化空間使用率。

enter image description here

在這個例子中,第6頁占用空間不足一半。第5頁刪除了很多記錄,也使用了不足50%。從InnoDB的角度看,它們是可以合並的:

enter image description here

合並操作的結果是:第5頁包含了之前的數據和第6頁的數據。第6頁變成了空頁,可用於新數據。

enter image description here

當我們更新一條記錄,新記錄的大小使頁面低於閾值時,也會發生相同的過程。

規則是:如果在相鄰頁有更新和刪除操作,將產生合並。如果合並成功,在INFORMATION_SCHEMA.INNODB_METRICS表中的index_page_merge_successful指標將會增加。

頁分裂

如上所述,一個頁最多可以填充100%。發生這種情況時,下一頁將獲取新記錄。

但是如果我們遇到以下情況呢?

enter image description here

第10頁沒有足夠的空間容納新的記錄(或者更新的記錄)。遵循下一頁的邏輯,這個記錄應該在第11頁上。然而:

enter image description here

第11頁也已滿,數據不能亂序插入。那該怎么辦呢?

還記得我們說過的鏈表嗎?此時第10頁之前的頁為第9頁,之后的頁為第11頁。

InnoDB將做的是(簡化版):

  1. 創建一個新頁。
  2. 確定原始頁(第10頁)可以在哪里拆分(在記錄級別)
  3. 移動記錄
  4. 重新定義頁之間關系

enter image description here

新的第12頁被創建:

enter image description here

第11頁保持原樣。改變的是頁之間的關系:

  • 第10頁之前的頁為第9頁,之后的頁為第12頁
  • 第12頁之前的頁為第10頁,之后的頁是第11頁
  • 第11頁之前的頁為第12頁,之后的頁為第13頁

B+樹的路徑仍然遵循邏輯組織,因此仍然可以看到一致性。但是,頁面的物理位置是無序的,在大多數情況下是在不同的程度的。

通常,我們可以說:頁分裂發生在插入或者更新,並導致頁錯位(在許多情況下,程度不同)。

InnoDB在INFORMATION_SCHEMA.INNODB_METRICS表中記錄了頁分裂的次數。查看index_page_splitsindex_page_reorg_attempts/successful指標。

一旦分裂的頁創建,將其回收的唯一方法是將創建的頁降至合並閾值下。當這發生時,InnoDB通過合並操作將數據從分裂頁遷移走。

另外一個組織數據的方法是OPTIMIZE TABlE。這是一個代價比較大和長的過程,但通常是處理太多頁比較稀疏的唯一方法。

另一個需要記住的是,在合並和分裂時,InnoDB在索引樹上需要加一個X的閂。在繁忙的系統上,這很容易成為一個問題。這會導致索引閂爭用。如果沒有合並和分裂(也就是寫入),只有一個單獨的頁,在InnoDB中被稱為“樂觀”更新,此時的閂為共享閂。合並和分裂被稱為“悲觀”更新,此時的閂為排他閂。

主鍵

一個好的主鍵不僅對檢索數據很重要,而且在寫入時正確將數據分布在區內(對於分裂和合並操作也很重要)。

第一個案例中,我有一個簡單的自增主鍵。第二個案例中,我的主鍵基於ID(1-200)和一個自增值。在第三個案例中,我使用相同的ID(1-200),但與UUID關聯。

當插入時,InnoDB必須添加頁。這是一個頁分裂操作:

enter image description here

根據我使用的主鍵的類型,行為會有很大的不同。

前兩種情況將有更“緊湊”的數據分布。這意味着它們還有更好的空間利用率,而UUID的半隨機特性將導致顯著的“稀疏”頁分布(會有更多的頁和相關的頁分裂)。

在頁合並時,嘗試合並的次數因主鍵的不同而更加不同。

enter image description here

在插入-更新-刪除操作中,自動遞增的頁合並嘗試更少,成功率比其他兩種類型低9.45%。具有UUID的主鍵有更多的合並嘗試,但同時也有高達22.34%的成功率,因為“稀疏”分布留下了許多部分為空的頁。

總結

MySQL/InnoDB經常執行這些操作,您對它們的了解非常有限。但是它們也會對您造成很大的影響,特別是在使用SAS和SSD的情況下(順便說一下,這兩種存儲存在不同的問題)。

不幸的是,我們也很少能在服務端使用參數來優化它。但好消息是在表結構設計時可以做很多事情。

使用適當的主鍵,設計輔助索引,記住不應濫用它們。在您知道插入/刪除/更新次數很多的表上添加適當的維護窗口。

還有重要一點需要記住。在InnoDB中,您不能有碎片記錄,否則在頁級別上,您會有一個噩夢。忽略維護表將導致IO層、內存和InnoDB buffer pool更多的負載。

必須定期重建一些表。可以使用分區或者外部工具(pt-osc)。不要讓表變得很大並充滿碎片。

浪費磁盤空間?需要加載三個頁而不是一個頁來檢索所需的記錄集?每次搜索都會導致更多的讀?

這是您的錯;馬虎是沒有借口的!

當心批量插入失敗或者回滾時帶來的MySQL表碎片

通常,DBA都了解使用DELETE語句會產生表碎片。在大多數情況下,當執行大量的刪除時,DBA總會重新構建表以回收磁盤空間。但是,您是否認為只有刪除才會導致表碎片?(答案:並不是)

在這篇博文中,我將解釋插入如何會帶來碎片。

在討論這個主題之前,我們需要了解MySQL,有兩種碎片:

  • 在表中的InnoDB頁完全空閑引起的碎片。
  • InnoDB頁未填充滿(頁中還有一些空閑空間)引起的碎片。

主要有三種由插入引起的碎片場景:

  • 插入,然后回滾
  • 插入語句失敗
  • 頁分裂引起的碎片

測試環境

我創建了自己的測試環境來測試這些案例。

  • DB:Percona版分支
  • Table:frag,ins_frag,frag_page_spl
  • 表大小:2G

場景1:插入后回滾

首先,我創建了一個新表"ins_flag"。然后我開啟一個事務(使用BEGIN),如下所示開始拷貝"frag"表中數據到"ins_flag"中。

mysql> create table ins_frag like frag;
Query OK, 0 rows affected (0.01 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into ins_frag select * from frag;
Query OK, 47521280 rows affected (3 min 7.45 sec)
Records: 47521280  Duplicates: 0  Warnings: 0

#Linux shell
sakthi-3.2# ls -lrth
total 8261632
-rw-r-----  1 _mysql  _mysql   2.0G Jun 17 02:43 frag.ibd
-rw-r-----  1 _mysql  _mysql   2.0G Jun 17 03:00 ins_frag.ibd

如上所示,您可以看到已經執行了插入,但是我還沒有提交或者回滾插入操作。您注意到2張表都已經占用2G磁盤空間。

現在我將回滾插入操作。

mysql> select count(*) from ins_frag;
+----------+
| count(*) |
+----------+
| 47521280 |
+----------+
1 row in set (1.87 sec)

mysql> rollback;
Query OK, 0 rows affected (5 min 45.21 sec)

mysql> select count(*) from ins_frag;
+----------+
| count(*) |
+----------+
|        0 |
+----------+
1 row in set (0.00 sec)


#Linux shell
sakthi-3.2# ls -lrth
total 8261632
-rw-r-----  1 _mysql  _mysql   2.0G Jun 17 02:43 frag.ibd
-rw-r-----  1 _mysql  _mysql   2.0G Jun 17 03:09 ins_frag.ibd

當插入回滾后,"ins_frag"表仍然占有相同的2GB的磁盤空間。讓我們在MySQL客戶端看看碎片空間。

mysql> SELECT
-> table_schema as 'DATABASE',
-> table_name as 'TABLE',
-> CONCAT(ROUND(( data_length + index_length ) / ( 1024 * 1024 * 1024 ), 2), 'G') 'TOTAL',
-> CONCAT(ROUND(data_free / ( 1024 * 1024 * 1024 ), 2), 'G') 'DATAFREE'
-> FROM information_schema.TABLES
-> where table_schema='percona' and table_name='ins_frag';
+----------+----------+-------+----------+
| DATABASE | TABLE.   | TOTAL | DATAFREE |
+----------+----------+-------+----------+
| percona  | ins_frag | 0.00G | 1.96G    |
+----------+----------+-------+----------+
1 row in set (0.01 sec)

清楚的顯示了插入之后回滾會產生碎片。我們需要重建表來回收磁盤空間。

mysql> alter table ins_frag engine=innodb;
Query OK, 0 rows affected (2.63 sec)
Records: 0  Duplicates: 0  Warnings: 0

#Linux shell

sakthi-3.2# ls -lrth
total 4131040
-rw-r-----  1 _mysql  _mysql   2.0G Jun 17 02:43 frag.ibd
-rw-r-----  1 _mysql  _mysql   112K Jun 17 03:11 ins_frag.ibd

場景2:插入語句失敗

為了測試這個場景,我啟用了2個MySQL客戶端會話(會話1和會話2)。

在會話1中,我將在事務中執行相同的插入語句。但是這次我會在會話2中中斷並殺掉這個插入語句。

會話1

#Linux shell

sakthi-3.2# ls -lrth
total 4131040
-rw-r-----  1 _mysql  _mysql   2.0G Jun 17 02:43 frag.ibd
-rw-r-----  1 _mysql  _mysql   112K Jun 17 04:02 ins_frag.ibd

#MySQL shell

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into ins_frag select * from frag;   #is running

會話2

mysql> pager grep -i insert ; show processlist;
PAGER set to 'grep -i insert'
| 33 | root            | localhost | percona | Query   |    14 | executing              | insert into ins_frag select * from frag |
4 rows in set (0.00 sec)

mysql> kill 33;
Query OK, 0 rows affected (0.00 sec)

插入中斷並失敗了。

在會話1查看:

mysql> insert into ins_frag select * from frag;
ERROR 2013 (HY000): Lost connection to MySQL server during query

#Linux shell

sakthi-3.2# ls -lrth
total 4591616
-rw-r-----  1 _mysql  _mysql   2.0G Jun 17 02:43 frag.ibd
-rw-r-----  1 _mysql  _mysql   212M Jun 17 04:21 ins_frag.ibd

#MySQL shell

mysql> select count(*) from ins_frag;
+----------+
| count(*) |
+----------+
|        0 |
+----------+
1 row in set (0.10 sec)

插入並未完成,表中無數據。但是仍然,這個表的ibd文件已經漲到212M。通過MySQL客戶端查看表空間碎片。

mysql> SELECT
-> table_schema as 'DATABASE',
-> table_name as 'TABLE',
-> CONCAT(ROUND(( data_length + index_length ) / ( 1024 * 1024 ), 2), 'M') 'TOTAL',
-> CONCAT(ROUND(data_free / ( 1024 * 1024 ), 2), 'M') 'DATAFREE'
-> FROM information_schema.TABLES
-> where table_schema='percona' and table_name='ins_frag';
+----------+----------+---------+----------+
| DATABASE | TABLE    | TOTAL   | DATAFREE |
+----------+----------+---------+----------+
| percona  | ins_frag | 0.03M   | 210.56M  |
+----------+----------+---------+----------+
1 row in set (0.01 sec)

表中有碎片,需要重建表回收這些空間。

mysql> alter table ins_frag engine='innodb';
Query OK, 0 rows affected (0.03 sec)
Records: 0  Duplicates: 0  Warnings: 0

#Linux shell

sakthi-3.2# ls -lrth
total 4131040
-rw-r-----  1 _mysql  _mysql   2.0G Jun 17 02:43 frag.ibd
-rw-r-----  1 _mysql  _mysql   112K Jun 17 04:32 ins_frag.ibd

場景3:頁分裂引起的碎片

我們知道,InnoDB記錄存儲在InnoDB頁中。默認情況下,每個頁大小是16K,但是您可以選擇更改頁大小。

如果InnoDB頁沒有足夠的空間容納新的記錄或索引條目,它將被分成2頁,每頁約占50%。這意味着,即使對表只有插入,沒有回滾和刪除,最終也可能只有平均75%的頁利用率——因此這種頁內部損失為25%。

當按排序建立索引,它們會有更多的擁塞,如果表很多插入到索引中隨機位置,就會導致頁分裂。

參閱Marco Tusa寫的博客InnoDB Page Merging and Page Splitting,詳細介紹了頁分裂和InnoDB 頁結構/操作。

為了實驗,我創建了一個具有排序索引的表(降序)

mysql> show create table frag_page_splG
*************************** 1. row ***************************
Table: frag_page_spl
Create Table: CREATE TABLE `frag_page_spl` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(16) DEFAULT NULL,
`messages` varchar(600) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_spl` (`messages` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.07 sec)

我們可以通過表INFORMATION_SCHEMA.INNODB_METRICS監控頁分裂情況。對此,您需要啟用InnoDB monitor。

mysql> SET GLOBAL innodb_monitor_enable=all;
Query OK, 0 rows affected (0.09 sec)

我寫了一個6個並發隨機插入的腳本。腳本執行結束后:

mysql> select name,count,type,status,comment from information_schema.innodb_metrics where name like '%index_page_spl%'G
*************************** 1. row ***************************
name: index_page_splits
count: 52186
type: counter
status: enabled
comment: Number of index page splits
1 row in set (0.05 sec)

mysql> SELECT
-> table_schema as 'DATABASE',
-> table_name as 'TABLE',
-> CONCAT(ROUND(( data_length + index_length ) / ( 1024 * 1024 ), 2), 'M') 'TOTAL',
-> CONCAT(ROUND(data_free / ( 1024 * 1024 ), 2), 'M') 'DATAFREE'
-> FROM information_schema.TABLES
-> where table_schema='percona' and table_name='frag_page_spl';
+----------+---------------+----------+----------+
| DATABASE | TABLE.        | TOTAL    | DATAFREE |
+----------+---------------+----------+----------+
| percona  | frag_page_spl | 2667.55M | 127.92M  |
+----------+---------------+----------+----------+
1 row in set (0.00 sec)

從指標上看,我們看到頁分裂次數在增加。輸出顯示有52186次頁分裂,產生了127.92MB的碎片。

一旦發生頁分裂,唯一的方法是將創建的頁降至合並閾值之下。當這種情況發生時,InnoDB通過合並操作將數據從分裂的頁中移出。對表和特定的索引合並閾值是可配置的。

另一種重新組織數據的方法是OPTIMIZE TABLE。這是一個非常重和漫長的過程,但通常這是解決過多頁比較稀疏的唯一方法。

總結

  • 前面兩種情況很少見。因為大多數應用程序都不會設計在表中寫入大量數據。
  • 在執行批量插入時(INSERT INTO SELECT * FROM, 加載mysqldump的數據, INSERT with huge data等)需要注意這些問題。
  • 碎片占用的磁盤空間始終是可重用的。

關於innodb_fill_factor

有個選項 innodb_fill_factor 用於定義InnoDB page的填充率,默認值是100,但其實最高只能填充約15KB的數據,因為InnoDB會預留1/16的空閑空間。在InnoDB文檔中,有這么一段話

An innodb_fill_factor setting of 100 leaves 1/16 of the space in clustered index pages free for future index growth.

另外,文檔中還有這樣一段話

When new records are inserted into an InnoDB clustered index, InnoDB tries to leave 1/16 of the page free for future insertions and updates of the index records. If index records are inserted in a sequential order (ascending or descending), the resulting index pages are about 15/16 full. If records are inserted in a random order, the pages are from 1/2 to 15/16 full.

上面這兩段話,綜合起來理解,就是

  1. 即便 innodb_fill_factor=100,也會預留1/16的空閑空間,用於現存記錄長度擴展用
  2. 在最佳的順序寫入數據模式下,page填充率有可能可以達到15/16
  3. 在隨機寫入新數據模式下,page填充率約為 1/2 ~ 15/16
  4. 預留1/16這個規則,只針對聚集索引的葉子節點有效。對於聚集索引的非葉子節點以及輔助索引(葉子及非葉子)節點都沒有這個規則
  5. 不過 innodb_fill_factor 選項對葉子節點及非葉子節點都有效,但對存儲text/blob溢出列的page無效

 

 參考文章:

https://cloud.tencent.com/developer/article/1656122

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM