MySQL實戰45講學習筆記:第十講

 一 、本節內容概要

前面咱們介紹過索引,你已經知道了在 MySQL 中一張表實際上是能夠支持多個索引的。可是,你寫 SQL 語句的時候,並無主動指定使用哪一個索引。也就是說,使用哪一個索引是由MySQL 來肯定的。mysql

不知道你有沒有碰到過這種狀況,一條原本能夠執行得很快的語句,卻因爲 MySQL 選錯了索引,而致使執行速度變得很慢?程序員

咱們一塊兒來看一個例子吧。算法

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

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)。數據庫

我是用存儲過程來插入數據的,這裏我貼出來方便你復現:bash

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 語句:session

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

你必定會說,這個語句還用分析嗎,很簡單呀,a 上有索引,確定是要使用索引 a 的 測試

你說得沒錯,圖 1 顯示的就是使用 explain 命令看到的這條語句的執行狀況。優化

 圖 1 使用 explain 命令查看語句執行狀況spa

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

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

圖 2 session A 和 session B 的執行流程

這裏,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 語句,就是這個實驗過程。

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 語句執行完成後的慢查詢日誌

圖 3 slow log 結果

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

這個例子對應的是咱們日常不斷地刪除歷史數據和新增數據的場景。這時,MySQL 居然會選錯索引,是否是有點奇怪呢?今天,咱們就從這個奇怪的結果提及吧。

2、優化器的邏輯

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

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

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

咱們這個簡單的查詢語句並無涉及到臨時表和排序,因此 MySQL 選錯索引確定是在判斷掃描行數的時候出問題了。

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

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

這個統計信息就是索引的「區分度」。顯然,一個索引上不一樣的值越多,這個索引的區分度就越好。而一個索引上不一樣的值的個數,咱們稱之爲「基數」(cardinality)。也就是
說,這個基數越大,索引的區分度越好。

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

 圖 4 表 t 的 show index 結果

二、那麼,MySQL 是怎樣獲得索引的基數的呢?

這裏,我給你簡單介紹一下 MySQL 採樣統計的方法。

爲何要採樣統計呢?由於把整張表取出來一行行統計,雖然能夠獲得精確的結果,可是代價過高了,因此只能選擇「採樣統計」。

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

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

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

  1. 設置爲 on 的時候,表示統計信息會持久化存儲。這時,默認的 N 是 20,M 是 10。
  2. 設置爲 off 的時候,表示統計信息只存儲在內存中。這時,默認的 N 是 8,M 是 16。

因爲是採樣統計,因此無論 N 是 20 仍是 8,這個基數都是很容易不許的。但,這還不是所有。

你能夠從圖 4 中看到,此次的索引統計值(cardinality 列)雖然不夠精確,但大致上仍是差很少的,選錯索引必定還有別的緣由。

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

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

 圖 5 意外的 explain 結果

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

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

到這裏,可能你的第一個疑問不是爲何不許,而是優化器爲何放着掃描 37000 行的執行計劃不用,卻選擇了掃描行數是 100000 的執行計劃呢?

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

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

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

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

因此冤有頭債有主,MySQL 選錯索引,這件事兒還得歸咎到沒能準確地判斷出掃描行數。至於爲何會獲得錯誤的掃描行數,這個緣由就做爲課後問題,留給你去分析了。
既然是統計信息不對,那就修正。

三、analyze table t 命令,能夠用來從新統計索引信息。

咱們來看一下執行效果。

圖 6 執行 analyze table t 命令恢復的 explain 結果

這回對了。

因此在實踐中,若是你發現 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 這兩個索引的結構圖。

 

圖 7 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;

 圖 8 使用 explain 方法查看執行計劃 

能夠看到,返回結果中 key 字段顯示,此次優化器選擇了索引 b,而 rows 字段顯示須要掃描的行數是 50198。從這個結果中,你能夠獲得兩個結論:

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

3、索引選擇異常和處理

其實大多數時候優化器都能找到正確的索引,但偶爾你仍是會碰到咱們上面舉例的這兩種狀況:本來能夠執行得很快的 SQL 語句,執行速度卻比你預期的慢不少,你應該怎麼辦呢?

一種方法是,像咱們第一個例子同樣,採用 force index 強行選擇一個索引。MySQL 會根據詞法解析的結果分析出可能可使用的索引做爲候選項,而後在候選列表中依次判斷
每一個索引須要掃描多少行。若是 force index 指定的索引在候選索引列表中,就直接選擇這個索引,再也不評估其餘索引的執行代價。

咱們來看看第二個例子。剛開始分析時,咱們認爲選擇索引 a 會更好。如今,咱們就來看看執行效果

圖 9 使用不一樣索引的語句執行耗時

能夠看到,本來語句須要執行 2.23 秒,而當你使用 force index(a) 的時候,只用了 0.05秒,比優化器的選擇快了 40 多倍。

也就是說,優化器沒有選擇正確的索引,force index 起到了「矯正」的做用。

不過不少程序員不喜歡使用 force index,一來這麼寫不優美,二來若是索引改了名字,這個語句也得改,顯得很麻煩。並且若是之後遷移到別的數據庫的話,這個語法還可能會不兼容

但其實使用 force index 最主要的問題仍是變動的及時性。由於選錯索引的狀況仍是比較少出現的,因此開發的時候一般不會先寫上 force index。而是等到線上出現問題的時
候,你纔會再去修改 SQL 語句、加上 force index。可是修改以後還要測試和發佈,對於生產系統來講,這個過程不夠敏捷。

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

既然優化器放棄了使用索引 a,說明 a 還不夠合適,因此第二種方法就是,咱們能夠考慮修改語句,引導 MySQL 使用咱們指望的索引。好比,在這個例子裏,顯然把「order by
b limit 1」 改爲 「order by b,a limit 1」 ,語義的邏輯是相同的。咱們來看看改以後的效果:

圖 10 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 是最小的那一
行,邏輯上一致,才能夠這麼作。

若是你以爲修改語義這件事兒不太好,這裏還有一種改法,圖 11 是執行效果。

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;

圖 11 改寫 SQL 的 explain


在這個例子裏,咱們用 limit 100 讓優化器意識到,使用 b 索引代價是很高的。實際上是咱們根據數據特徵誘導了一下優化器,也不具有通用性。

第三種方法是,在有些場景下,咱們能夠新建一個更合適的索引,來提供給優化器作選擇,或刪掉誤用的索引。

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

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

4、小結

今天咱們一塊兒聊了聊索引統計的更新機制,並提到了優化器存在選錯索引的可能性。對於因爲索引統計信息不許確致使的問題,你能夠用 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 左右。你能夠本身驗證一下這個結果。
這是什麼緣由呢?也請你分析一下吧。

你能夠把你的分析結論寫在留言區裏,我會在下一篇文章的末尾和你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。

5、上期問題時間

我在上一篇文章最後留給你的問題是,若是某次寫入使用了 change buffer 機制,以後主機異常重啓,是否會丟失 change buffer 和數據。

這個問題的答案是不會丟失,留言區的不少同窗都回答對了。雖然是隻更新內存,可是在事務提交的時候,咱們把 change buffer 的操做也記錄到 redo log 裏了,因此崩潰恢復
的時候,change buffer 也能找回來。

在評論區有同窗問到,merge 的過程是否會把數據直接寫回磁盤,這是個好問題。這裏,我再爲你分析一下。

merge 的執行流程是這樣的:

1. 從磁盤讀入數據頁到內存(老版本的數據頁);
2. 從 change buffer 裏找出這個數據頁的 change buffer 記錄 (可能有多個),依次應用,獲得新版數據頁;
3. 寫 redo log。這個 redo log 包含了數據的變動和 change buffer 的變動。

到這裏 merge 過程就結束了。這時候,數據頁和內存中 change buffer 對應的磁盤位置都尚未修改,屬於髒頁,以後各自刷回本身的物理數據,就是另一個過程了。

6、經典留言

今天這個問題不是特別明白爲何。session A開啓了一致性讀,session B delete或者insert,以前記錄都已經放進了undo了。二級索引的記錄也寫進了redo和change buffer,應該說刪除了索引頁也不影響session A的重複讀。估計是開啓了一致性讀以後,在這個事務執行期間,不能釋放空間,致使統計信息變大。仍是須要老師解釋下具體的細節

今天有兩個問題,想請教下老師

1.個人理解是因爲B是查找(50000,100000),因爲B+樹有序,經過二分查找找到b=50000的值,從50000往右掃描,一條一條回表查數據,在執行器上作where a(1,1000)的篩選,而後作判斷是否夠不夠limit的數,夠就結束循環。因爲這裏b(50000,100000)必然不存在a(1,1000),因此須要掃描5W行左右.可是若是把a改成(50001,51000),掃描行數沒有變。那麼是由於優化器給的掃描行數有問題仍是執行器沒有結束循環?爲何不結束循環?
(好像rows能直觀展現limit起做用,必須在執行器上過濾數據,不能在索引上過濾數據,不知道爲何這樣設計)

2.假設b上數據是會有不少重複的數據,b的最大值也存在多行重複
select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b desc limit 1;
這裏倒序去掃描b索引樹,選取的是b值最大,id值爲一個固定值(既不最大也不最小)
select * from t force index(a) where (a between 1 and 1000) and (b between 50000 and 100000) order by b desc limit 1;
因爲這裏選取的是a索引,排序不能用到索引,只能用優化排序.選取的是b值最大,id值最小那一行
這就是典型的兩條相同的sql,可是索引選擇的不一樣,出現的數據不一致。
因此若是是order by b,a就能夠避免這種狀況的引發的不一致,也能夠避免堆排序形成的不一致
可是若是是asc沒有出現這種狀況。這裏出現不一致,應該還不是因爲堆排序形成的。這是什麼緣由形成的?

做者回復:

1. 好問題,並且你作了個不錯的對照實驗。是的,加了limit 1 能減小掃描多少行,其實優化器也不肯定,【得執行才知道】,因此顯示的時候仍是按照「最多可能掃多少行」來顯示。

2. 你這個例子裏,若是確實是按照b掃描了,應該確定是ID最大值呀,除非ID最大的那個記錄,a條件不知足。可是必定是「知足a條件裏面最大的那個ID的」,你再驗證下。

而若是是用了a, 那就有臨時表排序,臨時表排序有三種算法,還份內存仍是磁盤臨時表… 這裏展開不了了,後面《order by是怎麼工做的》這篇會講

相關文章
相關標籤/搜索