MySQL 數據庫中的分頁方案及其效率對比


前言

MySQL 分頁是個很常見的項目需求,因為數據量的問題,我們既無法一次導出所有的數據給用戶,用戶也無法一次性看完所有的數據,所以分頁實際上是個很常見的需求。

除了感嘆中文內容圈子里有用的內容越來越稀缺,我想做點什么外,我也想將自己的找到解決方案記錄下來,以便以后自己忘記了,能有據可查。

通用分頁

對於 MySQL 這樣爛大街的數據庫,分頁的實現有無數的教程和例子,但是大多數的例子就是下面這樣的

# 對應的 URL 形式
/news?page=1&per_page=30

// MySQL 專門的 limit 語句實現
SELECT * FROM news limit (page * per_page), per_page

// 標准實現的數據庫查詢語句實現,如 PostgreSQL
SELECT * FROM news OFFSET (page * per_page) limit per_page

這樣的分頁實現,是最為常見的分頁方案,也是最簡單最容易理解的分頁方案。如果要考慮總頁數,那就自己增加一個全庫的 COUNT() 查詢總數即可。

但是這樣的分頁方案也有明顯的缺陷,那就是在數據量大的時候,limit 語句容易造成全表掃描,因而效率較低

這里的數據量大的程度,是指數據的條目數以百萬甚至千萬起算,並且這里的查詢均以為 主鍵 作為查詢的條件,不涉及其他索引和其他語句的查詢的前提下。

 

其實數據量小的情況下,直接使用 limit 查詢最方便,不必去折騰什么高性能的查詢分頁的實現,一來沒有必要,二來過早優化
在沒有用到排序的情況下,直接使用 limit 來分頁,即使有千萬級別的數據,其實性能是相當不錯的,能夠在秒級別返回查詢結果,對於多數的應用來說是夠用的

子查詢分頁

針對使用limit offset在大量數據里的性能問題,如果使用子查詢的話,尤其是涉及 主鍵 排序的時候的性能,有個稍微變通的查詢方法
`
// 對應的 URL 形式
/news?page=1&per_page=30

// MySQL 專門的 limit 子查詢實現
SELECT * FROM news WHERE id >= (SELCT id FROM news limit (page * (per_page - 1)), per_page)
`
如果使用 MySQL 5.1 不支持上面帶 limit 的子查詢,可以自己手動改為 inner join 的實現

還有一種類似的變種實現方法,就是使用 SQL In Query 實現如下
// 使用 IN 查詢 SELECT * FROM news WHERE id IN (SELECT id FROM news ORDER BY id limit (page * (per_page - 1)), per_page)
兩者的查詢效率是差不多的,千萬級別的數據,單純使用主鍵排序,能在秒級返回分頁查詢結果

使用第二種 SQL In Query 實現,可以考慮將需要分頁表中的主鍵 ID 單獨成表,然后獨立查詢,如果數據量實在太大的情況下,這樣的單獨主鍵 ID 表能極大提供分頁排序輸出的速度。

這樣的子查詢的方式,跟前面的最簡單的分頁方式近似,也兼顧了性能。千萬級別的數據能在秒級別返回查詢結果,也能一定程度使用帶索引的列進行排序。
這樣的方式稍微兼顧了主鍵排序的結果,還是可以使用的。很多的互聯網公司內(比如阿里巴巴,阿里的 JAVA 開發手冊內有這個案例)也是將這個語句作為默認的分頁實現的

流式分頁/游標分頁

其實沒有什么流式分頁這種業界的分頁方式,我自己創造的名詞實現吧

多數的時候大家將這樣的方式叫做 游標分頁

對於 timeline 這樣的數據流,傳統的分頁顯得很不合適,因為時間線的條目是一直增長的,並且增長得很快,你很難去定義第一頁第二頁這樣的概念。傳統的分頁對於時間線這樣的實現,很容易造成漏數據。

但是時間線的數據流是天然自增的數據,因此引入流式分頁的概念,嚴格來說,流式分頁並不能算成一種分頁的方式,因為它只是針對數據流某個時間間隙之間的數據

流式分頁的實現在 Twitter 和 Facebook 的 API 設計上表現得很明顯
`
// Twitter
"search_metadata": {
"max_id": 250126199840518145,
"since_id": 24012619984051000,
"refresh_url": "?since_id=250126199840518145&q=php",
"next_results": "?max_id=249279667666817023&q=php",
"count": 10,
"completed_in": 0.035,
"since_id_str": "24012619984051000",
"query": "php",
"max_id_str": "250126199840518145"
}

// Facebook
{
"data": [
... Endpoint data is here
],
"paging": {
"cursors": {
"after": "MTAxNTExOTQ1MjAwNzI5NDE=",
"before": "NDMyNzQyODI3OTQw"
},
"previous": "/albums?limit=25&before=NDMyNzQyODI3OTQw"
"next": "/albums?limit=25&after=MTAxNTExOTQ1MjAwNzI5NDE="
}
}
`
從兩者的 API 接口文檔數據,可以充分看得出兩者的設計殊途同歸

無論是 Twitter 還是 Facebook,其流式分頁都是通過指定當前查詢的 min_id/since_id 和 max_id/last_id 來查詢當前所有數據中的某個時間段的數據,通過指定 min_id/since_id 和 max_id/last_id 來查詢的效率極其高,以 Facebook 的查詢來說明編程的實現如下
`// 對應的 URL 形式,向前翻頁
/news?limit=30&before=[min_id]

// MySQL 專門的 limit 語句實現
SELECT * FROM news WHERE id <= [min_id] limit per_page

// 對應的 URL 形式,向后翻頁
/news?limit=30&after=[max_id]

// MySQL 專門的 limit 語句實現
SELECT * FROM news WHERE id >= [max_id] limit per_page`
使用流式翻頁的同學一般都知道 下一頁 是如何實現的,畢竟傳入最大的 max_id 即可

但是大家一般都會困惑於 上一頁 如何實現的,其實最簡單的方案就是類似 Facebook 這樣的針對向前翻頁和向后翻頁的區別 URL 參數對待,否則的話,要么建立緩存表或者另建立分頁表,要么就只能一頁一頁往下翻,不能往前翻頁(類似於手機時間線流動的感覺)

流式分頁/游標分頁實現起來尤其簡單,不過只適合主鍵 ID 自增且連續的情況,性能也好到不得了
前端記得針對 向前翻頁 和 向后翻頁 區別 URL 參數對待,這是編程中大多數同學沒法轉過彎來的地方。並且 min_id/since_id 和 max_id/last_id 不必同時傳入查詢,因為這樣就無法掌控查詢的條目數量(從用戶前端查詢的角度而言,不是從 DBA 的角度而言)
最好的方式是類似 Facebook 這樣的方式 min_id/since_id 和 max_id/last_id 只傳入其中一個,每個分頁的條目數量使用 limit 字段單獨管控,也方便限制用戶前端查詢條目數量,不至於查詢一個巨大的數量導致數據庫卡死。

其他分頁方式

此處暫無,以后我有發現再更新吧

其實有一些使用 MySQL+Redis 實現的分頁方式,感覺略微奇葩,只能針對特定的場景使用,這里就不一一整理了

結語

對於普通的應用,尤其是數據量在百萬級別以下的應用,其實 MySQL 通用的分頁方式性能足夠,而且足夠簡單並且方便使用,不必過早優化。只要做好最大分頁數量和分頁每頁的數量限制,MySQL 的緩存適當針對機器的性能和應用的實際需求調優一下,通用的分頁方式性能足夠。我想,多數的應用,其實都是到不了百萬級別的數據量吧

不過考慮到編程實現的方便程度,其實最后的流式分頁/游標分頁的方式,編程實現是最簡單的,並且性能根本無須考慮,哪怕你有億級的數據。並且流式分頁/游標分頁非常適合在 API 中分頁輸出,在手機信息流中這種無線滾動的信息流中進行分頁輸出,不會丟也不會重復輸出某個條目,優勢非常明顯,堪稱最佳的分頁方式。不過話說回來,缺點也很明顯,就是不能像普通分頁那樣有第一頁第二頁到第N頁的數字頁碼,簡單說就是沒有頁數的概念了,前端頁面輸出一般只有上一頁和下一頁這樣簡單的翻頁按鈕

需要提醒的是,所有的分頁查詢條件都必須建立索引,並且不能有 TEXT 字段。MySQL 的索引字段中,VARCHAR(32) 我都覺得很大了, TEXT 字段簡直是不能容忍的,不怕死的可以自己試試。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM