MySQL實戰45講學習筆記----orderby原理

以市民表爲例,假設你要查詢城市是“杭州”的所有人名字,並且按照姓名排序返回前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字段上創建索引之後,用explain命令來看看這個語句的執行情況。

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

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

這個SQL查詢語句的執行過程

                                            

圖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的值就越大。

示例表中有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排序會要求回表多造成磁盤讀,因此不會被優先選擇。

避免orderby排序

其實,並不是所有的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次。

覆蓋索引

覆蓋索引是指,索引上的信息足夠滿足查詢請求,不需要再回到主鍵索引上去取數據。

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

針對這個查詢,我們可以創建一個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”,表示的就是使用了覆蓋索引,性能上會快很多。當然,這裏並不是說要爲了每個查詢能用上覆蓋索引,就要把語句中涉及的字段都建上聯合索引,畢竟索引還是有維護代價的。這是一個需要權衡的決定。

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

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

這個語句執行的時候有排序過程

雖然有(city,name)聯合索引,對於單個city內部,name是遞增的。但是由於這條SQL語句不是要單獨地查一個city的值,而是同時查了"杭州"和" 蘇州 "兩個城市,因此所有滿足條件的name就不是遞增的了。也就是說,這條SQL語句需要排序。

實現一個在數據庫端不需要排序的方案

這裏要用到(city,name)聯合索引的特性,把這一條語句拆成兩條語句,執行流程如下:

  1. 執行select * from t where city=“杭州” order by name limit 100; 這個語句是不需要排序的,客戶端用一個長度爲100的內存數組A保存結果。

  2. 執行select * from t where city=“蘇州” order by name limit 100; 用相同的方法,假設結果被存進了內存數組B。

  3. 現在A和B是兩個有序數組,然後你可以用歸併排序的思想,得到name最小的前100值,就是我們需要的結果了。

分頁需求,要顯示第101頁,也就是說語句最後要改成 “limit 10000,100”, 實現方法

如果把這條SQL語句裏“limit 100”改成“limit 10000,100”的話,處理方式其實也差不多,即:要把上面的兩條語句改成寫:

select * from t where city="杭州" order by name limit 10100; 

 select * from t where city="蘇州" order by name limit 10100。

這時候數據量較大,可以同時起兩個連接一行行讀結果,用歸併排序算法拿到這兩個結果集裏,按順序取第10001~10100的name值,就是需要的結果了。

當然這個方案有一個明顯的損失,就是從數據庫返回給客戶端的數據量變大了。

所以,如果數據的單行比較大的話,可以考慮把這兩條SQL語句改成下面這種寫法:

select id,name from t where city="杭州" order by name limit 10100; 

select id,name from t where city="蘇州" order by name limit 10100。

然後,再用歸併排序的方法取得按name順序第10001~10100的name、id的值,然後拿着這100個id到數據庫中去查出所有記錄。

上面這些方法,需要你根據性能需求和開發的複雜度做出權衡。

學習了之後,感覺之前自己寫的sql語句慘不忍睹。。。。

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