說起 MySQL 的查詢優化,相信大家收藏了一堆奇技淫巧:不能使用 SELECT *、不使用 NULL 字段、合理創建索引、為字段選擇合適的數據類型.....
你是否真的理解這些優化技巧?是否理解它背后的工作原理?在實際場景下性能真有提升嗎?我想未必。
因而理解這些優化建議背后的原理就顯得尤為重要,希望本文能讓你重新審視這些優化建議,并在實際業務場景下合理的運用。
如果能在頭腦中構建一幅 MySQL 各組件之間如何協同工作的架構圖,將有助于深入理解 MySQL 服務器。下圖是 MySQL 的邏輯架構圖:
MySQL 邏輯架構
MySQL 的邏輯架構整體分為三層,最上層為客戶端層,并非 MySQL 所獨有,諸如:連接處理、授權認證、安全等功能均在這一層處理。
MySQL 的大多數核心服務均在中間這一層,包括查詢解析、分析、優化、緩存、內置函數(比如:時間、數學、加密等函數)。所有的跨存儲引擎的功能也在這一層實現:存儲過程、觸發器、視圖等。
最下層為存儲引擎,負責 MySQL 中的數據存儲和提取。和 Linux 下的文件系統類似,每種存儲引擎都有其優勢和劣勢。
中間的服務層通過 API 與存儲引擎通信,這些 API 接口屏蔽了不同存儲引擎間的差異。
我們總是希望 MySQL 能夠獲得更高的查詢性能,最好的辦法是弄清楚 MySQL 是如何優化和執行查詢的。
一旦理解了這一點,就會發現:很多的查詢優化工作實際上就是遵循一些原則讓 MySQL 的優化器能夠按照預想的合理方式運行。
當向 MySQL 發送一個請求的時候,MySQL 到底做了些什么呢?
MySQL 查詢過程
MySQL 的客戶端/服務端通信協議是“半雙工”的:在任一時刻,要么是服務器向客戶端發送數據,要么是客戶端向服務器發送數據,這兩個動作不能同時發生。
一旦一端開始發送消息,另一端要接收完整個消息才能響應它,所以我們無法也無須將一個消息切成小塊獨立發送,也沒有辦法進行流量控制。
客戶端用一個單獨的數據包將查詢請求發送給服務器,所以當查詢語句很長的時候,需要設置 max_allowed_packet 參數。
但是需要注意的是,如果查詢實在是太大,服務端會拒絕接收更多數據并拋出異常。
與之相反的是,服務器響應給用戶的數據通常會很多,由多個數據包組成。但是當服務器響應客戶端請求時,客戶端必須完整的接收整個返回結果,而不能簡單的只取前面幾條結果,然后讓服務器停止發送。
因而在實際開發中,盡量保持查詢簡單且只返回必需的數據,減小通信間數據包的大小和數量是一個非常好的習慣,這也是查詢中盡量避免使用 SELECT * 以及加上 LIMIT 限制的原因之一。
在解析一個查詢語句前,如果查詢緩存是打開的,那么 MySQL 會檢查這個查詢語句是否命中查詢緩存中的數據。
如果當前查詢恰好命中查詢緩存,在檢查一次用戶權限后直接返回緩存中的結果。這種情況下,查詢不會被解析,也不會生成執行計劃,更不會執行。
MySQL 將緩存存放在一個引用表(不要理解成 table,可以認為是類似于 HashMap 的數據結構),通過一個哈希值索引。
這個哈希值通過查詢本身、當前要查詢的數據庫、客戶端協議版本號等一些可能影響結果的信息計算得來。
所以兩個查詢在任何字符上的不同(例如:空格、注釋),都會導致緩存不會命中。
如果查詢中包含任何用戶自定義函數、存儲函數、用戶變量、臨時表、MySQL 庫中的系統表,其查詢結果都不會被緩存。
比如函數 NOW() 或者 CURRENT_DATE() 會因為不同的查詢時間,返回不同的查詢結果。
再比如包含 CURRENT_USER 或者 CONNECION_ID() 的查詢語句會因為不同的用戶而返回不同的結果,將這樣的查詢結果緩存起來沒有任何的意義。
既然是緩存,就會失效,那查詢緩存何時失效呢?MySQL 的查詢緩存系統會跟蹤查詢中涉及的每個表,如果這些表(數據或結構)發生變化,那么和這張表相關的所有緩存數據都將失效。
正因為如此,在任何的寫操作時,MySQL 必須將對應表的所有緩存都設置為失效。
如果查詢緩存非常大或者碎片很多,這個操作就可能帶來很大的系統消耗,甚至導致系統僵死一會兒。
而且查詢緩存對系統的額外消耗也不僅僅在寫操作,讀操作也不例外:
任何的查詢語句在開始之前都必須經過檢查,即使這條 SQL 語句永遠不會命中緩存。
如果查詢結果可以被緩存,那么執行完成后,會將結果存入緩存,也會帶來額外的系統消耗。
基于此,我們要知道并不是什么情況下查詢緩存都會提高系統性能,緩存和失效都會帶來額外消耗,只有當緩存帶來的資源節約大于其本身消耗的資源時,才會給系統帶來性能提升。
但如何評估打開緩存是否能夠帶來性能提升是一件非常困難的事情,也不在本文討論的范疇內。
如果系統確實存在一些性能問題,可以嘗試打開查詢緩存,并在數據庫設計上做一些優化,比如:
用多個小表代替一個大表,注意不要過度設計。
批量插入代替循環單條插入。
合理控制緩存空間大小,一般來說其大小設置為幾十兆比較合適。
可以通過 SQL_CACHE 和 SQL_NO_CACHE 來控制某個查詢語句是否需要進行緩存。
最后的忠告是不要輕易打開查詢緩存,特別是寫密集型應用。如果你實在是忍不住,可以將 query_cache_type 設置為 DEMAND。
這時只有加入 SQL_CACHE 的查詢才會走緩存,其他查詢則不會,這樣可以非常自由地控制哪些查詢需要被緩存。
當然查詢緩存系統本身是非常復雜的,這里討論的也只是很小的一部分,其他更深入的話題沒有涉及,比如:緩存是如何使用內存的?如何控制內存的碎片化?事務對查詢緩存有何影響等等。
MySQL 通過關鍵字將 SQL 語句進行解析,并生成一棵對應的解析樹。這個過程解析器主要通過語法規則來驗證和解析。比如 SQL 中是否使用了錯誤的關鍵字或者關鍵字的順序是否正確等等。
預處理則會根據 MySQL 規則進一步檢查解析樹是否合法。比如檢查要查詢的數據表和數據列是否存在等等。
經過前面的步驟生成的語法樹被認為是合法的了,并且由優化器將其轉化成查詢計劃。
多數情況下,一條查詢可以有很多種執行方式,最后都返回相應的結果。優化器的作用就是找到這其中最好的執行計劃。
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 的查詢優化器是一個非常復雜的部件,它使用了非常多的優化策略來生成一個最優的執行計劃:
重新定義表的關聯順序(多張表關聯查詢時,并不一定按照 SQL 中指定的順序進行,但有一些技巧可以指定關聯順序)。
優化 MIN() 和 MAX() 函數(找某列的最小值,如果該列有索引,只需要查找 B+Tree 索引最左端,反之則可以找到最大值,具體原理見下文)。
提前終止查詢(比如:使用 LIMIT 時,查找到滿足數量的結果集后會立即終止查詢)。
優化排序(在老版本 MySQL 會使用兩次傳輸排序,即先讀取行指針和需要排序的字段在內存中對其排序,然后再根據排序結果去讀取數據行,而新版本采用的是單次傳輸排序,也就是一次讀取所有的數據行,然后根據給定的列排序。對于 I/O 密集型應用,效率會高很多)。
隨著 MySQL 的不斷發展,優化器使用的優化策略也在不斷的進化,這里僅僅介紹幾個非常常用且容易理解的優化策略,其他的優化策略,大家自行查閱吧。