MySql大表分頁(附獨門祕技)
問題背景
MySql(InnoDB)中的訂單表需要按時間順序分頁查詢,且主鍵不是時間維度遞增,訂單表在百萬以上規模,此時如何高效地實現該需求?
注:本文並非主要講解如何建立索引,以下的分析均建立在有合適的索引的前提下
初步方案1
眾所周知,MySql中,有一個limit offset, pageSize的用法,可以實現分頁查詢
select * from order where user_id = xxx and 【其它業務條件】 order by created_time, id limit offset, pageSize
因為created_time可能重複,所以order by時應加上id,保證順序的確定性
點評
該方案在表規模較小的時候,不會暴露出問題,當order表增長到十萬級,並且查詢後面幾頁的時候,執行速度明顯變慢,可能降到100ms的量級,如果資料量增長到百萬級,則耗時達到秒級,如果增長到千萬級,那耗時就變得完全不可接受了(曾排查過這樣的線上慢SQL)
深入分析
方案1為啥在大表中表現這麼差呢?我們可以來揣測一下MySql是怎麼執行這個查詢的
假設我們在user_id,created_time,以及【其它業務條件】建立了聯合索引,當我要查詢第100000條到100049條的記錄時,因為MySql的索引是b+ tree結構,不像陣列可以隨機定位到第N條記錄,它需要花不小的成本去找到N的位置,N越大,成本越大
拋開b+ tree的細節不講,我們還可以藉助統計表記錄總數的SQL來理解
select count(1) from order
如果能非常高效地定位第N條記錄,那麼上述統計也能非常高效的執行,但實際上,在大表中統計記錄總條數,也是非常慢的(本文是在InnoDB的場景下)
方案1低效的根本原因在於:定位到offset的成本過高,未能充分利用索引的有序性
方案2
索引(b+ tree)的特點在於,資料是有序的,雖然找到第N條記錄的效率比較低,但找到某一條資料在索引中的位置,其效率是很高的(索引本來就是解決這個問題的)
我們換一種思路,每次取50條記錄,第一次取的時候,指定從上次結束的位置繼續往後取50條,這樣,我們便可以利用上索引的有序性了
我們先看一個以id為序,進行分頁查詢的例子
select * from order where id > 'pre max id' order by id limit 50
第一次查詢不用帶條件,後續查詢則傳入前一次查詢的最大id,簡單分析可知,MySql在執行時,先定位到pre max id的位置(id是有序的,定位非常快),然後從這往後取50條記錄即可,整個過程非常高效
我們回到最開始的問題,“按時間順序分頁查詢,且主鍵不是時間維度遞增”,此時我們不能用id作為分頁的條件,因為按它去分頁,便不是按時間順序了,但也不能直接把id換成時間,因為時間可能會重複,我們來分析一下
id | username | created_time |
xxx | zhangsan | 2019-01-01 |
ddd | zhangsan | 2019-02-03 |
yyy | zhangsan | 2019-02-03 |
abc | zhangsan | 2019-02-05 |
aaa | zhangsan | 2020-08-01 |
假如前一次分頁的最後一條記錄為id=ddd的這條(created_time為2019-02-03),下一次查詢使用created_time>2019-02-03作為條件時,則會把id=yyy的這條記錄漏掉,如果換成created_time>=2019-02-03也不行,id=ddd的這條記錄就又被查出來了
對於這個資料遺漏或重複的問題,我看到一種解決方案是這樣的:
分三種情況進行查詢
- 首次查詢,created_time>='xxxx-xx-xx',如果不要求以某時間開始,則無條件
select * from order where user_id = xxx and 【其它業務條件】 and created_time >= 'xxxx-xx-xx' order by created_time, id limit pageSize
- 如果上次查詢的記錄條數等於pageSize,則用created_time和id的組合條件來查詢,為了防止created_time在邊界位置發生重複時漏掉資料
select * from order where user_id = xxx and 【其它業務條件】 and created_time = 'created_time of latest recored' and id > 'id of latest recored' order by created_time, id limit pageSize
- 如果上次查詢的記錄數小於pageSize,並且上次查詢是第二種查詢,則僅用created_time來查詢,
select * from order where user_id = xxx and 【其它業務條件】 and created_time > 'created_time of latest recored' order by created_time, id limit pageSize
點評
上述方法確實可以解決漏掉資料或重複的問題,並且也有著不錯的效能,但缺點也比較明顯,查詢過於複雜,得分情況執行不同的SQL,並且分頁不穩定,中間查詢出來的記錄數可能小於pageSize,實際上後面還有資料
進一步深入分析
我嘗試在網上找過資料,只找到了以id為分頁順序,然後用id>'pre max id'這種方式來查,而我們要以可重複的created_time為分頁順序,如何寫出簡潔高效的SQL呢?
如果要成為一個優秀的程式設計師,我覺得分析&解決新問題的能力,是必不可少的,即使在網上能找到解決方案,優秀的分析能力也有助於借鑑並結合自己的場景,優化出更好的個性化方案。
我們在(user_id,created_time)建立了索引,並且我們知道InnoDB的輔助索引是包含了主鍵的,且主鍵一定不會重複,這意味著在索引上,每條記錄的順序是完全確定的,不存在重複的情況
我們要分頁的順序跟此索引的順序是吻合的,只需要沿著索引,一批一批地取資料就可以了,這是一個對索引很直接的利用,為什麼現在我沒辦法做到?
如果我是MySql的設計人員,針對這種很常見很直接的需求,我怎麼去提供支援?還是說不支援?
我舉一個例子,像java中的基於排序的TreeSet,我猜它一定有floor和ceiling這樣的方法(返回Set中,大於或小於指定元素的第一個元素),這是基於排序的資料結構該有的東西,如果它沒有,那早被人噴瞭然後加上去了
回到索引的話題,這種直接的需求,它應該支援,否則說不過去,現在的問題變成了:用什麼語法來,來實現在組合索引上,基於組合(user_id,created_time,id的組合)順序的遍歷?
此時腦海裡便回想起以前用過的(a,b) in ((1,2),(3,4),(7,4))這樣的組合寫法,然後猜測它也支援大於小於這類比較,跑去MySql中驗證一下:
select (3,7)>(3,7), (3,6)>(3,7), (3,8)>(3,7), (4,7)>(3,7), (4,2)>(3,7); 返回: 0 0 1 1 1
如此一來,這問題就變得和id>'pre max id'這種一樣簡單了。
注:這種寫法後來在官方文件中找到了對應的資料,官方稱這類運算為“行比較”(row comparisons)
https://dev.mysql.com/doc/refman/5.7/en/comparison-operators.html#operator_greater-than
方案3
由於有(a,b)>(1,2)這種語法,所以可以寫出簡潔又高效的SQL
select * from order where user_id = xxx and 【其它業務條件】 and (created_time, id) > (created_time and id of latest recode) order by created_time, id limit pageSize
此方式跟以id為序的分頁查詢是一樣的,首次查詢去掉組合條件即可,程式碼簡潔,同時又可以利用上組合索引,十分高效,耗時穩定,不會因為遍歷到末尾而效能降低
總結
方案1在小表的情況下,簡單方便,只用傳頁碼和頁大小即可,還可以隨機跳到指定頁,具有一定優勢
方案2和方案3在大表的情況下,有著優異的效能,以及穩定性,缺點是不能隨機地跳轉頁面,需要傳入上一頁的排序欄位。這個弊端在一定程度上可以規避,比如現在很多分頁都是一頁一頁地往下翻,比如微博、朋友圈動態等,或者是分批處理全表資料,不需要隨機跳轉
細心的同學可能發現,where條件裡還有【其它業務條件】,這樣還能正常走索引嗎?是否會發生全表掃描?這個問題其實是可以規避的,有空再寫一篇執行計劃並不完全可靠的案例。
題外話
方案3的寫法是我自己琢磨出來的,在網上也沒找到類似的資料,算獨門祕技吧,除此之外,我覺得同樣很有價值的是【進一步深入分析】中的思考過程,如果養成這種思考習慣,有利於創新,去解決別人沒遇到過的問題,在未知的領域,知道該從哪個方向去尋找答案;或者找到新的方法更好地去解決舊問題。
如果本文有幫助到你,或者覺得有價值,麻煩點個贊,這樣我會更有動力去更多地分享自己的經驗