分頁場景慢?MySQL的鍋!
牛牛六年前剛工作的時候,發現分頁場景下,當offset變大,MySQL處理速度非常慢!具體sql如下:
- select * from t_record where age > 10 offset 10000 limit 10
下表所示為表t_record結構,為了簡單起見,只列了我們將討論的字段,其余字段省略。
其中t_record是要查詢的數據表,表中一共有50000條記錄,age字段上有索引,且age>10的記錄有20000條。
這條語句非常慢,基本達到了秒級延遲,在第二次請求有緩存之后,才變快。
在數據量這么少的情況下,走索引還這么慢,這完全不能接受,我就問我導師為什么,他反問“索引場景,MySQL中獲得第n大的數,時間復雜度是多少?”
答案的追尋
小白直覺作答
當時只知道MySQL索引使用的是樹,瞎猜了個O(logn),心想二叉樹找一個節點不就是O(logn)么。自然而然,導師白了一眼,讓我自己去研究。
繼續解答
想來想去...只能從底層結構分析了,MySQL的索引是B+樹。仔細想一下,就會發現通過索引去找很別扭。因為你不知道前n個數在其他子樹的分布情況,也沒有標記讓你能快速選擇去哪個子樹尋找,我們無法利用B+樹分支過濾的查找特性。
這下我明白導師的用意了——offset n,就是從第n大的數開始找!第n大的數沒法使用樹分支查找,所以offset,也不能!
回到我們一開始的問題:
- select * from t_record where age > 10 offset 10000 limit 10
通過二級索引age,我們只能找到對應的起始節點,但無法通過樹結構過濾掉10000個節點,再獲取10個節點,因為我們無法知道某個子樹下有多少數據,就無法通過分支進行排除。
那該怎么辦呢?
我們來仔細看下B+樹的結構,它不光有常規樹的分支結構,底部還有一個由葉子節點組成鏈表。
顯而易見,最方便最快的方式,就是用樹定位到起始位置,然后直接通過葉子節點組成的鏈表,以O(n)的復雜度找到第n大的數據。
回到我們最初的問題,總結一下:問題的本質其實就是讓offset找到第n大的數,再通過鏈表遍歷,在數據量很大的情況下,確實會慢。
但是即使是O(n),也不至于僅有幾萬數據就慢得令人發指。
是不是還有其他影響因素?
系統學習
牛牛決定深入研究,帶著問題去查找了很多資料。
這里推薦兩本書,一本《MySQL技術內幕 InnoDB存儲引擎》,通過它可以對InnoDB的底層機制,如acid、mvcc、索引實現、文件存儲,有更深的理解。
第二本是《高性能MySQL》,這本書從使用層面著手,講得比較深入,并提到了很多設計和優化的思路,對日常工作和學習都有很大的幫助。
兩本書相結合,反復領會,MySQL就差不多能登堂入室了。
針對我們的問題,這里介紹兩個相關的概念:
聚簇索引:包含主鍵索引和對應的實際數據,索引的葉子節點就是數據節點;
輔助索引:也叫二級節點,其葉子節點還是索引節點,并沒有完整的數據,僅包含了索引值本身和主鍵id,用主鍵id反查聚蔟索引才能獲取完整數據。
如圖所示,offset會先從二級索引的鏈表順序找10000個節點。
注意,即使這10000個節點會被扔掉,MySQL也會通過二級索引上的主鍵id,去聚簇索引上查一遍數據,這可是10000次隨機IO,自然慢成哈士奇。
大家讀到這里可能會提出疑問,為什么MySQL會有這種行為?
這和它的優化器有關系,也算是MySQL的一個大坑,時至今日,也沒有優化。
問題的解決
針對分頁性能問題,《高性能MySQL》中提到了兩種方案,讓我們一起來看看:
方案一:產品上繞過
根據業務實際需求,看能否替換為上一頁、下一頁的功能,這樣子就可以通過和上次返回數據進行比較,搭上樹分支過濾的便車。
特別在ios,android端,以前那種完全的分頁是不常見的。即轉換為如下sql,第一次last_id傳0即可。
- select * from t_record where id > last_id limit 10
優點
1.能利用樹的分支結構,過濾掉第n個數之前的數據集;
2.直接通過主鍵索引查找,省略了二級索引查找過程,性能會更高。
缺點
1.使用場景其實是受限制的。比如,如果是針對age字段有條件判斷,再分頁,那么使用主鍵id查找就不滿足需求;
2.把主鍵id暴露出去了,這個本身不應該是業務層面關心的字段。
可以看到,該方案在我們的場景中,是不適用的。
因為我們還有age做過濾條件,此時用大于主鍵id的方式,雖然看起來變成順序IO了,但由于是根據主鍵id排列來尋找,而不是根據需要的age索引,所以會導致MySQL去查更多的數據。雖然不符合我們案例的需求,但還是來看看優缺點:
方案二:正面剛
這里先介紹一個概念:
索引覆蓋:當輔助索引查詢的數據只有主鍵id和輔助索引本身,那么就不必再去查聚簇索引。
思路如下:
- select * from t_record id in
- (select id from t_record where age > 10 offset 10000 limit 10)
這句話是說,先從條件查詢中,查找數據對應的數據庫唯一id值,因為主鍵在輔助索引上就有,所以不用回歸到聚簇索引的磁盤上拉取。
如此以來,offset部分均不需要去反查聚蔟索引,只有limit出來的10個主鍵id會去查詢聚簇索引,這樣只會十次隨機IO。
在業務確實需要用分頁的情況下,使用該方案可以大幅度提高性能。通常能滿足性能要求。
優點
1.維持了分頁需求,適用所有limit offset場景,大大減少隨機IO,提高了性能;
2.二級索引上,只查找id,傳輸的數據包也變小。
缺點
二級索引上還是會走下面的鏈表來遍歷,這部分時間復雜度還是O(n)。
方案選型
如果產品本身的需求,是分上下頁,且沒用其他過濾條件,可以用方案一。
方案二更具有普適性,同時由于合理分表的大小,一般也就500w,二級索引上O(n)的查找損耗,通常也在可接受范圍。
總結
從一個小問題,往下深究,不僅可以深入理解這個問題,在面試和工作中大放異彩,同時在探索的過程中,自身的知識儲備也能得到拓展,是技術的一個提升捷徑。祝大家工作順利,牛牛碼特!