mysql实战17 | 如何正确地显示随机消息?
這個英語學習 App 首頁有一個隨機顯示單詞的功能,也就是根據每個用戶的級別有一個單詞表,然后這個用戶每次訪問首頁的時候,都會隨機滾動顯示三個單詞。他們發現隨著單詞表變大,選單詞這個邏輯變得越來越慢,甚至影響到了首頁的打開速度。
現在,如果讓你來設計這個 SQL 語句,你會怎么寫呢?
為了便于理解,我對這個例子進行了簡化:去掉每個級別的用戶都有一個對應的單詞表這個邏輯,直接就是從一個單詞表中隨機選出三個單詞。這個表的建表語句和初始數據的命令如下:
為了便于量化說明,我在這個表里面插入了 10000 行記錄。接下來,我們就一起看看要隨機選擇 3 個單詞,有什么方法實現,存在什么問題以及如何改進。
內存臨時表
首先,你會想到用 order by rand() 來實現這個邏輯。
這個語句的意思很直白,隨機排序取前 3 個。雖然這個 SQL 語句寫法很簡單,但執行流程卻有點復雜的。
我們先用 explain 命令來看看這個語句的執行情況。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 圖 1 使用 explain 命令查看語句的執行情況
Extra 字段顯示 Using temporary,表示的是需要使用臨時表;Using filesort,表示的是需要執行排序操作。
因此這個 Extra 的意思就是,需要臨時表,并且需要在臨時表上排序。
這里,你可以先回顧一下上一篇文章中全字段排序和 rowid 排序的內容。我把上一篇文章的兩個流程圖貼過來,方便你復習。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?圖 2 全字段排序
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 圖 3 rowid 排序
然后,我再問你一個問題,你覺得對于臨時內存表的排序來說,它會選擇哪一種算法呢?回顧一下上一篇文章的一個結論:對于 InnoDB 表來說,執行全字段排序會減少磁盤訪問,因此會被優先選擇。
我強調了“InnoDB 表”,你肯定想到了,對于內存表,回表過程只是簡單地根據數據行的位置,直接訪問內存得到數據,根本不會導致多訪問磁盤。優化器沒有了這一層顧慮,那么它會優先考慮的,就是用于排序的行越小越好了,所以,MySQL 這時就會選擇 rowid 排序。
理解了這個算法選擇的邏輯,我們再來看看語句的執行流程。同時,通過今天的這個例子,我們來嘗試分析一下語句的掃描行數。
這條語句的執行流程是這樣的:
接下來,我們通過慢查詢日志(slow log)來驗證一下我們分析得到的掃描行數是否正確。
其中,Rows_examined:20003 就表示這個語句執行過程中掃描了 20003 行,也就驗證了我們分析得出的結論。
這里插一句題外話,在平時學習概念的過程中,你可以經常這樣做,先通過原理分析算出掃描行數,然后再通過查看慢查詢日志,來驗證自己的結論。我自己就是經常這么做,這個過程很有趣,分析對了開心,分析錯了但是弄清楚了也很開心。
現在,我來把完整的排序執行流程圖畫出來。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 圖 4 隨機排序完整流程圖 1
圖中的 pos 就是位置信息,你可能會覺得奇怪,這里的“位置信息”是個什么概念?在上一篇文章中,我們對 InnoDB 表排序的時候,明明用的還是 ID 字段。
這時候,我們就要回到一個基本概念:MySQL 的表是用什么方法來定位“一行數據”的。
在前面第 4和第 5篇介紹索引的文章中,有幾位同學問到,如果把一個 InnoDB 表的主鍵刪掉,是不是就沒有主鍵,就沒辦法回表了?
其實不是的。如果你創建的表沒有主鍵,或者把一個表的主鍵刪掉了,那么 InnoDB 會自己生成一個長度為 6 字節的 rowid 來作為主鍵。
這也就是排序模式里面,rowid 名字的來歷。實際上它表示的是:每個引擎用來唯一標識數據行的信息。
- 對于有主鍵的 InnoDB 表來說,這個 rowid 就是主鍵 ID;
- 對于沒有主鍵的 InnoDB 表來說,這個 rowid 就是由系統生成的;
- MEMORY 引擎不是索引組織表。在這個例子里面,你可以認為它就是一個數組。因此,這個 rowid 其實就是數組的下標。
到這里,我來稍微小結一下:order by rand() 使用了內存臨時表,內存臨時表排序的時候使用了 rowid 排序方法。
磁盤臨時表
那么,是不是所有的臨時表都是內存表呢?
其實不是的。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。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?圖 5 OPTIMIZER_TRACE 部分結果
然后,我們來看一下這次 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 行也是有序的了。但,我們的查詢并不需要這些數據是有序的。所以,想一下就明白了,這浪費了非常多的計算量。
而優先隊列算法,就可以精確地只得到三個最小值,執行流程如下:
這里我簡單畫了一個優先隊列排序過程的示意圖。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?圖 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 查詢語句:
你可能會問,這里也用到了 limit,為什么沒用優先隊列排序算法呢?原因是,這條 SQL 語句是 limit 1000,如果使用優先隊列算法的話,需要維護的堆的大小就是 1000 行的 (name,rowid),超過了我設置的 sort_buffer_size 大小,所以只能使用歸并排序算法。
總之,不論是使用哪種類型的臨時表,order by rand() 這種寫法都會讓計算過程非常復雜,需要大量的掃描行數,因此排序過程的資源消耗也會很大。
再回到我們文章開頭的問題,怎么正確地隨機排序呢?
隨機排序方法
我們先把問題簡化一下,如果只隨機選擇 1 個 word 值,可以怎么做呢?思路上是這樣的:
我們把這個算法,暫時稱作隨機算法 1。這里,我直接給你貼一下執行語句的序列:
這個方法效率很高,因為取 max(id) 和 min(id) 都是不需要掃描索引的,而第三步的 select 也可以用索引快速定位,可以認為就只掃描了 3 行。但實際上,這個算法本身并不嚴格滿足題目的隨機要求,因為 ID 中間可能有空洞,因此選擇不同行的概率不一樣,不是真正的隨機。
比如你有 4 個 id,分別是 1、2、4、5,如果按照上面的方法,那么取到 id=4 的這一行的概率是取得其他行概率的兩倍。
如果這四行的 id 分別是 1、2、40000、40001 呢?這個算法基本就能當 bug 來看待了。
所以,為了得到嚴格隨機的結果,你可以用下面這個流程:
- 取得整個表的行數,并記為 C。
- 取得 Y = floor(C * rand())。 floor 函數在這里的作用,就是取整數部分。
- 再用 limit Y,1 取得一行。
我們把這個算法,稱為隨機算法 2。下面這段代碼,就是上面流程的執行語句的序列。
由于 limit 后面的參數不能直接跟變量,所以我在上面的代碼中使用了 prepare+execute 的方法。你也可以把拼接 SQL 語句的方法寫在應用程序中,會更簡單些。
這個隨機算法 2,解決了算法 1 里面明顯的概率不均勻問題。
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 的代價要小很多呢?我就把這個問題留給你去課后思考吧。
現在,我們再看看,如果我們按照隨機算法 2 的思路,要隨機取 3 個 word 值呢?你可以這么做:
我們把這個算法,稱作隨機算法 3。下面這段代碼,就是上面流程的執行語句的序列。
小結
今天這篇文章,我是借著隨機排序的需求,跟你介紹了 MySQL 對臨時表排序的執行過程。
如果你直接使用 order by rand(),這個語句需要 Using temporary 和 Using filesort,查詢的執行代價往往是比較大的。所以,在設計的時候你要量避開這種寫法。
今天的例子里面,我們不是僅僅在數據庫內部解決問題,還會讓應用代碼配合拼接 SQL 語句。在實際應用的過程中,比較規范的用法就是:盡量將業務邏輯寫在業務代碼中,讓數據庫只做“讀寫數據”的事情。因此,這類方法的應用還是比較廣泛的。
最后,我給你留下一個思考題吧。
上面的隨機算法 3 的總掃描行數是 C+(Y1+1)+(Y2+1)+(Y3+1),實際上它還是可以繼續優化,來進一步減少掃描行數的。
我的問題是,如果你是這個需求的開發人員,你會怎么做,來減少掃描行數呢?說說你的方案,并說明你的方案需要的掃描行數。
你可以把你的設計和結論寫在留言區里,我會在下一篇文章的末尾和你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一起閱讀。
上期問題時間
我在上一篇文章最后留給你的問題是,select * from t where city in (“杭州”," 蘇州 ") order by name limit 100; 這個 SQL 語句是否需要排序?有什么方案可以避免排序?
雖然有 (city,name) 聯合索引,對于單個 city 內部,name 是遞增的。但是由于這條 SQL 語句不是要單獨地查一個 city 的值,而是同時查了"杭州"和" 蘇州 "兩個城市,因此所有滿足條件的 name 就不是遞增的了。也就是說,這條 SQL 語句需要排序。
那怎么避免排序呢?
這里,我們要用到 (city,name) 聯合索引的特性,把這一條語句拆成兩條語句,執行流程如下:
如果把這條 SQL 語句里“limit 100”改成“limit 10000,100”的話,處理方式其實也差不多,即:要把上面的兩條語句改成寫:
和
這時候數據量較大,可以同時起兩個連接一行行讀結果,用歸并排序算法拿到這兩個結果集里,按順序取第 10001~10100 的 name 值,就是需要的結果了。
當然這個方案有一個明顯的損失,就是從數據庫返回給客戶端的數據量變大了。
所以,如果數據的單行比較大的話,可以考慮把這兩條 SQL 語句改成下面這種寫法:
和
然后,再用歸并排序的方法取得按 name 順序第 10001~10100 的 name、id 的值,然后拿著這 100 個 id 到數據庫中去查出所有記錄。
上面這些方法,需要你根據性能需求和開發的復雜度做出權衡。
轉載于:https://juejin.im/post/5d035ea8f265da1b94214631
總結
以上是生活随笔為你收集整理的mysql实战17 | 如何正确地显示随机消息?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: android代码设置全屏
- 下一篇: LSP(分层服务提供者)