1. 程式人生 > >MySQL之查詢效能優化(一)

MySQL之查詢效能優化(一)

為什麼查詢速度會慢

通常來說,查詢的生命週期大致可以按照順序來看:從客戶端,到伺服器,然後在伺服器上進行解析,生成執行計劃,執行,並返回結果給客戶端。其中“執行”可以認為是整個生命週期中最重要的階段,這其中包括了大量為了檢索資料到儲存引擎的呼叫以及呼叫後的資料處理,包括排序、分組等。

在完成這些任務的時候,查詢需要在不同的地方花費時間,包括網路,CPU計算,生成統計資訊和執行計劃、鎖等待(互斥等待)等操作,尤其是向底層儲存引擎檢索資料的呼叫操作,這些呼叫需要在記憶體操作、CPU操作和記憶體不足時導致的I/O操作上消耗時間。

在每一個消耗大量時間的查詢案例中,都能看到一些不必要的額外操作、某些操作被額外地重複了很多次、某些操作執行得太慢等。優化查詢的目的就是減少和消除這些操作所花費的時間。

慢查詢基礎:優化資料訪問

查詢效能底下最基本的原因是訪問的資料太多。某些查詢可能不可避免地需要篩選大量資料,但這並不常見。大部分效能低下的查詢都可以通過減少訪問的資料量的方式進行優化。對於低效的查詢,一般通過兩個步驟來分析:

  1. 確認應用程式是否在檢索大量超過需要的資料。這通常意味著訪問了太多的行,但有時候也可能是訪問了太多的列。
  2. 確認MySQL伺服器層是否在分析大量不需要的資料行

是否向資料庫請求了不需要的資料

如果請求並不需要的資料,然後在應用層將這些程式丟棄。這會給MySQL伺服器帶來額外的負擔,並增加網路開銷,另外也會消耗應用伺服器的CPU記憶體和資源。

這裡有一些典型案例:

查詢不需要的記錄

比如資料庫中存有一百條新聞記錄,讀取所有的新聞記錄,卻只顯示前十條,這種情況比較好處理,加上limit即可。

單表查詢或多表關聯查詢時返回全部的列

在設計表的時候,並非所有的欄位都是用於前端展示,比方你會設計一個時間戳欄位,用於記錄某條資料的插入時間或更新時間,但前端對這個欄位並不關心,而在單表查詢或多表關聯查詢時,為圖方便,直接用"SELECT *"返回所有的欄位,這會讓優化器無法完成索引覆蓋這類優化,還會為伺服器帶來額外的IO、記憶體和CPU的消耗。當然,在資料量並不大、訪問量不大的情況下,可以使用"SELECT *"簡化開發。

重複查詢相同的資料

我曾經開發過一個複雜的模組,裡面有不少資料需要從資料庫查詢,如果用面向過程的方式來程式設計,需要傳遞非常多的引數,其中也包括從資料庫中查詢的物件,但是如果不傳遞那些物件,在別的呼叫方法中,又需要重新從資料庫中讀取資料。

MySQL是否在掃描額外的記錄

在確定查詢只返回所需要的列後,接下來便是看看查詢是否掃描過多的資料。對於MySQL,最簡單的衡量查詢開銷的三個指標如下:

  • 響應時間。
  • 掃描的行數。
  • 返回的行數。

沒有哪個指標能夠完美地衡量查詢的開銷,但它們大致反映了MySQL在內部執行查詢時需要多少資料,並可以推算出查詢執行的時間。這三個指標都會記錄到MySQL的慢日誌中,所以檢查慢日誌記錄是找出掃描行數過多的查詢的好辦法。 

響應時間

響應時間是兩個部分之和:服務時間和排隊時間。服務時間是指資料庫處理這個查詢真正花了多少時間。排隊時間是指伺服器應為等待某些資源而沒有真正執行查詢的時間——可能是等待IO操作完成,也可能是等待行鎖,等等。遺憾的是,我們無法把響應時間細分到上面這些部分,除非有什麼辦法能夠逐個測量上面這些消耗,不過很難做到。一般最常見和最重要的等待是IO和鎖等待,但是實際情況更加複雜。

所以在不同型別的應用壓力下,響應時間並沒有什麼一致的規律或者公式。諸如儲存引擎的鎖(表鎖、行鎖)、高併發資源競爭、硬體響應等諸多因素都會影響響應時間。所以,響應時間既可能是一個問題的結果也可能是一個問題的原因。

掃描的行數和返回的行數

分析查詢時,檢視該查詢掃描的行數是非常有幫助的。這在一定程度上能夠說明該查詢找到需要資料的效率高不高。理想情況下掃描的行數和返回的行數應該是相同的。但是實際情況中這種“美事”並不多。例如在做一個關聯查詢時,伺服器必須要掃描多行才能生成結果集中的一行。掃描的行數對返回的行數的比率通常很小,一般在1:1和10:1之間,不過有時候這個值也可能非常大。

掃描的行數和訪問型別

在評估查詢開銷的時候,需要考慮一下從表中找到某一行資料的成本。mysql有好幾種訪問方式可以查詢並返回一行結果。有些訪問方式可以需要掃描很多行才能返回一行結果,也有些訪問方式可以無需掃描就能返回結果。

在EXPLAIN語句的type列反應了訪問型別。訪問型別有很多種,從全表掃描到索引掃描、範圍掃描。唯一索引查詢、常數引用等。這裡列的這些,速度是從慢到快,掃描的行數也是從小到大。你不需要記住這些訪問型別,但要明白掃描表、掃描索引、範圍訪問和單值訪問的概念。

如果查詢沒有辦法找到合適的訪問型別,那麼最好的解決方法通常就是增加一個合適的索引,索引讓MySQL以最高效,掃描行數最少的方式找到需要的記錄。

例如,我們看看示例資料庫Sakila中的一個查詢案例:

SELECT * FROM film_actor WHERE film_id = 1; 

  

這個查詢返回十行資料,從EXPLAIN的結果可以看到,MySQL的索引idx_fk_film_id上使用了ref訪問型別來執行查詢:

mysql> EXPLAIN SELECT * FROM film_actor WHERE film_id = 1\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
   partitions: NULL
         type: ref
possible_keys: idx_fk_film_id
          key: idx_fk_film_id
      key_len: 2
          ref: const
         rows: 10
     filtered: 100.00
        Extra: NULL
1 row in set, 1 warning (0.00 sec)

  

EXPLAIN的結果顯示需要訪問十行資料。換句話說,查詢優化器認為這種訪問型別可以高效的完成查詢。如果沒有合適的索引會怎樣呢?MySQL就不得不使用一種更糟糕的訪問型別,下面我們來看看如果刪除對應的索引來執行這個查詢:

mysql> ALTER TABLE film_actor DROP FOREIGN KEY fk_film_actor_film;
Query OK, 0 rows affected (0.07 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> ALTER TABLE film_actor DROP KEY idx_fk_film_id;
Query OK, 0 rows affected (0.03 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> EXPLAIN SELECT * FROM film_actor WHERE film_id = 1\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 5462
     filtered: 10.00
        Extra: Using where
1 row in set, 1 warning (0.00 sec)

  

訪問型別變成了一個全表掃描,現在MySQL預估需要掃描5073條記錄就能完成這個查詢。這裡的USING WHERE 表示MySQL將通過WHERE 條件來篩選存取引擎返回的記錄。

一般MySQL能夠使用如下三種方式使用WHERE 條件,從好到壞依次為:

  • 在索引中使用WHERE條件來過濾掉不匹配的記錄。這是在儲存引擎層完成的。
  • 使用索引覆蓋掃描(在Extra列中出現Using index)來返回記錄,直接從索引中過濾掉不需要的記錄並返回命中結果。這是在MySQL伺服器層完成的,但無需再回表查詢記錄。
  • 從資料表中返回資料,然後過濾不滿足條件的記錄(在Extra列中出現Using Where)。這是在MySQL伺服器層完成的,MySQL需要從資料表中讀出記錄然後過濾。上面這個例子說明了好的索引是多麼重要。好的索引可以讓查詢使用合適的訪問型別,儘可能的只掃描需要的資料行。但也不是說增加索引就能讓掃描的行數等於返回的行數。例如下面的使用聚合函式COUNT()的查詢:
    SELECT actor_id ,COUNT(*) FROM film_actor GROUP BY actor_id;
    

    這個查詢需要讀取幾千行資料,但是僅返回了200行結果。沒有什麼索引能夠讓這樣的查詢減少需要掃描的行數。

    不幸的是,MySQL不會告訴我們生成結果實際上需要掃描多數行資料,而只會告訴我們生成結果時一共掃描了多數行資料。掃描的函式中大部分都很可能被WHERE條件過濾掉的,對最終的結果並沒有貢獻。在上面的例子中,我們刪除索引後,看到MySQL需要掃描索引記錄然後根據WHERE條件過濾,最終返回10行結果。理解一個查詢需要掃描多數行和實際需要使用的行數首先需要理解這個查詢背後的邏輯和思想。

如果發現查詢需要大量的資料但值返回少數行,那麼通常可以嘗試下面的技巧去優化它:

  • 使用索引覆蓋掃描,把所有需要用的列都放到索引中,這樣儲存引擎無需回表獲取對應的行就可以返回結果了。
  • 改變庫表結構。例如使用單獨的彙總表。
  • 重寫這個查詢,讓MySQL優化器能夠以更優化的方式執行這個查詢。

重構查詢的方式

一個複雜查詢還是多個簡單查詢

設計查詢的時候一個需要考慮的重要問題是,是否需要將一個複雜的查詢分成多個簡單的查詢。在傳統實現中,總是強調資料庫層完成儘可能多的工作,這樣做的邏輯在於以前總是認為網路通訊、查詢解析和優化是一件代價很高的事情。

但是這樣想法對於MySQL並不適用,MySQL從設計上讓連結和斷開都很輕量級,返回一個小查詢結果方面很高效。現代的網路速度比以前要快很多,無論是頻寬還是延遲。在某些版本的MySQL上,即使在一個通用伺服器上,也能夠執行每秒超過10萬的查詢,即使在一個千兆網絡卡也能夠輕鬆滿足每秒超過2000次的查詢。所以執行多個小的查詢現在已經不是大問題了。

MySQL內部能夠掃描記憶體中上百萬資料,相比之下,MySQL響應資料給客戶端就慢得多了。在其他條件都相同的時候,使用盡可能少的查詢當然是更好。但是有時候,將一個大查詢分解為多個肖查詢是很有必要的。別害怕這樣做,好好衡量一下這樣做是不是會減少工作量。

切分查詢

有時候對於一個大查詢我們需要“分而治之”, 將大查詢切分成小查詢,每個查詢功能完全一樣,只完成一小部分,每次只返回一小部分查詢結果。

刪除舊資料就是一個很好的例子。定期的清楚大量資料時,如果用一個大的語句一次性完成,則可能需要一次鎖住很多資料、佔滿整個事務日誌、耗盡系統資源、阻塞很多小的但是很重要的查詢。將一個大的DELETE語句切分成多個較小的查詢可以儘可能小地影響MySQL效能,同時還可以減少MySQL複製的延遲。例如,我們需要每個月執行一次下面的查詢:

mysql> DELETE FROM messages WHERE created<DATE_SUB(NOW(),INTERVAL 3 MONTH);

  

那麼可以用類似下面的辦法來完成工作:

rows_affected=0
do {
	rows_affected = do_query(
	"DELETE FROM messages WHERE created < DATE_SUB(NOW(),INTERVAL 3 MONTH)
	LIMIT 10000"
} where rows_affected > 0

  

一次刪除一萬行資料一般來說是一個比較高效而且對伺服器影響也最小的做法(如果是事務型引擎,很多時候小事務能夠更高效)。同時需要注意的是,如果每次刪除資料後都站定一會再做一下次刪除,這樣可以將伺服器上原本一次性的壓力分散到一個很長的時間段中,就可以大大降低對伺服器的影響,還可以大大減少刪除時鎖的持有時間。

分解關聯查詢

很多高效能的應用都會對關聯查詢進行分解,簡單的對每一個表進行一次單表查詢,然後將結果在應用程式中進行關聯。例如,下面這個查詢:

mysql> SELECT* FROM tag
-> JOIN tag_post ON tag_post.tag_id=tag.id
-> JOIN post ON tag_post.post_id=post,id
-> WHERE tag.tag="mysql";

  

可以分解成下面這些查詢來替代: 

mysql> SELECT * FROM tag WHERE tag="mysql";
mysql> SELECT * FROM tag_post WHERE tag_id=1234;
mysql> SELECT * FROM tag_post WHERE post.id in (123,456,789,9098.8904);

  

乍一看,這樣做並沒有什麼好處,原本一條查詢,這裡卻變成了多條查詢,返回結果又是一模一樣。事實上,用分解關聯查詢的方式重構查詢有如下的優勢:

  • 讓快取的效率更高。許多應用程式可以方便的快取單標查詢對應的記過物件。例如,上面查詢中的tag已經被快取了,那麼應用就可以跳過第一個查詢。再例如,應用已經快取了ID為123、456、789、9098的內容,那麼第三個查詢的IN()就可以少幾個ID。另外對MySQL的查詢快取來說,如果關聯中的某個表發生了變化,那麼就無法使用查詢快取了,而拆分後,如果某個表很少改變,那麼基於該表的查詢就可以重複利用查詢快取結果了。
  • 將查詢分解後,執行單個查詢可以減少鎖的競爭。
  • 在應用層做關聯,可以更容易對資料庫進行拆分,更容易做到高效能和可擴充套件性。
  • 查詢本身的效率也可能會有所提升。這個例子中,使用IN()代替關聯查詢,可以讓MySQL按照ID順序進行查詢,這可能比隨機關聯更高效。
  • 可以減少冗餘記錄的查詢。在應用層做關聯查詢,因為這對於某條記錄應用只需要查詢一次,而在資料庫中做關聯查詢,則可能需要重複地訪問一部分資料。從這點看,這樣的重構還可能會減少網路和記憶體的消耗。
  • 更進一步,這樣做相當於在應用中實現了雜湊關聯,而不是使用MySQL的巢狀迴圈關聯。某些場景雜湊關聯的效率要高很多。

在很多場景,通過重構查詢將關聯放到應用程式中將會更加高效,這樣的場景有很多,比如:當應用能夠方便地快取單個查詢的結果的時候、當可以將資料分佈到不同的MySQL伺服器上的時候、當能夠使用IN()的方式代替關聯查詢的時候、當查詢中使用同一個資料表的時候。