MySQL數據庫代碼語句優化實踐---LIMIT語句的分頁查詢場景

一、分頁查詢的優化需求背景

  在互聯網應用中,信息展示功能是個基本需求。在絕大多數應用中是必需的,並且需要多個展示頁面。這些展示頁面大多數都需要分頁顯示功能,這涉及到數據庫的相關操作就是“分頁查詢”。可以看出分頁查詢這個應用場景是很普遍並且是十分高頻的,頁面的每次刷新都要進行一次分頁查詢。

  既然“分頁查詢”使用頻次這麼高,那麼分頁查詢SQL語句的查詢性能對MySQL數據庫的性能影響就十分重要,尤其是對數據庫併發性能的影響尤爲關鍵。雖然利用頁面緩存和數據緩存可以一定程度上提高性能,但是在數據更新和查詢條件變化後還是要通過數據庫查詢。所以深度優化分頁查詢的SQL語句就十分必要,優化後對整個系統的性能提升將有巨大改變。

  下面我們以常見的文章列表頁(適合各類信息列表頁)爲例,再現我的優化過程。在文章列表頁的需求中,篩選通常是分全部、某個分類、某個作者(更多的也是同樣原理就不贅述了)等,排序通常就是發佈時間(或更新時間)。這個是最普遍的需求,我們就以這個需求爲案例應用場景,實踐SQL語句“LIMIT”的相關優化。

二、實驗數據表結構及相關信息

實驗主機配置:

  處理器:Intel Pentium G4560 @ 3.50GHz
  內存:4G DDR4
  硬盤:120G SSD
  系統:Windows7 旗艦版 64位

實驗軟件相關:

  MySQL版本:5.5.56
  MySQL配置(默認):
    key_buffer_size = 16M
    max_allowed_packet = 1M
    table_open_cache = 256
    sort_buffer_size = 512K
    net_buffer_length = 8K
    read_buffer_size = 1M
    read_rnd_buffer_size = 4M
    myisam_sort_buffer_size = 32M
  操作軟件:phpMyAdmin

實驗數據表:

-- ----------------------------
-- Table structure for article
-- ----------------------------
DROP TABLE IF EXISTS `article`;
CREATE TABLE `article` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
  `class_id` int(11) NOT NULL COMMENT '文章分類ID',
  `author_id` int(11) NOT NULL COMMENT '作者ID',
  `up_time` int(11) NOT NULL COMMENT '更新時間',
  `title` varchar(60) NOT NULL COMMENT '文章標題',
  `subtitle` varchar(100) NOT NULL COMMENT '文章副標題',
  `outline` text NOT NULL COMMENT '文章概要',
  `content` text NOT NULL COMMENT '文章內容',
  PRIMARY KEY (`id`),
  KEY `class_id` (`class_id`,`author_id`,`up_time`)
  KEY `up_time` (`up_time`)
) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='文章記錄表';

  插入記錄:一千萬條

三、認識 LIMIT 偏移量參數對性能的影響

  分頁查詢中,LIMIT的使用是再平常不過了,但是LIMIT的參數確有兩種情況,一種是不帶偏移量,一種是帶偏移量。下面我們以獲取所有文章(不分類)的若干頁爲例,來看一下不同參數的SQL語句的性能變化(我們需要每頁顯示20條)。

1、取整個文章記錄表中的前20條(省略偏移量參數)

SELECT * FROM `article` LIMIT 20

執行結果如下圖(查詢花費 0.0010秒):
在這裏插入圖片描述

2、在文章記錄表中,從首條開始,向後取20條

SELECT * FROM `article` LIMIT 0,20

執行結果如下圖(查詢花費 0.0010秒,去前面不帶偏移量參數的花費時間相同):
在這裏插入圖片描述

3、在文章記錄表中,在10000條位置,向後取20條

SELECT * FROM `article` LIMIT 10000,20

執行結果如下圖(查詢花費 0.0140秒,較之前有增加):
在這裏插入圖片描述

4、在文章記錄表中,在5000000條位置,向後取20條

SELECT * FROM `article` LIMIT 5000000,20

執行結果如下圖(查詢花費 4.1970秒,較之前有增加):
在這裏插入圖片描述

5、在文章記錄表中,在9999980條位置,向後取20條

SELECT * FROM `article` LIMIT 9999980,20

執行結果如下圖(查詢花費 8.6050秒,較之前有增加):
在這裏插入圖片描述

5、LIMIT偏移量實踐小結

  通過上面的實踐測試,可以看出,隨着LIMIT偏移量參數的增大,查詢花費時間會隨之變長。當偏移量位置到一定數量級,查詢花費時間就會十分慢,嚴重影響數據庫性能,尤其是併發性能。如果有這種情況發生,單純去擴充數據庫服務器資源已經不能很好解決問題了。因爲單用戶體驗擴充服務器也還是慢,多用戶併發時若不優化語句,會大量耗費服務器資源,系統成本會浪費很多,甚至到企業不可承受的程度。

四、利用索引定位方式去掉LIMIT的偏移量參數

  上述測試之所以LIMIT偏移量參數影響數據庫性能,是因爲LIMIT使用偏移量參數後,就會以遍歷的方式數過偏離量參數所描述的那麼多行數據,這就利用不上索引,所以性能很低。下面我們給上述語句加上 WHERE 條件,利用索引字段進行定位,看效果如何。

1、在文章記錄表中,在id爲10000的位置,向後取20條

SELECT * FROM `article` WHERE id > 10000 LIMIT 20

執行結果如下圖:
在這裏插入圖片描述
  查詢花費 0.0010秒,相比使用LIMIT偏移量的 0.0140秒,時間明顯縮短,與偏移量爲0時消耗時間相同。

2、在文章記錄表中,在id爲5000000的位置,向後取20條

SELECT * FROM `article` WHERE id > 5000000 LIMIT 20

執行結果如下圖:
在這裏插入圖片描述
  查詢花費 0.0010秒,比使用LIMIT偏移量的 4.1970秒時間縮短太多了,與偏移量爲0時消耗時間相同。

3、在文章記錄表中,在id爲9999980的位置,向後取20條

SELECT * FROM `article` WHERE id > 9999980 LIMIT 20

執行結果如下圖:
在這裏插入圖片描述
  查詢花費 0.0010秒,比使用LIMIT偏移量的 8.6050秒時間縮短太多了,與偏移量爲0時消耗時間相同。

4、先使用索引定位,再使用LIMIT不帶偏移量方式實踐小結

  由於使用了 “WHERE id > xxxx” 條件,id 字段是主鍵索引,所以不需要全表遍歷,通過索引可以快速定位到起始位置,然後通過 “LIMIT 20” 向後依次取出20條,花費時間就不會出現最開始那樣隨着起始位置變大,查詢花費時間會明顯變長的情況了。

  通過剛剛的測試我們還可以看到,起始位置的變化,花費時間幾乎都沒變化,都是與查詢表最前面20條的時間是一樣的。所以,這個方法是十分高效的,極大的提升了數據庫性能,尤其是併發性能。

五、如何獲得索引定位值

1、上一頁與下一頁數據

  上面的測試都是正序情況下的(以主鍵id正序排列),本頁數據的 “底部id值” 就是下一頁的索引定位值。假設當前頁結果集的底部id值爲9999960,那麼獲取下一頁數據就可以使用下面的語句:

SELECT * FROM `article` WHERE id > 9999960 LIMIT 20

  這個語句直接得到了id值比當前頁最後一個(9999960)大的正序排列的20條數據,這20條數據經過正序排列是緊挨着的,即使中間有id斷號(數據刪除或數據過濾)也不會影響取出20條這個數量。

  上一頁數據的索引定位值,如果id保證是連續的,並沒有過濾條件的情況下,本頁數據的 “頂部id值-20(每頁條數)-1” 就是上一頁的索引定位值。但實際應用中,這總情況很少,如果有過刪除或經過過濾的數據,id將不是連續的,那麼用這個公式計算出來的就不準確了。其實我們可以依據 “頂部id值” 先倒序查詢,再取結果集的底部id值”就是上一頁數據的正序查詢索引定位值,看下面的語句(假設當前頁頂部 id值爲9999941):

SELECT id FROM `article` WHERE id < 9999941 ORDER BY id DESC LIMIT 21

  我們是爲了找到該使用的id值,所以返回結果不要使用 *,使用 id,這樣會是語句執行更快速。y因爲最後的正序查詢的條件是大於,所以倒序反向找定位id的時候,要在每頁條數上加1,所以使用21這個數據。
  這個語句的執行結果得到了 “9999940…9999920” 結果集,底部id的 9999920 就剛好是正序查詢使用的上一頁索引定位值了。因爲這個查詢同樣是使用的索引定位,所以花費時間顯示仍然是 0.0010秒,非常快。那直接得到這個 9999920 該使用什麼樣的語句,看下面語句:

SELECT id
FROM (SELECT id FROM `article` WHERE id < 9999941 ORDER BY id DESC LIMIT 21) AS article_res 
ORDER BY id 
LIMIT 1

  上面語句使用了子查詢,最終返回數據是81,花費時間仍然在0.0010秒內,既然這麼高效,我們就繼續組SQL語句,以得到完整的上一頁數據:

SELECT * FROM `article`
WHERE id >
(SELECT id
FROM (SELECT id FROM `article` WHERE id < 9999941 ORDER BY id DESC LIMIT 21) AS article_res 
ORDER BY id 
LIMIT 1)
LIMIT 20

這條語句的執行結果截圖如下:
在這裏插入圖片描述
  其實,在實際應用中,我們還可以利用邏輯代碼的緩存,保存上一頁的索引定位ID值,如果緩存中存在這個id,就直接使用,可以很大程度避免使用子查詢。根據具體情況大家可以靈活運用。

  以上是正序查詢的情況,如果需求是倒序查詢,修改相關的值和條件及排序語句即可,這裏不再贅述。

2、直接跳轉到某一頁

  假定每頁需要顯示20條數據,想直接查詢第 499995 頁的數據,該如何確定索引定位值呢?

  主鍵id(排序id)連續
  這種情況最簡單,可以直接使用 499995*20 來計算。但侷限是必須是排序id是連續的,也就是排序使用的id與條數是對應的,否則會不準確。

  主鍵id(排序id)不連續
  這是最常見的需求應用場景,如果要通過SQL語句得到 499995 頁的定位索引就沒那麼輕鬆了。我們先使用普通的LIMIT用法獲取一下數據,看花費時間是多少(499995*20=9999900):

SELECT * FROM `article` LIMIT 9999900,20

  這條語句的執行結果(花費時間:8.5850秒):
在這裏插入圖片描述
  下面我們使用鋪蓋索引的方式先獲取定位id值,看是否可以縮短花費時間,先看下面的語句:

SELECT id FROM `article` LIMIT 9999900,1

  這條語句的執行結果(花費時間:1.2330秒,時間明顯縮短好幾倍):
在這裏插入圖片描述
  既然效果明顯,我們就繼續組語句,取出實際要的20條數據,看效果如何,請看下面的語句:

SELECT * FROM `article`
WHERE id > (SELECT id FROM `article` LIMIT 9999900,1)
LIMIT 20

  這條語句的執行結果(花費時間:1.2350秒):
在這裏插入圖片描述
  這個結果也是優化成功的,雖然沒有上一頁和下一頁優化的那麼驚人,但是相比8.5850秒,優化後的1.2350秒也是很顯著的。主要花費時間消耗在獲取索引定位值的LIMIT帶偏移量的語句上。

3、利用緩存或新表記錄索引定位值

  根據上面的測試結果,建議應用中除非特殊要求,否則儘量不要使用直接跳轉到某一頁這個功能。如果需要提供這個功能,除了剛纔的優化方案,還可以使用邏輯代碼的緩存,保存文章記錄表所有分頁的索引定位值。緩存的數據持久化在一個分頁索引定位表中(也可以直接使用這個表,不用緩存),在增、刪數據時更新分頁索引定位表和緩存。這種方案速度最快,缺點是要使用緩存和多建一個分頁索引定位表。
  下面是分頁索引定位表的結構:

-- ----------------------------
-- Table structure for index_locate
-- ----------------------------
DROP TABLE IF EXISTS `index_locate`;
CREATE TABLE `index_locate` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵ID,也是頁碼',
  `first_locate` int(11) NOT NULL COMMENT '列表頁第一條索引定位值',
  PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1 COMMENT='列表頁索引定位值記錄表';

  這張表裏記錄的每一頁的第一條記錄的索引定位值減去1的值(因爲我們在查詢數據的時候是大於或小於,所以要減去1),在新增文章記錄或刪除文章記錄的時候同時更新這張表(增加與刪除相比瀏覽查詢,是十分低頻的,所以多更新了這張表,對整體性能還是大大提升的)。

  有了這張表,我們就可以使用下面的語句快速查詢到某一頁的數據(假設要查詢499999頁的20條數據):

SELECT * FROM `article`
WHERE id > (SELECT first_locate FROM `index_locate` WHERE id=499999)
LIMIT 20

  這條語句的執行結果(花費時間:0.0010秒):
在這裏插入圖片描述
  可以看出這個優化效果十分理想。如果使用了分頁索引定位表,上一頁與下一頁也可以使用這個方案,大家可以根據自己的實際情況,酌情選擇使用那種方案。

六、經過分類過濾後排序的情況

  前面提到的都是直接使用主鍵id排序的情況,那麼如果是獲取某個分類或某個作者的文章,然後按照發布或更新時間排列,取出上一頁或下一頁以及直接跳轉到某一頁,會有什麼不同嗎?

  假設想獲取分類id爲18的第400000頁的數據,看下面的SQL語句:

SELECT * FROM `article` WHERE class_id = 18 order by id LIMIT 400000,20

  上面這條語句時間花費262秒,可見無法實際應用。

  假設我們知道18分類按照時間(id與發佈時間順序是一致的,所以直接使用id即可)排序第400000頁的第一條數據的id是6352948,我們把語句改寫成下面這樣:

SELECT * FROM `article` WHERE class_id = 18 AND id >=6352948 order by id LIMIT 20

  這條語句時間花費僅爲0.0020秒,可見速度提升驚人。但是關鍵我們怎麼知道這個索引定位值6352948呢?

  上一頁與下一頁的操作,使用前面的方法,加上 class_id 條件限制即可。

  直接跳轉到某一頁的情況,經過實踐,我最終使用的是增加一張分頁索引定位表(也可在上述該表增加一個分類過濾字段),花費時間則可以提升到0.0010秒。

  這種情況的另一個優化方案是分表,把不同的分類保存在不同的表中,這樣就可以充分利用各記錄表的主鍵id了。但是如果顯示某個作者的所有文章的時候,就要多次查詢了。要綜合使用分頁索引定位表效果更好。

七、常見問題答疑

1、UUID是否也適用這種優化方案?
  答:由於UUID使用的不是整型,無法使用大於或小於這樣的條件,所以不適用這套方案。可以增加一個整型同順序索引字段解決。

2、需求是按照時間排序的,能用這樣優化方式嗎?
  答:主鍵ID是自增的,所以順序與發佈時間是一致的,可以直接使用主鍵id,使用更新時間(發佈時間也可)字段排序也可以,優化效果基本差不多,前提是時間字段要建索引。

3、我的表中沒有主鍵id,怎麼優化?
  答:首先,表中沒有主鍵我建議還是要加上,無論是否用到。其次排序字段是整型並減了索引基本就可以使用這種優化方式,若沒有這類字段,建議增加一個即可。

4、分頁要計算總頁數,查詢中使用count()花費時間怎麼優化?
  答:不要在每次請求中都count(),因爲條數變化是低頻的,建議增加一個統計表,保存總條數和各分類的條數。然後從統計表中獲取統計值,在信息新增或刪除的時候更新統計表即可,這個是低頻的,整體優化還是高效的。

5、頁面數量又可能變化,因爲記錄條數可能變化,是不是就不能這樣優化了?
  答:可以,參見第4個問題和分類後過濾排序中的解決方法都可以。另外通常帶整體頁面刷新的時候重新統計也是可以的,不在上一頁和下一頁中統計就好了。

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