1. 程式人生 > >MySQL 的查詢優化

MySQL 的查詢優化

說起 MySQL 的查詢優化,相信大家收藏了一堆奇技淫巧:不能使用 SELECT *、不使用 NULL 欄位、合理建立索引、為欄位選擇合適的資料型別…… 你是否真的理解這些優化技巧?是否理解其背後的工作原理?在實際場景下效能真有提升嗎?我想未必。因而理解這些優化建議背後的原理就尤為重要,希望本文能讓你重新審視這些優化建議,並在實際業務場景下合理的運用。

1. 客戶端 / 服務端通訊協議

MySQL 客戶端 / 服務端通訊協議是 “半雙工” 的:在任一時刻,要麼是伺服器向客戶端傳送資料,要麼是客戶端向伺服器傳送資料,這兩個動作不能同時發生。一旦一端開始傳送訊息,另一端要接收完整個訊息才能響應它,所以我們無法也無須將一個訊息切成小塊獨立傳送,也沒有辦法進行流量控制。

客戶端用一個單獨的資料包將查詢請求傳送給伺服器,所以當查詢語句很長的時候,需要設定 max_allowed_packet 引數。但是需要注意的是,如果查詢實在是太大,服務端會拒絕接收更多資料並丟擲異常。

與之相反的是,伺服器響應給使用者的資料通常會很多,由多個數據包組成。但是當伺服器響應客戶端請求時,客戶端必須完整的接收整個返回結果,而不能簡單的只取前面幾條結果,然後讓伺服器停止傳送。因而在實際開發中,儘量保持查詢簡單且只返回必需的資料,減小通訊間資料包的大小和數量是一個非常好的習慣,這也是查詢中儘量避免使用 SELECT * 以及加上 LIMIT 限制的原因之一。

2. 查詢快取

在解析一個查詢語句前,如果查詢快取是開啟的,那麼 MySQL 會檢查這個查詢語句是否命中查詢快取中的資料。如果當前查詢恰好命中查詢快取,在檢查一次使用者許可權後直接返回快取中的結果。這種情況下,查詢不會被解析,也不會生成執行計劃,更不會執行。

MySQL 將快取存放在一個引用表(不要理解成 table,可以認為是類似於 HashMap 的資料結構),通過一個雜湊值索引,這個雜湊值通過查詢本身、當前要查詢的資料庫、客戶端協議版本號等一些可能影響結果的資訊計算得來。所以兩個查詢在任何字元上的不同(例如:空格、註釋),都會導致快取不會命中。

如果查詢中包含任何使用者自定義函式、儲存函式、使用者變數、臨時表、MySQL 庫中的系統表,其查詢結果都不會被快取。比如函式 NOW() 或者 CURRENT_DATE() 會因為不同的查詢時間,返回不同的查詢結果,再比如包含 CURRENT_USER 或者 CONNECION_ID() 的查詢語句會因為不同的使用者而返回不同的結果,將這樣的查詢結果快取起來沒有任何的意義。

既然是快取,就會失效,那查詢快取何時失效呢?

MySQL 的查詢快取系統會跟蹤查詢中涉及的每個表,如果這些表(資料或結構)發生變化,那麼和這張表相關的所有快取資料都將失效。正因為如此,在任何的寫操作時,MySQL 必須將對應表的所有快取都設定為失效。如果查詢快取非常大或者碎片很多,這個操作就可能帶來很大的系統消耗,甚至導致系統僵死一會兒。而且查詢快取對系統的額外消耗也不僅僅在寫操作,讀操作也不例外:

1、任何的查詢語句在開始之前都必須經過檢查,即使這條 SQL 語句永遠不會命中快取

2、如果查詢結果可以被快取,那麼執行完成後,會將結果存入快取,也會帶來額外的系統消耗

基於此,我們要知道並不是什麼情況下查詢快取都會提高系統性能,快取和失效都會帶來額外消耗,只有當快取帶來的資源節約大於其本身消耗的資源時,才會給系統帶來效能提升。但要如何評估開啟快取是否能夠帶來效能提升是一件非常困難的事情,也不在本文討論的範疇內。如果系統確實存在一些效能問題,可以嘗試開啟查詢快取,並在資料庫設計上做一些優化,比如:

1、用多個小表代替一個大表,注意不要過度設計

2、批量插入代替迴圈單條插入

3、合理控制快取空間大小,一般來說其大小設定為幾十兆比較合適

4、可以通過 SQL_CACHE 和 SQL_NO_CACHE 來控制某個查詢語句是否需要進行快取

最後的忠告是不要輕易開啟查詢快取,特別是寫密集型應用。如果你實在是忍不住,可以將 query_cache_type 設定為 DEMAND,這時只有加入 SQL_CACHE 的查詢才會走快取,其他查詢則不會,這樣可以非常自由地控制哪些查詢需要被快取。

當然查詢快取系統本身是非常複雜的,這裡討論的也只是很小的一部分,其他更深入的話題,比如:快取是如何使用記憶體的?如何控制記憶體的碎片化?事務對查詢快取有何影響等等,讀者可以自行閱讀相關資料,這裡權當拋磚引玉吧。

3. 語法解析和預處理

MySQL 通過關鍵字將 SQL 語句進行解析,並生成一棵對應的解析樹。這個過程解析器主要通過語法規則來驗證和解析。比如 SQL 中是否使用了錯誤的關鍵字或者關鍵字的順序是否正確等等。預處理則會根據 MySQL 規則進一步檢查解析樹是否合法。比如檢查要查詢的資料表和資料列是否存在等。

4. 查詢優化

經過前面的步驟生成的語法樹被認為是合法的了,並且由優化器將其轉化成查詢計劃。多數情況下,一條查詢可以有很多種執行方式,最後都返回相應的結果。優化器的作用就是找到這其中最好的執行計劃。

MySQL 使用基於成本的優化器,它嘗試預測一個查詢使用某種執行計劃時的成本,並選擇其中成本最小的一個。在 MySQL 可以通過查詢當前會話的 last_query_cost 的值來得到其計算當前查詢的成本。

mysql> select * from t_message limit 10;

...省略結果集


mysql> show status like 'last_query_cost';

+-----------------+-------------+

| Variable_name   | Value       |

+-----------------+-------------+

| Last_query_cost | 6391.799000 |

+-----------------+-------------+

示例中的結果表示優化器認為大概需要做 6391 個數據頁的隨機查詢才能完成上面的查詢。這個結果是根據一些列的統計資訊計算得來的,這些統計資訊包括:每張表或者索引的頁面個數、索引的基數、索引和資料行的長度、索引的分佈情況等等。

有非常多的原因會導致 MySQL 選擇錯誤的執行計劃,比如統計資訊不準確、不會考慮不受其控制的操作成本(使用者自定義函式、儲存過程)、MySQL 認為的最優跟我們想的不一樣(我們希望執行時間儘可能短,但 MySQL 值選擇它認為成本小的,但成本小並不意味著執行時間短)等等。

MySQL 的查詢優化器是一個非常複雜的部件,它使用了非常多的優化策略來生成一個最優的執行計劃:

1、重新定義表的關聯順序(多張表關聯查詢時,並不一定按照 SQL 中指定的順序進行,但有一些技巧可以指定關聯順序)

2、優化 MIN() 和 MAX() 函式(找某列的最小值,如果該列有索引,只需要查詢 B+Tree 索引最左端,反之則可以找到最大值,具體原理見下文)

3、提前終止查詢(比如:使用 Limit 時,查詢到滿足數量的結果集後會立即終止查詢)

4、優化排序(在老版本 MySQL 會使用兩次傳輸排序,即先讀取行指標和需要排序的欄位在記憶體中對其排序,然後再根據排序結果去讀取資料行,而新版本採用的是單次傳輸排序,也就是一次讀取所有的資料行,然後根據給定的列排序。對於 I/O 密集型應用,效率會高很多)

隨著 MySQL 的不斷髮展,優化器使用的優化策略也在不斷的進化,這裡僅僅介紹幾個非常常用且容易理解的優化策略,其他的優化策略,大家自行查閱吧。

5. 查詢執行引擎

在完成解析和優化階段以後,MySQL 會生成對應的執行計劃,查詢執行引擎根據執行計劃給出的指令逐步執行得出結果。整個執行過程的大部分操作均是通過呼叫儲存引擎實現的介面來完成,這些介面被稱為 handler API。查詢過程中的每一張表由一個 handler 例項表示。實際上,MySQL 在查詢優化階段就為每一張表建立了一個 handler 例項,優化器可以根據這些例項的介面來獲取表的相關資訊,包括表的所有列名、索引統計資訊等。儲存引擎介面提供了非常豐富的功能,但其底層僅有幾十個介面,這些介面像搭積木一樣完成了一次查詢的大部分操作。

6. 返回結果給客戶端

查詢執行的最後一個階段就是將結果返回給客戶端。即使查詢不到資料,MySQL 仍然會返回這個查詢的相關資訊,比如該查詢影響到的行數以及執行時間等。

如果查詢快取被開啟且這個查詢可以被快取,MySQL 也會將結果存放到快取中。

結果集返回客戶端是一個增量且逐步返回的過程。有可能 MySQL 在生成第一條結果時,就開始向客戶端逐步返回結果集了。這樣服務端就無須儲存太多結果而消耗過多記憶體,也可以讓客戶端第一時間獲得返回結果。需要注意的是,結果集中的每一行都會以一個滿足①中所描述的通訊協議的資料包傳送,再通過 TCP 協議進行傳輸,在傳輸過程中,可能對 MySQL 的資料包進行快取然後批量傳送。

回頭總結一下 MySQL 整個查詢執行過程,總的來說分為以下個步驟:

1、客戶端向 MySQL 伺服器傳送一條查詢請求

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

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

4、MySQL 根據執行計劃,呼叫儲存引擎的 API 來執行查詢

5、將結果返回給客戶端,同時快取查詢結果

效能優化建議

看了這麼多,你可能會期待給出一些優化手段,是的,下面會從 3 個不同方面給出一些優化建議。但請等等,還有一句忠告要先送給你:不要聽信你看到的關於優化的 “絕對真理”,包括本文所討論的內容,而應該是在實際的業務場景下通過測試來驗證你關於執行計劃以及響應時間的假設。

1. Scheme 設計與資料型別優化

選擇資料型別只要遵循小而簡單的原則就好,越小的資料型別通常會更快,佔用更少的磁碟、記憶體,處理時需要的 CPU 週期也更少。越簡單的資料型別在計算時需要更少的 CPU 週期,比如,整型就比字元操作代價低,因而會使用整型來儲存 ip 地址,使用 DATETIME 來儲存時間,而不是使用字串。

這裡總結幾個可能容易理解錯誤的技巧:

1、通常來說把可為 NULL 的列改為 NOT NULL 不會對效能提升有多少幫助,只是如果計劃在列上建立索引,就應該將該列設定為 NOT NULL。

2、對整數型別指定寬度,比如 INT(11),沒有任何卵用。INT 使用 32 位(4 個位元組)儲存空間,那麼它的表示範圍已經確定,所以 INT(1) 和 INT(20) 對於儲存和計算是相同的。

3、UNSIGNED 表示不允許負值,大致可以使正數的上限提高一倍。比如 TINYINT 儲存範圍是 - 128 ~ 127,而 UNSIGNED TINYINT 儲存的範圍卻是 0 – 255。

4、通常來講,沒有太大的必要使用 DECIMAL 資料型別。即使是在需要儲存財務資料時,仍然可以使用 BIGINT。比如需要精確到萬分之一,那麼可以將資料乘以一百萬然後使用 BIGINT 儲存。這樣可以避免浮點數計算不準確和 DECIMAL 精確計算代價高的問題。

5、TIMESTAMP 使用 4 個位元組儲存空間,DATETIME 使用 8 個位元組儲存空間。因而,TIMESTAMP 只能表示 1970 – 2038 年,比 DATETIME 表示的範圍小得多,而且 TIMESTAMP 的值因時區不同而不同。

6、大多數情況下沒有使用列舉型別的必要,其中一個缺點是列舉的字串列表是固定的,新增和刪除字串(列舉選項)必須使用 ALTER TABLE(如果只是在列表末尾追加元素,不需要重建表)。

7、schema 的列不要太多。原因是儲存引擎的 API 工作時需要在伺服器層和儲存引擎層之間通過行緩衝格式拷貝資料,然後在伺服器層將緩衝內容解碼成各個列,這個轉換過程的代價是非常高的。如果列太多而實際使用的列又很少的話,有可能會導致 CPU 佔用過高。

8、大表 ALTER TABLE 非常耗時,MySQL 執行大部分修改表結果操作的方法是用新的結構建立一個張空表,從舊錶中查出所有的資料插入新表,然後再刪除舊錶。尤其當記憶體不足而表又很大,而且還有很大索引的情況下,耗時更久。當然有一些奇技淫巧可以解決這個問題,有興趣可自行查閱。

2. 建立高效能索引

索引是提高 MySQL 查詢效能的一個重要途徑,但過多的索引可能會導致過高的磁碟使用率以及過高的記憶體佔用,從而影響應用程式的整體效能。應當儘量避免事後才想起新增索引,因為事後可能需要監控大量的 SQL 才能定位到問題所在,而且新增索引的時間肯定是遠大於初始新增索引所需要的時間,可見索引的新增也是非常有技術含量的。

特定型別查詢優化

1. 優化 COUNT() 查詢

COUNT() 可能是被大家誤解最多的函數了,它有兩種不同的作用,其一是統計某個列值的數量,其二是統計行數。統計列值時,要求列值是非空的,它不會統計 NULL。如果確認括號中的表示式不可能為空時,實際上就是在統計行數。最簡單的就是當使用 COUNT(*) 時,並不是我們所想象的那樣擴充套件成所有的列,實際上,它會忽略所有的列而直接統計所有的行數。

我們最常見的誤解也就在這兒,在括號內指定了一列卻希望統計結果是行數,而且還常常誤以為前者的效能會更好。但實際並非這樣,如果要統計行數,直接使用 COUNT(*),意義清晰,且效能更好。

有時候某些業務場景並不需要完全精確的 COUNT 值,可以用近似值來代替,EXPLAIN 出來的行數就是一個不錯的近似值,而且執行 EXPLAIN 並不需要真正地去執行查詢,所以成本非常低。通常來說,執行 COUNT() 都需要掃描大量的行才能獲取到精確的資料,因此很難優化,MySQL 層面還能做得也就只有覆蓋索引了。如果不還能解決問題,只有從架構層面解決了,比如新增彙總表,或者使用 redis 這樣的外部快取系統。

2.優化 LIMIT 分頁

當需要分頁操作時,通常會使用 LIMIT 加上偏移量的辦法實現,同時加上合適的 ORDER BY 字句。如果有對應的索引,通常效率會不錯,否則,MySQL 需要做大量的檔案排序操作。

一個常見的問題是當偏移量非常大的時候,比如:LIMIT 10000 20 這樣的查詢,MySQL 需要查詢 10020 條記錄然後只返回 20 條記錄,前面的 10000 條都將被拋棄,這樣的代價非常高。

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

SELECT film_id,description FROM film ORDER BY title LIMIT 50,5;

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

SELECT film.film_id,film.description

FROM film INNER JOIN (

SELECT film_id FROM film ORDER BY title LIMIT 50,5

) AS tmp USING(film_id);

這裡的延遲關聯將大大提升查詢效率,讓 MySQL 掃描儘可能少的頁面,獲取需要訪問的記錄後在根據關聯列回原表查詢所需要的列。

有時候如果可以使用書籤記錄上次取資料的位置,那麼下次就可以直接從該書籤記錄的位置開始掃描,這樣就可以避免使用 OFFSET,比如下面的查詢:

SELECT id FROM t LIMIT 10000, 10;

改為

SELECT id FROM t WHERE id > 10000 LIMIT 10;

其它優化的辦法還包括使用預先計算的彙總表,或者關聯到一個冗餘表,冗餘表中只包含主鍵列和需要做排序的列。