18 | 為什麼這些SQL語句邏輯相同,性能卻差異巨大? 在 MySQL 中,有很多看上去邏輯相同,但性能卻差異巨大的 SQL 語句。對這些語句使用不當的話,就會不經意間導致整個資料庫的壓力變大。 三個案例 案例一:條件欄位函數操作 假設你現在維護了一個交易系統,其中交易記錄表 tradelog 包 ...
18 | 為什麼這些SQL語句邏輯相同,性能卻差異巨大?
在 MySQL 中,有很多看上去邏輯相同,但性能卻差異巨大的 SQL 語句。對這些語句使用不當的話,就會不經意間導致整個資料庫的壓力變大。
三個案例
案例一:條件欄位函數操作
假設你現在維護了一個交易系統,其中交易記錄表 tradelog 包含交易流水號(tradeid)、交易員 id(operator)、交易時間(t_modified)等欄位,先忽略其他欄位。這個表的建表語句如下:
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 語句可能會這麼寫:
select count(*) from tradelog where month(t_modified)=7;
發現執行了特別久,才返回了結果。
原因:如果對欄位做了函數計算,就用不上索引了,這是 MySQL 的規定。
Q:為什麼條件是 where t_modified='2018-7-1’的時候可以用上索引,而改成 where month(t_modified)=7 的時候就不行了?
A:
由於加了 month() 函數操作,MySQL 無法再使用索引快速定位功能,而只能使用全索引掃描。
下麵是這個 t_modified 索引的示意圖。方框上面的數字就是 month() 函數對應的值。
如果你的 SQL 語句條件用的是 where t_modified='2018-7-1’的話,引擎就會按照上面綠色箭頭的路線,快速定位到 t_modified='2018-7-1’需要的結果。
B+ 樹提供的這個快速定位能力,來源於同一層兄弟節點的有序性。
但是,如果計算 month() 函數的話,傳入 7 的時候,在樹的第一層就不知道該怎麼辦了。
也就是說,對索引欄位做函數操作,可能會破壞索引值的有序性,因此優化器就決定放棄走樹搜索功能。
需要註意的是,優化器並不是要放棄使用這個索引。
在這個例子里,放棄了樹搜索功能,優化器可以選擇遍歷主鍵索引,也可以選擇遍歷索引 t_modified,優化器對比索引大小後發現,索引 t_modified 更小,遍歷這個索引比遍歷主鍵索引來得更快(註意是遍歷)。因此最終還是會選擇索引 t_modified。
使用 explain 命令,查看一下這條 SQL 語句的執行結果。
key="t_modified"表示的是,使用了 t_modified 這個索引;我在測試表數據中插入了 10 萬行數據,rows=100335,說明這條語句掃描了整個索引的所有值;Extra 欄位的 Using index,表示的是使用了覆蓋索引。
也就是說,由於在 t_modified 欄位加了 month() 函數操作,導致了全索引掃描。為了能夠用上索引的快速定位能力,我們就要把 SQL 語句改成基於欄位本身的範圍查詢。按照下麵這個寫法,優化器就能按照我們預期的,用上 t_modified 索引的快速定位能力了。
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');
即使是對於不改變有序性的函數,也不會考慮使用索引。比如,對於 select * from tradelog where id + 1 = 10000 這個 SQL 語句,這個加 1 操作並不會改變有序性,但是 MySQL 優化器還是不能用 id 索引快速定位到 9999 這一行。所以,需要你在寫 SQL 語句的時候,手動改寫成 where id = 10000 -1 才可以。
案例二:隱式類型轉換
select * from tradelog where tradeid=110717;
交易編號 tradeid 這個欄位上,本來就有索引,但是 explain 的結果卻顯示,這條語句需要走全表掃描。
原因:tradeid 的欄位類型是 varchar(32),而輸入的參數卻是整型,所以需要做類型轉換。
Q:數據類型轉換的規則是什麼?
A:
字元串和數字做比較的話,是將字元串轉換成數字。
看 select “10” > 9 的結果:
- 如果規則是“將字元串轉成數字”,那麼就是做數字比較,結果應該是 1;
- 如果規則是“將數字轉成字元串”,那麼就是做字元串比較,結果應該是 0。
Q:為什麼有數據類型轉換,就需要走全索引掃描?
A:
對於優化器來說,上面的SQL語句相當於:
select * from tradelog where CAST(tradid AS signed int) = 110717;
也就是說,這條語句觸發了我們上面說到的規則:對索引欄位做函數操作,優化器會放棄走樹搜索功能。
案例三:隱式字元編碼轉換
假設系統里還有另外一個表 trade_detail,用於記錄交易的操作細節。往交易日誌表 tradelog 和交易詳情表 trade_detail 這兩個表裡插入一些數據。
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 語句可以這麼寫:
select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /* 語句 Q1*/
語句 Q1 的 explain 結果如下
- 第一行顯示優化器會先在交易記錄表 tradelog 上查到 id=2 的行,這個步驟用上了主鍵索引,rows=1 表示只掃描一行;
- 第二行 key=NULL,表示沒有用上交易詳情表 trade_detail 上的 tradeid 索引,進行了全表掃描。
在這個執行計劃里,是從 tradelog 表中取 tradeid 欄位,再去 trade_detail 表裡查詢匹配欄位。
因此,我們把 tradelog 稱為驅動表,把 trade_detail 稱為被驅動表,把 tradeid 稱為關聯欄位。
語句 Q1 的執行過程如下:
- 第 1 步,是根據 id 在 tradelog 表裡找到 L2 這一行;
- 第 2 步,是從 L2 中取出 tradeid 欄位的值;
- 第 3 步,是根據 tradeid 值到 trade_detail 表中查找條件匹配的行。explain 的結果裡面第二行的 key=NULL 表示的就是,這個過程是通過遍歷主鍵索引的方式,一個一個地判斷 tradeid 的值是否匹配。
第 3 步不符合我們的預期。因為表 trade_detail 里 tradeid 欄位上是有索引的,我們本來是希望通過使用 tradeid 索引能夠快速定位到等值的行。但,這裡並沒有。
原因:因為這兩個表的字元集不同,一個是 utf8,一個是 utf8mb4,所以做表連接查詢的時候用不上關聯欄位的索引。
Q:為什麼字元集不同就用不上索引呢?
A:如果單獨把第 3 步改成 SQL 語句的話,那就是:
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;
再次觸發了上面說到的原則:對索引欄位做函數操作,優化器會放棄走樹搜索功能。
字元集不同只是條件之一,連接過程中要求在被驅動表的索引欄位上加函數操作,是直接導致對被驅動表做全表掃描的原因。
作為對比驗證,“查找 trade_detail 表裡 id=4 的操作,對應的操作者是誰”,再來看下這個語句和它的執行計劃。
select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4;
這個語句里 trade_detail 表成了驅動表,但是 explain 結果的第二行顯示,這次的查詢操作用上了被驅動表 tradelog 里的索引 (tradeid),掃描行數是 1。
Q:為什麼這次能用上被驅動表的 tradeid 索引呢?
A:
假設驅動表 trade_detail 里 id=4 的行記為 R4,那麼在連接的時候,被驅動表 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;
主動把 l.tradeid 轉成 utf8,就避免了被驅動表上的字元編碼轉換
小結
索引欄位不能進行函數操作,但是索引欄位的參數(=後面的值)可以用函數
補充:字元截斷
表結構如下:
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’, 假設現在執行語句是這麼寫的:
select * from table_a where b='1234567890abcd';
Q: 這時候,MySQL 會怎麼執行呢?
A: 最理想的情況是,MySQL 看到欄位 b 定義的是 varchar(10),直接返回空呀。可惜,MySQL 並沒有這麼做。
那要不,就是把’1234567890abcd’拿到索引裡面去做匹配,肯定也沒能夠快速判斷出索引樹 b 上並沒有這個值,也很快就能返回空結果。但實際上,MySQL 也不是這麼做的。
這條 SQL 語句的執行很慢,流程是這樣的:
- 在傳給引擎執行的時候,做了字元截斷。因為引擎裡面這個行只定義了長度是 10,所以只截了前 10 個位元組,就是’1234567890’進去做匹配;
- 這樣滿足條件的數據有 10 萬行;
- 因為是 select *, 所以要做 10 萬次回表;
- 但是每次回表以後查出整行,到 server 層一判斷,b 的值都不是’1234567890abcd’;
- 返回結果是空。
雖然執行過程中可能經過函數操作,但是最終在拿到結果後,server 層還是要做一輪判斷的。