你真的瞭解MySQL的order by嗎

2022-02-01 07:00:07
本篇文章給大家帶來了關於mysql中order by排序的相關知識,希望對大家有幫助。

排序這個詞,我的第一感覺是幾乎所有App都有排序的地方,淘寶商品有按照購買時間的排序、B站的評論有按照熱度排序的...,當然我們今天說的並不是巨量資料下該如何優雅的排序,如何提升排序效能的問題,我們說一說MySQL中的排序。

對於MySQL,一說到排序,你第一時間想到的是什麼?關鍵字order by?order by的欄位最好有索引?葉子結點已經是順序的?還是說盡量不要在MySQL內部排序?

事情的起因

現在假設有一張使用者的朋友表:

CREATE TABLE `user` (
  `id` int(10) AUTO_INCREMENT,
  `user_id` int(10),
  `friend_addr` varchar(1000),
  `friend_name` varchar(100),  
  PRIMARY KEY (`id`),
  KEY `user_id` (`user_id`)
) ENGINE=InnoDB;

表中目前有兩個點需要關注下:

使用者的 user_id ,朋友的姓名 friend_name、朋友的地址 friend_addr

user_id 是有索引的

有一天,有個初級開發工程師小猿,收到了來自初級產品經理小汪的需求:

小汪:小猿同志,現在需要在後臺加個功能,這個功能要支援根據使用者 id 能查到他所有的朋友姓名和地址,並且要求朋友的姓名是按照字典排序的。

小猿:好的,這個功能簡單,我馬上就上線。

於是小猿書寫了這樣的sql:

select friend_name,friend_addr from user where user_id=? order by name

在電光石火的瞬間,小猿趾高氣昂的上線了,這一切都很順利,直到有一天有個運營同學導致了這樣的查詢:

select friend_name,friend_addr from user where user_id=10086 order by name

然而,這個查詢竟然比平時慢很多,資料庫報了慢查詢,小猿此時慌的一b:這是怎麼回事?user_id 明明有索引啊,而且機智地我還只用了 select friend_name,friend_addr,並沒有用 select *呀。小猿此時不停地安慰自己,要淡定要淡定,然後突然想到有個explain命令,用explain來檢視下那條sql的執行計劃吧,當小猿用了explain之後,發現extra欄位裡面有個看起來很危險的字眼:using filesort。

「這個查詢竟然用到了傳說中的檔案排序,但是如果一個人朋友不是很多,就算了用了檔案排序,應該也很快吧」,除非這個user_id=10086的朋友很多,後來小猿去查了下,這個使用者的朋友竟然有10w多個~。

陷入了沉思的小猿心想:這個鍋看來是背定了,10w資料是有點大了,還有這個 using filesort 到底是怎麼個排序原理?

解剖檔案排序

有人可能說上面的問題是10w資料太大了,就算不排序也慢,這個其實是有道理的,10w資料一次性查出來,無論是MySQL記憶體緩衝區的佔用,還是網路頻寬的消耗都是非常大的,那如果我加了limit 1000呢?網路頻寬的問題肯定是解決了,因為封包整體變小了,但是 using filesort 的問題其實還是沒有解決,看到這裡你可能會有疑問,using filesort 難道是在檔案中排序的?在檔案中到底是怎麼排序的?或者我這樣問:如果給你來設計排序你會怎麼處理?帶著這些疑問和思考我們來看看 using filesort 會涉及到哪些技術難點以及是如何解決的?

  • 首先我們的 user_id 是有索引的,所以會先在 user_id 索引樹上檢索我們的目標資料,即 user_id=10086 的資料,但是我們要查詢的是 friend_name 和 friend_addr 欄位,很不幸,光靠 user_id 索引是找不到這兩個欄位值的

  • 於是需要回表,通過 user_id 對應的主鍵去主鍵索引樹上去查詢,ok,我們找到了第一條 user_id=10086 的 friend_name 和 friend_addr 欄位

  • 這時該怎麼辦?直接返回回去肯定不對,因為我需要對 friend_name 排序,如何排?資料都還沒找全,那麼就得把查到的資料先放在一個地方,這個地方就是 sort_buffer,看到名字我想你應該猜出來,沒錯,sort_buffer 就是用於這種情況下排序用的緩衝區,這裡需要注意的是每個執行緒都會有一個單獨的 sort_buffer,這麼做的目的主要是為了避免多個執行緒對同一塊記憶體進行操作帶來鎖競爭的問題。

  • 當第一條資料的 friend_name 和 friend_addr 已經放入 sort_buffer 中,這當然沒完,會一直重複同步的步驟,直至把所有 user_id=10086 的 friend_name 和 friend_addr 都放入到 sort_buffer 中才結束

  • sort_buffer 中的資料已經放入完畢,接下來就該排序了,這裡 MySQL 會對 friend_name 進行快排,通過快排後,sort_buffer 中 friend_name 就是有序的了

  • 最後返回 sort_buffer 中的前1000條,結束。

21.png

一切看起來很絲滑,但是 sort_buffer 佔用的是記憶體空間,這就尷尬了,記憶體本身就不是無限大的,它肯定是有上限的,當然 sort_buffer 也不能太小,太小的話,意義不大。在 InnoDB 儲存引擎中,這個值是預設是256K。

mysql> show variables  like 'sort_buffer_size';
+------------------+--------+
| Variable_name    | Value  |
+------------------+--------+
| sort_buffer_size | 262144 |
+------------------+--------+

也就是說,如果要放進 sort_buffer 中的資料是大於256K的話,那麼採用在 sort_buffer 中快排的方式肯定是行不通的,這時候,你可能會問:MySQL難道不能根據資料大小自動擴充嗎?額,MySQL是多執行緒模型,如果每個執行緒都擴充,那麼分給其他功能buffer就小了(比如change buffer等),就會影響其他功能的品質。

這時就得換種方式來排序了,沒錯,此時就是真正的檔案排序了,也就是磁碟的臨時檔案,MySQL會採用歸併排序的思想,把要排序的資料分成若干份,每一份資料在記憶體中排序後會放入臨時檔案中,最終對這些已經排序好的臨時檔案的資料再做一次合併排序就ok了,典型的分而治之原理,它的具體步驟如下:

  • 先將要排序的資料分割,分割成每塊資料都可以放到 sort_buffer 中

  • 對每塊資料在 sort_buffer 中進行排序,排序好後,寫入某個臨時檔案中

  • 當所有的資料都寫入臨時檔案後,這時對於每個臨時檔案而言,內部都是有序的,但是它們並不是一個整體,整體還不是有序的,所以接下來就得合併資料了

  • 假設現在存在 tmpX 和 tmpY 兩個臨時檔案,這時會從 tmpX 讀取一部分資料進入記憶體,然後從 tmpY 中讀取一部分資料進入記憶體,這裡你可能會好奇為什麼是一部分而不是整個或者單個?因為首先磁碟是緩慢的,所以儘量每次多讀點資料進入記憶體,但是不能讀太多,因為還有 buffer 空間的限制。

  • 對於 tmpX 假設讀進來了的是 tmpX[0-5] ,對於 tmpY 假設讀進來了的是 tmpY[0-5],於是只需要這樣比較:

如果 tmpX[0] < tmpY[0],那麼 tmpX[0] 肯定是最小的,然後 tmpX[1] 和 tmpY[0] 比較,如果 tmpX[1] > tmpY[0],那麼 tmpY[0] 肯定是第二小的...,就這樣兩兩比較最終就可以把 tmpX 和 tmpY 合併成一個有序的檔案tmpZ,多個這樣的tmpZ再次合併...,最終就可以把所有的資料合併成一個有序的大檔案。

20.png

檔案排序很慢,還有其他辦法嗎

通過上面的排序流程我們知道,如果要排序的資料很大,超過 sort_buffer 的大小,那麼就需要檔案排序,檔案排序涉及到分批排序與合併,很耗時,造成這個問題的根本原因是 sort_buffer 不夠用,不知道你發現沒有我們的 friend_name 需要排序,但是卻把 friend_addr 也塞進了 sort_buffer 中,這樣單行資料的大小就等於 friend_name 的長度 + friend_addr 的長度,能否讓 sort_buffer 中只存 friend_name 欄位,這樣的話,整體的利用空間就大了,不一定用得到到臨時檔案。沒錯,這就是接下來要說的另一種排序優化rowid排序。

rowid 排序的思想就是把不需要的資料不要放到 sort_buffer 中,讓 sort_buffer 中只保留必要的資料,那麼你認為什麼是必要的資料呢?只放 friend_name?這肯定不行,排序完了之後,friend_addr 怎麼辦?因此還要把主鍵id放進去,這樣排完之後,通過 id 再回次表,拿到 friend_addr 即可,因此它的大致流程如下:

  • 根據 user_id 索引,查到目標資料,然後回表,只把 id 和 friend_name 放進 sort_buffer 中

  • 重複1步驟,直至全部的目標資料都在 sort_buffer 中

  • 對 sort_buffer 中的資料按照 friend_name 欄位進行排序

  • 排序後根據 id 再次回表查到 friend_addr 返回,直至返回1000條資料,結束。

19.png

這裡面其實有幾點需要注意的:

  • 這種方式需要兩次回表的

  • sort_buffer 雖然小了,但是如果資料量本身還是很大,應該還是要臨時檔案排序的

那麼問題來了,兩種方式,MySQL 該如何選擇?得根據某個條件來判斷走哪種方式吧,這個條件就是進 sort_buffer 單行的長度,如果長度太大(friend_name + friend_addr的長度),就會採用 rowid 這種方式,否則第一種,長度的標準是根據 max_length_for_sort_data 來的,這個值預設是1024位元組:

mysql> show variables like 'max_length_for_sort_data';
+--------------------------+-------+
| Variable_name          | Value |
+--------------------------+-------+
| max_length_for_sort_data | 1024  |
+--------------------------+-------+

不想回表,不想再次排序

其實不管是上面哪種方法,他們都需要回表+排序,回表是因為二級索引上沒有目標欄位,排序是因為資料不是有序的,那如果二級索引上有目標欄位並且已經是排序好的了,那不就兩全其美了嘛。

沒錯,就是聯合索引,我們只需要建立一個 (user_id,friend_name,friend_addr)的聯合索引即可,這樣我就可以通過這個索引拿到目標資料,並且friend_name已經是排序好的,同時還有friend_addr欄位,一招搞定,不需要回表,不需要再次排序。因此對於上述的sql,它的大致流程如下:

  • 通過聯合索引找到user_id=10086的資料,然後讀取對應的 friend_name 和 friend_addr 欄位直接返回,因為 friend_name 已經是排序好的了,不需要額外處理

  • 重複第一步驟,順著葉子節點接著向後找,直至找到第一個不是10086的資料,結束。

18.png

聯合索引雖然可以解決這種問題,但是在實際應用中切不可盲目建立,要根據實際的業務邏輯來判斷是否需要建立,如果不是經常有類似的查詢,可以不用建立,因為聯合索引會佔用更多的儲存空間和維護開銷。

總結

  • 對於 order by 沒有用到索引的時候,這時 explain 中 Extra 欄位大概是會出現 using filesort 字眼

  • 出現 using filesort 的時候也不用太慌張,如果本身資料量不大,比如也就幾十條資料,那麼在 sort buffer 中使用快排也是很快的

  • 如果資料量很大,超過了 sort buffer 的大小,那麼是要進行臨時檔案排序的,也就是歸併排序,這部分是由 MySQL 優化器決定的

  • 如果查詢的欄位很多,想要儘量避免使用臨時檔案排序,可以嘗試設定下 max_length_for_sort_data 欄位的大小,讓其小於所有查詢欄位長度的總和,這樣放入或許可以避免,但是會多一次回表操作

  • 實際業務中,我們也可以給經常要查詢的欄位組合建立個聯合索引,這樣既不用回表也不需要單獨排序,但是聯合索引會佔用更多的儲存和開銷

  • 大量資料查詢的時候,儘量分批次,提前 explain 來觀察 sql 的執行計劃是個不錯的選擇。

推薦學習:

以上就是你真的瞭解MySQL的order by嗎的詳細內容,更多請關注TW511.COM其它相關文章!