18 | 爲何這些SQL語句邏輯相同,性能卻差別巨大?

在MySQL中,有不少看上去邏輯相同,但性能卻差別巨大的SQL語句。對這些語句使用不當的話,就會不經意間致使整個數據庫的壓力變大。mysql

我今天挑選了三個這樣的案例和你分享。但願再遇到類似的問題時,你能夠作到觸類旁通、快速解決問題。程序員

案例一:條件字段函數操做

假設你如今維護了一個交易系統,其中交易記錄表tradelog包含交易流水號(tradeid)、交易員id(operator)、交易時間(t_modified)等字段。爲了便於描述,咱們先忽略其餘字段。這個表的建表語句以下:sql

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字段上有索引,因而你就很放心地在生產庫中執行了這條語句,但卻發現執行了特別久,才返回告終果。ide

若是你問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),而輸入的參數倒是整型,因此須要作類型轉換。

那麼,如今這裏就有兩個問題:

  1. 數據類型轉換的規則是什麼?

  2. 爲何有數據類型轉換,就須要走全索引掃描?

先來看第一個問題,你可能會說,數據庫裏面類型這麼多,這種數據類型轉換規則更多,我記不住,應該怎麼辦呢?

這裏有一個簡單的方法,看 select 「10」 > 9的結果:

  1. 若是規則是「將字符串轉成數字」,那麼就是作數字比較,結果應該是1;

  2. 若是規則是「將數字轉成字符串」,那麼就是作字符串比較,結果應該是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*/

圖4 語句Q1的explain 結果

咱們一塊兒來看下這個結果:

  1. 第一行顯示優化器會先在交易記錄表tradelog上查到id=2的行,這個步驟用上了主鍵索引,rows=1表示只掃描一行;

  2. 第二行key=NULL,表示沒有用上交易詳情表trade_detail上的tradeid索引,進行了全表掃描。

在這個執行計劃裏,是從tradelog表中取tradeid字段,再去trade_detail表裏查詢匹配字段。所以,咱們把tradelog稱爲驅動表,把trade_detail稱爲被驅動表,把tradeid稱爲關聯字段。

接下來,咱們看下這個explain結果表示的執行流程:

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

圖6 explain 結果

這個語句裏trade_detail 表成了驅動表,可是explain結果的第二行顯示,此次的查詢操做用上了被驅動表tradelog裏的索引(tradeid),掃描行數是1。

這也是兩個tradeid字段的join操做,爲何此次能用上被驅動表的tradeid索引呢?咱們來分析一下。

假設驅動表trade_detail裏id=4的行記爲R4,那麼在鏈接的時候(圖5的第3步),被驅動表tradelog上執行的就是相似這樣的SQL 語句:

select operator from tradelog where traideid =$R4.tradeid.value;

這時候$R4.tradeid.value的字符集是utf8, 按照字符集轉換規則,要轉成utf8mb4,因此這個過程就被改寫成:

select operator from tradelog where traideid =CONVERT($R4.tradeid.value USING utf8mb4);

你看,這裏的CONVERT函數是加在輸入參數上的,這樣就能夠用上被驅動表的traideid索引。

理解了原理之後,就能夠用來指導操做了。若是要優化語句

select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2;

的執行過程,有兩種作法:

  • 比較常見的優化方法是,把trade_detail表上的tradeid字段的字符集也改爲utf8mb4,這樣就沒有字符集轉換的問題了。
alter table trade_detail modify tradeid varchar(32) CHARACTER SET utf8mb4 default null;
  • 若是可以修改字段的字符集的話,是最好不過了。但若是數據量比較大, 或者業務上暫時不能作這個DDL的話,那就只能採用修改SQL語句的方法了。
mysql> select d.* from tradelog l , trade_detail d where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2;

圖7 SQL語句優化後的explain結果

這裏,我主動把 l.tradeid轉成utf8,就避免了被驅動表上的字符編碼轉換,從explain結果能夠看到,此次索引走對了。

小結

今天我給你舉了三個例子,實際上是在說同一件事兒,即:對索引字段作函數操做,可能會破壞索引值的有序性,所以優化器就決定放棄走樹搜索功能。

第二個例子是隱式類型轉換,第三個例子是隱式字符編碼轉換,它們都跟第一個例子同樣,由於要求在索引字段上作函數操做而致使了全索引掃描。

MySQL的優化器確實有「偷懶」的嫌疑,即便簡單地把where id+1=1000改寫成where id=1000-1就可以用上索引快速查找,也不會主動作這個語句重寫。

所以,每次你的業務代碼升級時,把可能出現的、新的SQL語句explain一下,是一個很好的習慣。

最後,又到了思考題時間。

今天我留給你的課後問題是,你遇到過別的、相似今天咱們提到的性能問題嗎?你認爲緣由是什麼,又是怎麼解決的呢?

你能夠把你經歷和分析寫在留言區裏,我會在下一篇文章的末尾選取有趣的評論跟你們一塊兒分享和分析。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。

上期問題時間

我在上篇文章的最後,留給你的問題是:咱們文章中最後的一個方案是,經過三次limit Y,1 來獲得須要的數據,你以爲有沒有進一步的優化方法。

這裏我給出一種方法,取Y一、Y2和Y3裏面最大的一個數,記爲M,最小的一個數記爲N,而後執行下面這條SQL語句:

mysql> select * from t limit N, M-N+1;

再加上取整個表總行數的C行,這個方案的掃描行數總共只須要C+M+1行。

固然也能夠先取回id值,在應用中肯定了三個id值之後,再執行三次where id=X的語句也是能夠的。@倪大人 同窗在評論區就提到了這個方法。

相關文章
相關標籤/搜索