mysql為什么有些時候會選錯索引


 

 

 

1、基本概念

在MySQL中一張表其實是可以支持多個索引的。但是,你寫SQL語句的時候,並沒有主動指定使用哪個索引。也就是說,使用哪個索引是由MySQL來確定的。

 

一般在數據庫使用的時候回遇到這樣的問題,一條本來可以執行很快的語句,卻由於MySQL選錯了索引,導致執行速度變得很慢。

 

舉例說明:

我們先建一個簡單的表,表里有a、b兩個字段,並分別建上索引:

 

CREATE TABLE `t` (

  `id` int(11) NOT NULL,

  `a` int(11) DEFAULT NULL,

  `b` int(11) DEFAULT NULL,

  PRIMARY KEY (`id`),

  KEY `a` (`a`),

  KEY `b` (`b`)

) ENGINE=InnoDB;

然后,我們往表t中插入10萬行記錄,取值按整數遞增,即:(1,1,1),(2,2,2),(3,3,3) 直到(100000,100000,100000)。

 

是用存儲過程來插入數據的:

 

delimiter ;;

create procedure idata()

begin

  declare i int;

  set i=1;

  while(i<=100000)do

    insert into t values(i, i, i);

    set i=i+1;

  end while;

end;;

delimiter ;

call idata();

接下來,我們分析一條SQL語句:

 

mysql> select * from t where a between 10000 and 20000;

一定會說,這個語句還用分析嗎,很簡單呀,a上有索引,肯定是要使用索引a的。

 

圖1顯示的就是使用explain命令看到的這條語句的執行情況。

圖1看上去,這條查詢語句的執行也確實符合預期,key這個字段值是’a’,表示優化器選擇了索引a。

 

不過別急,這個案例不會這么簡單。在我們已經准備好的包含了10萬行數據的表上,我們再做如下操作。

這時候,session B的查詢語句select * from t where a between 10000 and 20000就不會再選擇索引a了。我們可以通過慢查詢日志(slow log)來查看一下具體的執行情況。

 

為了說明優化器選擇的結果是否正確,我增加了一個對照,即:使用force index(a)來讓優化器強制使用索引a(這部分內容,我還會在這篇文章的后半部分中提到)。

 

下面的三條SQL語句,就是這個實驗過程。

 

set long_query_time=0;

select * from t where a between 10000 and 20000; /*Q1*/

select * from t force index(a) where a between 10000 and 20000;/*Q2*/

第一句,是將慢查詢日志的閾值設置為0,表示這個線程接下來的語句都會被記錄入慢查詢日志中;

第二句,Q1是session B原來的查詢;

第三句,Q2是加了force index(a)來和session B原來的查詢語句執行情況對比。

如圖3所示是這三條SQL語句執行完成后的慢查詢日志。

 

可以看到,Q1掃描了10萬行,顯然是走了全表掃描,執行時間是40毫秒。Q2掃描了10001行,執行了21毫秒。也就是說,我們在沒有使用force index的時候,MySQL用錯了索引,導致了更長的執行時間。

 

這個例子對應的是我們平常不斷地刪除歷史數據和新增數據的場景。這時,MySQL竟然會選錯索引,是不是有點奇怪呢?

2、優化器的邏輯

 

選擇索引是優化器的工作。

 

而優化器選擇索引的目的,是找到一個最優的執行方案,並用最小的代價去執行語句。在數據庫里面,掃描行數是影響執行代價的因素之一。掃描的行數越少,意味着訪問磁盤數據的次數越少,消耗的CPU資源越少。

 

當然,掃描行數並不是唯一的判斷標准,優化器還會結合是否使用臨時表、是否排序等因素進行綜合判斷。

 

我們這個簡單的查詢語句並沒有涉及到臨時表和排序,所以MySQL選錯索引肯定是在判斷掃描行數的時候出問題了。

 

問題是怎么掃描的行數呢?

 

MySQL在真正開始執行語句之前,並不能精確地知道滿足這個條件的記錄有多少條,而只能根據統計信息來估算記錄數。

 

這個統計信息就是索引的“區分度”。顯然,一個索引上不同的值越多,這個索引的區分度就越好。而一個索引上不同的值的個數,我們稱之為“基數”(cardinality)。也就是說,這個基數越大,索引的區分度越好。

 

我們可以使用show index方法,看到一個索引的基數。如圖4所示,就是表t的show index 的結果 。雖然這個表的每一行的三個字段值都是一樣的,但是在統計信息中,這三個索引的基數值並不同,而且其實都不准確。

3、MySQL是怎樣得到索引的基數呢?

為什么要采樣統計呢?因為把整張表取出來一行行統計,雖然可以得到精確的結果,但是代價太高了,所以只能選擇“采樣統計”。

 

采樣統計的時候,InnoDB默認會選擇N個數據頁,統計這些頁面上的不同值,得到一個平均值,然后乘以這個索引的頁面數,就得到了這個索引的基數。

 

而數據表是會持續更新的,索引統計信息也不會固定不變。所以,當變更的數據行數超過1/M的時候,會自動觸發重新做一次索引統計。

 

在MySQL中,有兩種存儲索引統計的方式,可以通過設置參數innodb_stats_persistent的值來選擇:

 

設置為on的時候,表示統計信息會持久化存儲。這時,默認的N是20,M是10。

設置為off的時候,表示統計信息只存儲在內存中。這時,默認的N是8,M是16。

由於是采樣統計,所以不管N是20還是8,這個基數都是很容易不准的。

 

但,這還不是全部。

 

可以從圖4中看到,這次的索引統計值(cardinality列)雖然不夠精確,但大體上還是差不多的,選錯索引一定還有別的原因。

 

其實索引統計只是一個輸入,對於一個具體的語句來說,優化器還要判斷,執行這個語句本身要掃描多少行。

 

接下來,我們再一起看看優化器預估的,這兩個語句的掃描行數是多少。

 

 

rows這個字段表示的是預計掃描行數。

 

其中,Q1的結果還是符合預期的,rows的值是104620;但是Q2的rows值是37116,偏差就大了。而圖1中我們用explain命令看到的rows是只有10001行,是這個偏差誤導了優化器的判斷。

 

到這里,可能你的第一個疑問不是為什么不准,而是優化器為什么放着掃描37000行的執行計划不用,卻選擇了掃描行數是100000的執行計划呢?

 

這是因為,如果使用索引a,每次從索引a上拿到一個值,都要回到主鍵索引上查出整行數據,這個代價優化器也要算進去的。

 

而如果選擇掃描10萬行,是直接在主鍵索引上掃描的,沒有額外的代價。

 

優化器會估算這兩個選擇的代價,從結果看來,優化器認為直接掃描主鍵索引更快。當然,從執行時間看來,這個選擇並不是最優的。

 

使用普通索引需要把回表的代價算進去,在圖1執行explain的時候,也考慮了這個策略的代價 ,但圖1的選擇是對的。也就是說,這個策略並沒有問題。

 

所以冤有頭債有主,MySQL選錯索引,這件事兒還得歸咎到沒能准確地判斷出掃描行數。

 

既然是統計信息不對,那就修正。analyze table t 命令,可以用來重新統計索引信息。我們來看一下執行效果。

 

所以在實踐中,如果你發現explain的結果預估的rows值跟實際情況差距比較大,可以采用這個方法來處理。

其實,如果只是索引統計不准確,通過analyze命令可以解決很多問題,但是前面我們說了,優化器可不止是看掃描行數。

依然是基於這個表t,我們看看另外一個語句:

 

mysql> select * from t where (a between 1 and 1000)  and (b between 50000 and 100000) order by b limit 1;

從條件上看,這個查詢沒有符合條件的記錄,因此會返回空集合。

 

在開始執行這條語句之前,你可以先設想一下,如果你來選擇索引,會選擇哪一個呢?

 

為了便於分析,我們先來看一下a、b這兩個索引的結構圖。

如果使用索引a進行查詢,那么就是掃描索引a的前1000個值,然后取到對應的id,再到主鍵索引上去查出每一行,然后根據字段b來過濾。顯然這樣需要掃描1000行。

 

如果使用索引b進行查詢,那么就是掃描索引b的最后50001個值,與上面的執行過程相同,也是需要回到主鍵索引上取值再判斷,所以需要掃描50001行。

 

所以你一定會想,如果使用索引a的話,執行速度明顯會快很多。那么,下面我們就來看看到底是不是這么一回事兒。

 

圖8是執行explain的結果。

 

mysql> explain select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;

可以看到,返回結果中key字段顯示,這次優化器選擇了索引b,而rows字段顯示需要掃描的行數是50198。

從這個結果中,你可以得到兩個結論:

  1. 掃描行數的估計值依然不准確;
  2. 這個例子里MySQL又選錯了索引。

 

4、索引選擇異常和處理

其實大多數時候優化器都能找到正確的索引,但偶爾還是會碰到我們舉例的這兩種情況:原本可以執行得很快的SQL語句,執行速度卻比預期的慢很多,該怎么處理呢?

 

(1)       第一種方法采用force index強行選擇一個索引。MySQL會根據詞法解析的結果分析出可能可以使用的索引作為候選項,然后在候選列表中依次判斷每個索引需要掃描多少行。如果force index指定的索引在候選索引列表中,就直接選擇這個索引,不再評估其他索引的執行代價。

不過很多程序員不喜歡使用force index,一來這么寫不優美,二來如果索引改了名字,這個語句也得改,顯得很麻煩。而且如果以后遷移到別的數據庫的話,這個語法還可能會不兼容。

 

但其實使用force index最主要的問題還是變更的及時性。因為選錯索引的情況還是比較少出現的,所以開發的時候通常不會先寫上force index。而是等到線上出現問題的時候,你才會再去修改SQL語句、加上force index。但是修改之后還要測試和發布,對於生產系統來說,這個過程不夠敏捷。

 

所以,數據庫的問題最好還是在數據庫內部來解決。那么,在數據庫里面該怎樣解決呢?

(2)      第二種方法我們可以考慮修改語句,引導MySQL使用我們期望的索引。

 

比如,在這個例子里,顯然把“order by b limit 1” 改成 “order by b,a limit 1” ,語義的邏輯是相同的。

 

 

之前優化器選擇使用索引b,是因為它認為使用索引b可以避免排序(b本身是索引,已經是有序的了,如果選擇索引b的話,不需要再做排序,只需要遍歷),所以即使掃描行數多,也判定為代價更小。

 

現在order by b,a 這種寫法,要求按照b,a排序,就意味着使用這兩個索引都需要排序。因此,掃描行數成了影響決策的主要條件,於是此時優化器選了只需要掃描1000行的索引a。

 

當然,這種修改並不是通用的優化手段,只是剛好在這個語句里面有limit 1,因此如果有滿足條件的記錄, order by b limit 1和order by b,a limit 1 都會返回b是最小的那一行,邏輯上一致,才可以這么做。

 

如果你覺得修改語義這件事兒不太好,這里還有一種改法,下圖是執行效果。

mysql> select * from  (select * from t where (a between 1 and 1000)  and (b between 50000 and 100000) order by b limit 100)alias limit 1;

 

在這個例子里,我們用limit 100讓優化器意識到,使用b索引代價是很高的。其實是我們根據數據特征誘導了一下優化器,也不具備通用性。

 

(1)   第三種方法是,在有些場景下,我們可以新建一個更合適的索引,來提供給優化器做選擇,或刪掉誤用的索引。

不過,在這個例子中,我沒有找到通過新增索引來改變優化器行為的方法。這種情況其實比較少,尤其是經過DBA索引優化過的庫,再碰到這個bug,找到一個更合適的索引一般比較難。

 

如果我說還有一個方法是刪掉索引b,你可能會覺得好笑。但實際上我碰到過兩次這樣的例子,最終是DBA跟業務開發溝通后,發現這個優化器錯誤選擇的索引其實根本沒有必要存在,於是就刪掉了這個索引,優化器也就重新選擇到了正確的索引。

 

5、小結

索引統計的更新機制,並提到了優化器存在選錯索引的可能性。

 

對於由於索引統計信息不准確導致的問題,你可以用analyze table來解決。

 

而對於其他優化器誤判的情況,你可以在應用端用force index來強行指定索引,也可以通過修改語句來引導優化器,還可以通過增加或者刪除索引來繞過這個問題。

 


免責聲明!

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



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