千萬級別mysql 分庫分表後表分頁查詢優化方案初探

在實際的軟件系統開發過程中,隨着使用的用戶羣體越來越多,表數據也會隨着時間的推移,單表的數據量會越來越大。

以訂單表爲例,假如每天的訂單量在 4 萬左右,那麼一個月的訂單量就是 120 多萬,一年就是 1400 多萬,隨着年數的增加和單日下單量的增加,訂單表的數據量會越來越龐大,訂單數據的查詢不會像最初那樣簡單快速,如果查詢關鍵字段沒有走索引,會直接影響到用戶體驗,甚至會影響到服務是否能正常運行!

下面我以某個電商系統的客戶表爲例,數據庫是 Mysql,數據體量在 100 萬以上,詳細介紹分頁查詢下,不同階段的查詢效率情況(訂單表的情況也是類似的,只不過它的數據體量比客戶表更大)。

下面我們一起來測試一下,每次查詢客戶表時最多返回 100 條數據,不同的起始下,數據庫查詢性能的差異。
當起點位置在 0 的時候,僅耗時:18 ms

當起點位置在 1000 的時候,僅耗時:23 ms

當起點位置在 10000 的時候,僅耗時:54 ms

當起點位置在 100000 的時候,僅耗時:268 ms

當起點位置在 500000 的時候,僅耗時:1.16 s

當起點位置在 1000000 的時候,僅耗時:2.35 s

可以非常清晰的看出,隨着起點位置越大,分頁查詢效率成倍的下降,當起點位置在 1000000 以上的時候,對於百萬級數據體量的單表,查詢耗時基本上以秒爲單位。

而事實上,一般查詢耗時超過 1 秒的 SQL 都被稱爲慢 SQL,有的公司運維組要求的可能更加嚴格,如果 SQL 的執行耗時超過 0.2s,也被稱爲慢 SQL,必須在限定的時間內儘快優化,不然可能會影響服務的正常運行和用戶體驗。

對於千萬級的單表數據查詢,使用分頁查詢,起點位置在 10000000,查詢耗時結果:39 秒!

 

事實上,這還只是數據庫層面的耗時,還沒有算後端服務的處理鏈路時間,以及返回給前端的數據渲染時間。

以百萬級的單表查詢爲例,如果數據庫查詢耗時 1 秒,再經過後端的數據封裝處理,前端的數據渲染處理,以及網絡傳輸時間,沒有異常的情況下,差不多在 3~4 秒之間。

據互聯網軟件用戶體驗報告,當平均請求耗時在1秒之內,用戶體驗是最佳的,此時的軟件也是用戶留存度最高的;2 秒之內,還勉強過的去,用戶能接受;當超過 3 秒,體驗會稍差;超過 5 秒,基本上會卸載當前軟件。

 

有的公司爲了提升用戶體驗,會嚴格控制請求時長,當請求時長超過 3 秒,自動放棄請求,從而倒逼技術優化調整 SQL 語句查詢邏輯,甚至調整後端整體架構,比如引入緩存中間件 redis,搜索引擎 elasticSearch 等等。

繼續回到我們本文所需要探討的問題,當單表數據量到達百萬級的時候,查詢效率急劇下降,如何優化提升呢?

 

這篇文章就來講一講跨庫/表分頁查詢的解決方案。

架構背景

筆者曾經做過大型的電商系統中的訂單服務,在企業初期時業務量很少,單庫單表基本扛得住,但是隨着時間推移,數據量越來越多,訂單服務在讀寫的性能上逐漸變差,架構組也嘗試過各種優化方案,比如前面介紹過的:冷熱分離查詢分離各種方案。雖說提升一些性能,但是在每日百萬數據增長的情況下,也是杯水車薪。

最終經過架構組的討論,選擇了分庫分表;至於如何拆分,分片鍵如何選擇等等細節不是本文重點,不再贅述。

在分庫分表之前先來拆解一下業務需求:

  1. C端用戶需要查詢自己所有的訂單
  2. 後臺管理員、客服需要查詢訂單信息(根據訂單號、用戶信息.....查詢)
  3. B端商家需要查詢自己店鋪的訂單信息

針對以上三個需求,判斷下優先級,當然首先需要滿足C端用戶的業務場景,因此最終選用了uid作爲了shardingKey

當然選擇uid作爲shardingKey僅僅滿足了C端用戶的業務場景,對於後臺和C端用戶的業務場景如何做呢?很簡單,只需要將數據異構一份存放在ES或者HBase中就可以實現,比較簡單,不再贅述。

假設將訂單表根據hash(uid%2+1)拆分成了兩張表,如下圖:

 
 

假設現在需要根據訂單的時間進行排序分頁查詢(這裏不討論shardingKey路由,直接全表掃描),在單表中的SQL如下:

select * from t_order order by time asc limit 5,5;

這條SQL非常容易理解,就是翻頁查詢第2頁數據,每頁查詢5條數據,其中offest=5

假設現在t_order_1和t_order_2中的數據如下:

 
 

以上20條數據從小到大的排序如下:

 
 

t_order_1中對應的排序如下:

 
 

t_order_2中對應的排序如下:

 
 

那麼單表結構下最終結果只需要查詢一次,結果如下:

 
 

分表的架構下如何分頁查詢呢?下面介紹幾種方案

1. 全局查詢法

在數據拆分之後,如果還是上述的語句,在兩個表中直接執行,變成如下兩條SQL:

select * from t_order_1 order by time asc limit 5,5;

select * from t_order_2 order by time asc limit 5,5;

將獲取的數據然後在內存中再次進行排序,那麼最終的結果如下:

 
 

可以看到上述的結果肯定是不對的。

所以正確的SQL改寫成如下:

select * from t_order_1 order by time asc limit 0,10;

select * from t_order_2 order by time asc limit 0,10;

也就是說,要在每個表中將前兩頁的數據全部查詢出來,然後在內存中再次重新排序,最後從中取出第二頁的數據,這就是全局查詢法

該方案的缺點非常明顯:

  • 隨着頁碼的增加,每個節點返回的數據會增多,性能非常低
  • 服務層需要進行二次排序,增加了服務層的計算量,如果數據過大,對內存和CPU的要求也非常高

不過這種方案也有很多的優化方法,比如Sharding-JDBC中就對此種方案做出了優化,採用的是流式處理 + 歸併排序的方式來避免內存的過量佔用。

 

 

Sharding-Jdbc分頁修正

從多個數據庫獲取分頁數據與單數據庫的場景是不同的。 假設每10條數據爲一頁,取第2頁數據。在分片環境下獲取LIMIT 10, 10,歸併之後再根據排序條件取出前10條數據是不正確的。 舉例說明,若SQL爲:

SELECT score FROM t_score ORDER BY score DESC LIMIT 1, 2;

不進行Sql改寫的分頁執行結果:


 
 

通過圖中所示,想要取得兩個表中共同的按照分數排序的第2條和第3條數據,應該是95和90。 由於執行的SQL只能從每個表中獲取第2條和第3條數據,即從t_score_0表中獲取的是90和80;從t_score_0表中獲取的是85和75。 因此進行結果歸併時,只能從獲取的90,80,85和75之中進行歸併,那麼結果歸併無論怎麼實現,都不可能獲得正確的結果。

  • 正確的做法是將分頁條件改寫爲LIMIT 0, 3,取出所有前兩頁數據,再結合排序條件計算出正確的數據。 下圖展示了進行SQL改寫之後的分頁執行結果。
 
image.png
性能瓶頸

查詢偏移量過大的分頁會導致數據庫獲取數據性能低下,以MySQL爲例:

select * from t_order order by id limit 100000,10;

 
 

這句SQL會使得MySQL在無法利用索引的情況下跳過1000000條記錄後,再獲取10條記錄,其性能可想而知。 而在分庫分表的情況下(假設分爲2個庫),爲了保證數據的正確性,SQL會改寫爲:

select * from t_order order by id limit 0,100000;
 

即將偏移量前的記錄全部取出,並僅獲取排序後的最後10條記錄。這會在數據庫本身就執行很慢的情況下,進一步加劇性能瓶頸。 因爲原SQL僅需要傳輸10條記錄至客戶端,而改寫之後的SQL則會傳輸1,000,010 * 2的記錄至客戶端。

 
Sharding-Jdbc的優化

(1)採用流式處理 + 歸併排序的方式來避免內存的過量佔用。由於SQL改寫不可避免的佔用了額外的帶寬,但並不會導致內存暴漲。
與直覺不同,大多數人認爲Sharding-JDBC會將1,000,010 * 2記錄全部加載至內存,進而佔用大量內存而導致內存溢出。 但由於每個結果集的記錄是有序的,因此Sharding-JDBC每次僅獲取各個分片的當前結果集記錄,駐留在內存中的記錄僅爲當前路由到的分片的結果集的當前遊標指向而已。 對於本身即有序的待排序對象,歸併排序的時間複雜度僅爲O(n),性能損耗很小。

(2)Sharding-JDBC對僅落至單分片的查詢進行進一步優化。 落至單分片查詢的請求並不需要改寫SQL也可以保證記錄的正確性,因此在此種情況下,Sharding-JDBC並未進行SQL改寫,從而達到節省帶寬的目的。

分頁優化方案:

由於LIMIT並不能通過索引查詢數據,因此如果可以保證ID的連續性,通過ID進行分頁是比較好的解決方案:

 
select * from t_order where id > 10000 and id < 100010 order by id;

或通過記錄上次查詢結果的最後一條記錄的ID進行下一頁的查詢:

 
select * from t_order where id > 10000 limit 10;


    @Autowired
    private OrderInfoRepository orderInfoRepository;
    
    @Test
    public void testPage() {
        Pageable pageable = PageRequest.of(1,2,Sort.by("oid"));
        Page<OrderInfo> page =  orderInfoRepository.findAll(pageable);
        List<OrderInfo> list = page.getContent();
        for(OrderInfo info:list) {
            System.out.println("id="+info.getOid()+",status="+info.getStatus());
        }
    }

2. 禁止跳頁查詢法

數據量很大時,可以禁止跳頁查詢,只提供下一頁的查詢方法,比如APP或者小程序中的下拉翻頁,這是一種業務折中的方案,但是卻能極大的降低業務複雜度

比如第一頁的排序數據如下:

 
 

那麼查詢第二頁的時候可以將上一頁的最大值1664088392作爲查詢條件,此時的兩個表中的SQL改寫如下:

select * from t_order_1 where time>1664088392 order by time asc limit 5;

select * from t_order_2 time>1664088392 order by time asc limit 5;

然後同樣是需要在內存中再次進行重新排序,最後取出前5條數據

但是這樣的好處就是不用返回前兩頁的全部數據了,只需要返回一頁數據,在頁數很大的情況下也是一樣,在性能上的提升非常大

此種方案的缺點也是非常明顯:不能跳頁查詢,只能一頁一頁地查詢,比如說從第一頁直接跳到第五頁,因爲無法獲取到第四頁的最大值,所以這種跳頁查詢肯定是不行的。

3. 二次查詢法

以上兩種方案或多或少的都有一些缺點,下面介紹一下二次查詢法,這種方案既能滿足性能要求,也能滿足業務的要求,不過相對前面兩種方案理解起來比較困難。

還是上面的SQL:

select * from t_order order by time asc limit 5,5;

1. SQL改寫

第一步需要對上述的SQL進行改寫:

select * from t_order order by time asc limit 2,5;

注意:原先的SQL的offset=5,稱之爲全局offset,這裏由於是拆分成了兩張表,因此改寫後的offset=全局offset/2=5/2=2

最終的落到每張表的SQL如下:

select * from t_order_1 order by time asc limit 2,5;

select * from t_order_2 order by time asc limit 2,5;

執行後的結果如下:

 
 

下圖中紅色部分則爲最終結果:

 
 

2. 返回數據的最小值

t_order_1:5條數據中最小值爲:1664088479

t_order_2:5條數據中最小值爲:1664088392

那麼兩張表中的最小值爲1664088392,記爲time_min,來自t_order_2這張表,這個過程只需要比較各個分庫第一條數據,時間複雜度很低

3. 查詢二次改寫

第二次的SQL改寫也是非常簡單,使用between語句,起點就是第2步返回的最小值time_min,終點就是每個表中在第一次查詢時的最大值。

t_order_1這張表,第一次查詢時的最大值爲1664088581,則SQL改寫後:

select * from t_order_1 where time between $time_min and 1664088581 order by time asc;

t_order_2這張表,第一次查詢時的最大值爲1664088481,則SQL改寫後:

select * from t_order_2 where time between $time_min and 1664088481 order by time asc;

此時查詢的結果如下(紅色部分):

 
 

上述例子只是數據巧合導致第2步的結果和第3步的結果相同,實際情況下一般第3步的結果會比第2步的結果返回的數據會多。

4. 在每個結果集中虛擬一個time_min記錄,找到time_min在全局的offset

在每個結果集中虛擬一個time_min記錄,找到time_min在全局的offset,下圖藍色部分爲虛擬的time_min,紅色部分爲第2步的查詢結果集

 
 

因爲第1步改後的SQL的offset爲2,所以查詢結果集中每個分表的第一條數據offset爲3(2+1);

t_order_1中的第一條數據爲1664088479,這裏的offset爲3,則向上推移一個找到了虛擬的time_min,則offset=2

t_order_2中的第一條數據就是time_min,則offset=3

那麼此時的time_min的全局offset=2+3=5

5. 查找最終數據

找到了time_min的最終全局offset=5之後,那麼就可以知道排序的數據了。

將第2步獲取的兩個結果集在內存中重新排序後,結果如下:

 
 

現在time_min也就是1664088392的offset=5,那麼原先的SQL:select * from t_order order by time asc limit 5,5;的結果顯而易見了,向後推移一位,則結果爲:

 
 

剛好符合之前的結果,說明二次查詢的方案沒問題

這種方案的優點:可以精確地返回業務所需數據,每次返回的數據量都非常小,不會隨着翻頁增加數據的返回量

缺點也是很明顯:需要進行兩次查詢

總結

本篇文章中介紹了分庫分表後的分頁查詢的三種方案:

  1. 全局查詢法:這種方案最簡單,但是隨着頁碼的增加,性能越來越低
  2. 禁止跳頁查詢法:這種方案是在業務上更改,不能跳頁查詢,由於只返回一頁數據,性能較高
  3. 二次查詢法:數據精確,在數據分佈均衡的情況下適用,查詢的數據較少,不會隨着翻頁增加數據的返回量,性能較高




 

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