MySQL Order BY 排序過程

來份鍋包肉發表於2021-03-26

MySQL 在進行 Order By 操作排序時,通常有兩種排序方式:

  • 全欄位排序
  • Row_id 排序

MySQL 中每個執行緒在執行排序時,都會被分配一塊區域 - sort buffer,它的大小通過 sort_buffer_size 控制。

全欄位排序指的是,將要查詢的欄位,全都存入 sort buffer 中,然後對 sort buffer 進行排序,然後將結果返回給客戶端。

Row_id 排序:將被排序的欄位和對應主鍵索引的 ID 放入,sort buffer 中,然後對 sort buffer 進行排序,最後額外進行一次回表操作查詢額外的資訊,然後將結果返回給客戶端。

全欄位排序和 Row_id 排序的主要區別在:

  1. sort buffer 存入的內容不同
  2. 回表查詢的次數不一致。

對於 InnoDB 表來說,在記憶體足夠的情況下,會優先選擇全欄位排序的方式。在記憶體不足的情況下,可能會借用外部檔案進行排序。

但如果單行內容較大時,會導致拆分的外部檔案過多,進行歸併排序時,效率變低。此時會採用 Row_id 的排序方式。

對於 Memory 表來說,會優先選擇 Row_id 的排序方式。

接下來會對全欄位排序和 Row_id 排序進行驗證,最後並給出一些調優的技巧。

環境準備

假設存在如下表結構,表裡有 5萬的資料行, 其中 type 為二級索引。

# MySQL5.7.28, RR
CREATE TABLE `test_table` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(16) NOT NULL,
  `type` varchar(16) NOT NULL,
  `phone` int(11) NOT NULL,
  `addr` varchar(128) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `type` (`type`)
) ENGINE=InnoDB;

向表中插入資料:

import random
import MySQLdb

def prepare_data():
    result = []
    type = ["a", "b", "c", "d", "e"]

    for i in range(50000):
        index = random.randint(0, 4)
        result.append((str(i), str(type[index]), str(i + 10), str(i)))
    return result

def insert_data():
    db = MySQLdb.connect(host='10.124.207.xxx',
                         user='xxxx',
                         passwd='xxxxx',
                         db='usecase',
                         charset='utf8')
    sql = 'INSERT INTO test_table ( name, type, phone, addr) VALUES ( %s, %s, %s, %s);'
    cur = db.cursor()
    cur.executemany(sql, prepare_data())
    db.commit()
    db.close()

if __name__ == '__main__':
    try:
        insert_data()
    except Exception as e:

下面會進行查詢操作:

``select name,type,phone from test_table where type='a' order by name limit 1000 ;`

並對排序的過程進行分析。

全欄位排序

這裡對該查詢語句進行了 EXPLAIN 操作,可以看到在 Extra 列 :

  • 用到了索引
  • 進行了排序操作 - filesort (無法利用索引預設有序的情況)

針對本次 SELECT 來說,經歷如下的過程:

  1. 使用 type = a 的二級索引,找到滿足的第一個值。
  2. 根據該值,找到主鍵 ID。回表去找 name 和 phone 的值。
  3. 然後將 type,name, phone 存入 sort_buffer。
  4. 然後重複 1 - 4 過程,查詢所有 type =a 的內容,然後將 name, phone, type 存入 sort_buffer。
  5. 然後對分配記憶體裡的資訊進行排序。
  6. 然後選擇前 1000 條,返回給客戶端。

開啟 optimizer_trace ,檢視執行的流程:

可以看到,對應 "sort_mode": 中的內容為 sort_key 和其他欄位,這就表示用的是全欄位排序。

chosen: true 表示使用的是優先佇列的排序演算法。

但在實際情況下,往往會出現待排序的內容大於分配用於排序的空間,此時就會用到外部的檔案排序,而這種外部排序一般都使用歸併排序。

將 sort_buffer 調小,重新執行:

將預設大小臨時改小:

# 將 size 調小, 並重新登入
set sort_buffer_size= 24 * 1024;

/* 開啟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 type,name,phone from test_table where type='a' 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 表示使用的臨時檔案數為 20,上面的 chosen 表示由於空間不足,無法使用堆排序。

可以發現:

  • sort_buffer_size 大於要待排序的內容,則使用記憶體排序。

  • 如果小於,則使用外部臨時檔案輔助排序。

但還有一種情況,就是待排序的內容資料量太大或者單行查詢的欄位太多(如 SELECT *)這種情況,會導致生成的臨時檔案數量太多,效率不高。所以就出現了另一種 Row_id 的排序方式。

Row_id 排序

在單行查詢欄位很多時,在 sort_buffer 中僅僅儲存必要的欄位,最後額外再進行一次統一回表的操作,查詢必要的資訊。

這裡可以將 SET max_length_for_sort_data = 16; 改小,模擬這種情況。

這裡 sort_mode: sort_key, rowid 指的就是用的 Row_id 這種處理方式。

對應經歷的過程就會變成:

  1. 使用 type = a 的二級索引,找到滿足的第一個值。
  2. 根據該值,找到主鍵 ID。回表去找 row_id 的值。(發生變化)
  3. 然後將 type, row_id 存入 sort_buffer。(發生變化)
  4. 然後重複 1 - 4 過程,查詢所有 type = a 的內容,然後將 type, row_id 存入 sort_buffer。(發生變化)
  5. 然後對分配記憶體裡的資訊進行排序。
  6. 然後選擇前 1000 條,並取到需要的 row_id 集合。
  7. 根據 row_id,回表查詢所需要的資訊,然後返回給客戶端。(發生變化)

這裡 2, 3, 4, 7 和之前全欄位排序相比,發生了變化。

但需要注意的是,在記憶體足夠的情況下,InooDB 會優先選擇全排序的方式。但對於 Memory 方式的表結構,則會有不同的選擇。

記憶體臨時表的排序選擇

在如使用 Union 或者 Group By 等查詢的情況下,會建立臨時表,採用 Memory 作為儲存的引擎。

而對於記憶體臨時表,會優先採用 row_id 排序。

因為記憶體臨時表,本身會在原表基礎上,新建一張臨時表儲存需要的資訊。因為臨時表本身就在記憶體中,所以最後一次回表的操作,不會進行額外的磁碟 IO。所以 MySQL 會優先選擇 row_id 的排序方式。

優化方法

場景1:利用索引有序,讓 Order by 不排序

之前僅僅有 type 型別的索引,可以將其改成 type, name 的聯合索引:

alter table test_table add index type_name(type, name);

在建立聯合索引時,因為本身有序。在查詢 typem,name,phone 時,會將 sort buffer 中的排序過程省略,也就是全排序過程中的第五步。

可以看到,在 Extra 中 filesort 的過程,已經被省略了。

場景2:利用覆蓋索引,簡化回表流程

alter table test_table add index type_name(type, name, phone);

這裡由於想要查詢的欄位,都已經在二級索引上了。所以不需要進行回表,而且本身也是有序的。

並且可以發現,Extra 變成了 Using index. 表示直接使用了索引。

場景3:IN 導致 Order By 需要排序

假設存在 type_name(type, name); 聯合索引。

將之前的 type = 換成了 IN,由於是對 ab 兩個型別同時排序,所以就需要 filesort 操作。

如果不想讓 MySQL 進行排序的操作,可以將 IN 拆分成多個 = 執行,然後在呼叫端,自己進行合併。

總結

在 MySQL 中,使用 Order BY 時,通常有全欄位排序和 Row_id 兩種排序方式。

對於 InooDB 來說,在記憶體足夠的情況下,會優先選擇全欄位排序。在記憶體不足,並所需排序內容不多時,會採用外部歸併排序的方式。

但如果所在單行內容太大,導致拆分檔案過多的情況下,會選擇 Row_id 的排序方式。

對於 Memory 表來說,由於本身就在記憶體中,所以會優先選擇 Row_id 的排序方式。

使用 Order By 操作時,不一定真的意味著真的去做排序,可以利用索引本身有序,或者覆蓋索引,拆分 SQL 的方式,減少 MySQL 的排序過程。

參考

題外話:最近在系統的學習 MySQL,推薦一個比較好的學習材料就是<<丁奇老師的 MySQL 45 講>>,連結已經附在文章末尾。

文章中很多知識點就是從中學來,加入自己的理解並整理的。

大家在購買後,強烈推薦讀一讀評論區的內容,價值非常高,不少同學問出了自己在思考時的一些困惑。

丁奇 MySQL 45 講連結

ordery by 優化

taobao-內部臨時表

相關文章