寫在前面
文章涉及到的 customer 表來源於案例庫 sakila,下載地址為 http://downloads.mysql.com/docs/sakila-db.zip,另外文章演示的 Demo 基於 MySQL Community Server 8.0.19 版本。
MySQL 排序方式基本可以分為兩種
-
運用索引天然排序的特征直接返回排好序的數據
-
通過對返回數據進行排序,即 FileSort 排序
所有不是通過索引直接返回排序結果的排序都叫 FileSort 排序。FileSort 並不代表通過磁盤文件進行排序,而只是說進行了一個排序操作,至於 排序操作是否使用了磁盤文件取決於 MySQL 服務器對排序參數的設置和需要排序數據的大小。
EXPLAIN 排序分析
CREATE TABLE `customer` (
`customer_id` smallint unsigned NOT NULL AUTO_INCREMENT,
`store_id` tinyint unsigned NOT NULL,
`first_name` varchar(45) NOT NULL,
`last_name` varchar(45) NOT NULL,
`email` varchar(50) DEFAULT NULL,
`address_id` smallint unsigned NOT NULL,
`active` tinyint(1) NOT NULL DEFAULT '1',
`create_date` datetime NOT NULL,
`last_update` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`customer_id`),
KEY `idx_fk_store_id` (`store_id`),
KEY `idx_fk_address_id` (`address_id`),
KEY `idx_last_name` (`last_name`),
KEY `idx_storeid_email` (`store_id`,`email`),
CONSTRAINT `fk_customer_address` FOREIGN KEY (`address_id`) REFERENCES `address` (`address_id`) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT `fk_customer_store` FOREIGN KEY (`store_id`) REFERENCES `store` (`store_id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=600 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE DEFINER=`root`@`%` TRIGGER `customer_create_date` BEFORE INSERT ON `customer` FOR EACH ROW SET NEW.create_date = NOW();
主鍵索引
根據 customer_id 進行排序。因為 customer_id 是主鍵,記錄都是按照主鍵排好序的,所以無需進行額外的排序操作,直接返回所有數據即可。
EXPLAIN SELECT * FROM customer ORDER BY customer_id;

普通索引
由於整張表的所有記錄默認是根據 customer_id 排好序的,當然 store_id 索引樹也是排好序的,但是僅僅是對 store_id 這個字段來說是排好序的,這里的需求是整張表按 store_id 進行排序,所以涉及到了 FileSort 排序。
EXPLAIN SELECT * FROM customer ORDER BY store_id;

EXPLAIN SELECT store_id FROM customer ORDER BY store_id;

聯合索引
store_id , email 這兩個字段是有聯合索引 idx_storeid_email 的,只查詢 store_id 和 email 兩個字段,直接通過聯合索引所在的 B+ 樹返回查詢數據(該索引樹先根據 store_id 字段先進行排序,然后再根據 email 字段排序好的),所以這里的查詢結果就是排序好的。
EXPLAIN SELECT store_id, email FROM customer ORDER BY store_id;

和上面不同的是,排序的字段是 email,導致 FileSort 排序的原因是聯合索引 idx_storeid_email 的是先根據 store_id 字段先進行排序,然后再根據 email 字段排序好的,如果直接使用 email 排序,則無法使用 idx_storeid_email 索引樹排好的順序,需要先從 idx_storeid_email 索引樹上將 store_id 和 email 這兩個字段查出來然后再根據 email 字段進行排序。
EXPLAIN SELECT store_id, email FROM customer ORDER BY email;

按照聯合索引順序多字段排序
EXPLAIN SELECT store_id, email FROM customer ORDER BY store_id, email;

不按照聯合索引多字段順序進行排序
EXPLAIN SELECT store_id, email FROM customer ORDER BY email, store_id;

這里注意,和 SELECT 的字段順序沒有關系
EXPLAIN SELECT email, store_id FROM customer ORDER BY store_id, email;

idx_storeid_email 索引是按照 store_id 和 email 升序進行排序的,這里如果 email 按照降序來排序,那前面 store_id 升序排序可以繼續使用索引排好序,后面 email 降序是要進行再排序的。
EXPLAIN SELECT store_id, email FROM customer ORDER BY store_id ASC, email DESC;

固定 store_id = 1 情況下對 email 字段進行排序,使用 idx_storeid_email 索引即可
EXPLAIN SELECT store_id, email FROM customer WHERE store_id = 1 ORDER BY email;

where 條件先進行 store_id 范圍查詢導致 ORDER BY email 字段無法使用 idx_storeid_email 索引進行排序。
EXPLAIN SELECT store_id, email FROM customer WHERE store_id >= 1 AND store_id <= 3 ORDER BY email;

ORDER BY 可能出現 FileSort 的幾種情況
無法直接利用索引樹排好序的,基本都會出現 Using FileSort。常見的情況如下:
1、一般全表數據默認只會按照主鍵進行排好序,所以,如果需要 SELECT * 時,或者 SELECT 的字段沒有建立索引時如果不是按照主鍵進行排序,那么是要再排序的。
2、對於聯合索引來說,索引樹是按照多個字段的升序排列,如果你 order by 的時候涉及到多字段升序和降序混用,會導致無法利用索引的天然排序,或者是只能利用到一部分,另一部分需要再排序。
3、聯合索引情況下,order by 多字段排序的字段左右順序和聯合索引的字段左右順序不一致導致 FileSort。
4、聯合索引情況下,where 字段和 order by 字段的左右順序和聯合索引字段左右順序或者 where 字段出現范圍查詢都可能導致 FileSort。
排序的優化
從上面案例我們大致能了解到,要想優化排序,其實就是盡量使用索引排好的序,減少再排序,也就是盡量減少 Using FileSort 的出現。下面我們來了解一下排序的基本原理。
全字段排序和 rowid 排序
全字段排序
如果 MySQL 認為內存足夠大,會優先選擇全字段排序,把需要的字段都放到 sort_buffer 中,這樣排序后就會直接從內存里面返回查詢結果了,不用再回到原表去取數據。
這也就體現了 MySQL 的一個設計思想:如果內存夠,就要多利用內存,盡量減少磁盤訪問。
rowid 排序
上面的全字段排序涉及到一個問題,就是如果查詢要返回的字段很多的話,那么 sort_buffer 里面要放的字段數太多,這樣內存里能夠同時放下的行數很少,比如:符合條件的記錄一共 1000 行,內存只能放 500 行,那么還有 500 行就要水平拆分放入多個臨時文件進行排序(究竟拆分成多少個臨時文件排序,也涉及到相關算法,這里就不深入研究了),所以如果單行很大,這個方法效率不夠好。那么,如果 MySQL 認為排序的單行長度太大會怎么做呢?接下來,我來修改一個參數,讓 MySQL 采用另外一種算法。
SET max_length_for_sort_data = 16;
max_length_for_sort_data,是 MySQL 中專門控制用於排序的行數據的長度的一個參數。它的意思是,如果單行的長度超過這個值,MySQL 就認為單行太大,要換一個排序算法。
假設現在單行超過了 max_length_for_sort_data,那么就會采用 rowid 進行排序,首先會將需要排序的字段和主鍵取出來到內存中進行排序,排好序之后,再根據主鍵進行回表查出其他字段直接返回給客戶端。注意,這里回表查詢的數據不會存放到內存中,而是直接返回給客戶端。這樣就緩解了全字段排序可能導致很多記錄磁盤排序的問題。但是 rowid 排序同樣也引入了另一個問題,那就是回表,如果回表過多也會導致性能下降。
MySQL
8.0.19版本默認max_length_for_sort_data大小為4096 字節,即4kb;默認sort_buffer_size大小為262144 字節,即256kb。
總結
MySQL 通過 max_length_for_sort_data 的大小單行所有列的總大小來判斷使用哪種排序算法。如果 max_length_for_sort_data 設置足夠大,那么會使用一次掃描算法;但是,一次性性將很多行數據都加載到
sort_buffer 中,如果 sort_buffer 放不下,就會導致大量記錄會使用到磁盤文件排序。此時磁盤負載就會過高,內存中排序的記錄就會變少,相應的 CPU 利用率就會過低。如果設置的很小,則會使用 rowid 排序,也會導致回表過多,性能過差。
比較權衡的做法是,適當加大 sort_buffer_size 排序區,盡量讓排序在內存中完成,而不是在磁盤文件中進行排序;當然也不能無限加大 sort_buffer_size 排序區,因為 sort_buffer_size 參數是每個線程獨占的,設置過大可能會導致服務器 SWAP 嚴重,要考慮數據庫活躍連接數和服務器內存的大小來適當設置排序區。
盡量只使用必要的字段,SELECT 具體的字段名稱,而不是 SELECT * 選擇所有字段,這樣可以減少排序區的使用,提高 SQL 性能。
參考
《深入淺出 MySQL 數據庫開發、優化與管理維護第 2 版》
《極客時間· MySQL 實戰 45 講》
