MySQL實戰45講學習筆記:第十七講

 一 、引子

我在上一篇文章,爲你講解完 order by 語句的幾種執行模式後,就想到了以前一個作英語學習 App 的朋友碰到過的一個性能問題。今天這篇文章,我就從這個性能問題提及,和
你說說 MySQL 中的另一種排序需求,但願可以加深你對 MySQL 排序邏輯的理解。mysql

這個英語學習 App 首頁有一個隨機顯示單詞的功能,也就是根據每一個用戶的級別有一個單詞表,而後這個用戶每次訪問首頁的時候,都會隨機滾動顯示三個單詞。他們發現隨着單
詞表變大,選單詞這個邏輯變得愈來愈慢,甚至影響到了首頁的打開速度。算法

如今,若是讓你來設計這個 SQL 語句,你會怎麼寫呢?sql

爲了便於理解,我對這個例子進行了簡化:去掉每一個級別的用戶都有一個對應的單詞表這個邏輯,直接就是從一個單詞表中隨機選出三個單詞。這個表的建表語句和初始數據的命令以下:數據庫

mysql> CREATE TABLE `words` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `word` varchar(64) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=0;
  while i<10000 do
    insert into words(word) values(concat(char(97+(i div 1000)), char(97+(i % 1000 div 100)), char(97+(i % 100 div 10)), char(97+(i % 10))));
    set i=i+1;
  end while;
end;;
delimiter ;

call idata();

爲了便於量化說明,我在這個表裏面插入了 10000 行記錄。接下來,咱們就一塊兒看看要隨機選擇 3 個單詞,有什麼方法實現,存在什麼問題以及如何改進。數組

2、內存臨時表

首先,你會想到用 order by rand() 來實現這個邏輯。bash

mysql> select word from words order by rand() limit 3;

一、臨時內存表的排序來講,它會選擇哪種算法呢?

這個語句的意思很直白,隨機排序取前 3 個。雖然這個 SQL 語句寫法很簡單,但執行流程卻有點複雜的。數據結構

咱們先用 explain 命令來看看這個語句的執行狀況。函數

圖 1 使用 explain 命令查看語句的執行狀況性能

實際測試代碼以下:學習

 

mysql> explain select word from words order by rand() limit 3;
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra                           |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+
|  1 | SIMPLE      | words | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 9980 |   100.00 | Using temporary; Using filesort |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+
1 row in set, 1 warning (0.00 sec)

 

一、Using temporary是什麼意思?

Extra 字段顯示 Using temporary,表示的是須要使用臨時表;Using filesort,表示的是須要執行排序操做

所以這個 Extra 的意思就是,須要臨時表,而且須要在臨時表上排序。

這裏,你能夠先回顧一下上一篇文章中全字段排序和 rowid 排序的內容。我把上一篇文章的兩個流程圖貼過來,方便你複習。

 圖 2 全字段排序

 圖 3 rowid 排序

而後,我再問你一個問題,你以爲對於臨時內存表的排序來講,它會選擇哪種算法呢?回顧一下上一篇文章的一個結論:對於 InnoDB 表來講,執行全字段排序會減小磁盤訪
問,所以會被優先選擇。

我強調了「InnoDB 表」,你確定想到了,對於內存表,回表過程只是簡單地根據數據行的位置,直接訪問內存獲得數據,根本不會致使多訪問磁盤。優化器沒有了這一層顧慮,

那麼它會優先考慮的,就是用於排序的行越小越好了,因此,MySQL 這時就會選擇rowid 排序。

二、隨機排序完整流程

理解了這個算法選擇的邏輯,咱們再來看看語句的執行流程。同時,經過今天的這個例子,咱們來嘗試分析一下語句的掃描行數。

這條語句的執行流程是這樣的:

1. 建立一個臨時表。這個臨時表使用的是 memory 引擎,表裏有兩個字段,第一個字段是 double 類型,爲了後面描述方便,記爲字段 R,第二個字段是 varchar(64) 類型,記爲字段 W。而且, 這個表沒有建索引。

2. 從 words 表中,按主鍵順序取出全部的 word 值。對於每個 word 值,調用 rand()函數生成一個大於 0 小於 1 的隨機小數,並把這個隨機小數和 word 分別存入臨時表的
    R 和 W 字段中,到此,掃描行數是 10000。

3. 如今臨時表有 10000 行數據了,接下來你要在這個沒有索引的內存臨時表上,按照字段 R 排序。

4. 初始化 sort_buffer。sort_buffer 中有兩個字段,一個是 double 類型,另外一個是整型。
5. 從內存臨時表中一行一行地取出 R 值和位置信息(我後面會和你解釋這裏爲何是「位置信息」),分別存入 sort_buffer 中的兩個字段裏。這個過程要對內存臨時表作全表
    掃描,此時掃描行數增長 10000,變成了 20000。
6. 在 sort_buffer 中根據 R 的值進行排序。注意,這個過程沒有涉及到表操做,因此不會增長掃描行數。
7. 排序完成後,取出前三個結果的位置信息,依次到內存臨時表中取出 word 值,返回給客戶端。

一、推薦一個學習方法

這個過程當中,訪問了表的三行數據,總掃描行數變成了 20003。

接下來,咱們經過慢查詢日誌(slow log)來驗證一下咱們分析獲得的掃描行數是否正確。

# Query_time: 0.900376  Lock_time: 0.000347 Rows_sent: 3 Rows_examined: 20003
SET timestamp=1541402277;
select word from words order by rand() limit 3;

實際測試效果截圖

其中,Rows_examined:20003 就表示這個語句執行過程當中掃描了 20003 行,也就驗證了咱們分析得出的結論。

這裏插一句題外話,在平時學習概念的過程當中,你能夠常常這樣作,先經過原理分析算出掃描行數,而後再經過查看慢查詢日誌,來驗證本身的結論。我本身就是常常這麼作,這
個過程頗有趣,分析對了開心,分析錯了可是弄清楚了也很開心。

三、隨機排序完整流程圖

如今,我來把完整的排序執行流程圖畫出來。

圖 4 隨機排序完整流程圖 1

圖中的 pos 就是位置信息,你可能會以爲奇怪,這裏的「位置信息」是個什麼概念?在上一篇文章中,咱們對 InnoDB 表排序的時候,明明用的仍是 ID 字段。

四、MySQL 的表是用什麼方法來定位「一行數據」的。

這時候,咱們就要回到一個基本概念:MySQL 的表是用什麼方法來定位「一行數據」的。

在前面第 4和第 5篇介紹索引的文章中,有幾位同窗問到,若是把一個 InnoDB 表的主鍵刪掉,是否是就沒有主鍵,就沒辦法回表了?

其實不是的。如果你建立的表沒有主鍵,或者把一個表的主鍵刪掉了,那麼 InnoDB 會本身生成一個長度爲 6 字節的 rowid 來做爲主鍵。

這也就是排序模式裏面,rowid 名字的來歷。實際上它表示的是:每一個引擎用來惟一標識數據行的信息。

  1. 對於有主鍵的 InnoDB 表來講,這個 rowid 就是主鍵 ID;
  2. 對於沒有主鍵的 InnoDB 表來講,這個 rowid 就是由系統生成的;
  3. MEMORY 引擎不是索引組織表。在這個例子裏面,你能夠認爲它就是一個數組。所以,這個 rowid 其實就是數組的下標。

到這裏,我來稍微小結一下:order by rand() 使用了內存臨時表,內存臨時表排序的時候使用了 rowid 排序方法。

3、磁盤臨時表

一、那麼,是否是全部的臨時表都是內存表呢?

其實不是的。tmp_table_size 這個配置限制了內存臨時表的大小,默認值是 16M。若是臨時表大小超過了 tmp_table_size,那麼內存臨時表就會轉成磁盤臨時表。
磁盤臨時表使用的引擎默認是 InnoDB,是由參數 internal_tmp_disk_storage_engine控制的。

當使用磁盤臨時表的時候,對應的就是一個沒有顯式索引的 InnoDB 表的排序過程。爲了復現這個過程,我把 tmp_table_size 設置成 1024,把 sort_buffer_size 設置成
32768, 把 max_length_for_sort_data 設置成 16。

set tmp_table_size=1024;
set sort_buffer_size=32768;
set max_length_for_sort_data=16;
/* 打開 optimizer_trace,只對本線程有效 */
SET optimizer_trace='enabled=on'; 

/* 執行語句 */
select word from words order by rand() limit 3;

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

 圖 5 OPTIMIZER_TRACE 部分結果

實際測試代碼以下:

測試代碼

mysql> set tmp_table_size=1024;
Query OK, 0 rows affected (0.01 sec)

mysql> set sort_buffer_size=32768;
Query OK, 0 rows affected (0.00 sec)

mysql> set max_length_for_sort_data=16;
Query OK, 0 rows affected (0.00 sec)

mysql> SET optimizer_trace='enabled=on'; 
Query OK, 0 rows affected (0.01 sec)

mysql> select word from words order by rand() limit 3;
+------+
| word |
+------+
| cbgj |
| gdcg |
| iagj |
+------+
3 rows in set (0.07 sec)

關鍵測試結果代碼以下:

mysql> SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G
*************************** 1. row ***************************
                            QUERY: select word from words order by rand() limit 3
                            TRACE: {
  "steps": [
    {
      "join_preparation": {
......
            ],
            "filesort_priority_queue_optimization": {
              "limit": 3,
              "rows_estimate": 1213,
              "row_size": 14,
              "memory_available": 32768,
              "chosen": true
            },
            "filesort_execution": [
            ],
            "filesort_summary": {
              "rows": 4,
              "examined_rows": 10000,
              "number_of_tmp_files": 0,
              "sort_buffer_size": 88,
              "sort_mode": "<sort_key, rowid>"
            }
          }
        ]
      }
    }
  ]
}
MISSING_BYTES_BEYOND_MAX_MEM_SIZE: 0
          INSUFFICIENT_PRIVILEGES: 0
1 row in set (0.02 sec)

而後,咱們來看一下此次 OPTIMIZER_TRACE 的結果。

由於將 max_length_for_sort_data 設置成 16,小於 word 字段的長度定義,因此咱們看到 sort_mode 裏面顯示的是 rowid 排序,這個是符合預期的,參與排序的是隨機值 R 字
段和 rowid 字段組成的行。

這時候你可能心算了一下,發現不對。R 字段存放的隨機值就 8 個字節,rowid 是 6 個字節(至於爲何是 6 字節,就留給你課後思考吧),數據總行數是 10000,這樣算出來就
有 140000 字節,超過了 sort_buffer_size 定義的 32768 字節了。可是,number_of_tmp_files 的值竟然是 0,難道不須要用臨時文件嗎?

二、優先隊列排序算法

這個 SQL 語句的排序確實沒有用到臨時文件,採用是 MySQL 5.6 版本引入的一個新的排序算法,即:優先隊列排序算法。接下來,咱們就看看爲何沒有使用臨時文件的算法,
也就是歸併排序算法,而是採用了優先隊列排序算法。

其實,咱們如今的 SQL 語句,只須要取 R 值最小的 3 個 rowid。可是,若是使用歸併排序算法的話,雖然最終也能獲得前 3 個值,可是這個算法結束後,已經將 10000 行數據
都排好序了。

也就是說,後面的 9997 行也是有序的了。但,咱們的查詢並不須要這些數據是有序的。因此,想一下就明白了,這浪費了很是多的計算量。

而優先隊列算法,就能夠精確地只獲得三個最小值,執行流程以下:

1. 對於這 10000 個準備排序的 (R,rowid),先取前三行,構形成一個堆;(對數據結構印象模糊的同窗,能夠先設想成這是一個由三個元素組成的數組)1. 取下一個行 (R’,rowid’),

    跟當前堆裏面最大的 R 比較,若是 R’小於 R,把這個(R,rowid) 從堆中去掉,換成 (R’,rowid’);

2. 重複第 2 步,直到第 10000 個 (R’,rowid’) 完成比較。

這裏我簡單畫了一個優先隊列排序過程的示意圖。

三、優先隊列排序過程示意圖

圖 6 優先隊列排序算法示例

圖 6 是模擬 6 個 (R,rowid) 行,經過優先隊列排序找到最小的三個 R 值的行的過程。整個排序過程當中,爲了最快地拿到當前堆的最大值,老是保持最大值在堆頂,所以這是一個最大堆。

圖 5 的 OPTIMIZER_TRACE 結果中,filesort_priority_queue_optimization 這個部分的chosen=true,就表示使用了優先隊列排序算法,這個過程不須要臨時文件,所以對應的
number_of_tmp_files 是 0。

這個流程結束後,咱們構造的堆裏面,就是這個 10000 行裏面 R 值最小的三行。而後,依次把它們的 rowid 取出來,去臨時表裏面拿到 word 字段,這個過程就跟上一篇文章的
rowid 排序的過程同樣了。

咱們再看一下上面一篇文章的 SQL 查詢語句:

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

MySQL 的表是用什麼方法來定位「一行數據」的。

你可能會問,這裏也用到了 limit,爲何沒用優先隊列排序算法呢?緣由是,這條 SQL語句是 limit 1000,若是使用優先隊列算法的話,須要維護的堆的大小就是 1000 行的
(name,rowid),超過了我設置的 sort_buffer_size 大小,因此只能使用歸併排序算法。

總之,不管是使用哪一種類型的臨時表,order by rand() 這種寫法都會讓計算過程很是複雜,須要大量的掃描行數,所以排序過程的資源消耗也會很大。
再回到咱們文章開頭的問題,怎麼正確地隨機排序呢?

4、隨機排序方法

一、隨機算法 1

咱們先把問題簡化一下,若是隻隨機選擇 1 個 word 值,能夠怎麼作呢?思路上是這樣的:

  • 1. 取得這個表的主鍵 id 的最大值 M 和最小值 N;
  • 2. 用隨機函數生成一個最大值到最小值之間的數 X = (M-N)*rand() + N;
  • 3. 取不小於 X 的第一個 ID 的行。

咱們把這個算法,暫時稱做隨機算法 1。這裏,我直接給你貼一下執行語句的序列:

mysql> select max(id),min(id) into @M,@N from t ;
set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id >= @X limit 1;

這個方法效率很高,由於取 max(id) 和 min(id) 都是不須要掃描索引的,而第三步的select 也能夠用索引快速定位,能夠認爲就只掃描了 3 行。但實際上,這個算法自己並不
嚴格知足題目的隨機要求,由於 ID 中間可能有空洞,所以選擇不一樣行的機率不同,不是真正的隨機。

好比你有 4 個 id,分別是 一、二、四、5,若是按照上面的方法,那麼取到 id=4 的這一行的機率是取得其餘行機率的兩倍。

若是這四行的 id 分別是 一、二、40000、40001 呢?這個算法基本就能當 bug 來看待了。

二、隨機算法 2

因此,爲了獲得嚴格隨機的結果,你能夠用下面這個流程:

  • 1. 取得整個表的行數,並記爲 C。
  • 2. 取得 Y = floor(C * rand())。 floor 函數在這裏的做用,就是取整數部分。
  • 3. 再用 limit Y,1 取得一行。

咱們把這個算法,稱爲隨機算法 2。下面這段代碼,就是上面流程的執行語句的序列。

因爲 limit 後面的參數不能直接跟變量,因此我在上面的代碼中使用了 prepare+execute的方法。你也能夠把拼接 SQL 語句的方法寫在應用程序中,會更簡單些。

這個隨機算法 2,解決了算法 1 裏面明顯的機率不均勻問題。

mysql> select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1");
prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt;

MySQL 處理 limit Y,1 的作法就是按順序一個一個地讀出來,丟掉前 Y 個,而後把下一個記錄做爲返回結果,所以這一步須要掃描 Y+1 行。再加上,第一步掃描的 C 行,總共需
要掃描 C+Y+1 行,執行代價比隨機算法 1 的代價要高。

固然,隨機算法 2 跟直接 order by rand() 比起來,執行代價仍是小不少的。

你可能問了,若是按照這個表有 10000 行來計算的話,C=10000,要是隨機到比較大的Y 值,那掃描行數也跟 20000 差很少了,接近 order by rand() 的掃描行數,爲何說隨
機算法 2 的代價要小不少呢?我就把這個問題留給你去課後思考吧。

三、稱做隨機算法 3

如今,咱們再看看,若是咱們按照隨機算法 2 的思路,要隨機取 3 個 word 值呢?你能夠這麼作:

  • 1. 取得整個表的行數,記爲 C;
  • 2. 根據相同的隨機方法獲得 Y一、Y二、Y3;
  • 3. 再執行三個 limit Y, 1 語句獲得三行數據。

咱們把這個算法,稱做隨機算法 3。下面這段代碼,就是上面流程的執行語句的序列。

mysql> select count(*) into @C from t;
set @Y1 = floor(@C * rand());
set @Y2 = floor(@C * rand());
set @Y3 = floor(@C * rand());
select * from t limit @Y1,1; // 在應用代碼裏面取 Y一、Y二、Y3 值,拼出 SQL 後執行
select * from t limit @Y2,1;
select * from t limit @Y3,1;

四、記錄慢查詢

一、查看是否開啓慢查詢:
show variables like 'slow_query_log';

二、開始慢查詢
set global log_queries_not_using_indexes=on;

三、確認log_queries_not_using_indexes 是不開啓
show variables like '%log%'; 
log_queries_not_using_indexes              | ON  

四、查詢慢查詢日誌位置
mysql> show variables like 'slow%';
+---------------------+--------------------------------------+
| Variable_name       | Value                                |
+---------------------+--------------------------------------+
| slow_launch_time    | 2                                    |
| slow_query_log      | ON                                   |
| slow_query_log_file | /var/lib/mysql/0c94d4cac265-slow.log |
+---------------------+--------------------------------------+
3 rows in set (0.00 sec)

5、小結

今天這篇文章,我是藉着隨機排序的需求,跟你介紹了 MySQL 對臨時表排序的執行過程。查詢的執行代價每每是比較大的。因此,在設計的時候你要量避開這種寫法。

若是你直接使用 order by rand(),這個語句須要 Using temporary 和 Using filesort,

今天的例子裏面,咱們不是僅僅在數據庫內部解決問題,還會讓應用代碼配合拼接 SQL 語句。在實際應用的過程當中,比較規範的用法就是:儘可能將業務邏輯寫在業務代碼中,讓數
據庫只作「讀寫數據」的事情。所以,這類方法的應用仍是比較普遍的。

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

上面的隨機算法 3 的總掃描行數是 C+(Y1+1)+(Y2+1)+(Y3+1),實際上它仍是能夠繼續優化,來進一步減小掃描行數的。

個人問題是,若是你是這個需求的開發人員,你會怎麼作,來減小掃描行數呢?說說你的方案,並說明你的方案須要的掃描行數。

你能夠把你的設計和結論寫在留言區裏,我會在下一篇文章的末尾和你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。

6、上期問題時間

我在上一篇文章最後留給你的問題是,select * from t where city in (「杭州」," 蘇州 ")order by name limit 100; 這個 SQL 語句是否須要排序?有什麼方案能夠避免排序?

雖然有 (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 值,就是咱們須要的結果了。

若是把這條 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 到數據庫中去查出全部記錄。

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

相關文章
相關標籤/搜索