1. 程式人生 > 資料庫 >Mysql初探:索引

Mysql初探:索引

此文為極客時間:MySQL實戰45講的 4、5、9、10、11、15、18節索引相關部分的總結

一、Innodb索引模型

1.主鍵/非主鍵索引的區別

每個索引在Innodb中都是一顆B+樹,其中根據索引葉子節點的不同,分為主鍵索引和非主鍵索引。

image-20200930164149290

我們可以看到:

  • 主鍵索引將索引和整行的資料都放在了一起,所以又叫聚簇索引
  • 非主鍵索引的葉子節點內容是主鍵的值。所以又叫二級索引

其中,如果非主鍵索引查詢欄位沒有做到覆蓋索引,就需要先從非主鍵索引樹中找到對應的主鍵,然後再回到主鍵索引樹找到對應的行資料,這個過程叫做回表

而相應的,直接把全部的主鍵索引過一遍,然後每拿到一個主鍵索引,就把相應的資料拿出來,這個過程叫做全表掃描

2.索引維護

B+ 樹為了維護索引有序性,在插入新值的時候需要做必要的維護。

以上面這個圖為例,如果插入新的行 ID 值為 700,則只需要在 R5 的記錄後面插入一個新記錄。如果新插入的 ID 值為 400,就相對麻煩了,需要邏輯上挪動後面的資料,空出位置。

更糟的情況是,如果 R5 所在的資料頁已經滿了,根據 B+ 樹的演算法,這時候需要申請一個新的資料頁,然後挪動部分資料過去。這個過程稱為頁分裂。在這種情況下,效能自然會受影響。(同理,相鄰的兩個頁如果刪除了資料,也會執行一個合併的過程)

3.根據主鍵和非主鍵索引排序

假設t有欄位a,b,c,d,設定(a,b)為主鍵索引,c,(c,b)為非主鍵索引,當查詢的時候情況如下:

  • 使用(a,b),則預設排序為先按a排序,再按b排序
  • 使用c,則實際為(c,a,b),先按c排序,再按a排序,接著按b排序
  • 使用(c,b),這實際為(c,b,a),先按c排序,再按b排序,接著按a排序

我們可以認為,排序的時候innodb會預設去重並且在排序條件上加上主鍵

二 .為什麼要使用自增主鍵

1.索引有序

當我們使用自增主鍵的時候,插入新記錄的時候可以不指定 ID 的值,系統會獲取當前 ID 最大值加 1 作為下一條記錄的 ID 值。這樣的主鍵是預設有序的,不涉及到挪動其他記錄,也不會觸發葉子節點的分裂

2.節約空間

由於每個非主鍵索引的葉子節點上都是主鍵的值。如果用身份證號做主鍵,那麼每個二級索引的葉子節點佔用約 20 個位元組,而如果用整型做主鍵,則只要 4 個位元組,如果是長整型(bigint)則是 8 個位元組。

顯然,主鍵長度越小,普通索引的葉子節點就越小,普通索引佔用的空間也就越小。一頁加載出來的資料就越多

3.非自增主鍵的情況

一些專案會使用雪花演算法獲取 id,主鍵是遞增的,就並不會影響索引的有序性。

三、覆蓋索引與最左字首

1.索引覆蓋

假如我們建立了一個覆蓋欄位id和B的聯合索引,如果執行的語句是 select id,B from T where id between 3 and 5,由於要查詢的欄位id和B都已經在B索引樹上了,因此可以直接提供查詢結果,不需要回表。也就是說,在這個查詢裡面,索引已經“覆蓋了”我們的查詢需求,我們稱為覆蓋索引

2.最左字首

由於Innodb的索引結構是B+樹,所以索引可以通過最左字首原則讓聯合索引的“一部分”也能起作用。

我們以(name,age)聯合索引舉例:

image-20200930171920117

當搜尋條件是

select * from T where name = '張三'

或者

select * from T where name like '張%'

都能通過索引快速定位到第一個符合條件的記錄,然後向後遍歷獲取資料。

可見,這個最左字首可以是聯合索引的最左 N 個欄位,也可以是字串索引的最左 M 個字元

如果既有聯合查詢,又有基於 a、b 各自的查詢呢?查詢條件裡面只有 b 的語句,是無法使用 (a,b) 這個聯合索引的,這時候你不得不維護另外一個索引,也就是說你需要同時維護 (a,b)、(b) 這兩個索引。

這時候,我們要考慮的原則就是空間了。比如上面這個表的情況,name 欄位是比 age 欄位大的 ,那我就建議你建立一個(name,age) 的聯合索引和一個 (age) 的單欄位索引。

3.索引下推

根據上一個例子,我們有sql:

select * from T where name like '張%' where age = 10

在 MySQL 5.6 之前,只能從 ID3 開始一個個回表。到主鍵索引上找出資料行,再對比age欄位值。

而 MySQL 5.6 引入的索引下推優化(index condition pushdown), 可以在索引遍歷過程中,先對索引中包含的欄位先做判斷,直接過濾掉不滿足條件的記錄,減少回表次數。

InnoDB 在 (name,age) 索引內部就判斷了 age 是否等於 10,對於不等於 10 的記錄,直接判斷並跳過。在我們的這個例子中,只需要對 ID4、ID5 這兩條記錄回表取資料判斷,就只需要回表 2 次。

簡單的說,如果你的判斷欄位被聯合索引覆蓋了,但是又不符合最左字首,那樣資料庫引擎會自動在非主鍵索引樹階段就做完判斷,避免不必要的回表。

四、字首索引

1.字首索引的優劣

很多情況下,我們需要根據一個長字串型別的欄位去查詢記錄,比如身份證,郵箱,為了避免全表掃描,就需要為字串欄位新增索引。

由於Mysql支援字首索引,所以我們可以選擇將整個欄位新增索引,或者只將前一部分的字串加上索引

#整個欄位
alter table T add index index1(email);
#一部分欄位
alter table T add index index2(email(6));

假設我們執行一條查詢sql:

select id,name,email from SUser where email='[email protected]';

對於完整索引:

  1. 從 index1 索引樹找到滿足索引值是’[email protected]’的這條記錄,取得 ID2 的值;
  2. 到主鍵上查到主鍵值是 ID2 的行,判斷 email 的值是正確的,將這行記錄加入結果集;
  3. 取 index1 索引樹上剛剛查到的位置的下一條記錄,發現已經不滿足 email='[email protected]’的條件了,迴圈結束。

而對於字首索引:

  1. 從 index2 索引樹找到滿足索引值是’zhangs’的記錄,找到的第一個是 ID1;
  2. 到主鍵上查到主鍵值是 ID1 的行,判斷出 email 的值不是’[email protected]’,這行記錄丟棄;
  3. 取 index2 上剛剛查到的位置的下一條記錄,發現仍然是’zhangs’,取出 ID2,再到 ID 索引上取整行然後判斷,這次值對了,將這行記錄加入結果集;
  4. 重複上一步,直到在 index2 上取到的值不是’zhangs’時,迴圈結束。

根據這個流程,我們不難發現字首索引有以下問題:

  • 索引覆蓋失效:由於字首索引在命中以後,必須再回主鍵索引樹確定一次,所以索引覆蓋對字首索引來說是無效的。
  • 回表次數多:使用字首索引後,可能會導致查詢語句讀資料的次數變多。

2.如何選擇合適的長度

字首索引需要有足夠的區分度才能提高查詢效率。比如有ABCC,ABDD,ABEE三條資料,選前兩個個字元作為索引等於沒加索引,選前三個字元作為索引就很合適。當然,實際情況肯定會更復雜,我們就需要更具體的分析。

  • 首先,算出這個列上有多少個不同的值:

    select count(distinct email) as L from T;
    
  • 依次選取不同長度的字首來看這個值,比如我們要看一下 4~7 個位元組的字首索引,可以用這個語句:

    select 
      count(distinct left(email,4))as L4,
      count(distinct left(email,5))as L5,
      count(distinct left(email,6))as L6,
      count(distinct left(email,7))as L7,
    from T;
    
  • 使用字首索引必然會損失一部分割槽分度,所以我們需要預先設定一個可以接受的損失比例,比如 5%。然後,在返回的 L4~L7 中,找出不小於 L * 95% 的值,然後選擇最短的長度。

3.其他優化方式

對於郵箱,字首索引效果還比較明顯,因為@之前的字串一般不會有太多的相似度,但是對於比如像身份證這樣,同一個縣市裡的市民只有後幾位才會有較大區別的長字串,可能就需要設定一個非常長的字首索引了,這顯然不是我們樂意見到的。

  • 倒序儲存

    我們可以藉助reverse()函式實現倒序儲存。比如身份證存入的時候我們可以倒序儲存,查詢的時候也先反轉在查詢。這樣加索引以後只需要選擇前幾位辨識度高的即可。

  • Hash欄位

    我們藉助crc32/64()函式去獲取長字串的校驗碼,在表上另外開一個欄位用於儲存對應的校驗碼,以長度較短的校驗碼作為索引。不過由於crc32仍然會出現值重複的情況,所以查詢的時候還需要判斷拿到的記錄是否與條件欄位完全一致。

他們的異同如下:

  • 都不支援範圍查詢
  • 佔用空間:倒序儲存方式在主鍵索引上,不會消耗額外的儲存空間,而 hash 欄位方法需要增加一個欄位。當然,倒序儲存方式使用 4 個位元組的字首長度應該是不夠的,如果再長一點,這個消耗跟額外這個 hash 欄位也差不多抵消了。
  • 額外消耗:序方式每次寫和讀的時候,都需要額外呼叫一次 reverse 函式,而 hash 欄位的方式需要額外呼叫一次 crc32() 函式。如果只從這兩個函式的計算複雜度來看的話,reverse 函式額外消耗的 CPU 資源會更小些。
  • 查詢效率:使用 hash 欄位方式的查詢效能相對更穩定一些。因為 crc32() 算出來的值雖然有衝突的概率,但是概率非常小,可以認為每次查詢的平均掃描行數接近 1。而倒序儲存方式畢竟還是用的字首索引的方式,也就是說還是會增加掃描行數。

當然,還有一種折中的方法,就是拆分欄位

對於像郵箱這樣的欄位,有時候@後面的欄位往往都是固定的幾種,可以單獨拆分出來作為一個欄位,@前的作為單獨的欄位直接加全欄位索引,這樣減少的欄位長度,並且保證也了範圍查詢的效能。

4.小結

要給字串型別欄位的加索引,我們有以下幾種方式:

  1. 直接建立完整索引,這樣可能比較佔用空間;
  2. 建立字首索引,節省空間,但會增加查詢掃描次數,並且不能使用覆蓋索引;
  3. 倒序儲存,再建立字首索引,用於繞過字串本身字首的區分度不夠的問題;
  4. 建立 hash 欄位索引,查詢效能穩定,有額外的儲存和計算消耗,跟第三種方式一樣,都不支援範圍掃描。

五、唯一索引和 change buffer

1.對查詢的影響

對於普通索引,當執行定值查詢的時候,會先按索引找到對應的葉子節點,即資料頁,然後通過二分法查詢到第一條符合條件的資料,然後繼續查詢直到遇到第一個不符合條件的資料。

而對於唯一索引,當找到第一條符合條件的資料即返回,因為已經能確定是唯一的了。

由於mysql載入資料是根據頁來載入的,當已有的頁裡找不到對應的資料的時候,不會從磁碟單獨讀取一條資料,而是接著載入下一頁然後再在記憶體裡查詢,因此,對於普通索引來說,要多做的那一次“查詢和判斷下一條記錄”的操作,就只需要一次指標尋找和一次計算。由於一個數據頁可以放很多的資料,大多數情況下相鄰的資料都在同一頁,相對於現在強大的cpu效能,節省的那些查詢時間可以忽略不計。

也就是說,對於查詢,唯一索引和普通索引差別的不大

2.對更新的影響

首先我們需要了解一個新東西:change buffer,也就是寫緩衝。

change buffer 和 log buffer 一樣,也是 buffer pool 的一部分,他會佔用 buffer pool 的容量。

我們知道,mysql按頁去將磁碟中的資料讀取到記憶體中(一頁的大小通常是16k),當需要更新一個數據頁時,如果資料頁在記憶體中就直接更新,而如果這個資料頁還沒有在記憶體中的話,在不影響資料一致性的前提下,InooDB 會將這些更新操作快取在 change buffer 中,這樣就不需要從磁碟中讀入這個資料頁了。在下次查詢需要訪問這個資料頁的時候,將資料頁讀入記憶體,然後執行 change buffer 中與這個頁有關的操作。

將 change buffer 中的操作應用到原資料頁,得到最新結果的過程稱為 merge。一般在三種情況下會進行merge:

  • 這個資料頁被訪問
  • 在資料庫正常關閉的過程中
  • 後臺執行緒會定時執行

而唯一索引在插入或者更新時必須先獲取對應記錄以保證唯一性,也就是說當更新的時候必然要訪問資料頁,所以唯一索引無法使用 change buffer 。所以,如果要更新一條資料,而該資料所在頁又不在記憶體中,就要先把資料頁讀入記憶體,這一過程隨機的磁碟IO,是消耗非常大的操作。

所以,一般情況下,不推薦使用唯一索引。除非業務需要保證欄位的唯一性。

3.寫緩衝的使用場景

值得一提的是,並不是所有情況下使用 change buffer 都會帶來收益,因為 merge 的時候是真正進行資料更新的時刻,而 change buffer 的主要目的就是將記錄的變更動作快取下來,所以在一個數據頁做 merge 之前,change buffer 記錄的變更越多,收益就越大。

因此,對於寫多讀少的業務來說,頁面在寫完以後馬上被訪問到的概率比較小,此時 change buffer 的使用效果最好。這種業務模型常見的就是賬單類、日誌類的系統。

反過來,假設一個業務的更新模式是寫入之後馬上會做查詢,那麼即使滿足了條件,將更新先記錄在 change buffer,但之後由於馬上要訪問這個資料頁,會立即觸發 merge 過程。這樣隨機訪問 IO 的次數不會減少,反而增加了 change buffer 的維護代價。所以,對於這種立刻寫立刻讀的業務模式來說,change buffer 反而起到了副作用。這種情況就需要關閉寫緩衝。

六、索引失效的情況

一般來說,如果查詢很慢,應該優先考慮一下是不是沒加索引,或是因為 sql 的寫法而導致查詢未能走索引。針對以下例子,我們討論日常可能出現的“索引失效”的情況。

這裡我們需要針對“索引失效”的情況做一下區分:

  • 全表掃描:即真正意義上的索引失效,指的是不走索引回表而是直接進行全表掃描,把資料一行一行的拿出來對比欄位;
  • 索引掃描:指的是通過索樹快速定位了一部分資料,然後再根據索引樹上的主鍵id會主鍵索引樹把對應的資料拿出來;
  • 全索引掃描:指的是介於兩者中間的狀態:使用了索引,但是把全部的索引都走了一遍

這裡的情況大多數是指全索引掃描。

1.對條件欄位的函式操作

假如我們執行了這麼一條SQL:

# 統計7月的總記錄條數
select count(*) from tradelog where month(t_modified)=7;

其中原本 t_modified 是有索引的,但是使用了 month() 函式之後走了全索引掃描,影響了查詢速度。

image-20201030140942153

上圖是索引的樹結構,我們不難看出,由於索引同層節點間是有序的,如果使用日期去查詢的話,可以很快的定位到存在目標資料的下一層對應的父節點,也就是綠色箭頭的路線。但是使用了 month() 函式後,由於索引的節點並不直接包含 month() 計算得到的樹值,所以是無序的,如上圖的二級節點所示,所以只能選擇直接從葉子結點把所有的節點都過一遍,也就是說,如果有十萬條資料,他就得把十萬個索引節點都走一遍。

當然,雖然不能快速定位,但是查詢依然通過遍歷索引樹的方式走了查詢,沒有直接回表全表查詢,也就是說,其實還是走了索引,但是沒有用到 B+ 樹快速定位的性質,查詢是速度還是有所下降的。

總結一下,就是:由於加了函式操作,MySQL 無法再使用索引快速定位功能,而只能使用全索引掃描。

值得一提的是,雖然不是所有的函式操作都會破壞索引樹的有序性,但是優化器仍然會選擇不使用索引:

# 全索引掃描
select * from tradelog where id + 1 = 10000
# 快速定位
select * from tradelog where id = 10000 - 1

2.隱式型別轉換

假如要執行以下的sql:

# 全索引掃描
select * from tradelog where tradeid = 110717;

其中,由於 tradeid 是 varchar 型別,但是查詢的條件卻是 int 型別,這導致了隱式的型別轉換,也就是使用了函式操作。

而在 mysql 中,字串和數字比較,是轉換字串為數字,也就說,上面的 sql 實際上等同於:

select * from tradelog where CAST(tradid AS signed int)  = 110717;

這裡的原理和上文提到了對條件欄位的函式操作是一樣的,因為對條件欄位的操作破壞了索引樹的有序性,導致只能全索引掃描。換而言之,如果不破壞有序性,函式操作就不會影響索引樹的快速定位:

# 快速定位
select * from tradelog where id = '110717';
# 上面的sql等同於下面
select * from tradelog where  id = CAST('110717' AS signed int);

如上,id 是 int 型別的欄位,那麼不會導致索引的快速定位失效。

也就是說:要將函式操作的物件從條件欄位變成條件欄位的引數

3.隱式字元編碼轉換

假如要執行以下的 sql:

select d.* from tradelog l
left join trade_detail d on d.tradeid = l.tradeid
where l.id=2

其中,trade_detail 的 tradeid 欄位是有索引的,但是 explain 後卻顯示查詢 trade_detail 仍然全表掃描。

也就是說,我們希望 tradelog 拿到了 tradeid 以後,能夠直接在 trade_detail 的 tradeid 索引樹上找到對應的記錄,然後直接回表取出對應的資料,但是他卻沒通過索引,而是直接把 trade_detail 掃了一遍,把 tradeid 符合的資料拿出來了。

原因在於,tradelog 的字符集是 utf8,trade_detail 的字符集是 utf8mb4。而

utf8mb4 是 utf8 的超集。類似地,在程式設計語言裡面,做自動型別轉換的時候,為了避免資料在轉換過程中由於截斷導致資料錯誤,也都是“按資料長度增加的方向”進行轉換的。

所以對於 trade_detail,他的 sql 實際上是這樣的:

# 將trade_detail的tradeid轉成utf8mb4
select d.* from trade_detail d where CONVERT(d.tradeid USING utf8mb4) = l.tradeid

可見,這又是一個對條件欄位使用了函式操作的情況。

根據上面兩種情況的,我們有兩種方法來優化這個 sql:

  • 將 trade_detail 錶轉為 utf8mb4 的編碼格式

  • 將函式操作的物件從條件欄位變成條件欄位的引數

    select d.* from trade_detail d where d.tradeid = CONVERT(l.tradeid USING utf8mb4) 
    

七、總結

  1. 儘量做到索引覆蓋,減少回表次數;
  2. 排序總是會在按排序條件排完後,再根據主鍵排序,所以聯合索引的最後不必包含主鍵欄位;
  3. 索引需要儘可能的保證有序,並且儘可能的小;
  4. 條件欄位需要儘可能的使用索引覆蓋,以便索引下推,減少回表;
  5. 條件欄位需要儘可能的按照聯合索引的欄位順序排序,以便最左字首原則生效;
  6. 長字串索引使用索引:
    • 完整索引。這樣可能比較佔用空間;
    • 字首索引。節省空間,但會增加查詢掃描次數,並且不能使用覆蓋索引;
    • 倒序儲存。再建立字首索引,用於繞過字串本身字首的區分度不夠的問題;
    • 建立 hash 欄位索引。查詢效能穩定,有額外的儲存和計算消耗,跟第三種方式一樣,都不支援範圍掃描。
  7. 型別轉換和字符集轉換的實質都是實用的函式,而函式操作會引起的索引失效和全索引掃描問題,解決方式是將要將函式操作的物件從條件欄位變成條件欄位的引數