寫在前面 > 本文章是學習掘金小冊《MySQL 是怎樣運行的:從根兒上理解 MySQL》 之後整理的,文章大量使用和借鑑了該小冊的內容。另外小冊很不錯,講解十分到位,推薦閱讀。
我們在上一篇文章 《 InnoDB中數據是如何存儲的 》中詳細介紹了MySQL數據存儲的細節,包括 行格式
和 頁
。我們知道頁分爲很多種,本篇文章中主要涉及兩種 數據頁
和 索引頁
,我們知道 數據頁
是存儲數據的,那 索引頁
又是用來做什麼的呢?
ok,多的不說,少的不嘮,我們直接開始吧。
試想一下,如果沒有索引,我們單純依靠葉子節點組成的雙鏈表查詢數據的話,只能從頭開始向後遍歷,這樣的效率是很低的,索引的出現正是爲了解決這個問題的。
InnoDB索引
爲了方便描述,假設我們創建了一張表,包含三列,a
、b
、c
,其中a
爲 int 類型,b
、c
爲char 類型,且a
爲主鍵,然後我們向表中插入若干條數據,
insert into ... values(1, 'b1', 'c1');
insert into ... values(2, 'b2', 'c2');
insert into ... values(3, 'b3', 'c3');
insert into ... values(4, 'b4', 'c4');
依次類推...
這時MySQL 會將數據保存到數據頁
中,並且還會生成索引頁
:
如圖需要注意:
- 數據頁中保存了完整的數據(我們insert的全部數據),
- 索引頁中每條記錄包含了主鍵值(紅色部分數據)和 頁的編號(它們指向對應的數據頁)
- 索引頁中每條記錄並不包含主鍵外的其他列數據(其他列,比如第二列 b,第三列 c)
- 記錄和頁都是依靠主鍵從小到大進行排序
現在有了索引頁的幫助,我們再來檢索的話(這裏所說的檢索是利用主鍵檢索),可以利用索引頁通過二分查找快速找到記錄所在的頁,然後再在頁中找到對應的數據,這種方式比文章開頭說的遍歷鏈表要快很多。
雖然說索引頁中每條記錄只存儲主鍵值和對應的頁號,相比數據頁能存放更多的記錄,但是不論怎麼說一個頁只有16KB
大小,那如果表中的數據太多,那一個索引頁也是不夠用的。這時我們需要更多的索引頁:
如果你對數據結構有了解的話,就會知道,這是 B+樹 。
聚簇索引
我們上邊介紹的B+
樹本身就是一個目錄,或者說本身就是一個索引。它有兩個特點:
-
使用記錄主鍵值的大小進行記錄和頁的排序,這包括三個方面的含義:
- 頁內的記錄是按照主鍵的大小順序排成一個單向鏈表。
- 各個存放用戶記錄的頁也是根據頁中用戶記錄的主鍵大小順序排成一個雙向鏈表。
- 存放目錄項記錄的頁分爲不同的層次,在同一層次中的頁也是根據頁中目錄項記錄的主鍵大小順序排成一個雙向鏈表。
-
B+
樹的葉子節點存儲的是完整的用戶記錄。所謂完整的用戶記錄,就是指這個記錄中存儲了所有列的值(包括隱藏列)。
我們把具有這兩種特性的B+
樹稱爲聚簇索引
,所有完整的用戶記錄都存放在這個聚簇索引
的葉子節點處。這種聚簇索引
並不需要我們在MySQL
語句中顯式的使用INDEX
語句去創建(後邊會介紹索引相關的語句),InnoDB
存儲引擎會自動的爲我們創建聚簇索引。另外有趣的一點是,在InnoDB
存儲引擎中,聚簇索引
就是數據的存儲方式(所有的用戶記錄都存儲在了葉子節點
),也就是所謂的索引即數據,數據即索引。
二級索引
上邊介紹的聚簇索引
只能在搜索條件是主鍵值時才能發揮作用,因爲B+
樹中的數據都是按照主鍵進行排序的。那如果我們想以別的列作爲搜索條件該咋辦呢?這時候我們可以再建立一顆B+樹,我們現在爲第二列 b 建立索引,假設第二列 b 中數據大小是這樣的:b1 > b2 > b2 > ... > b12 > ...
,新建的B+樹如圖:
如圖需要注意幾點,
- 紅色的列依然是主鍵,藍色的列爲第二列 b
- 頁與頁之間以及頁內部記錄之間都是按照第二列 b 進行排序的
- 葉子節點存放的數據並不是完整的數據,只有主鍵和第二列 b
- 內部節點中不再是
主鍵+頁號
的搭配,而變成了第二列 b+頁號
的搭配。
這個時候,我們利用剛創建的索引查找數據的話,比如我們執行這樣的sql,select * from xxx where b = 'b7'
,查找過程是這樣的:
我們首先要在二級索引中查找,利用二分查找,由根節點向下,知道找到對應的葉子節點,也就是 b = 'b7'
的這條記錄,這個時候可以拿到這條記錄的主鍵也就是 a = 7
,我們再根據主鍵到聚簇索引那顆B+樹中查找,這個過程也被稱爲回表
,最終利用聚簇索引可以找到這條記錄的完整數據。
聯合索引
我們也可以同時以多個列的大小作爲排序規則,也就是同時爲多個列建立索引,比方說我們想讓B+
樹按照b
和c
列的大小進行排序,也就是:
- 先把各個記錄和頁按照
b
列進行排序。 - 在記錄的
b
列相同的情況下,採用c
列進行排序
也就是聯合索引,有以下特點:
- 內部節點中將包含
b
、c
、頁號
這三個部分,各條記錄先按照b
列的值進行排序,如果記錄的b
列相同,則按照c
列的值進行排序。 - 葉子節點處的用戶記錄由
b
、c
和主鍵a
列組成。
要注意一點,以c2和c3列的大小爲排序規則建立的B+樹稱爲聯合索引,本質上也是一個二級索引。
MyISAM中的索引方案簡單介紹
我們知道InnoDB
中索引即數據,也就是聚簇索引的那棵B+
樹的葉子節點中已經把所有完整的用戶記錄都包含了,而MyISAM
的索引方案雖然也使用樹形結構,但是卻將索引和數據分開存儲:
-
將表中的記錄按照記錄的插入順序單獨存儲在一個文件中,稱之爲
數據文件
。這個文件並不劃分爲若干個數據頁,有多少記錄就往這個文件中塞多少記錄就成了。我們可以通過行號而快速訪問到一條記錄。 -
使用
MyISAM
存儲引擎的表會把索引信息另外存儲到一個稱爲索引文件
的另一個文件中。MyISAM
會單獨爲表的主鍵創建一個索引,只不過在索引的葉子節點中存儲的不是完整的用戶記錄,而是主鍵值 + 行號
的組合。也就是先通過索引找到對應的行號,再通過行號去找對應的記錄!
索引如何使用
索引的代價
俗話說,世上沒有完美的東西,這句話在索引身上也同樣適用,在使用索引之前,我們必須明白在有些時候索引反而會成爲拖後腿的存在:
-
空間上的代價
這個是顯而易見的,每建立一個索引都要爲它建立一棵
B+
樹,每一棵B+
樹的每一個節點都是一個數據頁,一個頁默認會佔用16KB
的存儲空間,一棵很大的B+
樹由許多數據頁組成,那可是很大的一片存儲空間呢。 -
時間上的代價
每次對錶中的數據進行增、刪、改操作時,都需要去修改各個
B+
樹索引。而且我們講過,B+
樹每層節點都是按照索引列的值從小到大的順序排序而組成了雙向鏈表。不論是葉子節點中的記錄,還是內節點中的記錄(也就是不論是用戶記錄還是目錄項記錄)都是按照索引列的值從小到大的順序而形成了一個單向鏈表。而增、刪、改操作可能會對節點和記錄的排序造成破壞,所以存儲引擎需要額外的時間進行一些記錄移位,頁面分裂、頁面回收啥的操作來維護好節點和記錄的排序。如果我們建了許多索引,每個索引對應的B+
樹都要進行相關的維護操作,這還能不給性能拖後腿麼?
所以說,索引雖好可不要“貪杯”哦。
那麼我們平常寫的查詢語句那些能用到索引,那些無法使用索引呢?接下來我們先來了解B+
樹索引的適用場景,
B+樹索引適用的條件
爲了方便表述,我們先創建一個表:
CREATE TABLE staff_info(
id INT NOT NULL auto_increment,
name VARCHAR(100) NOT NULL,
birthday DATE NOT NULL,
phone_number CHAR(11) NOT NULL,
country varchar(100) NOT NULL,
PRIMARY KEY (id),
KEY idx_name_birthday_phone_number (name, birthday, phone_number)
);
對於這個staff_info
表我們需要注意兩點:
- 表中的主鍵是
id
列,它存儲一個自動遞增的整數。所以InnoDB
存儲引擎會自動爲id
列建立聚簇索引。 - 我們額外定義了一個二級索引
idx_name_birthday_phone_number
,它是由3個列組成的聯合索引。所以在這個索引對應的B+
樹的葉子節點處存儲的用戶記錄只保留name
、birthday
、phone_number
這三個列的值以及主鍵id
的值,並不會保存country
列的值。
全值匹配
當我們的搜索條件中的列和索引列一致的話,就稱爲全值匹配,這種情況可以完美使用索引,如:
SELECT * FROM staff_info WHERE name = 'Ashburn' AND birthday = '1990-09-27' AND phone_number = '15123983239';
注意,下面這樣寫同樣可以使用索引:
SELECT * FROM staff_info WHERE birthday = '1990-09-27' AND phone_number = '15123983239' AND name = 'Ashburn';
MySQL中的查詢優化器會幫我們分析決定查詢條件的先後順序,所以效果和上面的sql是一樣的。
匹配左邊的列
如果搜索語句中只包含聯合索引中左邊的列,也可以使用索引,比如這樣:
SELECT * FROM staff_info WHERE name = 'Ashburn';
或者這樣
SELECT * FROM staff_info WHERE name = 'Ashburn' AND birthday = '1990-09-27';
需要注意的是,搜索條件中的各個列必須是聯合索引中從最左邊連續的列。下面的sql是無法使用索引的:
SELECT * FROM staff_info WHERE birthday = '1990-09-27';
這是因爲,B+
樹的數據頁和記錄先是按照name
列的值排序的,在name
列的值相同的情況下才使用birthday
列進行排序,也就是說name
列的值不同的記錄中birthday
的值可能是無序的。而現在跳過name
列直接根據birthday
的值去查找,顯然是無法匹配的,會執行全表掃描。同理,下面的sql也是不行的:
SELECT * FROM staff_info WHERE name = 'Ashburn' AND phone_number = '15123983239';
匹配列前綴
我們前面說過聯合索引idx_name_birthday_phone_number
會先用name
列的值進行排序,在name
列的值相同的情況下才使用birthday
列進行排序,然後在birthday
列的值相同的情況下才使用phone_number
列進行排序。那麼比較兩個字符串的大小其實是從第一個字符開始以此比較大小,也就是說,在聯合索引idx_name_birthday_phone_number
中的這些字符串的前n個字符,也就是前綴都是排好序的。所以這樣寫也可以使用索引:
SELECT * FROM staff_info WHERE name LIKE 'As%';
匹配範圍值
像這樣範圍值匹配,同樣可以使用索引:
SELECT * FROM staff_info WHERE name > 'Asa' AND name < 'Barlow';
需要注意,這樣是不行的:
SELECT * FROM staff_info WHERE name > 'Asa' AND name < 'Barlow' AND birthday > '1980-01-01';
原因我們上面說過,在name
列的值相同的情況下才使用birthday
列進行排序,也就是說name
列的值不同的記錄中birthday
的值可能是無序的。
精確匹配某一列並範圍匹配另外一列
如果左邊的列是精確查找,右邊的列進行範圍查找,則可以使用索引:
SELECT * FROM staff_info WHERE name = 'Ashburn' AND birthday > '1980-01-01' AND birthday < '2000-12-31';
SELECT * FROM staff_info WHERE name = 'Ashburn' AND birthday = '1980-01-01' AND phone_number > '15100000000';
排序
先看下邊這個簡單的查詢語句:
SELECT * FROM staff_info ORDER BY name, birthday, phone_number LIMIT 10;
這個查詢的結果集需要先按照name
值排序,如果記錄的name
值相同,則需要按照birthday
來排序,如果birthday
的值相同,則需要按照phone_number
排序。大家可以回過頭去看我們建立的idx_name_birthday_phone_number
索引的示意圖,因爲這個B+
樹索引本身就是按照上述規則排好序的,所以直接從索引中提取數據,然後進行回表
操作取出該索引中不包含的列就好了。
有個問題需要注意,ORDER BY
的子句後邊的列的順序也必須按照索引列的順序給出,如果給出ORDER BY phone_number, birthday, name
的順序,那也是用不了B+
樹索引。
分組
下邊這個分組查詢,同樣可以使用索引:
SELECT name, birthday, phone_number, COUNT(*) FROM staff_info GROUP BY name, birthday, phone_number
回表的代價
我們現在來討論一下回表
。還是用idx_name_birthday_phone_number
索引爲例,看下邊這個查詢:
SELECT * FROM staff_info WHERE name > 'Asa' AND name < 'Barlow';
在使用idx_name_birthday_phone_number
索引進行查詢時大致可以分爲這兩個步驟:
- 從索引
idx_name_birthday_phone_number
對應的B+
樹中取出name
值在Asa
~Barlow
之間的用戶記錄。 - 由於索引
idx_name_birthday_phone_number
對應的B+
樹用戶記錄中只包含name
、birthday
、phone_number
、id
這4個字段,而查詢列表是*
,意味着要查詢表中所有字段,也就是還要包括country
字段。這時需要把從上一步中獲取到的每一條記錄的id
字段都到聚簇索引對應的B+
樹中找到完整的用戶記錄,也就是我們通常所說的回表
,然後把完整的用戶記錄返回給查詢用戶。
由於索引idx_name_birthday_phone_number
對應的B+
樹中的記錄首先會按照name
列的值進行排序,所以值在Asa
~Barlow
之間的記錄在磁盤中的存儲是相連的,集中分佈在一個或幾個數據頁中,我們可以很快的把這些連着的記錄從磁盤中讀出來,這種讀取方式我們也可以稱爲順序I/O
。根據第1步中獲取到的記錄的id
字段的值可能並不相連,而在聚簇索引中記錄是根據id
(也就是主鍵)的順序排列的,所以根據這些並不連續的id
值到聚簇索引中訪問完整的用戶記錄可能分佈在不同的數據頁中,這樣讀取完整的用戶記錄可能要訪問更多的數據頁,這種讀取方式我們也可以稱爲隨機I/O
。一般情況下,順序I/O比隨機I/O的性能高很多,所以步驟1的執行可能很快,而步驟2就慢一些。所以這個使用索引idx_name_birthday_phone_number
的查詢有這麼兩個特點:
- 會使用到兩個
B+
樹索引,一個二級索引,一個聚簇索引。 - 訪問二級索引使用
順序I/O
,訪問聚簇索引使用隨機I/O
。
需要回表的記錄越多,使用二級索引的性能就越低,甚至讓某些查詢寧願使用全表掃描也不使用二級索引
。比方說name
值在Asa
~Barlow
之間的用戶記錄數量佔全部記錄數量90%以上,那麼如果使用idx_name_birthday_phone_number
索引的話,有90%多的id
值需要回表,這不是喫力不討好麼,還不如直接去掃描聚簇索引(也就是全表掃描)。
覆蓋索引
爲了徹底告別回表
操作帶來的性能損耗,我們建議:最好在查詢列表裏只包含索引列,比如這樣:
SELECT name, birthday, phone_number FROM staff_info WHERE name > 'Asa' AND name < 'Barlow'
因爲我們只查詢name
, birthday
, phone_number
這三個索引列的值,所以在通過idx_name_birthday_phone_number
索引得到結果後就不必到聚簇索引
中再查找記錄的剩餘列,也就是country
列的值了,這樣就省去了回表
操作帶來的性能損耗。我們把這種只需要用到索引的查詢方式稱爲索引覆蓋
。
索引如何優化
有了上面的鋪墊,這部分內容可以說是水到渠成。一起來看一看。
只爲用於搜索、排序或分組的列創建索引
也就是說,只爲出現在WHERE
子句中的列、連接子句中的連接列,或者出現在ORDER BY
或GROUP BY
子句中的列創建索引。而出現在查詢列表中的列就沒必要建立索引了:
SELECT birthday, country FROM staff_info WHERE name = 'Ashburn';
像查詢列表中的birthday
、country
這兩個列就不需要建立索引,我們只需要爲出現在WHERE
子句中的name
列創建索引就可以了。
考慮列的基數
列的基數
指的是某一列中不重複數據的個數,比方說某個列包含值2, 5, 8, 2, 5, 8, 2, 5, 8
,雖然有9
條記錄,但該列的基數卻是3
。也就是說,在記錄行數一定的情況下,列的基數越大,該列中的值越分散,列的基數越小,該列中的值越集中。這個列的基數
指標非常重要,直接影響我們是否能有效的利用索引。假設某個列的基數爲1
,也就是所有記錄在該列中的值都一樣,那爲該列建立索引是沒有用的,因爲所有值都一樣就無法排序,無法進行快速查找了~ 而且如果某個建立了二級索引的列的重複值特別多,那麼使用這個二級索引查出的記錄還可能要做回表操作,這樣性能損耗就更大了。所以結論就是:最好爲那些列的基數大的列建立索引,爲基數太小列的建立索引效果可能不好。
索引列的類型儘量小
我們在定義表結構的時候要顯式的指定列的類型,以整數類型爲例,有TINYINT
、MEDIUMINT
、INT
、BIGINT
這麼幾種,它們佔用的存儲空間依次遞增,我們這裏所說的類型大小
指的就是該類型表示的數據範圍的大小。能表示的整數範圍當然也是依次遞增,如果我們想要對某個整數列建立索引的話,在表示的整數範圍允許的情況下,儘量讓索引列使用較小的類型,比如我們能使用INT
就不要使用BIGINT
,能使用MEDIUMINT
就不要使用INT
~ 這是因爲:
- 數據類型越小,在查詢時進行的比較操作越快
- 數據類型越小,索引佔用的存儲空間就越少,在一個數據頁內就可以放下更多的記錄,從而減少磁盤
I/O
帶來的性能損耗,也就意味着可以把更多的數據頁緩存在內存中,從而加快讀寫效率。
這個建議對於表的主鍵來說更加適用,因爲不僅是聚簇索引中會存儲主鍵值,其他所有的二級索引的節點處都會存儲一份記錄的主鍵值,如果主鍵適用更小的數據類型,也就意味着節省更多的存儲空間和更高效的I/O
。
索引字符串值的前綴
我們知道一個字符串其實是由若干個字符組成,如果我們在MySQL
中使用utf8
字符集去存儲字符串的話,編碼一個字符需要佔用1~3
個字節。假設我們的字符串很長,那存儲一個字符串就需要佔用很大的存儲空間。在我們需要爲這個字符串列建立索引時,那就意味着在對應的B+
樹中有這麼兩個問題:
B+
樹索引中的記錄需要把該列的完整字符串存儲起來,而且字符串越長,在索引中佔用的存儲空間越大。- 如果
B+
樹索引中索引列存儲的字符串很長,那在做字符串比較時會佔用更多的時間。
我們前邊兒說過索引列的字符串前綴其實也是排好序的,所以索引的設計者提出了個方案 --- 只對字符串的前幾個字符進行索引也就是說在二級索引的記錄中只保留字符串前幾個字符。這樣在查找記錄時雖然不能精確的定位到記錄的位置,但是能定位到相應前綴所在的位置,然後根據前綴相同的記錄的主鍵值回表查詢完整的字符串值,再對比就好了。這樣只在B+
樹中存儲字符串的前幾個字符的編碼,既節約空間,又減少了字符串的比較時間,還大概能解決排序的問題。
不過以上存在一個問題,如果使用了索引列前綴,比方說前邊只把name
列的前10個字符放到了二級索引中,下邊這個查詢可能就有點兒尷尬了:
SELECT * FROM staff_info ORDER BY name LIMIT 10;
因爲二級索引中不包含完整的name
列信息,所以無法對前十個字符相同,後邊的字符不同的記錄進行排序,也就是使用索引列前綴的方式無法支持使用索引排序。
讓索引列在比較表達式中單獨出現
假設表中有一個整數列my_col
,我們爲這個列建立了索引。下邊的兩個WHERE
子句雖然語義是一致的,但是在效率上卻有差別:
WHERE my_col * 2 < 4
WHERE my_col < 4/2
第1個WHERE
子句中my_col
列並不是以單獨列的形式出現的,而是以my_col * 2
這樣的表達式的形式出現的,存儲引擎會依次遍歷所有的記錄,計算這個表達式的值是不是小於4
,所以這種情況下是使用不到爲my_col
列建立的B+
樹索引的。而第2個WHERE
子句中my_col
列並是以單獨列的形式出現的,這樣的情況可以直接使用B+
樹索引。
所以結論就是:如果索引列在比較表達式中不是以單獨列的形式出現,而是以某個表達式,或者函數調用形式出現的話,是用不到索引的。
主鍵插入順序
對於一個使用InnoDB
存儲引擎的表來說,在我們沒有顯式的創建索引時,表中的數據實際上都是存儲在聚簇索引
的葉子節點的。而記錄又是存儲在數據頁中的,數據頁和記錄又是按照記錄主鍵值從小到大的順序進行排序,所以如果我們插入的記錄的主鍵值是依次增大的話,那我們每插滿一個數據頁就換到下一個數據頁繼續插,而如果我們插入的主鍵值忽大忽小的話,這就比較麻煩了,涉及到頁面分裂和記錄移位。如果讓主鍵具有AUTO_INCREMENT
(自增),在插入記錄時存儲引擎會自動爲我們填入自增的主鍵值,則可以避免這個問題。
總結
關於InnoDB
存儲引擎的B+
樹索引,有以下總結:
- 每個索引都對應一棵
B+
樹,B+
樹分爲好多層,最下邊一層是葉子節點,其餘的是內節點。所有用戶記錄
都存儲在B+
樹的葉子節點,所有目錄項記錄
都存儲在內節點。 InnoDB
存儲引擎會自動爲主鍵(如果沒有它會自動幫我們添加)建立聚簇索引
,聚簇索引的葉子節點包含完整的用戶記錄。- 我們可以爲自己感興趣的列建立
二級索引
,二級索引
的葉子節點包含的用戶記錄由索引列 + 主鍵
組成,所以如果想通過二級索引
來查找完整的用戶記錄的話,需要通過回表
操作,也就是在通過二級索引
找到主鍵值之後再到聚簇索引
中查找完整的用戶記錄。 B+
樹中每層節點都是按照索引列值從小到大的順序排序而組成了雙向鏈表,而且每個頁內的記錄(不論是用戶記錄還是目錄項記錄)都是按照索引列的值從小到大的順序而形成了一個單鏈表。如果是聯合索引
的話,則頁面和記錄先按照聯合索引
前邊的列排序,如果該列值相同,再按照聯合索引
後邊的列排序。- 通過索引查找記錄是從
B+
樹的根節點開始,一層一層向下搜索。由於每個頁面都按照索引列的值建立了Page Directory
(頁目錄),所以在這些頁面中的查找非常快。
索引的使用和優化:
-
B+
樹索引在空間和時間上都有代價,所以沒事兒別瞎建索引。 -
B+
樹索引適用於下邊這些情況:- 全值匹配
- 匹配左邊的列
- 匹配範圍值
- 精確匹配某一列並範圍匹配另外一列
- 用於排序
- 用於分組
-
在使用索引時需要注意下邊這些事項:
- 只爲用於搜索、排序或分組的列創建索引
- 爲列的基數大的列創建索引
- 索引列的類型儘量小
- 可以只對字符串值的前綴建立索引
- 只有索引列在比較表達式中單獨出現纔可以適用索引
- 爲了儘可能少的讓
聚簇索引
發生頁面分裂和記錄移位的情況,建議讓主鍵擁有AUTO_INCREMENT
屬性。 - 定位並刪除表中的重複和冗餘索引
- 儘量使用
覆蓋索引
進行查詢,避免回表
帶來的性能損耗。