1. 程式人生 > 其它 >MySQL查詢效能優化

MySQL查詢效能優化

  之前介紹瞭如何設計最優的庫表結構、如何建立最好的索引,這些對於髙效能來說是必不可少的。但這些還不夠——還需要合理的設計査詢。如果査詢寫得很糟糕,即使庫表結構再合理、索引再合適,也無法實現髙效能。

  查詢優化、索引優化、庫表結構優化需要齊頭並進,一個不落。在獲得編寫MySQL査詢的經驗的同時,也將學習到如何為高效的査詢設計表和索引。同樣的,也可以學習到在優化庫表結構時會影響到哪些型別的査詢。

  本章將從査詢設計的一些基本原則開始——這也是在發現査詢效率不髙的時候首先需要考慮的因素。然後會介紹一些更深的査詢優化的技巧,並會介紹一些MySQL優化器內部的機制。我們將展示MySQL是如何執行査詢的,你也將學會如何去改變一個査詢的執行計劃。最後,我們要看一下MySQL

優化器在哪些方面做得還不夠,並探索査詢優化的模式,以幫助MySQL更有效地執行査詢。

  本章的目標是幫助大家更深刻地理解MySQL如何真正地執行査詢,並明白髙效和低效 的原因何在,這樣才能充分發揮MySQL的優勢,並避開它的弱點。

1.為什麼查詢速度會慢

  在嘗試編寫快速的査詢之前,需要清楚一點,真正重要是響應時間。如果把査詢看作是 一個任務,那麼它由一系列子任務組成,每個子任務都會消耗一定的時間。如果要優化査詢,實際上要優化其子任務,要麼消除其中一些子任務,要麼減少子任務的執行次數,要麼讓子任務執行得更快。

  MySQL在執行査詢的時候有哪些子任務,哪些子任務執行的速度很慢?這裡很難給出 完整的列表,但如果對査詢進行剖析,就能看到査詢所執行的子任務。通常來說,査詢的生命週期大致可以按照順序來看:從客戶端,到伺服器,然後在伺服器上進行解析,生成執行計劃,執行,並返回結果給客戶端。其中“執行”可以認為是整個生命週期中最重要的階段,這其中包括了大量為了檢索資料到儲存引擎的呼叫以及呼叫後的資料處理,包括排序、分組等。

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

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

  再次申明一點,對於一個査詢的全部生命週期,上面列的並不完整。這裡我們只是想說明:瞭解査詢的生命週期、清楚査詢的時間消耗情況對於優化査詢有很大的意義。有了這些概念,我們再一起來看看如何優化査詢。

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

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

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

    2.確認MySQL伺服器層是否在分析大量超過需要的資料行。

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

  有些査詢會請求超過實際需要的資料,然後這些多餘的資料會被應用程式丟棄。這會給MySQL伺服器帶來額外的負擔,並增加網路開銷,另外也會消耗應用伺服器的CPU和記憶體資源。

  這裡有一些典型案例:

  查詢不需要的記錄

一個常見的錯誤是常常會誤以為MySQL會只返回需要的資料,實際上MySQL卻是先返回全部結果集再進行計算。我們經常會看到一些瞭解其他資料庫系統的人會設計出這類應用程式。這些開發者習慣使用這樣的技術,先使用SELECT語句査詢大量的結果,然後獲取前面的N行後關閉結果集(例如在新聞網站中取出100條記錄,但是隻是在頁面上顯示前面10條)。他們認為MySQL會執行査詢,並只返回他們需要的10條資料,然後停止査詢。實際情況是MySQL會査詢出全部的結果集,客戶端的應用程式會接收全部的結果集資料,然後拋棄其中大部分資料。最簡單有效的解決方法就是在這樣的査詢後面加上LIMIT。

  多表關聯時返回全部列

如果你想査詢所有在電影Academy Dinosaur中出現的演員,千萬不要按下面的寫法編寫査詢:

mysql> SELECT * FROM sakila.actor
    -> INNER JOIN sakila.film_actor USING(actor_id)
    -> INNER JOIN sakila.film USING(film_id)
    -> WHERE sakila.film.title = 'Academy Dinosaur';

這將返回這三個表的全部資料列。正確的方式應該是像下面這樣只取需要的列:

mysql> SELECT sakila.actor.* FROM sakila.actor...;

  總是取出全部列

每次看到SELECT *的時候都需要用懷疑的眼光審視,是不是真的需要返回全部的列?很可能不是必需的。取出全部列,會讓優化器無法完成索引覆蓋掃描這類優化,還會為伺服器帶來額外的I/0、記憶體和CPU的消耗。因此,一些DBA是嚴格禁止SELECT*的寫法的,這樣做有時候還能避免某些列被修改帶來的問題。

當然,查詢返回超過需要的資料也不總是壞事。在我們研究過的許多案例中,人們會告訴我們說這種有點浪費資料庫資源的方式可以簡化開發,因為能提高相同程式碼片段的複用性,如果清楚這樣做的效能影響,那麼這種做法也是值得考慮的。如果應用程式使用了某種快取機制,或者有其他考慮,獲取超過需要的資料也可能有其好處,但不要忘記這樣做的代價是什麼。獲取並快取所有的列的査詢,相比多個獨立的只獲取部分列的査詢可能就更有好處。

  重複查詢相同的資料

如果你不太小心,很容易出現這樣的錯誤——不斷地重複執行相同的査詢,然後每次都返回完全相同的資料。例如,在使用者評論的地方需要査詢使用者頭像的URL,那麼使用者多次評論的時候,可能就會反覆査詢這個資料。比較好的方案是,當初次查詢的時候將這個資料快取起來,需要的時候從快取中取出,這樣效能顯然會更好。

2.2 MySQL是否在掃描額外的記錄

  在確定査詢只返回需要的資料以後,接下來應該看看査詢為了返回結果是否掃描了過多 的資料。對於MySQL,最簡單的衡量査詢開銷的三個指標如下:

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

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

  響應時間

  要記住,響應時間只是一個表面上的值。這樣說可能看起來和前面關於響應時間的說法有矛盾?其實並不矛盾,響應時間仍然是最重要的指標,這有一點複雜,後面細細道來。

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

  所以在不同型別的應用壓力下,響應時間並沒有什麼一致的規律或者公式。諸如儲存引 擎的鎖(表鎖、行鎖)、髙併發資源競爭、硬體響應等諸多因素都會影響響應時間。所以,響應時間既可能是一個問題的結果也可能是一個問題的原因,不同案例情況不同,除非能夠使用《伺服器效能剖析》章節的“單個査詢問題還是伺服器問題” 一節介紹的技術來確定到底是因還是果。

  當你看到一個查詢的響應時間的時候,首先需要問問自己,這個響應時間是否是一個合理的值。實際上可以使用“快速上限估計”法來估算査詢的響應時間,這是由Tapio Lahdenmaki和MikeLeach編寫的Relational Database Index Design and the Optimizers(Wiley)一書提到的技術,限於篇幅,在這裡不會詳細展開。概括地說,瞭解這個査詢需要哪些索引以及它的執行計劃是什麼,然後計算大概需要多少個順序和隨機I/O,再用其乘以在具體硬體條件下一次I/O的消耗時間。最後把這些消耗都加起來,就可以獲得一個大概參考值來判斷當前響應時間是不是一個合理的值。

  掃描的行數和返回的行數

  分析査詢時,檢視該査詢掃描的行數是非常有幫助的。這在一定程度上能夠說明該査詢 找到需要的資料的效率高不高。

  對於找出那些“糟糕”的査詢,這個指標可能還不夠完美,因為並不是所有的行的訪問 代價都是相同的。較短的行的訪問速度更快,記憶體中的行也比磁碟中的行的訪問速度要快得多。

  理想情況下掃描的行數和返回的行數應該是相同的。但實際情況中這種“美事”並不多。例如在做一個關聯查詢時,伺服器必須要掃描多行才能生成結果集中的一行。掃描的行數對返回的行數的比率通常很小,一般在1:1和10:1之間,不過有時候這個值也可能非常非常大。

  掃描的行數和訪問型別

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

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

  如果査詢沒有辦法找到合適的訪問型別,那麼解決的最好辦法通常就是增加一個合適的 索引,這也正是<索引>章節討論過的問題。現在應該明白為什麼索引對於査詢優化如此重要了。索引讓MySQL以最高效、掃描行數最少的方式找到需要的記錄。

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

mysql> SELECT * FROM sakila.film_actor WHERE film_id = 1;

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

mysql> EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
         type: ref
possible_keys: idx_fk_film_id
          key: idx_fk_film_id
      key_len: 2
          ref: const
         rows: 10
        Extra:

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

mysql> ALTER TABLE sakila.film_actor DROP FOREIGN KEY fk_film_actor_film;
mysql> ALTER TABLE sakila.film_actor DROP KEY idx_fk_film_id;
mysql> EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 5073
        Extra: Using where

  正如我們預測的,訪問型別變成了一個全表掃描(ALL),現在MySQL預估需要掃描 5073條記錄來完成這個査詢。這裡的“UsingWhere”表示MySQL將通過WHERE條件來篩選儲存引擎返回的記錄。

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

  • 在索引中使用WHERE條件來過濾不匹配的記錄。這是在儲存引擎層完成的。
  • 使用索引覆蓋掃描(在Extra列中出現了Usingindex)來返回記錄,直接從索引中過濾不需要的記錄並返回命中的結果。這是在MySQL伺服器層完成的,但無須再回表査詢記錄。
  • 從資料表中返回資料,然後過濾不滿足條件的記錄(在Extra列中出現UsingWhere)。這在MySQL伺服器層完成,MySQL需要先從資料表讀出記錄然後過濾。

  上面這個例子說明了好的索引多麼重要。好的索引可以讓査詢使用合適的訪問型別,盡 可能地只掃描需要的資料行。但也不是說增加索引就能讓掃描的行數等於返回的行數。例如下面使用聚合函式COUNT()的査詢:

mysql> SELECT actor_id, COUNT(*) FROM sakila.film_actor GROUP BY actor_id;

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

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

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

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

3.重構查詢的方式

  在優化有問題的査詢時,目標應該是找到一個更優的方法獲得實際需要的結果——而不 一定總是需要從MySQL獲取一模一樣的結果集。有時候,可以將査詢轉換一種寫法讓其返回一樣的結果,但是效能更好。但也可以通過修改應用程式碼,用另一種方式完成査詢,最終達到一樣的目的。這一節將介紹如何通過這種方式來重構查詢,並展示何時需要使用這樣的技巧。

3.1 —個複雜查詢還是多個簡單查詢

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

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

  MySQL內部每秒能夠掃描記憶體中上百萬行資料,相比之下,MySQL響應資料給客戶端就慢得多了。在其他條件都相同的時候,使用盡可能少的査詢當然是更好的。但是有時候,將一個大査詢分解為多個小査詢是很有必要的。別害怕這樣做,好好衡量一下這樣做是不是會減少工作量。稍後將通過本章的一個示例來展示這個技巧的優勢。

  不過,在應用設計的時候,如果一個査詢能夠勝任時還寫成多個獨立査詢是不明智的。 例如,我們看到有些應用對一個數據表做10次獨立的査詢來返回10行資料,每個查詢返回一條結果,査詢10次!

3.2 切分查詢

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

  刪除舊的資料就是一個很好的例子。定期地清除大量資料時,如果用一個大的語句一次性完成的話,則可能需要一次鎖住很多資料、佔滿整個事務日誌、耗盡系統資源、阻塞很多小的但重要的查詢。將一個大的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")
} while rows_affected > 0

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

3.3 分解關聯查詢

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

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  post WHERE  post.id in (123,456,567,9098,8904);

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

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

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

4.查詢執行的基礎

  當希望MySQL能夠以更高的效能執行查詢時,最好的辦法就是弄清楚MySQL是如何優化和執行査詢的。一旦理解這一點,很多査詢優化工作實際上就是遵循一些原則讓優化器能夠按照預想的合理的方式執行。

  換句話說,是時候回頭看看我們前面討論的內容了:MySQL執行一個查詢的過程。根據圖6-1,我們可以看到當向MySQL傳送一個請求的時候,MySQL到底做了些什麼:

  1.客戶端傳送一條査詢給伺服器。

  2.伺服器先檢査査詢快取,如果命中了快取,則立刻返回儲存在快取中的結果。否則進入下一階段。

  3.伺服器端進行SQL解析、預處理,再由優化器生成對應的執行計劃。

  4.MySQL根據優化器生成的執行計劃,呼叫儲存引擎的API來執行査詢。

  5.將結果返回給客戶端。.

  上面的每一步都比想象的複雜,在後續章節中將繼續討論。我們會看到在每一個階段査詢處於何種狀態。査詢優化器是其中特別複雜也特別難理解的部分。還有很多的例外情況,例如,當査詢使用繫結變數後,執行路徑會有所不同,我們將在下一章討論這點。

4.1 MySQL客戶端/伺服器通訊協議

  一般來說,不需要去理解MySQL通訊協議的內部實現細節,只需要大致理解通訊協議是如何工作的。MySQL客戶端和伺服器之間的通訊協議是“半雙工”的,這意味著,在任何一個時刻,要麼是由伺服器向客戶端傳送資料,要麼是由客戶端向伺服器傳送資料,這兩個動作不能同時發生。所以,我們無法也無須將一個訊息切成小塊獨立來發送。

  這種協議讓MySQL通訊簡單快速,但是也從很多地方限制了MySQL。一個明顯的限制是,這意味著沒法進行流量控制。一旦一端開始發生訊息,另一端要接收完整個訊息才能響應它。這就像來回拋球的遊戲:在任何時刻,只有一個人能控制球,而且只有控制球的人才能將球拋回去(傳送訊息)。

  客戶端用一個單獨的資料包將査詢傳給伺服器。這也是為什麼當査詢的語句很長的時候,

  引數max_allowed_packet就特別重要了(如果查詢太大,服務端會拒絕接收更多的資料並丟擲相應錯誤)。一旦客戶端傳送了請求,它能做的事情就只是等待結果了。

  相反的,一般伺服器響應給使用者的資料通常很多,由多個數據包組成。當伺服器開始響應客戶端請求時,客戶端必須完整地接收整個返回結果,而不能簡單地只取前面幾條結果,然後讓伺服器停止傳送資料。這種情況下,客戶端若接收完整的結果,然後取前面幾條需要的結果,或者接收完幾條結果後就“粗暴”地斷開連線,都不是好主意。這也是在必要的時候一定要在査詢中加上LIMIT限制的原因。

  換一種方式解釋這種行為:當客戶端從伺服器取資料時,看起來是一個拉資料的過程,但實際上是MySQL在向客戶端推送資料的過程。客戶端不斷地接收從伺服器推送的資料,客戶端也沒法讓伺服器停下來。客戶端像是“從消防水管喝水”(這是一個術語)。

  多數連線MySQL的庫函式都可以獲得全部結果集並快取到記憶體裡,還可以逐行獲取需 要的資料。預設一般是獲得全部結果集並快取到記憶體中。MySQL通常需要等所有的資料都已經發送給客戶端才能釋放這條査詢所佔用的資源,所以接收全部結果並快取通常可以減少伺服器的壓力,讓査詢能夠早點結束、早點釋放相應的資源。

  當使用多數連線MySQL的庫函式從MySQL獲取資料時,其結果看起來都像是從MySQL伺服器獲取資料,而實際上都是從這個庫函式的快取獲取資料。多數情況下這沒什麼問題,但是如果需要返回一個很大的結果集的時候,這樣做並不好,因為庫函式會花很多時間和記憶體來儲存所有的結果集。如果能夠儘早開始處理這些結果集,就能大大減少記憶體的消耗,這種情況下可以不使用快取來記錄結果而是直接處理。這樣做的缺點是,對於伺服器來說,需要査詢完成後才能釋放資源,所以在和客戶端互動的整個過程中,伺服器的資源都是被這個査詢所佔用的。

  我們看看當使用PHP的時候是什麼情況。首先,下面是我們連線MySQL的通常寫法:

<?php
$link   = mysql_connect('localhost', 'user', 'p4ssword');
$result = mysql_query('SELECT * FROM HUGE_TABLE', $link);
while ( $row = mysql_fetch_array($result) ) {
   // Do something with result
}
?>

  這段程式碼看起來像是隻有當你需要的時候,才通過迴圈從伺服器端取出資料。而實際上, 在上面的程式碼中,在呼叫mysql_query()的時候,PHP就已經將整個結果集快取到記憶體中。下面的while迴圈只是從這個快取中逐行取出資料,相反如果使用下面的查詢,用mysql_unbuffered_query()代替mysql_query(),PHP則不會快取結果:

<?php
$link   = mysql_connect('localhost', 'user', 'p4ssword');
$result = mysql_unbuffered_query('SELECT * FROM HUGE_TABLE', $link);
while ( $row = mysql_fetch_array($result) ) {
   // Do something with result
}
?>

  不同的程式語言處理快取的方式不同。例如,在Perl的DBD:mysql驅動中需要指定C連線庫的mysql_use_result屬性(預設是mysql_buffer_result)。下面是一個例子:

#!/usr/bin/perl
use DBI;
my $dbh = DBI->connect('DBI:mysql:;host=localhost', 'user', 'p4ssword');
my $sth = $dbh->prepare('SELECT * FROM HUGE_TABLE', { mysql_use_result => 1 });
$sth->execute();
while ( my $row = $sth->fetchrow_array() ) {
   # Do something with result
}

  注意到上面的prepare()呼叫指定了mysql_use_result屬性為1,所以應用將直接“使用”返回的結果集而不會將其快取。也可以在連線MySQL的時候指定這個屬性,這會讓整個連線都使用不快取的方式處理結果集:

my $dbh = DBI->connect('DBI:mysql:;mysql_use_result=1', 'user', 'p4ssword');

  查詢狀態

  對於一個MySQL連線,或者說一個執行緒,任何時刻都有一個狀態,該狀態表示了MySQL當前正在做什麼。有很多種方式能査看當前的狀態,最簡單的是使用SHOW FULL PROCESSLIST命令(該命令返回結果中的Command列就表示當前的狀態)。在一個查詢的生命週期中,狀態會變化很多次。MySQL官方手冊中對這些狀態值的含義有最權威的解釋,下面將這些狀態列出來,並做一個簡單的解釋。

  Sleep:執行緒正在等待客戶端傳送新的請求。

  Query:執行緒正在執行査詢或者正在將結果傳送給客戶端。

  Locked:在MySQL伺服器層,該執行緒正在等待表鎖。在儲存引擎級別實現的鎖,例如InnoDB的行鎖,並不會體現線上程狀態中。對於MyISAM來說這是一個比較典型的狀態,但在其他沒有行鎖的引擎中也經常會出現。

  Analyzing and statistics:執行緒正在收集儲存引擎的統計資訊,並生成査詢的執行計劃。

  Copyingtotmptable[ondisk]:執行緒正在執行査詢,並且將其結果集都複製到一個臨時表中,這種狀態一般要麼是在做GROUPBY操作,要麼是檔案排序操作,或者是UNION操作。如果這個狀態後面還有“on disk”標記,那表示MySQL正在將一個記憶體臨時表放到磁碟上。

  Sortingresult:執行緒正在對結果集進行排序。

  Sendingdata:這表示多種情況:執行緒可能在多個狀態之間傳送資料,或者在生成結果集,或者在向客戶端返回資料。

  瞭解這些狀態的基本含義非常有用,這可以讓你很快地瞭解當前“誰正在持球”。在一個繁忙的伺服器上,可能會看到大量的不正常的狀態,例如statistics正佔用大量的時間。這通常表示,某個地方有異常了,可以通過使用<伺服器效能剖析>章節的一些技巧來診斷到底是哪個環節出現了問題。

4.2 查詢快取

  在解析一個査詢語句之前,如果査詢快取是開啟的,那麼MySQL會優先檢査這個査詢是否命中査詢快取中的資料。這個檢査是通過一個對大小寫敏感的雜湊査找實現的。査詢和快取中的査詢即使只有一個位元組不同,那也不會匹配快取結,這種情況下査詢就會進入下一階段的處理。

  如果當前的査詢恰好命中了查詢快取,那麼在返回査詢結果之前MySQL會檢査一次用 戶許可權。這仍然是無須解析査詢SQL語句的,因為在査詢快取中已經存放了當前査詢需要訪問的表資訊。如果許可權沒有問題,MySQL會跳過所有其他階段,直接從快取中拿到結果並返回給客戶端。這種情況下,査詢不會被解析,不用生成執行計劃,不會被執行。

4.3 查詢優化處理

  査詢的生命週期的下一步是將一個SQL轉換成一個執行計劃,MySQL再依照這個執行計劃和儲存引擎進行互動。這包括多個子階段:解析SQL、預處理、優化SQL執行計劃。這個過程中任何錯誤(例如語法錯誤)都可能終止査詢。這裡不打算詳細介紹MySQL內部實現,而只是選擇性地介紹其中幾個獨立的部分,在實際執行中,這幾部分可能一起執行也可能單獨執行。我們的目的是幫助大家理解MySQL如何執行査詢,以便寫出更優秀的査詢。

  語法解析器和預處理

  首先,MySQL通過關鍵字將SQL語句進行解析,並生成一棵對應的“解析樹”。MySQL解析器將使用MySQL語法規則驗證和解析査詢。例如,它將驗證是否使用錯誤的關鍵字,或者使用關鍵字的順序是否正確等,再或者它還會驗證引號是否能前後正確匹配。

  前處理器則根據一些MySQL規則進一步檢査解析樹是否合法,例如,這裡將檢査資料表和資料列是否存在,還會解析名字和別名,看看它們是否有歧義。

  下一步前處理器會驗證許可權。這通常很快,除非伺服器上有非常多的許可權配置。

  查詢優化器

  現在語法樹被認為是合法的了,並且由優化器將其轉化成執行計劃。一條査詢可以有很 多種執行方式,最後都返回相同的結果。優化器的作用就是找到這其中最好的執行計劃。

  MySQL使用基於成本的優化器,它將嘗試預測一個査詢使用某種執行計劃時的成本, 並選擇其中成本最小的一個。最初,成本的最小單位是隨機讀取一個4K資料頁的成本,後來(成本計算公式)變得更加複雜,並且引入了一些“因子”來估算某些操作的代價,如當執行一次WHERE條件比較的成本。可以通過査詢當前會話的Last_query_cost的值來得知MySQL計算的當前査詢的成本。

mysql> SELECT SQL_NO_CACHE COUNT(*) FROM sakila.film_actor;
+----------+
| count(*) |
+----------+
|     5462 |
+----------+
mysql> SHOW STATUS LIKE 'Last_query_cost';
+-----------------+-------------+
| Variable_name   | Value       |
+-----------------+-------------+
| Last_query_cost | 1040.599000 |
+-----------------+-------------+

  這個結果表示MySQL的優化器認為大概需要做1 040個數據頁的隨機査找才能完成上 面的査詢。這是根據一系列的統計資訊計算得來的:每個表或者索引的頁面個數、索引的基數(索引中不同值的數量)、索引和資料行的長度、索引分佈情況。優化器在評估成本的時候並不考慮任何層面的快取,它假設讀取任何資料都需要一次磁碟I/O。

  有很多種原因會導致MySQL優化器選擇錯誤的執行計劃,如下所示:

  • 統計資訊不準確。MySQL依賴儲存引擎提供的統計資訊來評估成本,但是有的儲存引擎提供的資訊是準確的,有的偏差可能非常大。例如,InnoDB因為其MVCC的架構,並不能維護一個數據表的行數的精確統計資訊。
  • 執行計劃中的成本估算不等同於實際執行的成本。所以即使統計資訊精準,優化器給出的執行計劃也可能不是最優的。例如有時候某個執行計劃雖然需要讀取更多的頁面,但是它的成本卻更小。因為如果這些頁面都是順序讀或者這些頁面都已經在記憶體中的話,那麼它的訪問成本將很小。MySQL層面並不知道哪些頁面在記憶體中、哪些在磁碟上,所以査詢實際執行過程中到底需要多少次物理I/O是無法得知的。
  • MySQL的最優可能和你想的最優不一樣。你可能希望執行時間儘可能的短,但是MySQL只是基於其成本模型選擇最優的執行計劃,而有些時候這並不是最快的執行方式。所以,這裡我們看到根據執行成本來選擇執行計劃並不是完美的模型。
  • MySQL從不考慮其他併發執行的査詢,這可能會影響到當前查詢的速度。
  • MySQL也並不是任何時候都是基於成本的優化。有時也會基於一些固定的規則,例如,如果存在全文捜索的MATCH()子句,則在存在全文索引的時候就使用全文索引。即使有時候使用別的索引和WHERE條件可以遠比這種方式要快,MySQL也仍然會使用對應的全文索引。
  • MySQL不會考慮不受其控制的操作成本,例如執行儲存過程或者使用者自定義函式的成本。
  • 後面我們還會看到,優化器有時候無法去估算所有可能的執行計劃,所以它可能錯過實際上最優的執行計劃。

  MySQL的査詢優化器是一個非常複雜的部件,它使用了很多優化策略來生成一個最優 的執行計劃。優化策略可以簡單地分為兩種,一種是靜態優化,一種是動態優化。靜態優化可以直接對解析樹進行分析,並完成優化。例如,優化器可以通過一些簡單的代數變換將WHERE條件轉換成另一種等價形式。靜態優化不依賴於特別的數值,如WHERE條件中帶入的一些常數等。靜態優化在第一次完成後就一直有效,即使使用不同的引數重複執行査詢也不會發生變化。可以認為這是一種“編譯時優化”。

  相反,動態優化則和査詢的上下文有關,也可能和很多其他因素有關,例如WHERE條件 中的取值、索引中條目對應的資料行數等。這需要在每次査詢的時候都重新評估,可以認為這是“執行時優化”。

  在執行語句和儲存過程的時候,動態優化和靜態優化的區別非常重要。MySQL對査詢的靜態優化只需要做一次,但對査詢的動態優化則在每次執行時都需要重新評估。有時候甚至在査詢的執行過程中也會重新優化。

  下面是一些MySQL能夠處理的優化型別:

  重新定義關聯表的順序

資料表的關聯並不總是按照在査詢中指定的順序進行。決定關聯的順序是優化器很重要的一部分功能,本章後面將深入介紹這一點。

  將外連線轉化成內連線

並不是所有的OUTER JOIN語句都必須以外連線的方式執行。諸多因素,例如WHERE條件、庫表結構都可能會讓外連線等價於一個內連線。MySQL能夠識別這點並重寫査詢,讓其可以調整關聯順序。

  使用等價變換規則

MySQL可以使用一些等價變換來簡化並規範表示式。它可以合併和減少一些比較,還可以移除一些恆成立和一些恆不成立的判斷。例如,(5=5 AND a>5)將被改寫為a>5。類似的,如果有(a<b AND b=c) AND a=5則會改寫為b>5 AND b=c AND a=5。這些規則對於我們編寫條件語句很有用,我們將在本章後續繼續討論。

  優化C0UNT()、MIN()和MAX()

索引和列是否可為空通常可以幫助MySQL優化這類表示式。例如,要找到某一列的最小值,只需要査詢對應B-Tree索引最左端的記錄,MySQL可以直接獲取索引的第一行記錄。在優化器生成執行計劃的時候就可以利用這一點,在B-Tree索引中,優化器會將這個表示式作為一個常數對待。類似的,如果要査找一個最大值,也只需讀取B-Tree索引的最後一條記錄。如果MySQL使用了這種型別的優化,那麼在EXPLAIN中就可以看到“Selecttablesoptimizedaway”。從字面意思可以看出,它表示優化器已經從執行計劃中移除了該表,並以一個常數取而代之。

類似的,沒有任何WHERE條件的C0UNT(*)査詢通常也可以使用儲存引擎提供的一些優化(例如,MyISAM維護了一個變數來存放資料表的行數)。

  預估並轉化為常數表示式

當MySQL檢測到一個表示式可以轉化為常數的時候,就會一直把該表示式作為常數進行優化處理。例如,一個使用者自定義變數在査詢中沒有發生變化時就可以轉換為一個常數。數學表示式則是另一種典型的例子。

讓人驚訝的是,在優化階段,有時候甚至一個査詢也能夠轉化為一個常數。一個例子是在索引列上執行MIN()函式。甚至是主鍵或者唯一鍵査找語句也可以轉換為常數表示式。如果WHERE子句中使用了該類索引的常數條件,MySQL可以在査詢開始階段就先査找到這些值,這樣優化器就能夠知道並轉換為常數表示式。下面是一個例子:

mysql> EXPLAIN SELECT film.film_id, film_actor.actor_id
    -> FROM sakila.film
    ->    INNER JOIN sakila.film_actor USING(film_id)
    -> WHERE film.film_id = 1;
+----+-------------+------------+-------+----------------+-------+------+
| id | select_type | table      | type  | key            | ref   | rows |
+----+-------------+------------+-------+----------------+-------+------+
|  1 | SIMPLE      | film       | const | PRIMARY        | const |    1 |
|  1 | SIMPLE      | film_actor | ref   | idx_fk_film_id | const |   10 |
10.+----+-------------+------------+-------+----------------+-------+------+

MySQL分兩步來執行這個査詢,也就是上面執行計劃的兩行輸出。第一步先從film表找到需要的行。因為在欄位上有主鍵索引,所以MySQL優化器知道這隻會返回一行資料,優化器在生成執行計劃的時候,就已經通過索引資訊知道將返回多少行資料。因為優化器已經明確知道有多少個值(WHERE條件中的值)需要做索引査詢,所以這裡的表訪問型別是const。

在執行計劃的第二步,MySQL將第一步中返回的film_id列當作一個已知取值的列來處理。因為優化器清楚在第一步執行完成後,該值就會是明確的了。注意到正如第一步中一樣,使用film_actor欄位對錶的訪問型別也是const。

另一種會看到常數條件的情況是通過等式將常數值從一個表傳到另一個表,這可以通過WHERE、USING或者ON語句來限制某列取值為常數。在上面的例子中,因為使用了USING子句,優化器知道這也限制了film_id在整個査詢過程中都始終是一個常量——因為它必須等於WHERE子句中的那個取值。

  覆蓋索引掃描

當索引中的列包含所有査詢中需要使用的列的時候,MySQL就可以使用索引返回需要的資料,而無須査詢對應的資料行,在前面的章節中已經討論過這點了。

  子查詢優化

MySQL在某些情況下可以將子査詢轉換一種效率更髙的形式,從而減少多個査詢多次對資料進行訪問。

  提前終止查詢

在發現已經滿足査詢需求的時候,MySQL總是能夠立刻終止査詢。一個典型的例子就是當使用了LIMIT子句的時候。除此之外,MySQL還有幾類情況也會提前終止査詢,例如發現了一個不成立的條件,這時MySQL可以立刻返回一個空結果。從下面的例子可以看到這一點:

mysql> EXPLAIN SELECT film.film_id FROM sakila.film WHERE film_id = −1;
+----+...+-----------------------------------------------------+
| id |...| Extra                                               |
+----+...+-----------------------------------------------------+
|  1 |...| Impossible WHERE noticed after reading const tables |
+----+...+-----------------------------------------------------+

從這個例子看到査詢在優化階段就已經終止。除此之外,MySQL在執行過程中,如果發現某些特殊的條件,則會提前終止査詢。當儲存引擎需要檢索“不同取值”或者判斷存在性的時候,MySQL都可以使用這類優化。例如,我們現在需要找到沒有演員的所有電影:

mysql> SELECT film.film_id
    -> FROM sakila.film
    ->    LEFT OUTER JOIN sakila.film_actor USING(film_id)
    -> WHERE film_actor.film_id IS NULL;

這個査詢將會過濾掉所有有演員的電影。每一部電影可能會有很多的演員,但是上面的查詢一且找到任何一個,就會停止並立刻判斷下一部電影,因為只要有一名演員,那麼WHERE條件則會過濾掉這類電影。類似這種“不同值/不存在”的優化一般可用於DISTINCT、NOTEXIST()或者LEFTJOIN型別的査詢。

  等值傳播

如果兩個列的值通過等式關聯,那麼MySQL能夠把其中一個列的WHERE條件傳遞到另一列上。例如,我們看下面的査詢:

mysql> SELECT film.film_id
    -> FROM sakila.film
    ->    INNER JOIN sakila.film_actor USING(film_id)
    -> WHERE film.film_id > 500;

因為這裡使用了欄位進行等值關聯,MySQL知道這裡的WHERE子句不僅適用於film表,而且對於film_actor表同樣適用。如果使用的是其他的資料庫管理系統,可能還需要手動通過一些條件來告知優化器這個WHERE條件適用於兩個表,那麼寫法就會如下:

... WHERE film.film_id > 500 AND film_actor.film_id > 500

在MySQL中這是不必要的,這樣寫反而會讓査詢更難維護。

  列表IN()的比較

在很多資料庫系統中,IN()完全等同於多個0R條件的子句,因為這兩者是完全等價的。在MySQL中這點是不成立的,MySQL將IN()列表中的資料先進行排序,然後通過二分査找的方式來確定列表中的值是否滿足條件,這是一個O(logn)複雜度的操作,等價地轉換成0R査詢的複雜度為〇(n),對於IN()列表中有大量取值的時候,MySQL的處理速度將會更快。

  上面列舉的遠不是MySQL優化器的全部,MySQL還會做大量其他的優化,即使本章全部用來描述也會篇幅不足,但上面的這些例子已經足以讓大家明白優化器的複雜性和智慧性了。如果說從上面這段討論中我們應該學到什麼,那就是“不要自以為比優化器更聰明”。最終你可能會佔點便宜,但是更有可能會使查詢變得更加複雜而難以維護,而最終的收益卻為零。讓優化器按照它的方式工作就可以了。

  當然,雖然優化器已經很智慧了,但是有時候也無法給出最優的結果。有時候你可能比 優化器更瞭解資料,例如,由於應用邏輯使得某些條件總是成立;還有時,優化器缺少某種功能特性,如雜湊索引;再如前面提到的,從優化器的執行成本角度評估出來的最優執行計劃,實際執行中可能比其他的執行計劃更慢。

  如果能夠確認優化器給出的不是最佳選捧,並且清楚背後的原理,那麼也可以幫助優化 器做進一步的優化。例如,可以在査詢中新增hint提示,也可以重寫査詢,或者重新設計更優的庫表結構,或者新增更合適的索引。

  資料和索引的統計資訊

  MySQL架構由多個層次組成。在伺服器層有査詢優化器,卻沒有儲存資料和索引的統計資訊。統計資訊申儲存引擎實現,不同的儲存引擎可能會儲存不同的統計資訊(也可以按照不同的格式儲存統計資訊)。某些引擎,例如Archive引擎,則根本就沒有儲存任何統計資訊!

  因為伺服器層沒有任何統計資訊,所以MySQL査詢優化器在生成査詢的執行計劃時, 需要向儲存引擎獲取相應的統計資訊。儲存引擎則提供給優化器對應的統計資訊,包括:每個表或者索引有多少個頁面、每個表的每個索引的基數是多少、資料行和索引長度、索引的分佈資訊等。優化器根據這些資訊來選擇一個最優的執行計劃。在後面的小節中我們將看到統計資訊是如何影響優化器的。

  MySQL如何執行關聯查詢

  MySQL中“關聯”—詞所包含的意義比一般意義上理解的要更廣泛。總的來說,MySQL認為任何一個査詢都是一次“關聯”——並不僅僅是一個査詢需要到兩個表匹配才叫關聯,所以在MySQL中,每一個査詢,每一個片段(包括子査詢,甚至基於單表的SELECT)都可能是關聯。

  所以,理解MySQL如何執行關聯査詢至關重要。我們先來看一個UNION査詢的例子。對於UNION査詢,MySQL先將一系列的單個査詢結果放到一個臨時表中,然後再重新讀出臨時表資料來完成UNION査詢。在MySQL的概念中,每個査詢都是一次關聯,所以讀取結果臨時表也是一次關聯。

  當前MySQL關聯執行的策略很簡單:MySQL對任何關聯都執行巢狀迴圈關聯操作,即MySQL先在一個表中迴圈取出單條資料,然後再巢狀迴圈到下一個表中尋找匹配的行,依次下去,直到找到所有表中匹配的行為止。然後根據各個表匹配的行,返回査詢中需要的各個列。MySQL會嘗試在最後一個關聯表中找到所有匹配的行,如果最後一個關聯表無法找到更多的行以後,MySQL返回到上一層次關聯表,看是否能夠找到更多的匹配記錄,依此類推迭代執行。

  按照這樣的方式查詢第一個表記錄,再巢狀査詢下一個關聯表,然後回溯到上一個表, 在MySQL中是通過巢狀迴圈的方式實現——正如其名“巢狀迴圈關聯”。請看下面的例子中的簡單査詢:

mysql> SELECT tbl1.col1, tbl2.col2
    -> FROM tbl1 INNER JOIN tbl2 USING(col3)
    -> WHERE tbl1.col1 IN(5,6);

  假設MySQL按照查詢中的表順序進行關聯操作,我們則可以用下面的偽程式碼表示MySQL將如何完成這個查詢:

outer_iter = iterator over tbl1 where col1 IN(5,6)
outer_row  = outer_iter.next
while outer_row
   inner_iter = iterator over tbl2 where col3 = outer_row.col3
   inner_row  = inner_iter.next
   while inner_row
      output [ outer_row.col1, inner_row.col2 ]
      inner_row = inner_iter.next
   end
   outer_row = outer_iter.next
end

  上面的執行計劃對於單表査詢和多表關聯査詢都適用,如果是一個單表查詢,那麼只需完成上面外層的基本操作。對於外連線上面的執行過程仍然適用。例如,我們將上面查詢修改如下:

mysql> SELECT tbl1.col1, tbl2.col2
    -> FROM tbl1 LEFT OUTER JOIN tbl2 USING(col3)
    -> WHERE tbl1.col1 IN(5,6);

  對應的虛擬碼如下,我們用黑體標示不同的部分:

outer_iter = iterator over tbl1 where col1 IN(5,6)
outer_row  = outer_iter.next
while outer_row
   inner_iter = iterator over tbl2 where col3 = outer_row.col3
   inner_row  = inner_iter.next
   if inner_row
      while inner_row
         output [ outer_row.col1, inner_row.col2 ]
         inner_row = inner_iter.next
      end
   else
      output [ outer_row.col1, NULL ]
   end
   outer_row = outer_iter.next
end

  另一種視覺化査詢執行計劃的方法是根據優化器執行的路徑繪製出對應的“泳道圖”。如圖6-2所示,繪製了前面示例中內連線的泳道圖,請從左至右,從上至下地看這幅圖。

  從本質上說,MySQL對所有的型別的査詢都以同樣的方式執行。例如,MySQL在FROM子句中遇到子査詢時,先執行子査詢並將其結果放到一個臨時表中(MySQL的臨時表是沒有任何索引的,在編寫複雜的子查詢和關聯查詢的時候需要注意這一點。這一點對UNION查詢也一樣。),然後將這個臨時表當作一個普通表對待(正如其名“派生表”)。MySQL在執行UNION査詢時也使用類似的臨時表,在遇到右外連線的時候,MySQL將其改寫成等價的左外連線。簡而言之,當前版本的MySQL會將所有的査詢型別都轉換成類似的執行計劃。

  不過,不是所有的査詢都可以轉換成上面的形式。例如,全外連線就無法通過巢狀迴圈 和回溯的方式完成,這時當發現關聯表中沒有找到任何匹配行的時候,則可能是因為關聯是恰好從一個沒有任何匹配的表開始。這大概也是MySQL並不支援全外連線的原因。還有些場景,雖然可以轉換成巢狀迴圈的方式,但是效率卻非常差,後面我們會看一個這樣的例子。

  執行計劃

  和很多其他關係資料庫不同,MySQL並不會生成査詢位元組碼來執行查詢。MySQL生成査詢的一棵指令樹,然後通過儲存引擎執行完成這棵指令樹並返回結果。最終的執行計劃包含了重構查詢的全部資訊。如果對某個査詢執行EXPLAINEXTENDED後,再執行SHOWWARNINGS,就可以看到重構出的查詢。

  任何多表查詢都可以使用一棵樹表示,例如,可以按照圖6-3執行一個四表的關聯操作。

  在電腦科學中,這被稱為一顆平衡樹。但是,這並不是MySQL執行查詢的方式。正如前面章節介紹的,MySQL總是從一個表開始一直巢狀迴圈、回溯完成所有表關聯。所以,MySQL的執行計劃總是如圖6-4所示,是一棵左測深度優先的樹。

  關聯查詢優化器

  MySQL優化器最重要的一部分就是關聯査詢優化,它決定了多個表關聯時的順序。通常多表關聯的時候,可以有多種不同的關聯順序來獲得相同的執行結果。關聯査詢優化器則通過評估不同順序時的成本來選擇一個代價最小的關聯順序。

  下面的査詢可以通過不同順序的關聯最後都獲得相同的結果:

mysql> SELECT film.film_id, film.title, film.release_year, actor.actor_id,
    ->    actor.first_name, actor.last_name
    ->    FROM sakila.film
    ->    INNER JOIN sakila.film_actor USING(film_id)
    ->    INNER JOIN sakila.actor USING(actor_id);

  容易看出,可以通過一些不同的執行計劃來完成上面的査詢。例如,MySQL可以從film表開始,使用film_actor表的索引film_id來査找對應的actor_id值,然後再根據actor表的主鍵找到對應的記錄。Oracle使用者會用下面的術語描述:“film表作為驅動表先査找file_actor表,然後以此結果為驅動表再査找actor表”。這樣做效率應該會不錯,我們再使用EXPLAIN看看MySQL將如何執行這個査詢:

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: actor
         type: ALL
possible_keys: PRIMARY
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 200
        Extra:
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
         type: ref
possible_keys: PRIMARY,idx_fk_film_id
          key: PRIMARY
      key_len: 2
          ref: sakila.actor.actor_id
         rows: 1
        Extra: Using index
*************************** 3. row ***************************
           id: 1
  select_type: SIMPLE
        table: film
         type: eq_ref
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 2
          ref: sakila.film_actor.film_id
         rows: 1
        Extra:

  這和我們前面給出的執行計劃完全不同。MySQL從actor表開始(我們從上面的EXPLAIN結果的第一行輸出可以看出這點),然後與我們前面的計劃按照相反的順序進行關聯。這樣是否效率更高呢?我們來看看,我們先使用STRAIGHT_JOIN關鍵字,按照我們之前的順序執行,這裡是對應的EXPLAIN輸出結果:

mysql> EXPLAIN SELECT STRAIGHT_JOIN film.film_id...\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film
         type: ALL
possible_keys: PRIMARY
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 951
        Extra:
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
         type: ref
possible_keys: PRIMARY,idx_fk_film_id
          key: idx_fk_film_id
      key_len: 2
          ref: sakila.film.film_id
         rows: 1
        Extra: Using index
*************************** 3. row ***************************
           id: 1
  select_type: SIMPLE
        table: actor
         type: eq_ref
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 2
          ref: sakila.film_actor.actor_id
         rows: 1
        Extra:

  我們來分析一下為什麼MySQL會將關聯順序倒轉過來:可以看到,關聯順序倒轉後的第一個關聯表只需要掃描很少的行數(嚴格來說,MySQL並不根據讀取的記錄來選擇最優的執行計劃。實際上,MySQL通過預估需要讀取的資料頁來選擇,讀取的資料頁越少越好。不過讀取的記錄數通常能夠很好地反映一個查詢的成本。)。在兩種關聯順序下,第二個和第三個關聯表都是根據索引査詢,速度都很快,不同的是需要掃描的索引項的數量是不同的:

  • 將film表作為第一個關聯表時,會找到951條記錄,然後對film_actor和actor表進行巢狀迴圈査詢。
  • 如果MySQL選擇首先掃描actor表,只會返回200條記錄進行後面的巢狀迴圈査詢。

  換句話說,倒轉的關聯順序會讓査詢進行更少的巢狀迴圈和回溯操作。為了驗證優化器 的選擇是否正確,我們單獨執行這兩個査詢,並且看看對應的Last_query_cost狀態值。我們看到倒轉的關聯順序的預估成本為241,而原來的査詢的預估成本為1154。

  這個簡單的例子主要想說明MySQL是如何選擇合適的關聯順序來讓査詢執行的成本盡 可能低的。重新定義關聯的順序是優化器非常重要的一部分功能。不過有的時候,優化器給出的並不是最優的關聯順序。這時可以使用STRAIGHT_JOIN關鍵字重寫査詢,讓優化器按照你認為的最優的關聯順序執行——不過老實說,人的判斷很難那麼精準。絕大多數時候,優化器做出的選擇都比普通人的判斷要更準確。

  關聯優化器會嘗試在所有的關聯順序中選擇一個成本最小的來生成執行計劃樹。如果可 能,優化器會遍歷每一個表然後逐個做巢狀迴圈計算每一棵可能的執行計劃樹的成本,最後返回一個最優的執行計劃。

  不過,糟糕的是,如果有超過n個表的關聯,那麼需要檢査n的階乘種關聯順序。我們 稱之為所有可能的執行計劃的“搜尋空間”,搜尋空間的增長速度非常塊——例如,若是10個表的關聯,那麼共有3 628 800種不同的關聯順序!當搜尋空間非常大的時候,優化器不可能逐一評估每一種關聯順序的成本。這時,優化器選擇使用“貪婪”搜尋的方式査找“最優”的關聯順序。實際上,當需要關聯的表超過optimizer_search_depth的限制的時候,就會選擇“貪婪”捜索模式了(optimizer_search_depth引數可以根據需要指定大小)。

  在MySQL這些年的發展過程中,優化器積累了很多“啟發式”的優化策略來加速執行計劃的生成。絕大多數情況下,這都是有效的,但因為不會去計算每一種關聯順序的成本,所以偶爾也會選擇一個不是最優的執行計劃。

  有時,各個査詢的順序並不能隨意安排,這時關聯優化器可以根據這些規則大大減少搜 索空間,例如,左連線、相關子査詢(後面我將繼續討論子査詢)。這是因為,後面的表的査詢需要依賴於前面表的査詢結果。這種依賴關係通常可以幫助優化器大大減少需要掃描的執行計劃數量。

  排序優化

  無論如何排序都是一個成本很高的操作,所以從效能角度考慮,應儘可能避免排序或者 儘可能避免對大量資料進行排序。

  <伺服器效能剖析>章節MySQL如何通過索引進行排序。當不能使用索引生成排序結果的時候,MySQL需要自己進行排序,如果資料量小則在記憶體中進行,如果資料量大則需要使用磁碟,不過MySQL將這個過程統一稱為檔案排序,即使完全是記憶體排序不需要任何磁碟檔案時也是如此。

  如果需要排序的資料量小於“排序緩衝區”,MySQL使用記憶體進行“快速排序”操作。如果記憶體不夠排序,那麼MySQL會先將資料分塊,對每個獨立的塊使用“快速排序”進行排序,並將各個塊的排序結果存放在磁碟上,然後將各個排好序的塊進行合併(merge),最後返回排序結果。

  MySQL有如下兩種排序演算法:

  兩次傳輸排序(舊版本使用

讀取行指標和需要排序的欄位,對其進行排序,然後再根據排序結果讀取所需要的資料行。

這需要進行兩次資料傳輸,即需要從資料表中讀取兩次資料,第二次讀取資料的時候,因為是讀取排序列進行排序後的所有記錄,這會產生大量的隨機I/0,所以兩次資料傳輸的成本非常髙。當使用的是MyISAM表的時候,成本可能會更髙,因為MyISAM使用系統呼叫進行資料的讀取(MyISAM非常依賴作業系統對資料的快取)。不過這樣做的優點是,在排序的時候儲存儘可能少的資料,這就讓“排序緩衝區”中可能容納儘可能多的行數進行排序。

  單次傳輸排序(新版本使用)

先讀取査詢所需要的所有列,然後再根據給定列進行排序,最後直接返回排序結果。這個演算法只在MySQL4.1和後續更新的版本才引入。因為不再需要從資料表中讀取兩次資料,對於I/O密集型的應用,這樣做的效率高了很多。另外,相比兩次傳輸排序,這個演算法只需要一次順序I/O讀取所有的資料,而無須任何的隨機I/O。缺點是,如果需要返回的列非常多、非常大,會額外佔用大量的空間,而這些列對排序操作本身來說是沒有任何作用的。因為單條排序記錄很大,所以可能會有更多的排序塊需要合併。

很難說哪個演算法效率更高,兩種演算法都有各自最好和最糟的場景。當査詢需要所有列的總長度不超過引數max_length_for_sort_data時,MySQL使用“單次傳輸排序”,可以通過調整這個引數來影響MySQL排序演算法的選擇。

  MySQL在進行檔案排序的時候需要使用的臨時儲存空間可能會比想象的要大得多。原 因在於MySQL在排序時,對每一個排序記錄都會分配一個足夠長的定長空間來存放。

  這個定長空間必須足夠長以容納其中最長的字串,例如,如果是VARCHAR列則需要分 配其完整長度;如果使用UTF-8字符集,那麼MySQL將會為每個字元預留三個位元組。我們曾經在一個庫表結構設計不合理的案例中看到,排序消耗的臨時空間比磁碟上的原表要大很多倍。

  在關聯査詢的時候如果需要排序,MySQL會分兩種情況來處理這樣的檔案排序。如果ORDERBY子句中的所有列都來自關聯的第一個表,那麼MySQL在關聯處理第一個表的時候就進行檔案排序。如果是這樣,那麼在MySQL的EXPLAIN結果中可以看到Extra欄位會有“Usingfilesort”。除此之外的所有情況,MySQL都會先將關聯的結果存放到一個臨時表中,然後在所有的關聯都結束後,再進行檔案排序。這種情況下,在MySQL的EXPLAIN結果的Extra欄位可以看到“Usingtemporary;Usingfilesort”。如果査詢中有LIMIT的話,LIMIT也會在排序之後應用,所以即使需要返回較少的資料,臨時表和需要排序的資料量仍然會非常大。

  MySQL 5.6在這裡做了很多重要的改進。當只需要返回部分排序結果的時候,例如使用 了LIMIT子句,MySQL不再對所有的結果進行排序,而是根據實際情況,選擇拋棄不滿足條件的結果,然後再進行排序。

4.4 查詢執行引擎

  在解析和優化階段,MySQL將生成査詢對應的執行計劃,MySQL的査詢執行引擎則根據這個執行計劃來完成整個査詢。這裡執行計劃是一個數據結構,而不是和很多其他的關係型資料庫那樣會生成對應的位元組碼。

  相對於査詢優化階段,査詢執行階段不是那麼複雜:MySQL只是簡單地根據執行計劃給出的指令逐步執行。在根據執行計劃逐步執行的過程中,有大量的操作需要通過呼叫儲存引擎實現的介面來完成,這些介面也就是我們稱為“handler API”的介面。査詢中的每一個表由一個handler的例項表示。前面我們有意忽略了這點,實際上,MySQL在優化階段就為每個表建立了一個handler例項,優化器根據這些例項的介面可以獲取表的相關資訊,包括表的所有列名、索引統計資訊,等等。

  儲存引擎介面有著非常豐富的功能,但是底層介面卻只有幾十個,這些介面像“搭積木” 一樣能夠完成查詢的大部分操作。例如,有一個査詢某個索引的第一行的介面,再有一個査詢某個索引條目的下一個條目的功能,有了這兩個功能我們就可以完成全索引掃描的操作了。這種簡單的介面模式,讓MySQL的儲存引擎外掛式架構成為可能,但是正如前面的討論,也給優化器帶來了一定的限制。

  為了執行査詢,MySQL只需要重複執行計劃中的各個操作,直到完成所有的資料査詢。

  提示:並不是所有的操作都由handler完成。例如,當MySQL需要進行表鎖的時候。handler可能會實現自己的級別的、更細粒度的鎖,如InnoDB就實現了自己的行基本鎖,但這並不能代替伺服器層的表鎖。

4.5 返回結果給客戶端

  查詢執行的最後一個階段是將結果返回給客戶端。即使査詢不需要返回結果集給客戶端,MySQL仍然會返回這個査詢的一些資訊,如該査詢影響到的行數。

  如果査詢可以被快取,那麼MySQL在這個階段也會將結果存放到査詢快取中。

  MySQL將結果集返回客戶端是一個增量、逐步返回的過程。例如,我們回頭看看前面的關聯操作,一旦伺服器處理完最後一個關聯表,開始生成第一條結果時,MySQL就可以開始向客戶端逐步返回結果集了。

  這樣處理有兩個好處:伺服器端無須儲存太多的結果,也就不會因為要返回太多結果而 消耗太多記憶體。另外,這樣的處理也讓MySQL客戶端第一時間獲得返回的結果。

  結果集中的每一行都會以一個滿足MySQL客戶端/伺服器通訊協議的封包傳送,再通過TCP協議進行傳輸,在TCP傳輸的過程中,可能對MySQL的封包進行快取然後批量傳輸。

5.MySQL查詢優化器的侷限性

  MySQL的萬能“巢狀迴圈”並不是對每種査詢都是最優的。不過還好,MySQL査詢優化器只對少部分査詢不適用,而且我們往往可以通過改寫査詢讓MySQL髙效地完成工作。MySQL5.6版本正式釋出後,會消除很多MySQL原本的限制,讓更多的査詢能夠以儘可能髙的效率完成。

5.1 關聯子查詢

  MySQL的子査詢實現得非常糟糕。最糟糕的一類査詢是WHERE條件中包含IN()的子査詢語句。例如,我們希望找到Sakila資料庫中,演員Penelope Guiness(他的actor_id為1)參演過的所有影片資訊。很自然的,我們會按照下面的方式用子查詢實現:

mysql> SELECT * FROM sakila.film
    -> WHERE film_id IN(
    ->    SELECT film_id FROM sakila.film_actor WHERE actor_id = 1);

  因為MySQL對IN()列表中的選項有專門的優化策略,一般會認為MySQL會先執行子査詢返回所有包含actor_id為1的film_id。一般來說,IN()列表査詢速度很快,所以我們會認為上面的査詢會這樣執行:

-- SELECT GROUP_CONCAT(film_id) FROM sakila.film_actor WHERE actor_id = 1;
-- Result: 1,23,25,106,140,166,277,361,438,499,506,509,605,635,749,832,939,970,980
SELECT * FROM sakila.film
WHERE film_id
IN(1,23,25,106,140,166,277,361,438,499,506,509,605,635,749,832,939,970,980);

  很不幸,MySQL不是這樣做的。MySQL會將相關的外層表壓到子査詢中,它認為這樣可以更髙效率地查詢到資料行。也就是說,MySQL會將査詢改寫成下面的樣子:

SELECT * FROM sakila.film
WHERE EXISTS (
   SELECT * FROM sakila.film_actor WHERE actor_id = 1
   AND film_actor.film_id = film.film_id);

  這時,子査詢需要根據film_id來關聯外部表film,因為需要film_id欄位,所以MySQL認為無法先執行這個子査詢。通過EXPLAIN我們可以看到子査詢是一個相關子査詢(DEPENDENTSUBQUERY)(可以使用EXPLAINEXTENDED來査看這個査詢被改寫成了什麼樣子):

mysql> EXPLAIN SELECT * FROM sakila.film ...;
+----+--------------------+------------+--------+------------------------+
| id | select_type        | table      | type   | possible_keys          |
+----+--------------------+------------+--------+------------------------+
|  1 | PRIMARY            | film       | ALL    | NULL                   |
|  2 | DEPENDENT SUBQUERY | film_actor | eq_ref | PRIMARY,idx_fk_film_id |
+----+--------------------+------------+--------+------------------------+

  根據EXPLAIN的輸出我們可以看到,MySQL先選擇對file表進行全表掃描,然後根據返回的film_id逐個執行子査詢。如果是一個很小的表,這個査詢糟糕的效能可能還不會引起注意,但是如果外層的表是一個非常大的表,那麼這個査詢的效能會非常糟糕。當然我們很容易用下面的辦法來重寫這個査詢:

mysql> SELECT film.* FROM sakila.film
    ->    INNER JOIN sakila.film_actor USING(film_id)
    -> WHERE actor_id = 1;

  另一個優化的辦法是使用函式GR0UP_C0NCAT()在IN()中構造一個由逗號分隔的列表。 有時這比上面的使用關聯改寫更快。因為使用IN()加子査詢,效能經常會非常糟,所以通常建議使用EXISTS()等效的改寫査詢來獲取更好的效率。下面是另一種改寫IN()加子査詢的辦法:

mysql> SELECT * FROM sakila.film
    -> WHERE EXISTS(
    ->    SELECT * FROM sakila.film_actor WHERE actor_id = 1
    ->       AND film_actor.film_id = film.film_id);

  提示: 這裡討論的優化器的限制直到Oracle推出的MySQL5.5都一直存在。MySQL的另 一個分支MariaDB則在原有的優化器的基礎上做了大量的改進,例如這裡提到的IN()加子査詢改進。

  如何用好關聯子查詢

  並不是所有關聯子査詢的效能都會很差。如果有人跟你說:“別用關聯子査詢”,那麼不 要理他。先測試,然後做出自己的判斷。很多時候,關聯子査詢是一種非常合理、自然,甚至是效能最好的寫法。我們看看下面的例子:

mysql> EXPLAIN SELECT film_id, language_id FROM sakila.film
    -> WHERE NOT EXISTS(
    ->    SELECT * FROM sakila.film_actor
    ->    WHERE film_actor.film_id = film.film_id
    -> )\G
 *************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: film
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 951
        Extra: Using where
*************************** 2. row ***************************
           id: 2
  select_type: DEPENDENT SUBQUERY
        table: film_actor
         type: ref
possible_keys: idx_fk_film_id
          key: idx_fk_film_id
      key_len: 2
          ref: film.film_id
         rows: 2
        Extra: Using where; Using index

  一般會建議使用左外連線(LEFTOUTERJOIN)重寫該査詢,以代替子査詢。理論上,改寫後MySQL的執行計劃完全不會改變。我們來看這個例子:

mysql> EXPLAIN SELECT film.film_id, film.language_id
    -> FROM sakila.film
    ->    LEFT OUTER JOIN sakila.film_actor USING(film_id)
    -> WHERE film_actor.film_id IS NULL\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 951
        Extra:
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
         type: ref
possible_keys: idx_fk_film_id
          key: idx_fk_film_id
      key_len: 2
          ref: sakila.film.film_id
         rows: 2
        Extra: Using where; Using index; Not exists

  可以看到,這裡的執行計劃基本上一樣,下面是一些微小的區別:

  • 表film_actor的訪問型別一個是DEPENDENTSUBQUERY,而另一個是SIMPLE。這個不同是由於語句的寫法不同導致的,一個是普通査詢,一個是子査詢。這對底層儲存引擎介面來說,沒有任何不同。
  • 對film表,第二個査詢的Extra中沒有“Usingwhere”,但這不重要,第二個査詢的USING子句和第一個査詢的WHERE子句實際上是完全一樣的。
  • 在第二個表film_actor的執行計劃的Extra列有“Notexists”。這是我們前面章節中提到的提前終止演算法(early-terminationalgorithm),MySQL通過使用“Notexists”優化來避免在表film_actor的索引中讀取任何額外的行。這完全等效於直接編寫NOTEXISTS子査詢,這個執行計劃中也是一樣,一旦匹配到一行資料,就立刻停止掃描。

  所以,從理論上講,MySQL將使用完全相同的執行計劃來完成這個査詢。現實世界中, 我們建議通過一些測試來判斷使用哪種寫法速度會更快。針對上面的案例,我們對兩種寫法進行了測試,表6-1中列出了測試結果。

6-1: NOT EXISTS和左外連線的效能比較
查詢 每秒查詢數結果(QPS)
NOT EXISTS子査詢 360 QPS
LEFT OUTER JOIN 425 QPS

  我們的測試顯示,使用子査詢的寫法要略微慢些!

  不過每個具體的案例會各有不同,有時候子査詢寫法也會快些。例如,當返回結果中只 有一個表中的某些列的時候。聽起來,這種情況對於關聯査詢效率也會很好。具體情況具體分析,例如下面的關聯,我們希望返回所有包含同一個演員參演的電影,因為一個電影會有很多演員參演,所以可能會返回一些重複的記錄:

mysql> SELECT film.film_id FROM sakila.film
    ->    INNER JOIN sakila.film_actor USING(film_id);

  我們需要使用DISTINCT和GROUPBY來移除重複的記錄:

mysql> SELECT DISTINCT film.film_id FROM sakila.film
    ->    INNER JOIN sakila.film_actor USING(film_id);

  但是,回頭看看這個査詢,到底這個査詢返回的結果集意義是什麼?至少這樣的寫法會 讓SQL的意義很不明顯。如果使用EXISTS則很容易表達“包含同一個參演演員”的邏輯,而且不需要使用DISTINCT和GROUPBY,也不會產生重複的結果集,我們知道一旦使用了DISTINCT和GROUPBY,那麼在査詢的執行過程中,通常需要產生臨時中間表。下面我們用子査詢的寫法替換上面的關聯:

mysql> SELECT film_id FROM sakila.film
    ->    WHERE EXISTS(SELECT * FROM sakila.film_actor
    ->    WHERE film.film_id = film_actor.film_id);

  再一次,我們需要通過測試來對比這兩種寫法,哪個更快一些。測試結果參考表6-2。

表6-2:EXISTS和關聯效能對比
查詢 每秒查詢數結果(QPS)
INNERJOIN 185QPS
EXISTS 325QPS

  在這個案例中,我們看到子査詢速度要比關聯査詢更快些。

  通過上面這個詳細的案例,主要想說明兩點:一是不需要聽取那些關於子査詢的“絕對 真理”,二是應該用測試來驗證對子査詢的執行計劃和響應時間的假設。最後,關於子査詢我們需要提到的是一個MySQL的bug。在MYSQL5.1.48和之前的版本中,下面的寫法會鎖住table2中的一條記錄:

SELECT ... FROM table1 WHERE col = (SELECT ... FROM table2 WHERE ...);

  如果遇到該bug,子査詢在高併發情況下的效能,就會和在單執行緒測試時的效能相差甚遠。這個bug的編號是46947,雖然這個問題已經被修復了,但是我們仍然要提醒讀者:不要主觀猜測,應該通過測試來驗證猜想。

5.2 UNION的限制

  有時,MySQL無法將限制條件從外層“下推”到內層,這使得原本能夠限制部分返回結果的條件無法應用到內層査詢的優化上。

  如果希望UNION的各個子句能夠根據LIMIT只取部分結果集,或者希望能夠先排好序再 合併結果集的話,就需要在UNION的各個子句中分別使用這些子句。例如,想將兩個子査詢結果聯合起來,然後再取前20條記錄,那麼MySQL會將兩個表都存放到同一個臨時表中,然後再取出前20行記錄:

(SELECT first_name, last_name
 FROM sakila.actor
 ORDER BY last_name)
UNION ALL
(SELECT first_name, last_name
 FROM sakila.customer
 ORDER BY last_name)
LIMIT 20;

  這條査詢將會把actor中的200條記錄和customer表中的599條記錄存放在一個臨時 表中,然後再從臨時表中取出前20條。可以通過在UNION的兩個子査詢中分別加上一個LIMIT20來減少臨時表中的資料:

(SELECT first_name, last_name
 FROM sakila.actor
 ORDER BY last_name
 LIMIT 20)
UNION ALL
(SELECT first_name, last_name
 FROM sakila.customer
 ORDER BY last_name
 LIMIT 20)
LIMIT 20;

  現在中間的臨時表只會包含40條記錄了,除了效能考慮之外,在這裡還需要注意一點: 從臨時表中取出資料的順序並不是一定的,所以如果想獲得正確的順序,還需要加上一個全域性的ORDERBY和LIMIT操作。

5.3 索引合併優化

  在前面的章節已經討論過,在5.0和更新的版本中,當WHERE子句中包含多個複雜條件的時候,MySQL能夠訪問單個表的多個索引以合併和交叉過濾的方式來定位需要査找的行。

5.4 等值傳遞

  某些時候,等值傳遞會帶來一些意想不到的額外消耗。例如,有一個非常大的IN()列表, 而MySQL優化器發現存在WHERE、ON或者USING的子句,將這個列表的值和另一個表的某個列相關聯。

  那麼優化器會將IN()列表都複製應用到關聯的各個表中。通常,因為各個表新增了過濾條件,優化器可以更高效地從儲存引擎過濾記錄。但是如果這個列表非常大,則會導致優化和執行都會變慢。在5.6之前,除了修改MySQL原始碼,目前還沒有什麼辦法能夠繞過該問題(不過這個問題很少會碰到)。

5.5 並行執行

  MySQL無法利用多核特性來並行執行査詢。很多其他的關係型資料庫能夠提供這個特 性,但是MySQL做不到。這裡特別指出是想告訴讀者不要花時間去嘗試尋找並行執行査詢的方法。

5.6 雜湊關聯

  Mysql5.6之前(5.6之後版本去翻閱官方文件來確認),MySQL並不支援雜湊關聯——MySQL的所有關聯都是巢狀迴圈關聯。不過,可以通過建立一個雜湊索引來曲線地實現雜湊關聯。如果使用的是Memory儲存引擎,則索引都是雜湊索引,所以關聯的時候也類似於雜湊關聯。

5.7 鬆散索引掃描

  由於歷史原因,MySQL並不支援鬆散索引掃描,也就無法按照不連續的方式掃描一個索引。通常,MySQL的索引掃描需要先定義一個起點和終點,即使需要的資料只是這段索引中很少數的幾個,MySQL仍需要掃描這段索引中每一個條目。

  下面我們通過一個示例說明這點。假設我們有如下索引(a,b),有下面的査詢:

mysql> SELECT ... FROM tbl WHERE b BETWEEN 2 AND 3;

  因為索引的前導欄位是列a,但是在査詢中只指定了欄位b,MySQL無法使用這個索引, 從而只能通過全表掃描找到匹配的行,如圖6-5所示。

  瞭解索引的物理結構的話,不難發現還可以有一個更快的辦法執行上面的査詢。索引的 物理結構(不是儲存引擎的API)使得可以先掃描a列第一個值對應的b列的範圍,然後再跳到a列第二個不同值掃描對應的b列的範圍。圖6-6展示瞭如果由MySQL來實現這個過程會怎樣。

  注意到,這時就無須再使用WHERE子句過濾,因為鬆散索引掃描已經跳過了所有不需要 的記錄。

  上面是一個簡單的例子,除了鬆散索引掃描,新增一個合適的索引當然也可以優化上述 査詢。但對於某些場景,增加索引是沒用的,例如,對於第一個索引列是範圍條件,第二個索引列是等值條件的査詢,靠增加索引就無法解決問題。

  MySQL5.0之後的版本,在某些特殊的場景下是可以使用鬆散索引掃描的,例如,在一個分組査詢中需要找到分組的最大值和最小值:

mysql> EXPLAIN SELECT actor_id, MAX(film_id)
    -> FROM sakila.film_actor
    -> GROUP BY actor_id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
         type: range
possible_keys: NULL
          key: PRIMARY
      key_len: 2
          ref: NULL
         rows: 396
        Extra: Using index for group-by

  在EXPLAIN中的Extra欄位顯示“Usingindexforgroup-by”,表示這裡將使用鬆散索引掃描,不過如果MySQL能寫上“looseindexprobe”,相信會更好理解。

  在MySQL很好地支援鬆散索引掃描之前,一個簡單的繞過問題的辦法就是給前面的列加上可能的常數值。在前面索引案例學習的章節中,我們已經看到這樣做的好處了。

  在MySQL5.6之後的版本,關於鬆散索引掃描的一些限制將會通過“索引條件下推(index conditionpushdown) ” 的方式解決。

5.8 最大值和最小值優化

  對於MIN()和MAX()査詢,MySQL的優化做得並不好。這裡有一個例子:

mysql> SELECT MIN(actor_id) FROM sakila.actor WHERE first_name = 'PENELOPE';

  因為在first_name欄位上並沒有索引,因此MySQL將會進行一次全表掃描。如果MySQL能夠進行主鍵掃描,那麼理論上,當MySQL讀到第一個滿足條件的記錄的時候,就是我們需要找的最小值了,因為主鍵是嚴格按照actor_id欄位的大小順序排列的。但是MySQL這時只會做全表掃描,我們可以通過査看SHOWSTATUS的全表掃描計數器來驗證這一點。一個曲線的優化辦法是移除MIN(),然後使用LIMIT來將査詢重寫如下:

mysql> SELECT actor_id FROM sakila.actor USE INDEX(PRIMARY)
    -> WHERE first_name = 'PENELOPE' LIMIT 1;

  這個策略可以讓MySQL掃描儘可能少的記錄數。如果你是一個完美主義者,可能會說這個SQL已經無法表達她的本意了。一般我們通過SQL告訴伺服器我們需要什麼資料,由伺服器來決定如何最優地獲取資料,不過在這個案例中,我們其實是告訴MySQL如何去獲取我們需要的資料,通過SQL並不能一眼就看出我們其實是想要一個最小值。確實如此,有時候為了獲得更高的效能,我們不得不放棄一些原則。

5.9 在同一個表上查詢和更新

  MySQL不允許對同一張表同時進行査詢和更新。這其實並不是優化器的限制,如果清楚MySQL是如何執行査詢的,就可以避免這種情況。下面是一個無法執行的SQL,雖然這是一個符合標準的SQL語句。這個SQL語句嘗試將兩個表中相似行的數量記錄到欄位cnt中:

mysql> UPDATE tbl AS outer_tbl
    ->    SET cnt = (
    ->       SELECT count(*) FROM tbl AS inner_tbl
    ->       WHERE inner_tbl.type = outer_tbl.type
    ->    );
ERROR 1093 (HY000): You can’t specify target table 'outer_tbl' for update in FROM
clause

  可以通過使用生成表的形式來繞過上面的限制,因為MySQL只會把這個表當作一個臨 時表來處理。實際上,這執行了兩個査詢:一個是子査詢中的SELECT語句,另一個是多表關聯UPDATE,只是關聯的表是一個臨時表。子査詢會在UPDATE語句開啟表之前就完成,所以下面的査詢將會正常執行:

mysql> UPDATE tbl
    ->    INNER JOIN(
    ->       SELECT type, count(*) AS cnt
    ->       FROM tbl
    ->       GROUP BY type
    ->    ) AS der USING(type)
    -> SET tbl.cnt = der.cnt;

6.查詢優化器的提示(hint)

  如果對優化器選擇的執行計劃不滿意,可以使用優化器提供的幾個提示(hint)來控制 最終的執行計劃。下面將列舉一些常見的提示,並簡單地給出什麼時候使用該提示。通過在査詢中加入相應的提示,就可以控制該査詢的執行計劃。關於每個提示的具體用法,建議直接閱讀MySQL官方手冊。有些提示和版本有直接關係。可以使用的一些提示如下:

  HIGH_PRIORITY和LOW_PRIORITY

這個提示告訴MySQL,當多個語句同時訪問某一個表的時候,哪些語句的優先順序相對高些、哪些語句的優先順序相對低些。

HIGH_PRIORITY用於SELECT語句的時候,MySQL會將此SELECT語句重新排程到所有正在等待表鎖以便修改資料的語句之前。實際上MySQL是將其放在表的佇列的最前面,而不是按照常規順序等待。HIGH_PRIORITY還可以用於INSERT語句,其效果只是簡單地抵消了全域性LOW_PRIORITY設定對該語句的影響。

LOW_PRIORITY則正好相反:它會讓該語句一直處於等待狀態,只要佇列中還有需要訪問同一個表的語句——即使是那些比該語句還晚提交到伺服器的語句。這就像一個過於禮貌的人站在餐廳門口,只要還有其他顧客在等待就一直不進去,很明顯這容易把自己給餓壞。L0W_PRI0RITY提示在SELECT、INSERT、UPDATE和DELETE語句中都可以使用。

這兩個提示只對使用表鎖的儲存引擎有效,千萬不要在InnoDB或者其他有細粒度鎖機制和併發控制的引擎中使用。即使是在MyISAM中使用也要注意,因為這兩個提示會導致併發插入被禁用,可能會嚴重降低效能。

HIGH_PRIORITY和L0W_PRI0RITY經常讓人感到困惑。這兩個提示並不會獲取更多資源讓査詢“積極”工作,也不會少獲取資源讓査詢“消極”工作。它們只是簡單地控制了MySQL訪問某個資料表的佇列順序。

  DELAYED

這個提示對INSERT和REPLACE有效。MySQL會將使用該提示的語句立即返回給客戶端,並將插入的行資料放入到緩衝區,然後在表空閒時批量將資料寫入。日誌系統使用這樣的提示非常有效,或者是其他需要寫入大量資料但是客戶端卻不需要等待單條語句完成I/O的應用。這個用法有一些限制:並不是所有的儲存引擎都支援這樣的做法;並且該提示會導致函式LAST_INSERT_ID()無法正常工作。

  STRAIGHT_JOIN

這個提示可以放置在SELECT語句的SELECT關鍵字之後,也可以放置在任何兩個關聯表的名字之間。第一個用法是讓査詢中所有的表按照在語句中出現的順序進行關聯。第二個用法則是固定其前後兩個表的關聯順序。

當MySQL沒能選擇正確的關聯順序的時候,或者由於可能的順序太多導致MySQL無法評估所有的關聯順序的時候,STRAIGHT_JOIN都會很有用。在後面這種情況,MySQL可能會花費大量時間在“statistics”狀態,加上這個提示則會大大減少優化器的捜索空間。

可以先使用EXPLAIN語句來査看優化器選擇的關聯順序,然後使用該提示來重寫査詢,再看看它的關聯順序。當你確定無論怎樣的where條件,某個固定的關聯順序始終是最佳的時候,使用這個提示可以大大提髙優化器的效率。但是在升級MySQL版本的時候,需要重新審視下這類查詢,某些新的優化特性可能會因為該提示而失效。

  SQL_SMALL_RESULT和SQL_BIG_RESULT

這兩個提示只對SELECT語句有效。它們告訴優化器對GROUPBY或者DISTINCT査詢如何使用臨時表及排序。SQL_SMALL_RESULT告訴優化器結果集會很小,可以將結果集放在記憶體中的索引臨時表,以避免排序操作。如果是SQL_BIG_RESULT,則告訴優化器結果集可能會非常大,建議使用磁碟臨時表做排序操作。

  SQL_BUFFER_RESULT

這個提示告訴優化器將查詢結果放入到一個臨時表,然後儘可能快地釋放表鎖。這和前面提到的由客戶端快取結果不同。當你沒法使用客戶端快取的時候,使用伺服器端的快取通常很有效。帶來的好處是無須在客戶端上消耗太多的記憶體,還可以儘可能快地釋放對應的表鎖。代價是,伺服器端將需要更多的記憶體。

  SQL_CACHE和SQL_NO_CACHE

這個提示告訴MySQL這個結果集是否應該快取在査詢快取中,下一章將詳細介紹如何使用。

  SQL_CALC_FOUND_ROWS

嚴格來說,這並不是一個優化器提示。它不會告訴優化器任何關於執行計劃的東西。它會讓MySQL返回的結果集包含更多的資訊。査詢中加上該提示MySQL會計算除去LIMIT子句後這個查詢要返回的結果集的總數,而實際上只返回LIMIT要求的結果集。可以通過函式F0UND_R0W()獲得這個值。(參閱後面的“SQL_CALC_FOUND_ROWS優化”部分,瞭解下為什麼不應該使用該提示。)

  FORUPDATE和LOCKINSHAREMODE

這也不是真正的優化器提示。這兩個提示主要控制SELECT語句的鎖機制,但只對實現了行級鎖的儲存引擎有效。使用該提示會對符合查詢條件的資料行加鎖。對於INSERT.. .SELECT語句是不需要這兩個提示的,因為對於MySQL5.0和更新版本會預設給這些記錄加上讀鎖。(可以禁用該預設行為,但不是個好主意,在關於複製和備份的章節中將解釋這一點。)

唯一內建的支援這兩個提示的引擎就是InnoDB。另外需要記住的是,這兩個提示會讓某些優化無法正常使用,例如索引覆蓋掃描。InnoDB不能在不訪問主鍵的情況下排他地鎖定行,因為行的版本資訊儲存在主鍵中。

糟糕的是,這兩個提示經常被濫用,很容易造成伺服器的鎖爭用問題,後面章節將討論這點。應該儘可能地避免使用這兩個提示,通常都有其他更好的方式可以實現同樣的目的。

  USEINDEX、IGNOREINDEX和FORCEINDEX

這幾個提示會告訴優化器使用或者不使用哪些索引來査詢記錄(例如,在決定關聯順序的時候使用哪個索引)。在MySQL5.0和更早的版本,這些提示並不會影響到優化器選擇哪個索引進行排序和分組,在MyQL5.1和之後的版本可以通過新增選項FORORDERBY和FORGROUPBY來指定是否對排序和分組有效。

FORCEINDEX和USEINDEX基本相同,除了一點:FORCEINDEX會告訴優化器全表掃描的成本會遠遠高於索引掃描,哪怕實際上該索引用處不大。當發現優化器選擇了錯誤的索引,或者因為某些原因(比如在不使用ORDERBY的時候希望結果有序)要使用另一個索引時,可以使用該提示。在前面關於如何使用LIMIT髙效地獲取最小值的案例中,已經演示過這種用法。

  在MySQL5.0和更新版本中,新增了一些引數用來控制優化器的行為:

  optimizer_search_depth

這個引數控制優化器在窮舉執行計劃時的限度。如果査詢長時間處於“Statistics”狀態,那麼可以考慮調低此引數。

  optimizer_prune_level

該引數預設是開啟的,這讓優化器會根據需要掃描的行數來決定是否跳過某些執行計劃。

  optimizer_switch

這個變數包含了一些開啟/關閉優化器特性的標誌位。例如在MySQL5.1中可以通過這個引數來控制禁用索引合併的特性。

  前兩個引數是用來控制優化器可以走的一些“捷徑”。這些捷徑可以讓優化器在處理非 常複雜的SQL語句時,仍然可以很高效,但這也可能讓優化器錯過一些真正最優的執行計劃。所以應該根據實際需要來修改這些引數。

  討論:MySQL升級後的驗證

  在優化器面前耍一些“小聰明”是不好的。這樣做收效甚小,但是卻給維護帶來了很多額外的工作量。在MySQL版本升級的時候,這個問題就很突出了,你設定的“優化器提示”很可能會讓新版的優化策略失效。

  MySQL5.0版本引入了大量優化策略,在5.6版本中,優化器的改進也是近些年來最大的一次改進。如果要更新到這些版本,當然希望能夠從這些改進中受益。

  新版MySQL基本上在各個方面都有非常大的改進,5.5和5.6這兩個版本尤為突出。升級操作一般來說都很順利,但仍然建議仔細檢查各個細節,以防止一些邊界情況影響你的應用程式。不過還好,要避免這些,你不需要付出太多的精力。使用PerconaToolkit中的pt-upgrade工具,就可以檢查在新版本中執行的SQL是否與老版本一樣,返回相同的結果。

7.優化特定型別的查詢

  這一節,將介紹如何優化特定型別的査詢。在其他部分都會分散介紹這些優化技巧,不過這裡將會彙總一下,以便參考和査閱。

  本節介紹的多數優化技巧都是和特定的版本有關的,所以對於未來MySQL的版本未必 適用。毫無疑問,某一天優化器自己也會實現這裡列出的部分或者全部優化技巧。

7.1 優化COUNT()查詢

  C0UNT()聚合函式,以及如何優化使用了該函式的查詢,很可能是MySQL中最容易被誤解的前10個話題之一。在網上隨便搜尋一下就能看到很多錯誤的理解,可能比我們想象的多得多。

  在做優化之前,先來看看C0UNT()函式真正的作用是什麼。

  C0UNT()的作用

  C0UNT()是一個特殊的函式,有兩種非常不同的作用:它可以統計某個列值的數量,也可以統計行數。在統計列值時要求列值是非空的(不統計NULL)。如果在C0UNT()的括號中指定了列或者列的表示式,則統計的就是這個表示式有值的結果數。因為很多人對NULL理解有問題,所以這裡很容易產生誤解。如果想了解更多關於SQL語句中NULL的含義,建議閱讀一些關於SQL語句基礎的書籍。(關於這個話題,網際網路上的一些資訊是不夠精確的。)

  COUNT()的另一個作用是統計結果集的行數。當MySQL確認括號內的表示式值不可能為空時,實際上就是在統計行數。最簡單的就是當我們使用COUNT(*)的時候,這種情況下萬用字元*並不會像我們猜想的那樣擴充套件成所有的列,實際上,它會忽略所有的列而直接統計所有的行數。

  我們發現一個最常見的錯誤就是,在括號內指定了一個列卻希望統計結果集的行數。如 果希望知道的是結果集的行數,最好使用COUNT(*),這樣寫意義清晰,效能也會很好。

  關於MyISAM的神話

  一個容易產生的誤解就是:MyISAM的COUNT()函式總是非常快,不過這是有前提條件的, 即只有沒有任何WHERE條件的COUNT(*)才非常快,因為此時無須實際地去計算表的行數。MySQL可以利用儲存引擎的特性直接獲得這個值。如果MySQL知道某列col不可能為NULL值,那麼MySQL內部會將COUNT(col)表示式優化為C0UNT(*)。

  當統計帶WHERE子句的結果集行數,可以是統計某個列值的數量時,MyISAM的COUNT()和其他儲存引擎沒有任何不同,就不再有神話般的速度了。所以在MyISAM引擎表上執行C0UNT()有時候比別的引擎快,有時候比別的引擎慢,這受很多因素影響,要視具體情況而定。

  簡單的優化

  有時候可以使用MyISAM在C0UNT(*)全表非常快的這個特性,來加速一些特定條件的C0UNT()的査詢。在下面的例子中,我們使用標準資料庫world來看看如何快速査找到所有ID大於5的城市。可以像下面這樣來寫這個査詢:

mysql> SELECT COUNT(*) FROM world.City WHERE ID > 5;

  通過SHOWSTATUS的結果可以看到該査詢需要掃描4097行資料。如果將條件反轉一下, 先査找ID小於等於5的城市數,然後用總城市數一減就能得到同樣的結果,卻可以將掃描的行數減少到5行以內:

mysql> SELECT (SELECT COUNT(*) FROM world.City) - COUNT(*)
    -> FROM world.City WHERE ID <= 5;

  這樣做可以大大減少需要掃描的行數,是因為在査詢優化階段會將其中的子查詢直接當 作一個常數來處理,我們可以通過EXPLAIN來驗證這點:

+----+-------------+-------+...+------+------------------------------+
| id | select_type | table |...| rows | Extra                        |
+----+-------------+-------+...+------+------------------------------+
|  1 | PRIMARY     | City  |...|    6 | Using where; Using index     |
|  2 | SUBQUERY    | NULL  |...| NULL | Select tables optimized away |
+----+-------------+-------+...+------+------------------------------+

  在郵件組和IRC聊天頻道中,通常會看到這樣的問題:如何在同一個査詢中統計同一個列的不同值的數量,以減少査詢的語句量。例如,假設可能需要通過一個査詢返回各種不同顏色的商品數量,此時不能使用OR語句(比如SELECTC0UNT(color=’blue’

ORcolor=’red’)FROMitems;),因為這樣做就無法區分不同顏色的商品數量;也不能在WHERE條件中指定顏色(比如SELECTC0UNT(*)FROMitemsWHEREcolor=’blue’ANDcolor=’RED';),因為顏色的條件是互斥的。下面的査詢可以在一定程度上解決這個問題。

mysql> SELECT SUM(IF(color = 'blue', 1, 0)) AS blue,SUM(IF(color = 'red', 1, 0))
    -> AS red FROM items;

  也可以使用C0UNT()而不是SUM()實現同樣的目的,只需要將滿足條件設定為真,不滿 足條件設定為NULL即可:

mysql> SELECT COUNT(color = 'blue' OR NULL) AS blue, COUNT(color = 'red' OR NULL)
    -> AS red FROM items;

  使用近似值

  有時候某些業務場景並不要求完全精確的COUNT值,此時可以用近似值來代替。EXPLAIN出來的優化器估算的行數就是一個不錯的近似值,執行EXPLAIN並不需要真正地去執行査詢,所以成本很低。

  很多時候,計算精確值的成本非常髙,而計算近似值則非常簡單。曾經有一個客戶希望 我們統計他的網站的當前活躍使用者數是多少,這個活躍使用者數儲存在快取中,過期時間為30分鐘,所以每隔30分鐘需要重新計算並放入快取。因此這個活躍使用者數本身就不是精確值,所以使用近似值代替是可以接受的。另外,如果要精確統計線上人數,通常WHERE條件會很複雜,一方面需要剔除當前非活躍使用者,另一方面還要剔除系統中某些特定ID的“預設”使用者,去掉這些約束條件對總數的影響很小,但卻可能很好地提升該査詢的效能。更進一步地優化則可以嘗試刪除DISTINCT這樣的約束來避免檔案排序。這樣重寫過的査詢要比原來的精確統計的査詢快很多,而返回的結果則幾乎相同。

  更復雜的優化

  通常來說,C0UNT()都需要掃描大量的行(意味著要訪問大量資料)才能獲得精確的結果,因此是很難優化的。除了前面的方法,在MySQL層面還能做的就只有索引覆蓋掃描了。如果這還不夠,就需要考慮修改應用的架構,可以增加彙總表,或者增加類似這樣的外部快取系統。可能很快你就會發現陷入到一個熟悉的困境,“快速,精確和實現簡單”,三者永遠只能滿足其二,必須舍掉其中一個。

7.2 優化關聯查詢

  這個話題基本一直都在討論,這裡需要特別提到的是:

  • 確保ON或者USING子句中的列上有索引。在建立索引的時候就要考慮到關聯的順序。當表A和表B用列c關聯的時候,如果優化器的關聯順序是B、A,那麼就不需要在B表的對應列上建上索引。沒有用到的索引只會帶來額外的負擔。一般來說,除非有其他理由,否則只需要在關聯順序中的第二個表的相應列上建立索引。
  • 確保任何的GROUPBY和ORDERBY中的表示式只涉及到一個表中的列,這樣MySQL才有可能使用索引來優化這個過程。
  • 當升級MySQL的時候需要注意:關聯語法、運算子優先順序等其他可能會發生變化的地方。因為以前是普通關聯的地方可能會變成笛卡兒積,不同型別的關聯可能會生成不同的結果等。

7.3 優化子查詢

  關於子查詢優化我們給出的最重要的優化建議就是儘可能使用關聯査詢代替,至少MySQL版本5.5之前需要這樣。本章的前面章節已經詳細介紹了這點。“儘可能使用關聯”並不是絕對的,如果使用的是MySQL5.6或更新的版本或者MariaDB,那麼就可以直接忽略關於子查詢的這些建議了。

7.4 優化GROUPBY和DISTINCT

  在很多場景下,MySQL都使用同樣的辦法優化這兩種査詢,事實上,MySQL優化器會在內部處理的時候相互轉化這兩類査詢。它們都可以使用索引來優化,這也是最有效的優化辦法。

  在MySQL中,當無法使用索引的時候,GROUPBY使用兩種策略來完成:使用臨時表或 者檔案排序來做分組。對於任何査詢語句,這兩種策略的效能都有可以提升的地方。可以通過使用提示SQL_BIG_RESULT和SQL_SMALL_RESULT來讓優化器按照你希望的方式執行。在本章的前面章節我們已經討論了這點。

  如果需要對關聯査詢做分組(GROUPBY),並且是按照査找表中的某個列進行分組,那 麼通常採用査找表的標識列分組的效率會比其他列更髙。例如下面的査詢效率不會很好:

mysql> SELECT actor.first_name, actor.last_name, COUNT(*)
    -> FROM sakila.film_actor
    ->    INNER JOIN sakila.actor USING(actor_id)
    -> GROUP BY actor.first_name, actor.last_name;

  如果査詢按照下面的寫法效率則會更髙:

mysql> SELECT actor.first_name, actor.last_name, COUNT(*)
    -> FROM sakila.film_actor
    ->    INNER JOIN sakila.actor USING(actor_id)
    -> GROUP BY film_actor.actor_id;

  使用actor.actor_id列分組的效率甚至會比使用film_actor.actor_id更好。這一點通過簡單的測試即可驗證。

  這個査詢利用了演員的姓名和ID直接相關的特點,因此改寫後的結果不受影響,但顯 然不是所有的關聯語句的分組査詢都可以改寫成在SELECT中直接使用非分組列的形式的。甚至可能會在伺服器上設定SQL_M0DE來禁止這樣的寫法。如果是這樣,也可以通過MIN()或者MAX()函式來繞過這種限制,但一定要清楚,SELECT後面出現的非分組列一定是直接依賴分組列,並且在每個組內的值是唯一的,或者是業務上根本不在乎這個值具體是什麼:

mysql> SELECT MIN(actor.first_name), MAX(actor.last_name), ...;

  較真的人可能會說這樣寫的分組査詢是有問題的,確實如此。從MIN()或者MAX()函式的用法就可以看出這個査詢是有問題的。但若更在乎的是MySQL執行査詢的效率時這樣做也無可厚非。如果實在較真的話也可以改寫成下面的形式:

mysql> SELECT actor.first_name, actor.last_name, c.cnt
    -> FROM sakila.actor
    ->    INNER JOIN (
    ->       SELECT actor_id, COUNT(*) AS cnt
    ->       FROM sakila.film_actor
    ->       GROUP BY actor_id
    ->    ) AS c USING(actor_id) ;

  這樣寫更滿足關係理論,但成本有點髙,因為子査詢需要建立和填充臨時表,而子査詢 中建立的臨時表是沒有任何索引的(5.7之後修復了這個限制)。

  在分組査詢的SELECT中直接使用非分組列通常都不是什麼好主意,因為這樣的結果通常是不定的,當索引改變,或者優化器選擇不同的優化策略時都可能導致結果不一樣。我們碰到的大多數這種査詢最後都導致了故障(因為MySQL不會對這類査詢返回錯誤),而且這種寫法大部分是由於偷懶而不是為優化而故意這麼設計的。建議始終使用含義明確的語法。事實上,我們建議將MySQL的SQL_M0DE設定為包含ONLY_FULL_GROUP_BY,這時MySQL會對這類査詢直接返回一個錯誤,提醒你需要重寫這個査詢。

  如果沒有通過ORDERBY子句顯式地指定排序列,當査詢使用GROUPBY子句的時候,結果集會自動按照分組的欄位進行排序。如果不關心結果集的順序,而這種預設排序又導致了需要檔案排序,則可以使用ORDERBYNULL,讓MySQL不再進行檔案排序。也可以在GROUPBY子句中直接使用DESC或者ASC關鍵字,使分組的結果集按需要的方向排序。

  優化GROUPBYWITHROLLUP

  分組査詢的一個變種就是要求MySQL對返回的分組結果再做一次超級聚合。可以使用WITHROLLUP子句來實現這種邏輯,但可能會不夠優化。可以通過EXPLAIN來觀察其執行計劃,特別要注意分組是否是通過檔案排序或者臨時表實現的。然後再去掉WITHROLLUP子句看執行計劃是否相同。也可以通過本節前面介紹的優化器提示來固定執行計劃。

  很多時候,如果可以,在應用程式中做超級聚合是更好的,雖然這需要返回給客戶端更多的結果。也可以在FROM子句中巢狀使用子査詢,或者是通過一個臨時表存放中間資料,然後和臨時表執行UNION來得到最終結果。

  最好的辦法是儘可能的將WITHROLLUP功能轉移到應用程式中處理。

7.5 優化LIMIT分頁

  在系統中需要進行分頁操作的時候,我們通常會使用LIMIT加上偏移量的辦法實現,同 時加上合適的ORDERBY子句。如果有對應的索引,通常效率會不錯,否則,MySQL需要做大量的檔案排序操作。

  一個非常常見又令人頭疼的問題就是,在偏移量非常大的時,例如可能是LIMIT1000,20這樣的査詢,這時MySQL需要査詢10020條記錄然後只返回最後20條,前面10 000條記錄都將被拋棄,這樣的代價非常髙。如果所有的頁面被訪問的頻率都相同,那麼這樣的査詢平均需要訪問半個表的資料。要優化這種査詢,要麼是在頁面中限制分頁的數量,要麼是優化大偏移量的效能。

  優化此類分頁査詢的一個最簡單的辦法就是儘可能地使用索引覆蓋掃描,而不是査詢所有的列。然後根據需要做一次關聯操作再返回所需的列。對於偏移量很大的時候,這樣做的效率會提升非常大。考慮下面的査詢:

mysql> SELECT film_id, description FROM sakila.film ORDER BY title LIMIT 50, 5;

  如果這個表非常大,那麼這個査詢最好改寫成下面的樣子:

mysql> SELECT film.film_id, film.description
    -> FROM sakila.film
    ->    INNER JOIN (
    ->       SELECT film_id FROM sakila.film
    ->       ORDER BY title LIMIT 50, 5
    ->    ) AS lim USING(film_id);

  這裡的“延遲關聯”將大大提升査詢效率,它讓MySQL掃描儘可能少的頁面,獲取需要訪問的記錄後再根據關聯列回原表査詢需要的所有列。這個技術也可以用於優化關聯査詢中的LIMIT子句。

  有時候也可以將LIMIT査詢轉換為已知位置的査詢,讓MySQL通過範圍掃描獲得到對應的結果。例如,如果在一個位置列上有索引,並且預先計算出了邊界值,上面的査詢就可以改寫為:

mysql> SELECT film_id, description FROM sakila.film
    -> WHERE position BETWEEN 50 AND 54 ORDER BY position;

  對資料進行排名的問題也與此類似,但往往還會同時和GROUPBY混合使用。在這種情況下通常都需要預先計算並存儲排名資訊。

  LIMIT和OFFSET的問題,其實是OFFSET的問題,它會導致MySQL掃描大量不需要的行然後再拋棄掉。如果可以使用書籤記錄上次取資料的位置,那麼下次就可以直接從該書籤記錄的位置開始掃描,這樣就可以避免使用OFFSET。例如,若需要按照租借記錄做翻頁,那麼可以根據最新一條租借記錄向後追溯,這種做法可行是因為租借記錄的主鍵是單調增長的。首先使用下面的査詢獲得第一組結果:

mysql> SELECT * FROM sakila.rental
    -> ORDER BY rental_id DESC LIMIT 20;

  假設上面的査詢返回的是主鍵為16049到16030的租借記錄,那麼下一頁查詢就可以從16030這個點開始:

mysql> SELECT * FROM sakila.rental
    -> WHERE rental_id < 16030
    -> ORDER BY rental_id DESC LIMIT 20;

  該技術的好處是無論翻頁到多麼後面,其效能都會很好。

  其他優化辦法還包括使用預先計算的彙總表,或者關聯到一個冗餘表,冗餘表只包含主 鍵列和需要做排序的資料列。還可以使用Sphinx優化一些搜尋操作。

7.6 優化SQL_CALC_FOUND_ROWS

  分頁的時候,另一個常用的技巧是在LIMIT語句中加上SQL_CALC_F0UND_R0Ws提示(hint),這樣就可以獲得去掉LIMIT以後滿足條件的行數,因此可以作為分頁的總數。看起來,MySQL做了一些非常“髙深”的優化,像是通過某種方法預測了總行數。但實際上,MySQL只有在掃描了所有滿足條件的行以後,才會知道行數,所以加上這個提示以後,不管是否需要,MySQL都會掃描所有滿足條件的行,然後再拋棄掉不需要的行,而不是在滿足LIMIT的行數後就終止掃描。所以該提示的代價可能非常高。

  一個更好的設計是將具體的頁數換成“下一頁”按鈕,假設每頁顯示20條記錄,那麼我們每次査詢時都是用LIMIT返回21條記錄並只顯示20條,如果第21條存在,那麼我們就顯示“下一頁”按鈕,否則就說明沒有更多的資料,也就無須顯示“下一頁”按鈕了。

  另一種做法是先獲取並快取較多的資料——例如,快取1000條——然後每次分頁都從 這個快取中獲取。這樣做可以讓應用程式根據結果集的大小採取不同的策略,如果結果集少於1000,就可以在頁面上顯示所有的分頁連結,因為資料都在快取中,所以這樣做效能不會有問題。如果結果集大於1000,則可以在頁面上設計一個額外的“找到的結果多於1 000條”之類的按鈕。這兩種策略都比每次生成全部結果集再拋棄掉不需要的資料的效率要高很多。

  有時候也可以考慮使用EXPLAIN的結果中的rows列的值來作為結果集總數的近似值 (實際上Google的捜索結果總數也是個近似值)。當需要精確結果的時候,再單獨使用C0UNT(*)來滿足需求,這時如果能夠使用索引覆蓋掃描則通常也會比SQL_CALC_F0UND_ROWS快得多。

7.7 優化UNION查詢

  MySQL總是通過建立並填充臨時表的方式來執行UNION査詢。因此很多優化策略在UNION査詢中都沒法很好地使用。經常需要手工地將WHERE、LIMIT、ORDERBY等子句“下推”到UNION的各個子査詢中,以便優化器可以充分利用這些條件進行優化(例如,直接將這些子句冗餘地寫一份到各個子査詢)。

  除非確實需要伺服器消除重複的行,否則就一定要使用UNIONALL,這一點很重要。如 果沒有ALL關鍵字,MySQL會給臨時表加上DISTINCT選項,這會導致對整個臨時表的資料做唯一性檢査。這樣做的代價非常髙。即使有ALL關鍵字,MySQL仍然會使用臨時表儲存結果。事實上,MySQL總是將結果放入臨時表,然後再讀出,再返回給客戶端。雖然很多時候這樣做是沒有必要的(例如,MySQL可以直接把這些結果返回給客戶端)。

7.8 靜態查詢分析

  PerconaToolkit中的pt-query-advisor能夠解析査詢日誌、分析査詢模式,然後給出所有可能存在潛在問題的査詢,並給出足夠詳細的建議。這像是給MySQL所有的査詢做一次全面的健康檢査。它能檢測出許多常見的問題,諸如我們前面介紹的內容。

7.9 使用使用者自定義變數

  使用者自定義變數是一個容易被遺忘的MySQL特性,但是如果能夠用好,發揮其潛力,在某些場景可以寫出非常髙效的査詢語句。在査詢中混合使用過程化和關係化邏輯的時候,自定義變數可能會非常有用。單純的關係査詢將所有的東西都當成無序的資料集合,並且一次性操作它們。MySQL則採用了更加程式化的處理方式。MySQL的這種方式有它的弱點,但如果能熟練地掌握,則會發現其強大之處,而使用者自定義變數也可以給這種方式帶來很大的幫助。

  使用者自定義變數是一個用來儲存內容的臨時容器,在連線MySQL的整個過程中都存在。 可以使用下面的SET和SELECT語句來定義它們:

mysql> SET @one       := 1;
mysql> SET @min_actor := (SELECT MIN(actor_id) FROM sakila.actor);
mysql> SET @last_week := CURRENT_DATE-INTERVAL 1 WEEK;

  然後可以在任何可以使用表示式的地方使用這些自定義變數:

mysql> SELECT ... WHERE col <= @last_week;

  在瞭解自定義變數的強大之前,我們再看看它自身的一些屬性和限制,看看在哪些場景 下我們不能使用使用者自定義變數:

  • 使用自定義變數的査詢,無法使用査詢快取。
  • 不能在使用常量或者識別符號的地方使用自定義變數,例如表名、列名和LIMIT子句中。
  • 使用者自定義變數的生命週期是在一個連線中有效,所以不能用它們來做連線間的通訊。
  • 如果使用連線池或者持久化連線,自定義變數可能讓看起來毫無關係的程式碼發生互動(如果是這樣,通常是程式碼bug或者連線池bug,這類情況確實可能發生)。
  • 在5.0之前的版本,是大小寫敏感的,所以要注意程式碼在不同MySQL版本間的相容性問題。
  • 不能顯式地宣告自定義變數的型別。確定未定義變數的具體型別的時機在不同MySQL版本中也可能不一樣。如果你希望變數是整數型別,那麼最好在初始化的時候就賦值為0,如果希望是浮點型則賦值為0.0,如果希望是字串則賦值為’’,使用者自定義變數的型別在賦值的時候會改變。MySQL的使用者自定義變數是一個動態型別。
  • MySQL優化器在某些場景下可能會將這些變數優化掉,這可能導致程式碼不按預想的方式執行。
  • 賦值的順序和賦值的時間點並不總是固定的,這依賴於優化器的決定。實際情況可能很讓人困惑,後面我們將看到這一點。
  • 賦值符號:=的優先順序非常低,所以需要注意,賦值表示式應該使用明確的括號。
  • 使用未定義變數不會產生任何語法錯誤,如果沒有意識到這一點,非常容易犯錯。

  優化排名語句

  使用使用者自定義變數的一個重要特性是你可以在給一個變數賦值的同時使用這個變數。換句話說,使用者自定義變數的賦值具有“左值”特性。下面的例子展示瞭如何使用變數來實現一個類似“行號(rownumber)”的功能:

mysql> SET @rownum := 0;
mysql> SELECT actor_id, @rownum := @rownum + 1 AS rownum
    -> FROM sakila.actor LIMIT 3;
+----------+--------+
| actor_id | rownum |
+----------+--------+
|        1 |      1 |
|        2 |      2 |
|        3 |      3 |
+----------+--------+

  這個例子的實際意義並不大,它只是實現了一個和該表主鍵一樣的列。不過,我們也可 以把這當作一個排名。現在我們來看一個更復雜的用法。我們先編寫一個査詢獲取演過最多電影的前10位演員,然後根據他們的出演電影次數做一個排名,如果出演的電影數量一樣,則排名相同。我們先編寫一個査詢,返回每個演員參演電影的數量:

mysql> SELECT actor_id, COUNT(*) as cnt
    -> FROM sakila.film_actor
    -> GROUP BY actor_id
    -> ORDER BY cnt DESC
    -> LIMIT 10;
+----------+-----+
| actor_id | cnt |
+----------+-----+
|      107 |  42 |
|      102 |  41 |
|      198 |  40 |
|      181 |  39 |
|       23 |  37 |
|       81 |  36 |
|      106 |  35 |
|       60 |  35 |
|       13 |  35 |
|      158 |  35 |
+----------+-----+

  現在我們再把排名加上去,這裡看到有四名演員都參演了35部電影,所以他們的排名 應該是相同的。我們使用三個變數來實現:一個用來記錄當前的排名,一個用來記錄前一個演員的排名,還有一個用來記錄當前演員參演的電影數量。只有當前演員參演的電影的數量和前一個演員不同時,排名才變化。我們先試試下面的寫法:

mysql> SET @curr_cnt := 0, @prev_cnt := 0, @rank := 0;
mysql> SELECT actor_id,
    ->    @curr_cnt := COUNT(*) AS cnt,
    ->    @rank     := IF(@prev_cnt <> @curr_cnt, @rank + 1, @rank) AS rank,
    ->    @prev_cnt := @curr_cnt AS dummy
    -> FROM sakila.film_actor
    -> GROUP BY actor_id
    -> ORDER BY cnt DESC
    -> LIMIT 10;
+----------+-----+------+-------+
| actor_id | cnt | rank | dummy |
+----------+-----+------+-------+
|      107 |  42 |    0 |     0 |
|      102 |  41 |    0 |     0 |
...

  Oops——排名和統計列一直都無法更新,這是什麼原因?

  對這類問題,是沒法給出一個放之四海皆準的答案的,例如,一個變數名的拼寫錯誤就 可能導致這樣的問題(這個案例中並不是這個原因),具體問題要具體分析。這裡,通過EXPLAIN我們看到將會使用臨時表和檔案排序,所以可能是由於變數賦值的時間和我們預料的不同。

  在使用使用者自定義變數的時候,經常會遇到一些“詭異”的現象,要揪出這些問題的原 因通常都不容易,但是相比其帶來的好處,深究這些問題是值得的。使用SQL語句生成排名值通常需要做兩次計算,例如,需要額外計算一次出演過相同數量電影的演員有哪些。使用變數則可一次完成——這對效能是一個很大的提升。

  針對這個案例,另一個簡單的方案是在FROM子句中使用子査詢生成一箇中間的臨時表:

mysql> SET @curr_cnt := 0, @prev_cnt := 0, @rank := 0;
    -> SELECT actor_id,
    ->    @curr_cnt := cnt AS cnt,
    ->    @rank     := IF(@prev_cnt <> @curr_cnt, @rank + 1, @rank) AS rank,
    ->    @prev_cnt := @curr_cnt AS dummy
    -> FROM (
    ->    SELECT actor_id, COUNT(*) AS cnt
    ->    FROM sakila.film_actor
    ->    GROUP BY actor_id
    ->    ORDER BY cnt DESC
    ->    LIMIT 10
    -> ) as der;
+----------+-----+------+-------+
| actor_id | cnt | rank | dummy |
+----------+-----+------+-------+
|      107 |  42 |    1 |    42 |
|      102 |  41 |    2 |    41 |
|      198 |  40 |    3 |    40 |
|      181 |  39 |    4 |    39 |
|       23 |  37 |    5 |    37 |
|       81 |  36 |    6 |    36 |
|      106 |  35 |    7 |    35 |
|       60 |  35 |    7 |    35 |
|       13 |  35 |    7 |    35 |
|      158 |  35 |    7 |    35 |
+----------+-----+------+-------+

  避免重複查詢剛剛更新的資料

  如果在更新行的同時又希望獲得該行的資訊,要怎麼做才能避免重複的査詢呢?不幸的是,MySQL並不支援像PostgreSQL那樣的UPDATERETURNING語法,這個語法可以幫你在更新行的時候同時返回該行的資訊。還好在MySQL中你可以使用變數來解決這個問題。例如,我們的一個客戶希望能夠更高效地更新一條記錄的時間戳,同時希望査詢當前記錄中存放的時間戳是什麼。簡單地,可以用下面的程式碼來實現:

UPDATE t1 SET lastUpdated = NOW() WHERE id = 1;
SELECT lastUpdated FROM t1 WHERE id = 1;

  使用變數,我們可以按如下方式重寫査詢:

UPDATE t1 SET lastUpdated = NOW() WHERE id = 1 AND @now := NOW();
SELECT @now;

  上面看起來仍然需要兩個査詢,需要兩次網路來回,但是這裡的第二個査詢無須訪問任 何資料表,所以會快非常多。(如果網路延遲非常大,那麼這個優化的意義可能不大,不過對這個客戶,這樣做的效果很好。)

  統計更新和插入的數量

  當使用了INSERTONDUPLICATEKEYUPDATE的時候,如果想知道到底插入了多少行資料, 到底有多少資料是因為衝突而改寫成更新操作的?KerstianK6hntopp在他的部落格上給出了一個解決這個問題的辦法(http://mysqldump.azundris.com/archives/86-Down-the-dirty-road.html。)。實現辦法的本質如下:

INSERT INTO t1(c1, c2) VALUES(4, 4), (2, 1), (3, 1)
ON DUPLICATE KEY UPDATE
   c1 = VALUES(c1) + ( 0 * ( @x := @x +1 ) );

  當每次由於衝突導致更新時對變數@x自增一次。然後通過對這個表示式乘以0來讓其不影響要更新的內容。另外,MySQL的協議會返回被更改的總行數,所以不需要單獨統計這個值。

  確定取值的順序

  使用使用者自定義變數的一個最常見的問題就是沒有注意到在賦值和讀取變數的時候可能是在査詢的不同階段。例如,在SELECT子句中進行賦值然後在WHERE子句中讀取變數,則可能變數取值並不如你所想。下面的査詢看起來只返回一個結果,但事實並非如此:

mysql> SET @rownum := 0;
mysql> SELECT actor_id, @rownum := @rownum + 1 AS cnt
    -> FROM sakila.actor
    -> WHERE @rownum <= 1;
+----------+------+
| actor_id | cnt  |
+----------+------+
|        1 |    1 |
|        2 |    2 |
+----------+------+

  因為WHERE和SELECT是在査詢執行的不同階段被執行的。如果在査詢中再加入ORDER BY的話,結果可能會更不同:

mysql> SET @rownum := 0;
mysql> SELECT actor_id, @rownum := @rownum + 1 AS cnt
    -> FROM sakila.actor
    -> WHERE @rownum <= 1
    -> ORDER BY first_name;

  這是因為ORDERBY引入了檔案排序,而WHERE條件是在檔案排序操作之前取值的,所以這條査詢會返回表中的全部記錄。解決這個問題的辦法是讓變數的賦值和取值發生在執行査詢的同一階段:

mysql> SET @rownum := 0;
mysql> SELECT actor_id, @rownum AS rownum
    -> FROM sakila.actor
    -> WHERE (@rownum := @rownum + 1) <= 1;
+----------+--------+
| actor_id | rownum |
+----------+--------+
|        1 | 1      |
+----------+--------+

  小測試:如果在上面的査詢中再加上ORDERBY,那會返回什麼結果?試試看吧。如果 得出的結果出乎你的意料,想想為什麼?再看下面這個査詢會返回什麼,下面的査詢中ORDERBY子句會改變變數值,那WHERE語句執行時變數值是多少。

mysql> SET @rownum := 0;
mysql> SELECT actor_id, first_name, @rownum AS rownum
    -> FROM sakila.actor
    -> WHERE @rownum <= 1
    -> ORDER BY first_name, LEAST(0, @rownum := @rownum + 1);

  這個最出人意料的變數行為的答案可以在EXPLAIN語句中找到,注意看在Extra列中的“Usingwhere”、“Usingtemporary” 或者“Usingfilesort”。

  在上面的最後一個例子中,我們引入了一個新的技巧:我們將賦值語句放到LEAST()函 數中,這樣就可以在完全不改變排序順序的時候完成賦值操作(在上面例子中,LEAST()函式總是返回0)。這個技巧在不希望對子句的執行結果有影響卻又要完成變數賦值的時候很有用。這個例子中,無須在返回值中新增額外列。這樣的函式還有GREATEST()、LENGHT()、ISNULL()、NULLIFL()、IF()和C0ALESCE(),可以單獨使用也可以組合使用。例如,C0ALESCE()可以在一組引數中取第一個已經被定義的變數。

  編寫偷懶的UNION

  假設需要編寫一個UNION査詢,其第一個子査詢作為分支條件先執行,如果找到了匹配的行,則跳過第二個分支。在某些業務場景中確實會有這樣的需求,比如先在一個頻繁訪問的表中査找“熱”資料,找不到再去另外一個較少訪問的表中査找“冷”資料。(區分熱資料和冷資料是一個很好的提髙快取命中率的辦法)。

  下面的査詢會在兩個地方査找一個使用者--個主使用者表、一個長時間不活躍的使用者表,不活躍使用者表的目的是為了實現更髙效的歸檔:(Baron認為在一些社交網站上歸檔一些常見不活躍使用者後,使用者重新回到網站時有這樣的需求,當用戶再次登入時,一方面我們需要將其從歸檔中重新拿出來,另外,還可以給他傳送一份歡迎郵件。這對一些不活躍的使用者是非常好的一個優化)

SELECT id FROM users WHERE id = 123
UNION ALL
SELECT id FROM users_archived WHERE id = 123;

  上面這個査詢是可以正常工作的,但是即使在users表中已經找到了記錄,上面的査詢 還是會去歸檔表users_archived中再査找一次。我們可以用一個偷懶的UNION査詢來抑制這樣的資料返回,而且只有當第一個表中沒有資料時,我們才在第二個表中査詢。一旦在第一個表中找到記錄,我們就定義一個變數@found。我們通過在結果列中做一次賦值來實現,然後將賦值放在函式GREATEST中來避免返回額外的資料。為了明確我們的結果到底來自哪個表,我們新增了一個包含表名的列。最後我們需要在査詢的末尾將變數重置為NULL,這樣保證遍歷時不干擾後面的結果。完成的査詢如下:

SELECT GREATEST(@found := −1, id) AS id, 'users' AS which_tbl
FROM users WHERE id = 1
UNION ALL
  SELECT id, 'users_archived'
  FROM users_archived WHERE id = 1 AND @found IS NULL
UNION ALL
  SELECT 1, 'reset' FROM DUAL WHERE ( @found := NULL ) IS NOT NULL;

  使用者自定義變數的其他用處

  不僅是在SELECT語句中,在其他任何型別的SQL語句中都可以對變數進行賦值。事實上,這也是使用者自定義變數最大的用途。例如,可以像前面使用子査詢的方式改進排名語句一樣來改進UPDATE語句。

  不過,我們需要使用一些技巧來獲得我們希望的結果。有時,優化器會把變數當作一個 編譯時常量來對待,而不是對其進行賦值。將函式放在類似於LEAST()這樣的函式中通常可以避免這樣的問題。另一個辦法是在査詢被執行前檢査變數是否被賦值。不同的場景下使用不同的辦法。

  通過一些實踐,可以瞭解所有使用者自定義變數能夠做的有趣的事情,例如下面這些用法:

  • 査詢執行時計算總數和平均值。
  • 模擬GROUP語句中的函式FIRST()和LAST()。
  • 對大量資料做一些資料計算。
  • 計算一個大表的MD5雜湊值。
  • 編寫一個樣本處理函式,當樣本中的數值超過某個邊界值的時候將其變成0。
  • 模擬讀/寫遊標。
  • 在SHOW語句的WHERE子句中加入變數值。

8.案例學習

  通常,我們要做的不是査詢優化,不是庫表結構優化,不是索引優化也不是應用設計優 化——在實踐中可能要面對所有這些攪和在一起的情況。本節的案例將為大家介紹一些經常困擾使用者的問題和解決方法。另外推薦Bill Karwin的書SQL Antipatterns(一本實踐型的書籍)。它將介紹如何使用SQL解決各種程式設計師疑難雜症。

8.1 使用MySQL構建一個隊列表

  使用MySQL來實現隊列表是一個取巧的做法,我們看到很多系統在高流量、高併發的情況下表現並不好。典型的模式是一個表包含多種型別的記錄:未處理記錄、已處理記錄、正在處理記錄等。一個或者多個消費者執行緒在表中査找未處理的記錄,然後聲稱正在處理,當處理完成後,再將記錄更新成已處理狀態。一般的,例如郵件傳送、多命令處理、評論修改等會使用類似模式。

  通常有兩個原因使得大家認為這樣的處理方式並不合適。第一,隨著隊列表越來越大和 索引深度的增加,找到未處理記錄的速度會隨之變慢。你可以通過將隊列表分成兩部分來解決這個問題,就是將已處理記錄歸檔或者存放到歷史表,這可以始終保證隊列表很小。

  第二,一般的處理過程分兩步,先找到未處理記錄然後加鎖。找到記錄會增加伺服器的 壓力,而加鎖操作則會讓各個消費者程序增加競爭,因為這是一個序列化的操作。其它文章會解釋為什麼會限制可擴充套件性。

  找到來處理記錄一般來說都沒問題,如果有問題則可以通過使用訊息的方式來通知各個 消費者。具體的,可以使用一個帶有註釋的SLEEP()函式做超時處理,如下:

SELECT /* waiting on unsent_emails */ SLEEP(10000);

  這讓執行緒一直阻塞,直到兩個條件之一滿足:10000秒後超時,或者另一個執行緒使用KILLQUERY結束當前的SLEEP。因此,當再向隊列表中新增一批資料後,可以通過SHOWPR0CESSLIST,根據註釋找到當前正在休眠的執行緒,並將其KILL。你可以使用函式GET_L0CK()和RELEASE_L0CK()來實現通知,或者可以在資料庫之外實現,例如使用一個訊息服務。

  最後需要解決的問題是如何讓消費者標記正在處理的記錄,而不至於讓多個消費者重複 處理一個記錄。我們看到大家一般使用SELECTFORUPDATE來實現。這通常是擴充套件性問題的根源,這會導致大量的事務阻塞並等待。

  一般,我們要儘量避免使用SELECTFORUPDATE。不光是隊列表,任何情況下都要儘量避免。總是有別的更好的辦法實現你的目的。在隊列表的案例中,可以直接使用UPDATE來更新記錄,然後檢査是否還有其他的記錄需要處理。我們看看具體實現,我們先建立如下的表:

CREATE TABLE unsent_emails (
  id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
  -- columns for the message, from, to, subject, etc.
  status ENUM('unsent', 'claimed', 'sent'),
  owner  INT UNSIGNED NOT NULL DEFAULT 0,
  ts     TIMESTAMP,
  KEY    (owner, status, ts)
);

  該表的列owner用來儲存當前正在處理這個記錄的連線ID,即由函式C0NNECTI0N_ID() 返回的ID。如果當前記錄沒有被任何消費者處理,則該值為0。

  我們還經常看到的一個辦法是,如下面所示的一次處理10條記錄:

BEGIN;
SELECT id FROM unsent_emails
   WHERE owner = 0 AND status = 'unsent'
   LIMIT 10 FOR UPDATE;
-- result: 123, 456, 789
UPDATE unsent_emails
   SET status = 'claimed', owner = CONNECTION_ID()
   WHERE id IN(123, 456, 789);
COMMIT;

  看到這裡的SELECT査詢可以使用到索引的兩個列,因此理論上査找的效率應該更快。問題是,在上面兩個査詢之間的“間隙時間”,這裡的鎖會讓所有其他同樣的査詢全部都被阻塞。所有的這樣的査詢將使用相同的索引,掃描索引相同的部分,所以很可能會被阻塞。

  如果改進成下面的寫法,則會更加髙效:

SET AUTOCOMMIT = 1;
COMMIT;
UPDATE unsent_emails
   SET status = 'claimed', owner = CONNECTION_ID()
   WHERE owner = 0 AND status = 'unsent'
   LIMIT 10;
SET AUTOCOMMIT = 0;
SELECT id FROM unsent_emails
   WHERE owner = CONNECTION_ID() AND status = 'claimed';
-- result: 123, 456, 789

  根本就無須使用SELECT査詢去找到哪些記錄還沒有被處理。客戶端的協議會告訴你更新了幾條記錄,所以可以知道這次需要處理多少條記錄。

  所有的SELECTFORUPDATE都可以使用類似的方法改寫。

  最後還需要處理一種特殊情況:那些正在被程序處理,而程序本身卻由於某種原因退出 的情況。這種情況處理起來很簡單。你只需要定期執行UPDATE語句將它都更新成原始狀態就可以了,然後執行SH0W PR0CESSLIST,獲取當前正在工作的執行緒ID,並使用一些WHERE條件避免取到那些剛開始處理的程序。假設我們獲取的執行緒ID有(10、20、30),下面的更新語句會將處理時間超過10分鐘的記錄狀態都更新成初始狀態:

UPDATE unsent_emails
   SET owner = 0, status = 'unsent'
   WHERE owner NOT IN(0, 10, 20, 30) AND status = 'claimed'
   AND ts < CURRENT_TIMESTAMP - INTERVAL 10 MINUTE;

  另外,注意看看是如何巧妙地設計索引讓這個査詢更加髙效的。這也是上一章(高效能的索引)和本章知識的結合。因為我們將範圍條件放在WHERE條件的末尾,這個査詢恰好能夠使用索引的全部列。其他的查詢也都能用上這個索引,這就避免了再新增一個額外的索引來滿足其他的查詢。

  這裡我們將總結一下這個案例中的一些基礎原則:

  • 儘量少做事,可以的話就不要做任何事情。除非不得已,否則不要使用輪詢,因為這會增加負載,而且還會帶來很多低產出的工作。
  • 儘可能快地完成需要做的事情。儘量使用UPDATE代替先SELECTFORUPDATE再UPDATE的寫法,因為事務提交的速度越快,持有的鎖時間就越短,可以大大減少競爭和加速序列執行效率。將已經處理完成和未處理的資料分開,保證資料集足夠小。
  • 這個案例的另一個啟發是,某些査詢是無法優化的;考慮使用不同的查詢或者不同的策略去實現相同的目的。通常對於SELECTFORUPDATE就需要這樣處理。

  有時,最好的辦法就是將任務佇列從資料庫中遷移出來。Redis就是一個很好的佇列容 器,也可以使用memcache來實現。另一個選擇是使用Q4M儲存引擎,但我們沒有在生產環境使用過這個儲存引擎,所以這裡也沒辦法提供更多的參考。RabbitMQ和kafka也可以實現類似的功能。

8.2 計算兩點之間的距離

  不建議使用者使用MySQL做太複雜的空間資訊儲存——PostgreSQL在這方面是不錯的選擇——我們這裡將介紹一些常用的計算模式。一個典型的例子是計算以某個點為中心,一定半徑內的所有點。

  典型的實際案例可能是査找某個點附近所有可以出租的房子,或者社交網站中“匹配” 附近的使用者,等等。假設我們有如下表:

CREATE TABLE locations (
  id   INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(30),
  lat  FLOAT NOT NULL,
  lon  FLOAT NOT NULL
);
INSERT INTO locations(name, lat, lon)
  VALUES('Charlottesville, Virginia', 38.03, −78.48),
        ('Chicago, Illinois',         41.85, −87.65),
        ('Washington, DC',            38.89, −77.04);

  這裡經度和緯度的單位是“度”,通常我們假設地球是圓的,然後使用兩點所在最大圓(半正矢)公式來計算兩點之間的距離。現在有座標latA和lonA、latB和lonB,那麼點A和點B的距離計算公式如下:

ACOS(
   COS(latA) * COS(latB) * COS(lonA - lonB)
     + SIN(latA) * SIN(latB)
)

  計算出的結果是一個弧度,如果要將結果的單位轉換成英里或者千米,則需要乘以地球 的半徑,也就是3959英里或者6371千米。假設我們需要找出所有距離Baron所居住的地方Charlottesville100英里以內的點,那麼我們需要將經緯度帶入上面的計算公式:

SELECT * FROM locations WHERE 3979 * ACOS(
   COS(RADIANS(lat)) * COS(RADIANS(38.03)) * COS(RADIANS(lon) - RADIANS(-78.48))
     + SIN(RADIANS(lat)) * SIN(RADIANS(38.03))
) <= 100;
+----+---------------------------+-------+--------+
| id | name                      | lat   | lon    |
+----+---------------------------+-------+--------+
|  1 | Charlottesville, Virginia | 38.03 | −78.48 |
|  3 | Washington, DC            | 38.89 | −77.04 |
+----+---------------------------+-------+--------+

  這類查詢不僅無法使用索引,而且還會非常消耗CPU時間,給伺服器帶來很大的壓力,而且我們還得反覆計算這個。那要怎樣優化呢?

  這個設計中有幾個地方可以做優化。第一,看看是否真的需要這麼精確的計算。其實這 種演算法已經有很多不精確的地方了,如下所示:

  • 兩個地方之間的直線距離可能是100英里,但實際上它們之間的行走距離很可能不是這個值。無論你們在哪兩個地方,要到達彼此位置的行走距離多半都不是直線距離,路上可能需要繞很多的彎,比如說如果有一條河,需要繞遠走到一個有橋的地方。所以,這裡計算的絕對距離只是一個參考值。
  • 如果我們根據郵政編碼來確定某個人所在的地區,再根據這個地區的中心位置計算他和別人的距離,那麼這本身就是一個估算。Baron住在Charlottesville,不過不是在中心地區,他對華盛頓物理位置的中心也不感興趣。

  所以,通常並不需要精確計算,很多應用如果這樣計算,多半是認真過頭了。這類似於 有效數字的估算:計算結果的精度永遠都不會比測量的值更高。(換句話說,“錯進,錯出”。)

  如果不需要太高的精度,那麼我們認為地球是圓的應該也沒什麼問題,其實準確的說應 該是橢圓。根據畢達哥拉斯定理,做些三角函式變換,我們可以把上面的公式轉換得更簡單,只需要做些求和、乘積以及平方根運算,就可以得出一個點是否在另一個點多少英里之內。(要想有更多的優化,你可以將三角函式的計算放到應用中,而不要在資料庫中計算。三角函式是非常消耗CPU的操作。如果將座標都轉換成孤度存放,則對資料庫來說就簡化了很多。為了保證我們的案例簡單,不要引入太多別的因子,所以這裡我們將不再做更多的優化了。)

  等等,為什麼就到這為止?我們是否真需要計算一個圓周呢?為什麼不直接使用一個正 方形代替?邊長為200英里的正方形,一個頂點到中心的距離大概是141英里,這和實際計算的100英里相差得並不是那麼遠。那我們根據正方形公式來計算弧度為0.0253(100英里)的中心到邊長的距離:

SELECT * FROM locations
WHERE lat BETWEEN  38.03 - DEGREES(0.0253) AND  38.03 + DEGREES(0.0253)
  AND lon BETWEEN −78.48 - DEGREES(0.0253) AND −78.48 + DEGREES(0.0253);

  現在我們看看如何使用索引來優化這個査詢。簡單地,我們可以增加索引(lat,lon)或者 (lon,lat)。不過這樣做效果並不會很好。正如我們所知,MySQL5.5和之前的版本,如果第一列是範圍査詢的話,就無法使用索引後面的列了。因為兩個列都是範圍的,所以這裡只能使用索引的一個列(BETWEEN等效於一個大於和一個小於)。

  我們再次想起了通常使用的IN()優化。我們先新增兩個列,用來儲存座標的近似值FL00R(),然後在查詢中使用IN()將所有點的整數值都放到列表中。下面是我們需要新增的列和索引:

mysql> ALTER TABLE locations
    ->   ADD lat_floor INT NOT NULL DEFAULT 0,
    ->   ADD lon_floor INT NOT NULL DEFAULT 0,
    ->   ADD KEY(lat_floor, lon_floor);
mysql> UPDATE locations
    -> SET lat_floor = FLOOR(lat), lon_floor = FLOOR(lon);

  現在我們可以根據座標的一定範圍的近似值來搜尋了,這個近似值包括地板值和天花板 值,地理上分別對應的是南北。下面的査詢為我們只展示瞭如何查某個範圍的所有點;數值需要在應用程式中計算而不是MySQL中:

mysql> SELECT FLOOR( 38.03 - DEGREES(0.0253)) AS lat_lb,
    ->      CEILING( 38.03 + DEGREES(0.0253)) AS lat_ub,
    ->        FLOOR(-78.48 - DEGREES(0.0253)) AS lon_lb,
    ->      CEILING(-78.48 + DEGREES(0.0253)) AS lon_ub;
+--------+--------+--------+--------+
| lat_lb | lat_ub | lon_lb | lon_ub |
+--------+--------+--------+--------+
|     36 |     40 |    −80 |    −77 |
+--------+--------+--------+--------+

  現在我們就可以生成IN()列表中的整數了,也就是前面計算的地板和天花板數值之間的數字。下面是加上WHERE條件的完整査詢:

SELECT * FROM locations
WHERE lat BETWEEN  38.03 - DEGREES(0.0253) AND  38.03 + DEGREES(0.0253)
  AND lon BETWEEN −78.48 - DEGREES(0.0253) AND −78.48 + DEGREES(0.0253)
  AND lat_floor IN(36,37,38,39,40) AND lon_floor IN(-80,-79,-78,-77);

  使用近似值會讓我們的計算結果有些偏差,所以我們還需要一些額外的條件剔除在正方 形之外的點。這和前面使用CRC32做雜湊索引類似:先建一個索引幫我們過濾出近似值,再使用精確條件匹配所有的記錄並移除不滿足條件的記錄。

  事實上,到這時我們就無須根據正方形的近似來過濾資料了,我們可以使用最大圓公式或者畢達哥拉斯定理來計算:

SELECT * FROM locations
WHERE lat_floor IN(36,37,38,39,40) AND lon_floor IN(-80,-79,-78,-77)
 AND 3979 * ACOS(
   COS(RADIANS(lat)) * COS(RADIANS(38.03)) * COS(RADIANS(lon) - RADIANS(-78.48))
     + SIN(RADIANS(lat)) * SIN(RADIANS(38.03))
 ) <= 100;

  這時計算精度再次回到前面——使用一個精確的圓周——不過,現在的做法更快。只要能夠高效地過濾掉大部分的點,例如使用近似整數和索引,之後再做精確數學計算的代價並不大。只是不要直接使用大圓周的演算法,否則速度會很慢。

  提示:Sphinx有很多內建的地理資訊捜索功能,比MySQL實現要好很多。如果正在考慮使用MyISAM的GIS函式,並使用上面的技巧來計算,那麼你需要記住:這樣做效果並不會很好,MyISAM本身也並不適合大資料量、髙併發的應用,另外MyISAM本身還有一些弱點,如資料檔案崩潰、表級鎖等。

  回顧一下上面的案例,我們採用了下面這些常用的優化策略:

  • 儘量少做事,可能的話儘量不做事。這個案例中就不要對所有的點計算大圓周公式;先使用簡單的方案過濾大多數資料,然後再到過濾出來的更小的集合上使用複雜的公式運算。
  • 快速地完成事情。確保在你的設計中儘可能地讓査詢都用上合適的索引,使用近似計算(例如本案例中,認為地球是平的,使用一個正方形來近似圓周)來避免複雜的計算。
  • 需要的時候,儘可能讓應用程式完成一些計算。例如本案例中,在應用程式中計算所有的三角函式。

8.3 使用使用者自定義函式

  當SQL語句已經無法高效地完成某些任務的時候,這裡我們將介紹最後一個高階的優化 技巧。當你需要更快的速度,那麼C和C++是很好的選擇。當然,你需要一定的C或C++程式設計技巧,否則你寫的程式很可能會讓伺服器崩潰。這和“能力越強,責任越大”類似。

  這裡將通過一個案例看看如何用好一個使用者自定義函式。有一個客戶,在專案中需要如下的功能:“我們需要根據兩個隨機的64位數字計算它們的X0R值,來看兩個數值是否匹配。大約有3500萬條的記錄需要在秒級別完成。”經過簡單的計算就知道,當前的硬體條件下,不可能在MySQL中完成。那如何解決這個問題呢?

  問題的答案是使用YvesTrudeau編寫的一個計算程式,這個程式使用SSE4.2指令集, 以一個後臺程式的方式執行在通用伺服器上,然後我們編寫一個使用者自定義函式,通過簡單的網路通訊協議和前面的程式進行互動。

  Yves的測試表明,分散式執行上面的程式,可以達到在130毫秒內完成4百萬次匹配計算。通過這樣的方式,可以將密集型的計算放到一些通用的伺服器上,同時可以對外界完全透明,看起來是MySQL完成了全部的工作。正如他們在Twitter上說的:#太好了!這是一個典型的業務優化案例,而不僅僅是優化了一個簡單的技術問題。

9.總結

  如果把建立高效能應用程式比作是一個環環相扣的“難題”,除了前面介紹的schema、 索引和査詢語句設計之外,査詢優化應該是解開“難題”的最後一步了。要想寫一個好的査詢,你必須要理解schema設計、索引設計等,反之亦然。

  理解査詢是如何被執行的以及時間都消耗在哪些地方,這依然是我們介紹的響應時間的一部分。再加上一些諸如解析和優化過程的知識,就可以更進一步地理解上一章討論的MySQL如何訪問表和索引的內容了。這也從另一個維度幫助讀者理解MySQL在訪問表和索引時査詢和索引的關係。

  優化通常都需要三管齊下:不做、少做、快速地做。我們希望這裡的案例能夠幫助你將 理論和實踐聯絡起來。

  除了這些基礎的手段,包括査詢、表結構、索引等,MySQL還有一些高階的特性可以幫助你優化應用,例如分割槽,分割槽和索引有些類似但是原理不同。MySQL還支援査詢快取,它可以幫你快取査詢結果,當完全相同的査詢再次執行時,直接使用快取結果(其它mysql特性文章介紹)

作者:小家電維修

相見有時,後會無期。