深入解析InnoDB索引結構,向MySQL調優持續進軍
0、導讀
InnoDB表的索引有哪些特性,以及索引組織結構是怎樣的
1、InnoDB聚集索引特點
我們知道,InnoDB引擎的聚集索引組織表,必然會有一個聚集索引。
行資料(row data)儲存在聚集索引的葉子節點(除了發生overflow的列,參見,後面簡稱“前置文”),並且其儲存的相對順序取決於聚集索引的順序。這裡說相對順序而不是物理順序,是因為葉子節點資料頁中,行資料的物理順序和相對順序可能並不是一致的,放在後面會講。
InnoDB聚集索引的選擇先後順序是這樣的:
- 如果有顯式定義的主鍵(PRIMARY KEY),則會選擇該主鍵作為聚集索引
- 否則,選擇第一個所有列都不允許為NULL的唯一索引
- 若前兩者都沒有,則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引擎表,總結幾條建議吧。- 每個表都要有顯式主鍵,最好是自增整型,且沒有業務用途
- 無論是主鍵索引,還是輔助索引,都儘可能選擇資料型別較小的列
- 定義輔助索引時,沒必要顯式加上主鍵索引列(針對MySQL 5.6之後)
- 行資料越短越好,如果每個列都是固定長的則更好(不是像VARCHAR這樣的可變長度型別)
喜歡請多多點贊評論,關注小編,你們的支援就是小編最大的動力!!!