1. 程式人生 > 實用技巧 >深入解析InnoDB索引結構,向MySQL調優持續進軍

深入解析InnoDB索引結構,向MySQL調優持續進軍

0、導讀

InnoDB表的索引有哪些特性,以及索引組織結構是怎樣的

1、InnoDB聚集索引特點

我們知道,InnoDB引擎的聚集索引組織表,必然會有一個聚集索引。

行資料(row data)儲存在聚集索引的葉子節點(除了發生overflow的列,參見,後面簡稱“前置文”),並且其儲存的相對順序取決於聚集索引的順序。這裡說相對順序而不是物理順序,是因為葉子節點資料頁中,行資料的物理順序和相對順序可能並不是一致的,放在後面會講。

InnoDB聚集索引的選擇先後順序是這樣的:

  1. 如果有顯式定義的主鍵(PRIMARY KEY),則會選擇該主鍵作為聚集索引
  2. 否則,選擇第一個所有列都不允許為NULL的唯一索引
  3. 若前兩者都沒有,則InnoDB會選擇內建的DB_ROW_ID作為聚集索引,命名為GEN_CLUST_INDEX

特別提醒:DB_ROW_ID佔用6個位元組,每次自增,且是整個例項內全域性分配。也就是說,當前例項如果有多個表都採用了內建的DB_ROW_ID作為聚集索引,則在這些表插入新資料時,他們的內建DB_ROW_ID值並不是連續的,而是跳躍的。像下面這樣:

1 t1表的ROW_ID:1、3、7、10
2 t2表的ROW_ID:2、4、5、6、8、9

2、InnoDB索引結構

InnoDB預設的索引資料結構採用B+樹(空間索引採用R樹),索引資料儲存在葉子節點。

InnoDB的基本I/O儲存單位是資料頁(page),一個page預設是16KB。我們在前置文說過,每個page預設會預留1/16空閒空間用於後續資料“變長”更新所需,因此在最理想的順序插入狀態下,其產生的碎片也最少,這時候差不多能填滿15/16的page空間。如果是隨機寫入的話,則page空間利用率大概是1/2 ~ 15/16。

當 row_format = DYNAMIC|COMPRESSED 時,索引最多長度為 3072位元組,當 row_format = REDUNDANT|COMPACT 時,索引最大長度為 767位元組。當page size不是預設的16KB時,最大索引長度限制也會跟著發生變化。

我們接下來分別驗證關於InnoDB索引的基本結構特點。

首先建立如下測試表:

1 [[email protected]] [innodb]> CREATE TABLE `t1` (
2  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
3  `c1` int(10) unsigned NOT NULL DEFAULT '0',
4 `c2` varchar(100) NOT NULL, 5 `c3` varchar(100) NOT NULL, 6 PRIMARY KEY (`id`), 7 KEY `c1` (`c1`) 8 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

用下面的方法寫入10條測試資料:

1 set @uuid1=uuid(); set @uuid2=uuid();
2 insert into t1 select 0, round(rand()*1024),
3         @uuid1, concat(@uuid1, @uuid2);

看下 t1 表的整體結構:

 1 # 用innodb_ruby工具檢視
 2 [[email protected]]# innodb_space -s ibdata1 -T innodb/t1 space-indexes
 3 id  name    root  fseg    fseg_id  used  allocated  fill_factor
 4 238  PRIMARY  3   internal  1     1    1      100.00%
 5 238  PRIMARY  3   leaf    2     0    0      0.00%
 6 239  c1     4   internal  3     1    1      100.00%
 7 239  c1     4   leaf    4     0    0      0.0
 8  
 9 # 用innblock工具檢視
10 [[email protected]]# innblock innodb/t1.ibd scan 16
11 ...
12 ===INDEX_ID:238
13 level0 total block is (1)
14 block_no:   3,level:  0|*|
15 ===INDEX_ID:239
16 level0 total block is (1)
17 block_no:   4,level:  0|*|

可以看到

索引ID索引型別根節點page no索引層高
238 主鍵索引(聚集索引) 3 1
239 輔助索引 4 1

3、InnoDB索引特點驗證

3.1 特點1:聚集索引葉子節點儲存整行資料

先掃描第3個page,擷取其中第一條物理記錄的內容:
 1 [[email protected]]# innodb_space -s ibdata1 -T innodb/t1 -p 3 page-dump
 2 ...
 3 records:
 4 {:format=>:compact,
 5  :offset=>127,
 6  :header=>
 7  {:next=>263,
 8   :type=>:conventional,
 9   :heap_number=>2,
10   :n_owned=>0,
11   :min_rec=>false,
12   :deleted=>false,
13   :nulls=>[],
14   :lengths=>{"c2"=>36, "c3"=>72},
15   :externs=>[],
16   :length=>7},
17  :next=>263,
18  :type=>:clustered,
19  #第一條物理記錄,id=1
20  :key=>[{:name=>"id", :type=>"INT UNSIGNED", :value=>1}],
21  :row=>
22  [{:name=>"c1", :type=>"INT UNSIGNED", :value=>777},
23   {:name=>"c2",
24   :type=>"VARCHAR(400)",
25   :value=>"a1c1a7c7-bda5-11e9-8476-0050568bba82"},
26   {:name=>"c3",
27   :type=>"VARCHAR(400)",
28   :value=>
29    "a1c1a7c7-bda5-11e9-8476-0050568bba82a1c1aec5-bda5-11e9-8476-0050568bba82"}],
30  :sys=>
31  [{:name=>"DB_TRX_ID", :type=>"TRX_ID", :value=>10950},
32   {:name=>"DB_ROLL_PTR",
33   :type=>"ROLL_PTR",
34   :value=>
35    {:is_insert=>true,
36    :rseg_id=>119,
37    :undo_log=>{:page=>469, :offset=>272}}}],
38  :length=>129,
39  :transaction_id=>10950,
40  :roll_pointer=>
41  {:is_insert=>true, :rseg_id=>119, :undo_log=>{:page=>469, :offset=>272}}}

很明顯,的確是儲存了整條資料的內容。

聚集索引樹的鍵值(key)是主鍵索引值(i=10),聚集索引節點值(value)是其他非聚集索引列(c1,c2,c3)以及隱含列(DB_TRX_ID、DB_ROLL_PTR)。

優化建議1:儘量不要儲存大物件資料,使得每個葉子節點都能儲存更多資料,降低碎片率,提高buffer pool利用率。此外也能儘量避免發生overflow。

3.2 特點2:聚集索引非葉子節點儲存指向子節點的指標

對上面的測試表繼續寫入新資料,直到聚集索引樹從一層分裂成兩層。

我們根據舊文InnoDB表聚集索引層高什麼時候發生變化裡的計算方式,推算出來預計一個葉子節點最多可儲存111條記錄,因此在插入第112條記錄時,就會從一層高度分裂成兩層高度。經過實測,也的確是如此。

 1 [[email protected]] [innodb]>select count(*) from t1;
 2 +----------+
 3 | count(*) |
 4 +----------+
 5 |   112 |
 6 +----------+
 7  
 8 [[email protected]]# innblock innodb/t1.ibd scan 16
 9 ...
10 ===INDEX_ID:238
11 level1 total block is (1)
12 block_no:   3,level:  1|*|
13 level0 total block is (2)
14 block_no:   5,level:  0|*|block_no:   6,level:  0|*|
15 ...

此時可以看到根節點依舊是pageno=3,而葉子節點變成了[5, 6]兩個page。由此可知,根節點上應該只有兩條物理記錄,儲存著分別指向pageno=[5, 6]這兩個page的指標。

我們解析下3號page,看看它的具體結構:

 1 [[email protected]]# innodb_space -s ibdata1 -T innodb/t1 -p 3 page-dump
 2 ...
 3 records:
 4 {:format=>:compact,
 5  :offset=>125,
 6  :header=>
 7  {:next=>138,
 8   :type=>:node_pointer,
 9   :heap_number=>2,
10   :n_owned=>0,
11   :min_rec=>true, #第一條記錄是min_key
12   :deleted=>false,
13   :nulls=>[],
14   :lengths=>{},
15   :externs=>[],
16   :length=>5},
17  :next=>138,
18  :type=>:clustered,
19  #第一條記錄,只儲存key值
20  :key=>[{:name=>"id", :type=>"INT UNSIGNED", :value=>1}],
21  :row=>[],
22  :sys=>[],
23  :child_page_number=>5, #value值是指向的葉子節點pageno=5
24  :length=>8} #整條記錄消耗8位元組,除去key值4位元組外,指標也需要4位元組
25  
26 {:format=>:compact,
27  :offset=>138,
28  :header=>
29  {:next=>112,
30   :type=>:node_pointer,
31   :heap_number=>3,
32   :n_owned=>0,
33   :min_rec=>false,
34   :deleted=>false,
35   :nulls=>[],
36   :lengths=>{},
37   :externs=>[],
38   :length=>5},
39  :next=>112,
40  :type=>:clustered,
41  #第二條記錄,只儲存key值
42  :key=>[{:name=>"id", :type=>"INT UNSIGNED", :value=>56}],
43  :row=>[],
44  :sys=>[],
45  :child_page_number=>6, #value值是指向的葉子節點pageno=6
46  :length=>8}

優化建議2:索引列資料長度越小越好,這樣索引樹儲存效率越高,在非葉子節點能儲存越多資料,延緩索引樹層高分裂的速度,平均搜尋效率更高。

3.3 特點3:輔助索引同時會儲存主鍵索引列值

在輔助索引中,總是同時會儲存主鍵索引(或者說聚集索引)的列值,其作用就是在對輔助索引掃描時,可以從葉子節點直接得到對應的聚集索引值,並可根據該值回表查詢獲取行資料(如果需要回表查詢的話)。這個特性也被稱為Index Extensions(5.6版本之後的優化器新特性,詳見Use of Index Extensions)。

此外,在輔助索引的非葉子節點中,索引記錄的key值是索引定義的列值,而對應的value值則是聚集索引列值(簡稱PKV)。如果輔助索引定義時已經包含了部分聚集索引列,則索引記錄的value值是未被包含的餘下的聚集索引列值。

建立如下測試表:

1 CREATE TABLE `t3` (
2  `a` int(10) unsigned NOT NULL AUTO_INCREMENT,
3  `b` int(10) unsigned NOT NULL DEFAULT '0',
4  `c` varchar(20) NOT NULL DEFAULT '',
5  `d` varchar(20) NOT NULL DEFAULT '',
6  `e` varchar(20) NOT NULL DEFAULT '',
7  PRIMARY KEY (`a`,`b`),
8  KEY `k1` (`c`,`b`)
9 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

隨機插入一些測試資料:

 1 # 呼叫shell指令碼寫入500條資料
 2 [[email protected]]# cat insert.sh
 3 #!/bin/bash
 4 . ~/.bash_profile
 5 cd /data/perconad
 6 i=1
 7 max=500
 8 while [ $i -le $max ]
 9 do
10  mysql -Smysql.sock -e "insert ignore into t3 select
11   rand()*1024, rand()*1024, left(md5(uuid()),20) ,
12   left(uuid(),20), left(uuid(),20);" innodb
13  i=`expr $i + 1`
14 done
15  
16 # 實際寫入498條資料(其中有2條主鍵衝突失敗)
17 [[email protected]] [innodb]>select count(*) from t3;
18 +----------+
19 | count(*) |
20 +----------+
21 |   498 |
22 +----------+

解析資料結構:

 1 # 主鍵
 2 [root@test1 perconad]# innodb_space -s ibdata1 -T innodb/t2 space-indexes
 3 id  name   root fseg    fseg_id  used  allocated  fill_factor
 4 245  PRIMARY 3   internal  1     1   1      100.00%
 5 245  PRIMARY 3   leaf    2     5   5      100.00%
 6 246  k1    4   internal  3     1   1      100.00%
 7 246  k1    4   leaf    4     2   2      1
 8  
 9 [[email protected]]# innodb_space -s ibdata1 -T innodb/t2 -p 4 page-dump
10 ...
11 records:
12 {:format=>:compact,
13  :offset=>126,
14  :header=>
15  {:next=>164,
16   :type=>:node_pointer,
17   :heap_number=>2,
18   :n_owned=>0,
19   :min_rec=>true,
20   :deleted=>false,
21   :nulls=>[],
22   :lengths=>{"c"=>20},
23   :externs=>[],
24   :length=>6},
25  :next=>164,
26  :type=>:secondary,
27  :key=>
28  [{:name=>"c", :type=>"VARCHAR(80)", :value=>"00a5d42dd56632893b5f"},
29   {:name=>"b", :type=>"INT UNSIGNED", :value=>323}],
30  :row=>
31  [{:name=>"a", :type=>"INT UNSIGNED", :value=>310},
32   {:name=>"b", :type=>"INT UNSIGNED", :value=>9}],
33   # 此處給解析成b列的值了,實際上是指向葉子節點的指標,即child_page_number=9
34   # b列真實值是323
35  :sys=>[],
36  :child_page_number=>335544345,
37  # 此處解析不準確,實際上是下一條記錄的record header,共6個位元組
38  :length=>36}
39  
40 {:format=>:compact,
41  :offset=>164,
42  :header=>
43  {:next=>112,
44   :type=>:node_pointer,
45   :heap_number=>3,
46   :n_owned=>0,
47   :min_rec=>false,
48   :deleted=>false,
49   :nulls=>[],
50   :lengths=>{"c"=>20},
51   :externs=>[],
52   :length=>6},
53  :next=>112,
54  :type=>:secondary,
55  :key=>
56  [{:name=>"c", :type=>"VARCHAR(80)", :value=>"7458824a39892aa77e1a"},
57   {:name=>"b", :type=>"INT UNSIGNED", :value=>887}],
58  :row=>
59  [{:name=>"a", :type=>"INT UNSIGNED", :value=>623},
60   {:name=>"b", :type=>"INT UNSIGNED", :value=>10}],
61   # 同上,其實是child_page_number=10,而非b列的值
62  :sys=>[],
63  :child_page_number=>0,
64  :length=>36} #資料長度16位元組

順便說下,輔助索引上沒儲存TRX_ID, ROLL_PTR這些(他們只儲存在聚集索引上)。

上面用innodb_ruby工具解析的非葉子節點部分內容不夠準確,所以我們用二進位制方式開啟資料檔案二次求證確認:

 1 # 此處也可以用 hexdump 工具
 2 [[email protected]]# vim -b path/t3.ibd
 3 ...
 4 :%!xxd
 5  
 6 # 找到輔助索引所在的那部分資料
 7 0010050: 0002 0272 0000 00e1 0000 0002 01b2 0100 ...r............
 8 0010060: 0200 1b69 6e66 696d 756d 0003 000b 0000 ...infimum......
 9 0010070: 7375 7072 656d 756d 1410 0011 0026 3030 supremum.....&00
10 0010080: 6135 6434 3264 6435 3636 3332 3839 3362 a5d42dd56632893b
11 0010090: 3566 0000 0143 0000 0136 0000 0009 1400 5f...C...6......
12 00100a0: 0019 ffcc 3734 3538 3832 3461 3339 3839 ....7458824a3989
13 00100b0: 3261 6137 3765 3161 0000 0377 0000 026f 2aa77e1a...w...o
14 00100c0: 0000 000a 0000 0000 0000 0000 0000 0000 ................
15  
16 # 參考page物理結構方式進行解析,得到下面的結果
17 /* 第一條記錄 */
18 1410 0011 0026, record header, 5位元組
19 3030 6135 6434 3264 6435 3636 3332 3839 3362 3566,c='00a5d42dd56632893b5f',20B
20 0000 0143, b=323, 4B
21 0000 0136, a=310, 4B
22 0000 0009, child_pageno=9, 4B
23  
24 /* 2 */
25 1400 0019 ffcc, record header
26 3734 3538 3832 3461 3339 3839 3261 6137 3765 3161, c='7458824a39892aa77e1a'
27 0000 0377, b=887
28 0000 026f, a=623
29 0000 000a, child_pageno=10

現在反過來看,上面用innodb_ruby工具解析出來的page-dump結果應該是這樣的才對(我只選取一條記錄,請自行對比和之前的不同之處):

 1 {:format=>:compact,
 2  :offset=>164,
 3  :header=>
 4  {:next=>112,
 5   :type=>:node_pointer,
 6   :heap_number=>3,
 7   :n_owned=>0,
 8   :min_rec=>false,
 9   :deleted=>false,
10   :nulls=>[],
11   :lengths=>{"c"=>20},
12   :externs=>[],
13   :length=>6},
14  :next=>112,
15  :type=>:secondary,
16  :key=>
17  [{:name=>"c", :type=>"VARCHAR(80)", :value=>"7458824a39892aa77e1a"},
18   {:name=>"b", :type=>"INT UNSIGNED", :value=>887}],
19  :row=> [{:name=>"a", :type=>"INT UNSIGNED", :value=>623}],
20  :sys=>[],
21  :child_page_number=>10,
22  :length=>36}

可以看到,的確如前面所說,輔助索引的非葉子節點的value值儲存的是聚集索引列值。

優化建議3:輔助索引列定義的長度越小越好,定義輔助索引時,沒必要顯式的加上聚集索引列(5.6版本之後)。

3.4 特點4:沒有可用的聚集索引列時,會使用內建的ROW_ID作為聚集索引

建立幾個像下面這樣的表,使其選擇內建的ROW_ID作為聚集索引:

1 [[email protected]] [innodb]> CREATE TABLE `tn1` (
2  `c1` int(10) unsigned NOT NULL DEFAULT 0,
3  `c2` int(10) unsigned NOT NULL DEFAULT 0
4 ) ENGINE=InnoDB;
迴圈對幾個表寫資料:
1 insert into tt1 select 1,1;
2 insert into tt2 select 1,1;
3 insert into tt3 select 1,1;
4 insert into tt1 select 2,2;
5 insert into tt2 select 2,2;
6 insert into tt3 select 2,2;

檢視 tn1 - tn3 表裡的資料(這裡由於innodb_ruby工具解析的結果不準確,所以我改用hexdump來分析):

 1 tn1
 2 000c060: 0200 1a69 6e66 696d 756d 0003 000b 0000 ...infimum......
 3 000c070: 7375 7072 656d 756d 0000 1000 2000 0000 supremum.... ...
 4 000c080: 0003 1200 0000 003d f6aa 0000 01d9 0110 .......=........
 5 000c090: 0000 0001 0000 0001 0000 18ff d300 0000 ................
 6 000c0a0: 0003 1500 0000 003d f9ad 0000 01da 0110 .......=........
 7 000c0b0: 0000 0002 0000 0002 0000 0000 0000 0000 ................
 8  
 9 tn2
10 000c060: 0200 1a69 6e66 696d 756d 0003 000b 0000 ...infimum......
11 000c070: 7375 7072 656d 756d 0000 1000 2000 0000 supremum.... ...
12 000c080: 0003 1300 0000 003d f7ab 0000 0122 0110 .......=....."..
13 000c090: 0000 0001 0000 0001 0000 18ff d300 0000 ................
14 000c0a0: 0003 1600 0000 003d feb0 0000 01db 0110 .......=........
15 000c0b0: 0000 0002 0000 0002 0000 0000 0000 0000 ................
16 tn3
17 000c060: 0200 1a69 6e66 696d 756d 0003 000b 0000 ...infimum......
18 000c070: 7375 7072 656d 756d 0000 1000 2000 0000 supremum.... ...
19 000c080: 0003 1400 0000 003d f8ac 0000 0123 0110 .......=.....#..
20 000c090: 0000 0001 0000 0001 0000 18ff d300 0000 ................
21 000c0a0: 0003 1700 0000 003e 03b3 0000 012a 0110 .......>.....*..
22 000c0b0: 0000 0002 0000 0002 0000 0000 0000 0000 ................
其中表示DB_ROW_ID的值分別是:
 1 tn1
 2 0003 12 => (1,1)
 3 0003 15 => (2,2)
 4  
 5 tn2
 6 0003 13 => (1,1)
 7 0003 16 => (2,2)
 8  
 9 tn3
10 0003 14 => (1,1)
11 0003 17 => (2,2)

很明顯,內建的DB_ROW_ID的確是在整個例項級別共享自增分配的,而不是每個表獨享一個DB_ROW_ID序列。

我們可以想象下,如果一個例項中有多個表都用到這個DB_ROW_ID的話,勢必會造成併發請求的競爭/等待。此外也可能會造成主從複製環境下,從庫上relay log回放時可能會因為資料掃描機制的問題造成嚴重的複製延遲問題。詳情參考從庫資料的查詢和引數slave_rows_search_algorithms。

優化建議4:自行顯示定義可用的聚集索引/主鍵索引,不要讓InnoDB選擇內建的DB_ROW_ID作為聚集索引,避免潛在的效能損失。

篇幅已經有點大了,本次的淺析工作就先到這裡吧,以後再繼續。

4、幾點總結

最後針對InnoDB引擎表,總結幾條建議吧。
  1. 每個表都要有顯式主鍵,最好是自增整型,且沒有業務用途
  2. 無論是主鍵索引,還是輔助索引,都儘可能選擇資料型別較小的列
  3. 定義輔助索引時,沒必要顯式加上主鍵索引列(針對MySQL 5.6之後)
  4. 行資料越短越好,如果每個列都是固定長的則更好(不是像VARCHAR這樣的可變長度型別)

喜歡請多多點贊評論,關注小編,你們的支援就是小編最大的動力!!!