“order by”是怎麼工作的? 16 | “order by”是怎麼工作的?

16 | “order by”是怎麼工作的?

在你開發應用的時候,一定會經常碰到需要根據指定的字段排序來顯示結果的需求。還是以我們前面舉例用過的市民表爲例,假設你要查詢城市是“杭州”的所有人名字,並且按照姓名排序返回前1000個人的姓名、年齡。

假設這個表的部分定義是這樣的:

CREATE TABLE `t` (
`id` int(11) NOT NULL,
`city` varchar(16) NOT NULL,
`name` varchar(16) NOT NULL,
`age` int(11) NOT NULL,
`addr` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `city` (`city`)
) ENGINE=InnoDB;

這時,你的SQL語句可以這麼寫:

select city,name,age from t where city='杭州' order by name limit 1000 ;

這個語句看上去邏輯很清晰,但是你瞭解它的執行流程嗎?今天,我就和你聊聊這個語句是怎麼執行的,以及有什麼參數會影響執行的行爲。

全字段排序

前面我們介紹過索引,所以你現在就很清楚了,爲避免全表掃描,我們需要在city字段加上索引。

在city字段上創建索引之後,我們用explain命令來看看這個語句的執行情況。

圖1 使用explain命令查看語句的執行情況

Extra這個字段中的“Using filesort”表示的就是需要排序,MySQL會給每個線程分配一塊內存用於排序,稱爲sort_buffer。

爲了說明這個SQL查詢語句的執行過程,我們先來看一下city這個索引的示意圖。

圖2 city字段的索引示意圖

從圖中可以看到,滿足city='杭州’條件的行,是從ID_X到ID_(X+N)的這些記錄。

通常情況下,這個語句執行流程如下所示 :

  1. 初始化sort_buffer,確定放入name、city、age這三個字段;

  2. 從索引city找到第一個滿足city='杭州’條件的主鍵id,也就是圖中的ID_X;

  3. 到主鍵id索引取出整行,取name、city、age三個字段的值,存入sort_buffer中;

  4. 從索引city取下一個記錄的主鍵id;

  5. 重複步驟3、4直到city的值不滿足查詢條件爲止,對應的主鍵id也就是圖中的ID_Y;

  6. 對sort_buffer中的數據按照字段name做快速排序;

  7. 按照排序結果取前1000行返回給客戶端。

我們暫且把這個排序過程,稱爲全字段排序,執行流程的示意圖如下所示,下一篇文章中我們還會用到這個排序。

圖3 全字段排序

圖中“按name排序”這個動作,可能在內存中完成,也可能需要使用外部排序,這取決於排序所需的內存和參數sort_buffer_size。

sort_buffer_size,就是MySQL爲排序開闢的內存(sort_buffer)的大小。如果要排序的數據量小於sort_buffer_size,排序就在內存中完成。但如果排序數據量太大,內存放不下,則不得不利用磁盤臨時文件輔助排序。

你可以用下面介紹的方法,來確定一個排序語句是否使用了臨時文件。

/* 打開optimizer_trace,只對本線程有效 */
SET optimizer_trace='enabled=on'; 

/* @a保存Innodb_rows_read的初始值 */
select VARIABLE_VALUE into @a from performance_schema.session_status where variable_name = 'Innodb_rows_read';

/* 執行語句 */
select city, name,age from t where city='杭州' order by name limit 1000; 

/* 查看 OPTIMIZER_TRACE 輸出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G

/* @b保存Innodb_rows_read的當前值 */
select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read';

/* 計算Innodb_rows_read差值 */
select @b-@a;

這個方法是通過查看 OPTIMIZER_TRACE 的結果來確認的,你可以從 number_of_tmp_files中看到是否使用了臨時文件。

圖4 全排序的OPTIMIZER_TRACE部分結果

number_of_tmp_files表示的是,排序過程中使用的臨時文件數。你一定奇怪,爲什麼需要12個文件?內存放不下時,就需要使用外部排序,外部排序一般使用歸併排序算法。可以這麼簡單理解,MySQL將需要排序的數據分成12份,每一份單獨排序後存在這些臨時文件中。然後把這12個有序文件再合併成一個有序的大文件。

如果sort_buffer_size超過了需要排序的數據量的大小,number_of_tmp_files就是0,表示排序可以直接在內存中完成。

否則就需要放在臨時文件中排序。sort_buffer_size越小,需要分成的份數越多,number_of_tmp_files的值就越大。

接下來,我再和你解釋一下圖4中其他兩個值的意思。

我們的示例表中有4000條滿足city='杭州’的記錄,所以你可以看到 examined_rows=4000,表示參與排序的行數是4000行。

sort_mode 裏面的packed_additional_fields的意思是,排序過程對字符串做了“緊湊”處理。即使name字段的定義是varchar(16),在排序過程中還是要按照實際長度來分配空間的。

同時,最後一個查詢語句select @b-@a 的返回結果是4000,表示整個執行過程只掃描了4000行。

這裏需要注意的是,爲了避免對結論造成干擾,我把internal_tmp_disk_storage_engine設置成MyISAM。否則,select @b-@a的結果會顯示爲4001。

這是因爲查詢OPTIMIZER_TRACE這個表時,需要用到臨時表,而internal_tmp_disk_storage_engine的默認值是InnoDB。如果使用的是InnoDB引擎的話,把數據從臨時表取出來的時候,會讓Innodb_rows_read的值加1。

rowid排序

在上面這個算法過程裏面,只對原表的數據讀了一遍,剩下的操作都是在sort_buffer和臨時文件中執行的。但這個算法有一個問題,就是如果查詢要返回的字段很多的話,那麼sort_buffer裏面要放的字段數太多,這樣內存裏能夠同時放下的行數很少,要分成很多個臨時文件,排序的性能會很差。

所以如果單行很大,這個方法效率不夠好。

那麼,如果MySQL認爲排序的單行長度太大會怎麼做呢?

接下來,我來修改一個參數,讓MySQL採用另外一種算法。

SET max_length_for_sort_data = 16;

max_length_for_sort_data,是MySQL中專門控制用於排序的行數據的長度的一個參數。它的意思是,如果單行的長度超過這個值,MySQL就認爲單行太大,要換一個算法。

city、name、age 這三個字段的定義總長度是36,我把max_length_for_sort_data設置爲16,我們再來看看計算過程有什麼改變。

新的算法放入sort_buffer的字段,只有要排序的列(即name字段)和主鍵id。

但這時,排序的結果就因爲少了city和age字段的值,不能直接返回了,整個執行流程就變成如下所示的樣子:

  1. 初始化sort_buffer,確定放入兩個字段,即name和id;

  2. 從索引city找到第一個滿足city='杭州’條件的主鍵id,也就是圖中的ID_X;

  3. 到主鍵id索引取出整行,取name、id這兩個字段,存入sort_buffer中;

  4. 從索引city取下一個記錄的主鍵id;

  5. 重複步驟3、4直到不滿足city='杭州’條件爲止,也就是圖中的ID_Y;

  6. 對sort_buffer中的數據按照字段name進行排序;

  7. 遍歷排序結果,取前1000行,並按照id的值回到原表中取出city、name和age三個字段返回給客戶端。

這個執行流程的示意圖如下,我把它稱爲rowid排序。

圖5 rowid排序

對比圖3的全字段排序流程圖你會發現,rowid排序多訪問了一次表t的主鍵索引,就是步驟7。

需要說明的是,最後的“結果集”是一個邏輯概念,實際上MySQL服務端從排序後的sort_buffer中依次取出id,然後到原表查到city、name和age這三個字段的結果,不需要在服務端再耗費內存存儲結果,是直接返回給客戶端的。

根據這個說明過程和圖示,你可以想一下,這個時候執行select @b-@a,結果會是多少呢?

現在,我們就來看看結果有什麼不同。

首先,圖中的examined_rows的值還是4000,表示用於排序的數據是4000行。但是select @b-@a這個語句的值變成5000了。

因爲這時候除了排序過程外,在排序完成後,還要根據id去原表取值。由於語句是limit 1000,因此會多讀1000行。

圖6 rowid排序的OPTIMIZER_TRACE部分輸出

從OPTIMIZER_TRACE的結果中,你還能看到另外兩個信息也變了。

  • sort_mode變成了<sort_key, rowid>,表示參與排序的只有name和id這兩個字段。
  • number_of_tmp_files變成10了,是因爲這時候參與排序的行數雖然仍然是4000行,但是每一行都變小了,因此需要排序的總數據量就變小了,需要的臨時文件也相應地變少了。

全字段排序 VS rowid排序

我們來分析一下,從這兩個執行流程裏,還能得出什麼結論。

如果MySQL實在是擔心排序內存太小,會影響排序效率,纔會採用rowid排序算法,這樣排序過程中一次可以排序更多行,但是需要再回到原表去取數據。

如果MySQL認爲內存足夠大,會優先選擇全字段排序,把需要的字段都放到sort_buffer中,這樣排序後就會直接從內存裏面返回查詢結果了,不用再回到原表去取數據。

這也就體現了MySQL的一個設計思想:如果內存夠,就要多利用內存,儘量減少磁盤訪問。

對於InnoDB表來說,rowid排序會要求回表多造成磁盤讀,因此不會被優先選擇。

這個結論看上去有點廢話的感覺,但是你要記住它,下一篇文章我們就會用到。

看到這裏,你就瞭解了,MySQL做排序是一個成本比較高的操作。那麼你會問,是不是所有的order by都需要排序操作呢?如果不排序就能得到正確的結果,那對系統的消耗會小很多,語句的執行時間也會變得更短。

其實,並不是所有的order by語句,都需要排序操作的。從上面分析的執行過程,我們可以看到,MySQL之所以需要生成臨時表,並且在臨時表上做排序操作,其原因是原來的數據都是無序的。

你可以設想下,如果能夠保證從city這個索引上取出來的行,天然就是按照name遞增排序的話,是不是就可以不用再排序了呢?

確實是這樣的。

所以,我們可以在這個市民表上創建一個city和name的聯合索引,對應的SQL語句是:

alter table t add index city_user(city, name);

作爲與city索引的對比,我們來看看這個索引的示意圖。

圖7 city和name聯合索引示意圖

在這個索引裏面,我們依然可以用樹搜索的方式定位到第一個滿足city='杭州’的記錄,並且額外確保了,接下來按順序取“下一條記錄”的遍歷過程中,只要city的值是杭州,name的值就一定是有序的。

這樣整個查詢過程的流程就變成了:

  1. 從索引(city,name)找到第一個滿足city='杭州’條件的主鍵id;

  2. 到主鍵id索引取出整行,取name、city、age三個字段的值,作爲結果集的一部分直接返回;

  3. 從索引(city,name)取下一個記錄主鍵id;

  4. 重複步驟2、3,直到查到第1000條記錄,或者是不滿足city='杭州’條件時循環結束。

圖8 引入(city,name)聯合索引後,查詢語句的執行計劃

可以看到,這個查詢過程不需要臨時表,也不需要排序。接下來,我們用explain的結果來印證一下。

圖9 引入(city,name)聯合索引後,查詢語句的執行計劃

從圖中可以看到,Extra字段中沒有Using filesort了,也就是不需要排序了。而且由於(city,name)這個聯合索引本身有序,所以這個查詢也不用把4000行全都讀一遍,只要找到滿足條件的前1000條記錄就可以退出了。也就是說,在我們這個例子裏,只需要掃描1000次。

既然說到這裏了,我們再往前討論,這個語句的執行流程有沒有可能進一步簡化呢?不知道你還記不記得,我在第5篇文章《 深入淺出索引(下)》中,和你介紹的覆蓋索引。

這裏我們可以再稍微複習一下。覆蓋索引是指,索引上的信息足夠滿足查詢請求,不需要再回到主鍵索引上去取數據。

按照覆蓋索引的概念,我們可以再優化一下這個查詢語句的執行流程。

針對這個查詢,我們可以創建一個city、name和age的聯合索引,對應的SQL語句就是:

alter table t add index city_user_age(city, name, age);

這時,對於city字段的值相同的行來說,還是按照name字段的值遞增排序的,此時的查詢語句也就不再需要排序了。這樣整個查詢語句的執行流程就變成了:

  1. 從索引(city,name,age)找到第一個滿足city='杭州’條件的記錄,取出其中的city、name和age這三個字段的值,作爲結果集的一部分直接返回;

  2. 從索引(city,name,age)取下一個記錄,同樣取出這三個字段的值,作爲結果集的一部分直接返回;

  3. 重複執行步驟2,直到查到第1000條記錄,或者是不滿足city='杭州’條件時循環結束。

圖10 引入(city,name,age)聯合索引後,查詢語句的執行流程

然後,我們再來看看explain的結果。

圖11 引入(city,name,age)聯合索引後,查詢語句的執行計劃

可以看到,Extra字段裏面多了“Using index”,表示的就是使用了覆蓋索引,性能上會快很多。

當然,這裏並不是說要爲了每個查詢能用上覆蓋索引,就要把語句中涉及的字段都建上聯合索引,畢竟索引還是有維護代價的。這是一個需要權衡的決定。

小結

今天這篇文章,我和你介紹了MySQL裏面order by語句的幾種算法流程。

在開發系統的時候,你總是不可避免地會使用到order by語句。你心裏要清楚每個語句的排序邏輯是怎麼實現的,還要能夠分析出在最壞情況下,每個語句的執行對系統資源的消耗,這樣才能做到下筆如有神,不犯低級錯誤。

最後,我給你留下一個思考題吧。

假設你的表裏面已經有了city_name(city, name)這個聯合索引,然後你要查杭州和蘇州兩個城市中所有的市民的姓名,並且按名字排序,顯示前100條記錄。如果SQL查詢語句是這麼寫的 :

mysql> select * from t where city in ('杭州',"蘇州") order by name limit 100;

那麼,這個語句執行的時候會有排序過程嗎,爲什麼?

如果業務端代碼由你來開發,需要實現一個在數據庫端不需要排序的方案,你會怎麼實現呢?

進一步地,如果有分頁需求,要顯示第101頁,也就是說語句最後要改成 “limit 10000,100”, 你的實現方法又會是什麼呢?

你可以把你的思考和觀點寫在留言區裏,我會在下一篇文章的末尾和你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一起閱讀。

上期問題時間

上期的問題是,當MySQL去更新一行,但是要修改的值跟原來的值是相同的,這時候MySQL會真的去執行一次修改嗎?還是看到值相同就直接返回呢?

這是第一次我們課後問題的三個選項都有同學選的,所以我要和你需要詳細說明一下。

第一個選項是,MySQL讀出數據,發現值與原來相同,不更新,直接返回,執行結束。這裏我們可以用一個鎖實驗來確認。

假設,當前表t裏的值是(1,2)。

圖12 鎖驗證方式

session B的update 語句被blocked了,加鎖這個動作是InnoDB才能做的,所以排除選項1。

第二個選項是,MySQL調用了InnoDB引擎提供的接口,但是引擎發現值與原來相同,不更新,直接返回。有沒有這種可能呢?這裏我用一個可見性實驗來確認。

假設當前表裏的值是(1,2)。

圖13 可見性驗證方式

session A的第二個select 語句是一致性讀(快照讀),它是不能看見session B的更新的。

現在它返回的是(1,3),表示它看見了某個新的版本,這個版本只能是session A自己的update語句做更新的時候生成。(如果你對這個邏輯有疑惑的話,可以回顧下第8篇文章《事務到底是隔離的還是不隔離的?》中的相關內容)

所以,我們上期思考題的答案應該是選項3,即:InnoDB認真執行了“把這個值修改成(1,2)"這個操作,該加鎖的加鎖,該更新的更新。

然後你會說,MySQL怎麼這麼笨,就不會更新前判斷一下值是不是相同嗎?如果判斷一下,不就不用浪費InnoDB操作,多去更新一次了?

其實MySQL是確認了的。只是在這個語句裏面,MySQL認爲讀出來的值,只有一個確定的 (id=1), 而要寫的是(a=3),只從這兩個信息是看不出來“不需要修改”的。

作爲驗證,你可以看一下下面這個例子。

圖14 可見性驗證方式--對照

補充說明:

上面我們的驗證結果都是在binlog_format=statement格式下進行的。

@didiren 補充了一個case, 如果是binlog_format=row 並且binlog_row_image=FULL的時候,由於MySQL需要在binlog裏面記錄所有的字段,所以在讀數據的時候就會把所有數據都讀出來了。

根據上面說的規則,“既然讀了數據,就會判斷”, 因此在這時候,select * from t where id=1,結果就是“返回 (1,2)”。

同理,如果是binlog_row_image=NOBLOB, 會讀出除blob 外的所有字段,在我們這個例子裏,結果還是“返回 (1,2)”。

對應的代碼如圖15所示。這是MySQL 5.6版本引入的,在此之前我沒有看過。所以,特此說明。

圖15 binlog_row_image=FULL讀字段邏輯

類似的,@mahonebags 同學提到了timestamp字段的問題。結論是:如果表中有timestamp字段而且設置了自動更新的話,那麼更新“別的字段”的時候,MySQL會讀入所有涉及的字段,這樣通過判斷,就會發現不需要修改。

這兩個點我會在後面講更新性能的文章中再展開。

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