mysql:(七)索引

10 | MySQL爲什麼有時候會選錯索引

前面我們介紹過索引,你已經知道了在 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`)
);

然後,我們往表 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 語句:

select * from t where a between 10000 and 20000;

a 上有索引,肯定是要使用索引 a 的。

 

這裏,session A 的操作你已經很熟悉了,它就是開啓了一個事務。隨後,session B 把數

據都刪除後,又調用了 idata 這個存儲過程,插入了 10 萬行數據。

這時候,session B 的查詢語句 select * from t where a between 10000 and 20000 就

不會再選擇索引 a 了。我們可以通過慢查詢日誌(slow log)來查看一下具體的執行情

況。

爲了說明優化器選擇的結果是否正確,我增加了一個對照,即:使用 force index(a) 來讓

優化器強制使用索引 a(這部分內容,我還會在這篇文章的後半部分中提到)。

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

可以看到,Q1 掃描了 10 萬行,顯然是走了全表掃描,執行時間是 40 毫秒。Q2 掃描了

10001 行,執行了 21 毫秒。也就是說,我們在沒有使用 force index 的時候,MySQL

用錯了索引,導致了更長的執行時間。

這個例子對應的是我們平常不斷地刪除歷史數據和新增數據的場景。這時,MySQL 竟然

會選錯索引,是不是有點奇怪呢?今天,我們就從這個奇怪的結果說起吧。

 

優化器的邏輯

在第一篇文章中,我們就提到過,選擇索引是優化器的工作。

而優化器選擇索引的目的,是找到一個最優的執行方案,並用最小的代價去執行語句。在

數據庫裏面,掃描行數是影響執行代價的因素之一。掃描的行數越少,意味着訪問磁盤數

據的次數越少,消耗的 CPU 資源越少。

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

素進行綜合判斷。

我們這個簡單的查詢語句並沒有涉及到臨時表和排序,所以 MySQL 選錯索引肯定是在判

斷掃描行數的時候出問題了。

那麼,問題就是:掃描行數是怎麼判斷的?

MySQL 在真正開始執行語句之前,並不能精確地知道滿足這個條件的記錄有多少條,而

只能根據統計信息來估算記錄數。

這個統計信息就是索引的“區分度”。顯然,一個索引上不同的值越多,這個索引的區分

度就越好。而一個索引上不同的值的個數,我們稱之爲“基數”(cardinality)。也就是

說,這個基數越大,索引的區分度越好。

我們可以使用 show index 方法,看到一個索引的基數。如圖 4 所示,就是表 t 的 show

index 的結果 。雖然這個表的每一行的三個字段值都是一樣的,但是在統計信息中,這三

個索引的基數值並不同,而且其實都不準確。

那麼,MySQL 是怎樣得到索引的基數的呢?這裏,我給你簡單介紹一下 MySQL 採樣統

計的方法。

爲什麼要採樣統計呢?因爲把整張表取出來一行行統計,雖然可以得到精確的結果,但是

代價太高了,所以只能選擇“採樣統計”。

採樣統計的時候,InnoDB 默認會選擇 N 個數據頁,統計這些頁面上的不同值,得到一個

平均值,然後乘以這個索引的頁面數,就得到了這個索引的基數。

而數據表是會持續更新的,索引統計信息也不會固定不變。所以,當變更的數據行數超過

1/M 的時候,會自動觸發重新做一次索引統計。

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

的值來選擇:

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

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

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

但,這還不是全部。

你可以從圖 4 中看到,這次的索引統計值(cardinality 列)雖然不夠精確,但大體上還是

差不多的,選錯索引一定還有別的原因。

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

本身要掃描多少行。

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

其中,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 命令可以解決很多問題,但是前面我們說

了,優化器可不止是看掃描行數。

  EXPLAIN SELECT * FROM t WHERE (a BETWEEN 1 AND 1000) AND (b BETWEEN 1 AND 100000)ORDER BY a LIMIT 1;

如果使用索引 a 進行查詢,那麼就是掃描索引 a 的前 1000 個值,然後取到對應的 id,再

到主鍵索引上去查出每一行,然後根據字段 b 來過濾。顯然這樣需要掃描 1000 行。

如果使用索引 b 進行查詢,那麼就是掃描索引 b 的最後 50001 個值,與上面的執行過程

相同,也是需要回到主鍵索引上取值再判斷,所以需要掃描 50001 行。

所以你一定會想,如果使用索引 a 的話,執行速度明顯會快很多。那麼,下面我們就來看

看到底是不是這麼一回事兒。

索引選擇異常和處理

其實大多數時候優化器都能找到正確的索引,但偶爾你還是會碰到我們上面舉例的這兩種

情況:原本可以執行得很快的 SQL 語句,執行速度卻比你預期的慢很多,你應該怎麼辦

呢?

不過很多程序員不喜歡使用 force index,一來這麼寫不優美,二來如果索引改了名字,

這個語句也得改,顯得很麻煩。而且如果以後遷移到別的數據庫的話,這個語法還可能會

不兼容。

但其實使用 force index 最主要的問題還是變更的及時性。因爲選錯索引的情況還是比較

少出現的,所以開發的時候通常不會先寫上 force index。而是等到線上出現問題的時

候,你纔會再去修改 SQL 語句、加上 force index。但是修改之後還要測試和發佈,對於

生產系統來說,這個過程不夠敏捷。

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

既然優化器放棄了使用索引 a,說明 a 還不夠合適,所以第二種方法就是,我們可以考慮

修改語句,引導 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 是最小的那一

行,邏輯上一致,纔可以這麼做。

 

小結

今天我們一起聊了聊索引統計的更新機制,並提到了優化器存在選錯索引的可能性。

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

而對於其他優化器誤判的情況,你可以在應用端用 force index 來強行指定索引,也可以

通過修改語句來引導優化器,還可以通過增加或者刪除索引來繞過這個問題。

你可能會說,今天這篇文章後面的幾個例子,怎麼都沒有展開說明其原理。我要告訴你的

是,今天的話題,我們面對的是 MySQL 的 bug,每一個展開都必須深入到一行行代碼去

量化,實在不是我們在這裏應該做的事情

所以,我把我用過的解決方法跟你分享,希望你在碰到類似情況的時候,能夠有一些思

路。

你平時在處理 MySQL 優化器 bug 的時候有什麼別的方法,也發到評論區分享一下吧。

最後,我給你留下一個思考題。前面我們在構造第一個例子的過程中,通過 session A 的

配合,讓 session B 刪除數據後又重新插入了一遍數據,然後就發現 explain 結果中,

rows 字段從 10001 變成 37000 多。

而如果沒有 session A 的配合,只是單獨執行 delete from t 、call idata()、explain 這

三句話,會看到 rows 字段其實還是 10000 左右。你可以自己驗證一下這個結果。

這是什麼原因呢?也請你分析一下吧。

----------------------------------------------------------------------

在上一篇文章最後,我給你留的問題是,爲什麼經過這個操作序列,explain 的結果就不

對了?這裏,我來爲你分析一下原因。

delete 語句刪掉了所有的數據,然後再通過 call idata() 插入了 10 萬行數據,看上去是

覆蓋了原來的 10 萬行。

但是,session A 開啓了事務並沒有提交,所以之前插入的 10 萬行數據是不能刪除的。這

樣,之前的數據每一行數據都有兩個版本,舊版本是 delete 之前的數據,新版本是標記爲

deleted 的數據。

這樣,索引 a 上的數據其實就有兩份。

然後你會說,不對啊,主鍵上的數據也不能刪,那沒有使用 force index 的語句,使用

explain 命令看到的掃描行數爲什麼還是 100000 左右?(潛臺詞,如果這個也翻倍,也

許優化器還會認爲選字段 a 作爲索引更合適)

是的,不過這個是主鍵,主鍵是直接按照表的行數來估計的。而表的行數,優化器直接用

的是 show table status 的值。

-----------------------------------------------------------------------------------------------------------------------

感謝文章 作者

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章