說起MySQL的查詢優化,相信大家累積一堆技巧:不能使用SELECT *、不使用NULL字段、合理創建索引、為欄位選擇合適的資料類型..... 你是否真的理解這些最佳化技巧?是否理解背後的工作原理?在實際場景下效能真有提升嗎?我想未必。因而理解這些優化建議背後的原理就特別重要,希望本文能讓你重新審視這些優化建議,並在實際業務場景下合理的運用。
MySQL邏輯架構
#如果能在腦中建構一幅MySQL各元件之間如何協同運作的架構圖,有助於深入理解MySQL伺服器。下圖展示了MySQL的邏輯架構圖。
#MySQL邏輯架構,來自:高效能MySQL
## MySQL邏輯架構整體分為三層,最上層為客戶端層,並非MySQL所獨有,諸如:連線處理、授權認證、安全性等功能均在這一層處理。
MySQL大多數核心服務都在中間這一層,包括查詢解析、分析、最佳化、快取、內建函數(例如:時間、數學、加密等函數)。所有的跨儲存引擎的功能也在這一層實現:預存程序、觸發器、檢視等。
最下層為儲存引擎,其負責MySQL中的資料儲存與擷取。和Linux下的檔案系統類似,每種儲存引擎都有其優點和缺點。中間的服務層透過API與儲存引擎通信,這些API介面屏蔽了不同儲存引擎間的差異。
MySQL查詢流程我們總是希望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程式碼
mysql> select * from t_message limit 10;
...省略結果集
重新定義表格的關聯順序(多張表關聯查詢時,不一定會按照SQL中指定的順序進行,但有一些技巧可以指定關聯順序)
dl
erAPI。查詢過程中的每一張表由一個handler實例表示。實際上,MySQL在查詢優化階段就為每一張表創建了一個handler實例,優化器可以根據這些實例的接口來獲取表的相關信息,包括表的所有列名、索引統計信息等。儲存引擎介面提供了非常豐富的功能,但其底層僅有幾十個接口,這些接口像搭積木一樣完成了一次查詢的大部分操作。 傳回結果給客戶端查詢執行的最後一個階段就是將結果傳回給客戶端。即使查詢不到數據,MySQL仍然會傳回這個查詢的相關信息,例如改查詢影響到的行數以及執行時間等等。 如果查詢快取被開啟且這個查詢可以被緩存,MySQL也會將結果存放到快取中。 結果集回傳客戶端是一個增量且逐步回傳的過程。有可能MySQL在產生第一條結果時,就開始逐步到客戶端回傳結果集了。這樣服務端就無須儲存太多結果而消耗過多內存,也可以讓客戶端第一時間獲得回傳結果。需要注意的是,結果集中的每一行都會以一個滿足①中所描述的通訊協定的資料包發送,再透過TCP協定傳輸,在傳輸過程中,可能對MySQL的資料包進行快取然後批次發送。回頭總結一下MySQL整個查詢執行過程,總的來說分成6個步驟:
客戶端向MySQL伺服器發送一則查詢請求
伺服器先檢查查詢快取,如果命中緩存,則立刻傳回儲存在快取中的結果。否則進入下一階段
伺服器進行SQL解析、預處理、再由優化器產生對應的執行計劃
MySQL根據執行計劃,呼叫儲存引擎的API來執行查詢
將結果傳回給客戶端,同時快取查詢結果
##效能最佳化建議
看了這麼多,你可能會期待給出一些優化手段,是的,下面會從3個不同方面給出一些優化建議。但請等等,還有一句忠告要先送給你:不要聽信你看到的關於優化的“絕對真理”,包括本文所討論的內容,而應該是在實際的業務場景下通過測試來驗證你關於執行計劃以及回應時間的假設。 Scheme設計與資料類型最佳化選擇資料類型只要遵循小而簡單的原則就好,越小的資料類型通常會更快,佔用更少的磁碟、內存,處理時所需的CPU週期也更少。越簡單的資料類型在計算時需要更少的CPU週期,例如,整數就比字元操作代價低,因而會使用整數來儲存ip位址,使用DATETIME來儲存時間,而不是使用字串。
這裡總結幾個可能容易理解錯誤的技巧:整數型別指定寬度,例如INT(11),沒有任何卵用。 INT使用16為儲存空間,那麼它的表示範圍已經確定,所以INT(1)和INT(20)對於儲存和計算是相同的。
通常我們所說的索引是指B-Tree索引,它是目前關係型資料庫中查找資料最為常用且有效的索引,大多數儲存引擎都支援這種索引。使用B-Tree這個術語,是因為MySQL在CREATE TABLE或其它語句中使用了這個關鍵字,但實際上不同的儲存引擎可能使用不同的資料結構,例如InnoDB就是使用的B+ Tree。
B+Tree中的B是指balance,意為平衡。要注意的是,B+樹索引並不能找到一個給定鍵值的具體行,它找到的只是被查找數據行所在的頁,接著數據庫會把頁讀入到內存,再在內存中進行查找,最後得到要找的資料。
在介紹B+Tree前,先了解二元查找樹,它是經典的資料結構,其左子樹的值總是小於根的值,右子樹的值總是大於根的值,如下圖①。若要在這課樹中找出值為5的記錄,其大致流程:先找到根,其值為6,大於5,所以找出左子樹,找出3,而5大於3,接著找3的右子樹,總共找了3次。同樣的方法,如果找出值為8的記錄,也需要找3次。所以二元查找樹的平均查找次數為(3 + 3 + 3 + 2 + 2 + 1) / 6 = 2.3次,而順序查找的話,查找值為2的記錄,僅需要1次,但查找值為8的記錄則需要6次,所以順序查找的平均查找次數為:(1 + 2 + 3 + 4 + 5 + 6) / 6 = 3.3次,因為大多數情況下二元查找樹的平均查找速度比順序查找要快。
二元尋找樹與平衡二元樹
由於二元查找樹可以任意構造,同樣的值,可以構造出如圖②的二元查找樹,顯然這棵二元樹的查詢效率和順序查找差不多。若想二元查找數的查詢效能最高,需要這棵二元查找樹是平衡的,也即平衡二元樹(AVL樹)。
平衡二元樹首先需要符合二元查找樹的定義,其次必須滿足任何節點的兩個子樹的高度差不能大於1。顯然圖②不滿足平衡二元樹的定義,而圖①是一課平衡二元樹。平衡二元樹的查找效能是比較高的(效能最好的是最優二元樹),查詢效能越好,維護的成本就越大。例如圖①的平衡二元樹,當使用者需要插入一個新的值9的節點時,就需要做出以下變動。
#平衡二元樹旋轉
透過一次左旋操作就插入後的樹重新變成平衡二元樹是最簡單的情況了,實際應用場景中可能需要旋轉多次。至此我們可以考慮一個問題,平衡二元樹的查找效率還不錯,實作也非常簡單,對應的維護成本還能接受,為什麼MySQL索引不直接使用平衡二元樹?
隨著資料庫中資料的增加,索引本身大小隨之增加,不可能全部儲存在記憶體中,因此索引往往以索引檔案的形式儲存的磁碟上。這樣的話,索引查找過程中就要產生磁碟I/O消耗,相對於記憶體存取,I/O存取的消耗量要高幾個數量級。可以想像一下一棵幾百萬個節點的二元樹的深度是多少?如果將這麼大深度的一顆二元樹放磁碟上,每讀取一個節點,需要一次磁碟的I/O讀取,整個查找的耗時顯然是不能夠接受的。那麼如何減少查找過程中的I/O訪問次數呢?
一種行之有效的解決方法是減少樹的深度,將二元樹變成m叉樹(多路搜尋樹),而B+Tree就是一種多路搜尋樹。在理解B+Tree時,只需要理解其最重要的兩個特徵即可:第一,所有的關鍵字(可以理解為資料)都儲存在葉子節點(Leaf Page),非葉子節點(Index Page)並不儲存真正的數據,所有記錄節點都是按鍵值大小順序存放在同一層葉子節點上。其次,所有的葉子節點由指標連接。如下圖為高度為2的簡化了的B+Tree。
#簡化B+Tree
## #######簡化B+Tree###### ###
怎麼理解這兩個特徵? MySQL將每個節點的大小設定為一個頁的整數倍(原因下文會介紹),也就是在節點空間大小一定的情況下,每個節點可以儲存更多的內結點,讓每個結點能索引的範圍更大更精確。所有的葉子節點使用指標連結的好處是可以進行區間訪問,例如上圖中,如果查找大於20而小於30的記錄,只需要找到節點20,就可以遍歷指針依次找到25、30。如果沒有連結指標的話,就無法進行區間查找。這也是MySQL使用B+Tree作為索引儲存結構的重要原因。
MySQL為何將節點大小設為頁的整數倍,這就需要理解磁碟的儲存原理。磁碟本身存取比主存慢很多,在加上機械運動損耗(特別是普通的機械硬碟),磁碟的存取速度往往是主存的幾百萬分之一,為了盡量減少磁碟I/O ,磁碟往往不是嚴格按需讀取,而是每次都會預讀,即使只需要一個字節,磁碟也會從這個位置開始,順序向後讀取一定長度的資料放入內存,預讀的長度一般為頁的整數倍。
引用
頁是電腦管理記憶體的邏輯區塊,硬體及OS往往將主記憶體和磁碟儲存區分割為連續的大小相等的區塊,每個儲存區塊稱為一頁(在許多OS中,頁的大小通常為4K)。主存和磁碟以頁為單位交換資料。當程式要讀取的資料不在主記憶體時,會觸發一個缺頁異常,此時系統會向磁碟發出讀盤訊號,磁碟會找到資料的起始位置並向後連續讀取一頁或幾頁載入記憶體中,然後異常返回,程式繼續運行。
MySQL巧妙利用了磁碟預讀原理,將一個節點的大小設為等於一個頁,這樣每個節點只需要一次I/O就可以完全載入。為了達到這個目的,每次新建節點時,直接申請一個頁的空間,這樣就保證一個節點物理上也存儲在一個頁裡,加之計算機存儲分配都是按頁對齊的,就實現了讀取一個節點只需一次I/O。假設B+Tree的高度為h,一次檢索最多需要h-1I/O(根節點常駐記憶體),複雜度$O(h) = O(\log_{M}N)$。實際應用場景中,M通常較大,常常超過100,因此樹的高度一般都比較小,通常不超過3。
最後簡單了解下B+Tree節點的操作,在整體上對索引的維護有一個大概的了解,雖然索引可以大大提高查詢效率,但維護索引仍要花費很大的代價,因此合理的創建索引也就特別重要。
仍以上面的樹為例,我們假設每個節點只能儲存4個內節點。首先要插入第一個節點28,如下圖所示。
leaf page與index page都沒有滿
接著插入下一個節點70,在Index Page中查詢後得知應該插入到50 - 70之間的葉子節點,但葉子節點已滿,這時候就需要進行也分裂的操作,當前的葉子節點起點為50,所以根據中間值來拆分葉子節點,如下圖所示。
Leaf Page分割
最後插入一個節點95,這時候Index Page和Leaf Page都滿了,就需要做兩次拆分,如下圖。
#Leaf Page與Index Page分割
分割後最終形成了這樣一顆樹。
#最終樹
B+Tree為了保持平衡,對於新插入的值需要做大量的拆分頁操作,而頁的拆分需要I/O操作,為了盡可能的減少頁的拆分操作,B+Tree也提供了類似於平衡二元樹的旋轉功能。當LeafPage已滿但其左右兄弟節點沒有滿的情況下,B+Tree並不急於去做拆分操作,而是將記錄移到目前所在頁的兄弟節點上。通常情況下,左兄弟會先檢查用來做旋轉操作。就例如上面第二個範例,插入70的時候,並不會去做頁拆分,而是左旋操作。
#左旋運算
透過旋轉作業可以最大限度的減少頁分裂,從而減少索引維護過程中的磁碟的I/O操作,也提高索引維護效率。需要注意的是,刪除節點跟插入節點類型,仍然需要旋轉和分割操作,這裡就不再說明。
高效能策略
透過上文,相信你對B+Tree的資料結構已經有了大致的了解,但MySQL中索引是如何組織資料的儲存呢?以簡單的範例來說明,假如有下列資料表:
Mysql程式碼
o##n
# gender enum(`m`,`f`) not null,
key
();
對於表中每一行數據,索引中包含了last_name、first_name、dob列的值,下圖展示了索引是如何組織資料儲存的。
#索引如何組織資料存儲,來自:高效能MySQL表達式的一部分,也不能是函數的參數
。比如:Mysql代碼 select * from where id + 1 = 5 我們很容易看出其等價於id = 4,但MySQL無法自動解析這個表達式,使用函數
是同樣的道理。索引選擇性是指不重複的索引值和資料表的總記錄數的比值,選擇性越高查詢效率越高,因為選擇性越高的索引可以讓MySQL在查詢時過濾掉更多的行。唯一索引的選擇性是1,這時最好的索引選擇性,效能也是最好的。
理解索引選擇性的概念後,就不難確定哪個欄位的選擇性較高了,查一下就知道了,例如:
Mysql代碼
SELECT * FROM payment where staff_id = 2 and customer_id = 584
只有當索引的列順序和ORDER BY子句的順序完全一致,並且所有列的排序方向也一樣時,才能夠使用索引來排序結果。如果查詢需要關聯多張表,則只有ORDER BY子句所引用的欄位全部為第一張表時,才能使用索引做排序。 ORDER BY子句和查詢的限制是一樣的,都要滿足最左前綴的要求(有一種情況例外,就是最左的列被指定為常數,下面是一個簡單的示例),其他情況下都需要執行排序操作,而無法利用索引排序。
Mysql程式碼
// 最左邊列為常數,索引:(date,staff_id,customer_id)
#select staff_id,customer_id from demo where date = '2015-06-01' order by staff_id,customer_id
要理解最佳化關聯查詢的第一個技巧,就需要理解MySQL是如何執行關聯查詢的。當前MySQL關聯執行的策略非常簡單,它對任何的關聯都執行嵌套循環關聯操作,即先在一個表中循環取出單條數據,然後在嵌套循環到下一個表中尋找匹配的行,依次下去,直到找到所有表中匹配的行為。然後根據各個表匹配的行,返回查詢中需要的各個列。
太抽象了?以上面的範例來說明,例如有這樣的一個查詢:
Mysql程式碼
#SELECT A.xx,B.yy
= outer_iterator.next;
}
可以看到,最外層的詢問是根據A.xx列來查詢的,A.c上如果有索引的話,整個關聯查詢也不會使用。再看內層的查詢,很明顯B.c上如果有索引的話,能夠加速查詢,因此只需要在關聯順序中的第二張表的相應列上建立索引即可。
優化LIMIT分頁
當需要分頁操作時,通常會使用LIMIT加上偏移量的辦法實現,同時加上適當的ORDER BY字句。如果有對應的索引,通常效率會不錯,否則,MySQL需要做大量的檔案排序操作。
一個常見的問題是當偏移量非常大的時候,例如:LIMIT 10000 20這樣的查詢,MySQL需要查詢10020筆記錄然後只回傳20筆記錄,前面的10,000筆都將被拋棄,這樣的代價非常高。
優化這個查詢一個最簡單的辦法就是盡可能的使用覆寫索引掃描,而不是查詢所有的欄位。然後根據需要做一次關聯查詢再傳回所有的列。對於偏移量很大時,這樣做的效率會提升非常大。考慮下面的查詢:
Mysql代碼
SELECT film_id,description FROM film ORDER BY title LIMIT 50,5;
如果這張表非常大,那麼這個查詢最好改成下面的樣子:
Mysql程式碼
SELECT film.film_id, film.description
FROM film INNER JOIN (
SELECT film_id FROM#fil5
##) AS tmp USING(film_id);SET
,比如下面的查詢: SELECT id FROM t LIMIT 10000, 10;其他最佳化的方法還包括使用預先計算的總表,或關聯到一個冗餘表,冗餘表中只包含主鍵列和需要做排序的列。
最佳化UNION
MySQL處理UNION的策略是先建立暫存表,然後再把各個查詢結果插入到暫存表中,最後再來查詢。因此很多最佳化策略在UNION查詢中都沒有辦法很好的時候。經常需要手動將WHERE、LIMIT、ORDER BY等字句「下推」到各個子查詢中,以便優化器可以充分利用這些條件先優化。
除非確實需要伺服器去重,否則就一定要使用UNION ALL,如果沒有ALL關鍵字,MySQL會為臨時表加上DISTINCT選項,這會導致整個臨時表的數據做唯一性檢查,這樣做的代價非常高。當然即使使用ALL關鍵字,MySQL總是將結果放入臨時表,然後再讀出,然後再傳回給客戶端。雖然很多時候沒有這個必要,例如有時候可以直接把每個子查詢的結果回傳給客戶端。
結語
理解查詢是如何執行以及時間都消耗在哪些地方,再加上一些最佳化流程的知識,可以幫助大家更好的理解MySQL,理解常見優化技巧背後的原理。希望本文中的原理、範例能幫助大家更好的將理論和實務連結起來,更多的將理論知識運用到實務上。
其他也沒啥說的了,給大家留兩個思考題吧,可以在腦袋裡想想答案,這也是大家常掛在嘴邊的,但很少有人會思考為什麼?
有非常多的程式設計師在分享時都會拋出這樣一個觀點:盡可能不要使用預存程序,預存程序非常不容易維護,也會增加使用成本,應該把業務邏輯放到客戶端。既然客戶端都能做這些事,那為什麼還要預存流程?
JOIN本身也蠻方便的,直接查詢就好了,為什麼還需要檢視呢?
[1] 姜承堯著;MySQL技術內幕-InnoDB儲存引擎;機械工業出版社,2013
[2] Baron Scbwartz 等;寧海元周振興等譯;高性能MySQL(第三版); 電子工業出版社,2013
[3] 由 B-/B+樹看MySQL索引結構
以上是MySQL最佳化原理的詳細內容。更多資訊請關注PHP中文網其他相關文章!