您好,登錄后才能下訂單哦!
這篇文章主要講解了“MySQL中為什么簡單的一行查詢也會慢”,文中的講解內容簡單清晰,易于學習與理解,下面請大家跟著小編的思路慢慢深入,一起來研究和學習“MySQL中為什么簡單的一行查詢也會慢”吧!
在MySQL中,有很多看上去邏輯相同,但性能卻差異巨大的SQL語句。對這些語句使用不當的話,就會不經意間導致整個數據庫的壓力變大。
假設你現在維護了一個交易系統,其中交易記錄表tradelog包含交易流水號(tradeid)、交易員id(operator)、交易時間(t_modified)等字段。為了便于描述,我們先忽略其他字段。這個表的建表語句如下:
mysql> CREATE TABLE `tradelog` ( `id` int(11) NOT NULL, `tradeid` varchar(32) DEFAULT NULL, `operator` int(11) DEFAULT NULL, `t_modified` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `tradeid` (`tradeid`), KEY `t_modified` (`t_modified`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
假設,現在已經記錄了從2016年初到2018年底的所有數據,運營部門有一個需求是,要統計發生在所有年份中7月份的交易記錄總數。這個邏輯看上去并不復雜,你的SQL語句可能會這么寫:
mysql> select count(*) from tradelog where month(t_modified)=7;
由于t_modified字段上有索引,于是你就很放心地在生產庫中執行了這條語句,但卻發現執行了特別久,才返回了結果。
如果你問DBA同事為什么會出現這樣的情況,他大概會告訴你:如果對字段做了函數計算,就用不上索引了,這是MySQL的規定。
現在你已經學過了InnoDB的索引結構了,可以再追問一句為什么?為什么條件是where t_modified='2018-7-1’的時候可以用上索引,而改成where month(t_modified)=7的時候就不行了?
下面是這個t_modified索引的示意圖。方框上面的數字就是month()函數對應的值。
圖1 t_modified索引示意圖
如果你的SQL語句條件用的是where t_modified='2018-7-1’的話,引擎就會按照上面綠色箭頭的路線,快速定位到 t_modified='2018-7-1’需要的結果。
實際上,B+樹提供的這個快速定位能力,來源于同一層兄弟節點的有序性。
但是,如果計算month()函數的話,你會看到傳入7的時候,在樹的第一層就不知道該怎么辦了。
也就是說,對索引字段做函數操作,可能會破壞索引值的有序性,因此優化器就決定放棄走樹搜索功能。
需要注意的是,優化器并不是要放棄使用這個索引。
在這個例子里,放棄了樹搜索功能,優化器可以選擇遍歷主鍵索引,也可以選擇遍歷索引t_modified,優化器對比索引大小后發現,索引t_modified更小,遍歷這個索引比遍歷主鍵索引來得更快。因此最終還是會選擇索引t_modified。
接下來,我們使用explain命令,查看一下這條SQL語句的執行結果。
圖2 explain 結果
key="t_modified"表示的是,使用了t_modified這個索引;我在測試表數據中插入了10萬行數據,rows=100335,說明這條語句掃描了整個索引的所有值;Extra字段的Using index,表示的是使用了覆蓋索引。
也就是說,由于在t_modified字段加了month()函數操作,導致了全索引掃描。為了能夠用上索引的快速定位能力,我們就要把SQL語句改成基于字段本身的范圍查詢。按照下面這個寫法,優化器就能按照我們預期的,用上t_modified索引的快速定位能力了。
mysql> select count(*) from tradelog where -> (t_modified >= '2016-7-1' and t_modified<'2016-8-1') or -> (t_modified >= '2017-7-1' and t_modified<'2017-8-1') or -> (t_modified >= '2018-7-1' and t_modified<'2018-8-1');
當然,如果你的系統上線時間更早,或者后面又插入了之后年份的數據的話,你就需要再把其他年份補齊。
到這里我給你說明了,由于加了month()函數操作,MySQL無法再使用索引快速定位功能,而只能使用全索引掃描。
不過優化器在個問題上確實有“偷懶”行為,即使是對于不改變有序性的函數,也不會考慮使用索引。比如,對于select * from tradelog where id + 1 = 10000這個SQL語句,這個加1操作并不會改變有序性,但是MySQL優化器還是不能用id索引快速定位到9999這一行。所以,需要你在寫SQL語句的時候,手動改寫成 where id = 10000 -1才可以。
接下來我再跟你說一說,另一個經常讓程序員掉坑里的例子。
我們一起看一下這條SQL語句:
mysql> select * from tradelog where tradeid=110717;
交易編號tradeid這個字段上,本來就有索引,但是explain的結果卻顯示,這條語句需要走全表掃描。你可能也發現了,tradeid的字段類型是varchar(32),而輸入的參數卻是整型,所以需要做類型轉換。
那么,現在這里就有兩個問題:
數據類型轉換的規則是什么?
為什么有數據類型轉換,就需要走全索引掃描?
先來看第一個問題,你可能會說,數據庫里面類型這么多,這種數據類型轉換規則更多,我記不住,應該怎么辦呢?
這里有一個簡單的方法,看 select “10” > 9的結果:
如果規則是“將字符串轉成數字”,那么就是做數字比較,結果應該是1;
如果規則是“將數字轉成字符串”,那么就是做字符串比較,結果應該是0。
驗證結果如圖3所示。
圖3 MySQL中字符串和數字轉換的效果示意圖
從圖中可知,select “10” > 9返回的是1,所以你就能確認MySQL里的轉換規則了:在MySQL中,字符串和數字做比較的話,是將字符串轉換成數字。
這時,你再看這個全表掃描的語句:
mysql> select * from tradelog where tradeid=110717;
就知道對于優化器來說,這個語句相當于:
mysql> select * from tradelog where CAST(tradid AS signed int) = 110717;
也就是說,這條語句觸發了我們上面說到的規則:對索引字段做函數操作,優化器會放棄走樹搜索功能。
現在,我留給你一個小問題,id的類型是int,如果執行下面這個語句,是否會導致全表掃描呢?
select * from tradelog where id="83126";
你可以先自己分析一下,再到數據庫里面去驗證確認。
接下來,我們再來看一個稍微復雜點的例子。
假設系統里還有另外一個表trade_detail,用于記錄交易的操作細節。為了便于量化分析和復現,我往交易日志表tradelog和交易詳情表trade_detail這兩個表里插入一些數據。
mysql> CREATE TABLE `trade_detail` ( `id` int(11) NOT NULL, `tradeid` varchar(32) DEFAULT NULL, `trade_step` int(11) DEFAULT NULL, /*操作步驟*/ `step_info` varchar(32) DEFAULT NULL, /*步驟信息*/ PRIMARY KEY (`id`), KEY `tradeid` (`tradeid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; insert into tradelog values(1, 'aaaaaaaa', 1000, now()); insert into tradelog values(2, 'aaaaaaab', 1000, now()); insert into tradelog values(3, 'aaaaaaac', 1000, now()); insert into trade_detail values(1, 'aaaaaaaa', 1, 'add'); insert into trade_detail values(2, 'aaaaaaaa', 2, 'update'); insert into trade_detail values(3, 'aaaaaaaa', 3, 'commit'); insert into trade_detail values(4, 'aaaaaaab', 1, 'add'); insert into trade_detail values(5, 'aaaaaaab', 2, 'update'); insert into trade_detail values(6, 'aaaaaaab', 3, 'update again'); insert into trade_detail values(7, 'aaaaaaab', 4, 'commit'); insert into trade_detail values(8, 'aaaaaaac', 1, 'add'); insert into trade_detail values(9, 'aaaaaaac', 2, 'update'); insert into trade_detail values(10, 'aaaaaaac', 3, 'update again'); insert into trade_detail values(11, 'aaaaaaac', 4, 'commit');
這時候,如果要查詢id=2的交易的所有操作步驟信息,SQL語句可以這么寫:
mysql> select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /*語句Q1*/
圖5 語句Q1的執行過程
圖中:
第1步,是根據id在tradelog表里找到L2這一行;
第2步,是從L2中取出tradeid字段的值;
第3步,是根據tradeid值到trade_detail表中查找條件匹配的行。explain的結果里面第二行的key=NULL表示的就是,這個過程是通過遍歷主鍵索引的方式,一個一個地判斷tradeid的值是否匹配。
進行到這里,你會發現第3步不符合我們的預期。因為表trade_detail里tradeid字段上是有索引的,我們本來是希望通過使用tradeid索引能夠快速定位到等值的行。但,這里并沒有。
如果你去問DBA同學,他們可能會告訴你,因為這兩個表的字符集不同,一個是utf8,一個是utf8mb4,所以做表連接查詢的時候用不上關聯字段的索引。這個回答,也是通常你搜索這個問題時會得到的答案。
但是你應該再追問一下,為什么字符集不同就用不上索引呢?
我們說問題是出在執行步驟的第3步,如果單獨把這一步改成SQL語句的話,那就是:
mysql> select * from trade_detail where tradeid=$L2.tradeid.value;
其中,$L2.tradeid.value的字符集是utf8mb4。
參照前面的兩個例子,你肯定就想到了,字符集utf8mb4是utf8的超集,所以當這兩個類型的字符串在做比較的時候,MySQL內部的操作是,先把utf8字符串轉成utf8mb4字符集,再做比較。
這個設定很好理解,utf8mb4是utf8的超集。類似地,在程序設計語言里面,做自動類型轉換的時候,為了避免數據在轉換過程中由于截斷導致數據錯誤,也都是“按數據長度增加的方向”進行轉換的。
因此, 在執行上面這個語句的時候,需要將被驅動數據表里的字段一個個地轉換成utf8mb4,再跟L2做比較。
也就是說,實際上這個語句等同于下面這個寫法:
select * from trade_detail where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value;
CONVERT()函數,在這里的意思是把輸入的字符串轉成utf8mb4字符集。
這就再次觸發了我們上面說到的原則:對索引字段做函數操作,優化器會放棄走樹搜索功能。
到這里,你終于明確了,字符集不同只是條件之一,連接過程中要求在被驅動表的索引字段上加函數操作,是直接導致對被驅動表做全表掃描的原因。
作為對比驗證,我給你提另外一個需求,“查找trade_detail表里id=4的操作,對應的操作者是誰”,再來看下這個語句和它的執行計劃。
mysql>select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4;
圖7 SQL語句優化后的explain結果
這里,我主動把 l.tradeid轉成utf8,就避免了被驅動表上的字符編碼轉換,從explain結果可以看到,這次索引走對了。
今天我給你舉了三個例子,其實是在說同一件事兒,即:對索引字段做函數操作,可能會破壞索引值的有序性,因此優化器就決定放棄走樹搜索功能。
第二個例子是隱式類型轉換,第三個例子是隱式字符編碼轉換,它們都跟第一個例子一樣,因為要求在索引字段上做函數操作而導致了全索引掃描。
MySQL的優化器確實有“偷懶”的嫌疑,即使簡單地把where id+1=1000改寫成where id=1000-1就能夠用上索引快速查找,也不會主動做這個語句重寫。
因此,每次你的業務代碼升級時,把可能出現的、新的SQL語句explain一下,是一個很好的習慣。
為了便于描述,我還是構造一個表,基于這個表來說明今天的問題。這個表有兩個字段id和c,并且我在里面插入了10萬行記錄。
mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=100000)do insert into t values(i,i); set i=i+1; end while; end;; delimiter ; call idata();
接下來,我會用幾個不同的場景來舉例,有些是前面的文章中我們已經介紹過的知識點,你看看能不能一眼看穿,來檢驗一下吧。
如圖1所示,在表t執行下面的SQL語句:
mysql> select * from t where id=1;
查詢結果長時間不返回。
圖2 Waiting for table metadata lock狀態示意圖
出現這個狀態表示的是,現在有一個線程正在表t上請求或者持有MDL寫鎖,把select語句堵住了。
在MySQL 5.7版本下復現這個場景,也很容易。如圖3所示,我給出了簡單的復現步驟。
圖4 查獲加表鎖的線程id
接下來,我給你舉另外一種查詢被堵住的情況。
我在表t上,執行下面的SQL語句:
mysql> select * from information_schema.processlist where id=1;
這里,我先賣個關子。
你可以看一下圖5。我查出來這個線程的狀態是Waiting for table flush,你可以設想一下這是什么原因。
圖5 Waiting for table flush狀態示意圖
這個狀態表示的是,現在有一個線程正要對表t做flush操作。MySQL里面對表做flush操作的用法,一般有以下兩個:
flush tables t with read lock; flush tables with read lock;
這兩個flush語句,如果指定表t的話,代表的是只關閉表t;如果沒有指定具體的表名,則表示關閉MySQL里所有打開的表。
但是正常這兩個語句執行起來都很快,除非它們也被別的線程堵住了。
所以,出現Waiting for table flush狀態的可能情況是:有一個flush tables命令被別的語句堵住了,然后它又堵住了我們的select語句。
現在,我們一起來復現一下這種情況,復現步驟如圖6所示:
圖6 Waiting for table flush的復現步驟
在session A中,我故意每行都調用一次sleep(1),這樣這個語句默認要執行10萬秒,在這期間表t一直是被session A“打開”著。然后,session B的flush tables t命令再要去關閉表t,就需要等session A的查詢結束。這樣,session C要再次查詢的話,就會被flush 命令堵住了。
圖7是這個復現步驟的show processlist結果。這個例子的排查也很簡單,你看到這個show processlist的結果,肯定就知道應該怎么做了。
圖 8 行鎖復現
圖10 通過sys.innodb_lock_waits 查行鎖
可以看到,這個信息很全,4號線程是造成堵塞的罪魁禍首。而干掉這個罪魁禍首的方式,就是KILL QUERY 4或KILL 4。
不過,這里不應該顯示“KILL QUERY 4”。這個命令表示停止4號線程當前正在執行的語句,而這個方法其實是沒有用的。因為占有行鎖的是update語句,這個語句已經是之前執行完成了的,現在執行KILL QUERY,無法讓這個事務去掉id=1上的行鎖。
實際上,KILL 4才有效,也就是說直接斷開這個連接。這里隱含的一個邏輯就是,連接被斷開的時候,會自動回滾這個連接里面正在執行的線程,也就釋放了id=1上的行鎖。
經過了重重封“鎖”,我們再來看看一些查詢慢的例子。
先來看一條你一定知道原因的SQL語句:
mysql> select * from t where c=50000 limit 1;
由于字段c上沒有索引,這個語句只能走id主鍵順序掃描,因此需要掃描5萬行。
作為確認,你可以看一下慢查詢日志。注意,這里為了把所有語句記錄到slow log里,我在連接后先執行了 set long_query_time=0,將慢查詢日志的時間閾值設置為0。
圖12 掃描一行卻執行得很慢
是不是有點奇怪呢,這些時間都花在哪里了?
如果我把這個slow log的截圖再往下拉一點,你可以看到下一個語句,select * from t where id=1 lock in share mode,執行時掃描行數也是1行,執行時間是0.2毫秒。
圖14 兩個語句的輸出結果
第一個語句的查詢結果里c=1,帶lock in share mode的語句返回的是c=1000001。看到這里應該有更多的同學知道原因了。如果你還是沒有頭緒的話,也別著急。我先跟你說明一下復現步驟,再分析原因。
圖16 id=1的數據狀態
session B更新完100萬次,生成了100萬個回滾日志(undo log)。
帶lock in share mode的SQL語句,是當前讀,因此會直接讀到1000001這個結果,所以速度很快;而select * from t where id=1這個語句,是一致性讀,因此需要從1000001開始,依次執行undo log,執行了100萬次以后,才將1這個結果返回。
注意,undo log里記錄的其實是“把2改成1”,“把3改成2”這樣的操作邏輯,畫成減1的目的是方便你看圖。
今天我給你舉了在一個簡單的表上,執行“查一行”,可能會出現的被鎖住和執行慢的例子。這其中涉及到了表鎖、行鎖和一致性讀的概念。
在實際使用中,碰到的場景會更復雜。但大同小異,你可以按照我在文章中介紹的定位方法,來定位并解決問題。
最后,我給你留一個問題吧。
我們在舉例加鎖讀的時候,用的是這個語句,select * from t where id=1 lock in share mode。由于id上有索引,所以可以直接定位到id=1這一行,因此讀鎖也是只加在了這一行上。
但如果是下面的SQL語句,
begin; select * from t where c=5 for update; commit;
這個語句序列是怎么加鎖的呢?加的鎖又是什么時候釋放呢?
表結構如下:
mysql> CREATE TABLE `table_a` ( `id` int(11) NOT NULL, `b` varchar(10) DEFAULT NULL, PRIMARY KEY (`id`), KEY `b` (`b`) ) ENGINE=InnoDB;
假設現在表里面,有100萬行數據,其中有10萬行數據的b的值是’1234567890’, 假設現在執行語句是這么寫的:
mysql> select * from table_a where b='1234567890abcd';
這時候,MySQL會怎么執行呢?
最理想的情況是,MySQL看到字段b定義的是varchar(10),那肯定返回空呀。可惜,MySQL并沒有這么做。
那要不,就是把’1234567890abcd’拿到索引里面去做匹配,肯定也沒能夠快速判斷出索引樹b上并沒有這個值,也很快就能返回空結果。
但實際上,MySQL也不是這么做的。
這條SQL語句的執行很慢,流程是這樣的:
在傳給引擎執行的時候,做了字符截斷。因為引擎里面這個行只定義了長度是10,所以只截了前10個字節,就是’1234567890’進去做匹配;
這樣滿足條件的數據有10萬行;
因為是select *, 所以要做10萬次回表;
但是每次回表以后查出整行,到server層一判斷,b的值都不是’1234567890abcd’;
返回結果是空。
感謝各位的閱讀,以上就是“MySQL中為什么簡單的一行查詢也會慢”的內容了,經過本文的學習后,相信大家對MySQL中為什么簡單的一行查詢也會慢這一問題有了更深刻的體會,具體使用情況還需要大家實踐驗證。這里是億速云,小編將為大家推送更多相關知識點的文章,歡迎關注!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。