華文網

萬字總結:學習MySQL優化原理,這一篇就夠了!

說起MySQL的查詢優化,相信大家收藏了一堆奇技淫巧:不能使用SELECT *、不使用NULL欄位、合理創建索引、為欄位選擇合適的資料類型..... 你是否真的理解這些優化技巧?是否理解其背後的工作原理?在實際場景下性能真有提升嗎?我想未必。

因而理解這些優化建議背後的原理就尤為重要,希望本文能讓你重新審視這些優化建議,並在實際業務場景下合理的運用。

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> 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的不斷發展,優化器使用的優化策略也在不斷的進化,這裡僅僅介紹幾個非常常用且容易理解的優化策略,其他的優化策略,大家自行查閱吧。

查詢執行引擎

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

返回結果給用戶端

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

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

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

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

用戶端向MySQL伺服器發送一條查詢請求

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

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

MySQL根據執行計畫,調用存儲引擎的API來執行查詢

將結果返回給用戶端,同時緩存查詢結果

性能優化建議

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

1Scheme設計與資料類型優化

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

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

通常來說把可為NULL的列改為NOT NULL不會對性能提升有多少説明,只是如果計畫在列上創建索引,就應該將該列設置為NOT NULL。

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

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

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

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

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

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

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

2創建高性能索引

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

接下來將向你展示一系列創建高性能索引的策略,以及每條策略其背後的工作原理。但在此之前,先瞭解與索引相關的一些演算法和資料結構,將有助於更好的理解後文的內容。

3索引相關的資料結構和演算法

通常我們所說的索引是指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

怎麼理解這兩個特徵?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也提供了類似于平衡二叉樹的旋轉功能。當Leaf Page已滿但其左右兄弟節點沒有滿的情況下,B+Tree並不急於去做拆分操作,而是將記錄移到當前所在頁的兄弟節點上。通常情況下,左兄弟會被先檢查用來做旋轉操作。就比如上面第二個示例,當插入70的時候,並不會去做頁拆分,而是左旋操作。

左旋操作

通過旋轉操作可以最大限度的減少頁分裂,從而減少索引維護過程中的磁片的I/O操作,也提高索引維護效率。需要注意的是,刪除節點跟插入節點類似,仍然需要旋轉和拆分操作,這裡就不再說明。

高性能策略

通過上文,相信你對B+Tree的資料結構已經有了大致的瞭解,但MySQL中索引是如何組織資料的存儲呢?以一個簡單的示例來說明,假如有如下 資料表:

CREATE TABLE People(

last_name varchar(50) not null,

first_name varchar(50) not null,

dob date not null,

gender enum(`m`,`f`) not null,

key(last_name,first_name,dob)

);

對於表中每一行資料,索引中包含了last_name、first_name、dob列的值,下圖展示了索引是如何組織資料存儲的。

索引如何組織資料存儲,來自:高性能MySQL

可以看到,索引首先根據第一個欄位來排列順序,當名字相同時,則根據第三個欄位,即出生日期來排序,正是因為這個原因,才有了索引的“最左原則”。

1、MySQL不會使用索引的情況:非獨立的列

“獨立的列”是指索引列不能是運算式的一部分,也不能是函數的參數。比如:

select * from where id + 1 = 5

我們很容易看出其等價於 id = 4,但是MySQL無法自動解析這個運算式,使用函數是同樣的道理。

2、首碼索引

如果列很長,通常可以索引開始的部分字元,這樣可以有效節約索引空間,從而提高索引效率。

3、多列索引和索引順序

在多數情況下,在多個列上建立獨立的索引並不能提高查詢性能。理由非常簡單,MySQL不知道選擇哪個索引的查詢效率更好,所以在老版本,比如MySQL5.0之前就會隨便選擇一個列的索引,而新的版本會採用合併索引的策略。舉個簡單的例子,在一張電影演員表中,在actor_id和film_id兩個列上都建立了獨立的索引,然後有如下查詢:

select film_id,actor_id from film_actor where actor_id = 1 or film_id = 1

老版本的MySQL會隨機選擇一個索引,但新版本做如下的優化:

select film_id,actor_id from film_actor where actor_id = 1

union all

select film_id,actor_id from film_actor where film_id = 1 and actor_id <> 1

當出現多個索引做相交操作時(多個AND條件),通常來說一個包含所有相關列的索引要優於多個獨立索引。

當出現多個索引做聯合操作時(多個OR條件),對結果集的合併、排序等操作需要耗費大量的CPU和記憶體資源,特別是當其中的某些索引的選擇性不高,需要返回合併大量資料時,查詢成本更高。所以這種情況下還不如走全資料表掃描。

因此explain時如果發現有索引合併(Extra欄位出現Using union),應該好好檢查一下查詢和表結構是不是已經是最優的,如果查詢和表都沒有問題,那只能說明索引建的非常糟糕,應當慎重考慮索引是否合適,有可能一個包含所有相關列的多列索引更適合。

前面我們提到過索引如何組織資料存儲的,從圖中可以看到多列索引時,索引的順序對於查詢是至關重要的,很明顯應該把選擇性更高的欄位放到索引的前面,這樣通過第一個欄位就可以過濾掉大多數不符合條件的資料。

索引選擇性是指不重複的索引值和資料表的總記錄數的比值,選擇性越高查詢效率越高,因為選擇性越高的索引可以讓MySQL在查詢時過濾掉更多的行。唯一索引的選擇性是1,這時最好的索引選擇性,性能也是最好的。

理解索引選擇性的概念後,就不難確定哪個欄位的選擇性較高了,查一下就知道了,比如:

SELECT * FROM payment where staff_id = 2 and customer_id = 584

是應該創建(staff_id,customer_id)的索引還是應該顛倒一下順序?執行下麵的查詢,哪個欄位的選擇性更接近1就把哪個欄位索引前面就好。

select count(distinct staff_id)/count(*) as staff_id_selectivity,

count(distinct customer_id)/count(*) as customer_id_selectivity,

count(*) from payment

多數情況下使用這個原則沒有任何問題,但仍然注意你的資料中是否存在一些特殊情況。舉個簡單的例子,比如要查詢某個使用者組下有過交易的使用者資訊:

select user_id from trade where user_group_id = 1 and trade_amount > 0

MySQL為這個查詢選擇了索引(user_group_id,trade_amount),如果不考慮特殊情況,這看起來沒有任何問題,但實際情況是這張表的大多數資料都是從老系統中遷移過來的,由於新老系統的資料不相容,所以就給老系統遷移過來的資料賦予了一個預設的用戶組。這種情況下,通過索引掃描的行數跟全資料表掃描基本沒什麼區別,索引也就起不到任何作用。

推廣開來說,經驗法則和推論在多數情況下是有用的,可以指導我們開發和設計,但實際情況往往會更複雜,實際業務場景下的某些特殊情況可能會摧毀你的整個設計。

4、避免多個範圍條件

實際開發中,我們會經常使用多個範圍條件,比如想查詢某個時間段內登錄過的用戶:

select user.* from user where login_time > '2017-04-01' and age between 18 and 30;

這個查詢有一個問題:它有兩個範圍條件,login_time列和age列,MySQL可以使用login_time列的索引或者age列的索引,但無法同時使用它們。

5、覆蓋索引

如果一個索引包含或者說覆蓋所有需要查詢的欄位的值,那麼就沒有必要再回表查詢,這就稱為覆蓋索引。覆蓋索引是非常有用的工具,可以極大的提高性能,因為查詢只需要掃描索引會帶來許多好處:

索引條目遠小於資料行大小,如果唯讀取索引,極大減少資料訪問量

索引是有按照列值順序存儲的,對於I/O密集型的範圍查詢要比隨機從磁片讀取每一行資料的IO要少的多

6、使用索引掃描來排序

MySQL有兩種方式可以生產有序的結果集,其一是對結果集進行排序的操作,其二是按照索引順序掃描得出的結果自然是有序的。如果explain的結果中type列的值為index表示使用了索引掃描來做排序。

掃描索引本身很快,因為只需要從一條索引記錄移動到相鄰的下一條記錄。但如果索引本身不能覆蓋所有需要查詢的列,那麼就不得不每掃描一條索引記錄就回表查詢一次對應的行。這個讀取操作基本上是隨機I/O,因此按照索引順序讀取資料的速度通常要比順序地全資料表掃描要慢。

在設計索引時,如果一個索引既能夠滿足排序,又滿足查詢,是最好的。

只有當索引的列順序和ORDER BY子句的順序完全一致,並且所有列的排序方向也一樣時,才能夠使用索引來對結果做排序。如果查詢需要關聯多張表,則只有ORDER BY子句引用的欄位全部為第一張表時,才能使用索引做排序。ORDER BY子句和查詢的限制是一樣的,都要滿足最左首碼的要求(有一種情況例外,就是最左的列被指定為常數,下面是一個簡單的示例),其它情況下都需要執行排序操作,而無法利用索引排序。

// 最左列為常數,索引:(date,staff_id,customer_id)

select staff_id,customer_id from demo where date = '2015-06-01' order by staff_id,customer_id

7、冗餘和重複索引

冗餘索引是指在相同的列上按照相同的順序創建的相同類型的索引,應當儘量避免這種索引,發現後立即刪除。比如有一個索引(A,B),再創建索引(A)就是冗餘索引。冗餘索引經常發生在為表添加新索引時,比如有人新建了索引(A,B),但這個索引不是擴展已有的索引(A)。

大多數情況下都應該儘量擴展已有的索引而不是創建新索引。但有極少情況下出現性能方面的考慮需要冗餘索引,比如擴展已有索引而導致其變得過大,從而影響到其他使用該索引的查詢。

8、刪除長期未使用的索引

定期刪除一些長時間未使用過的索引是一個非常好的習慣。

關於索引這個話題打算就此打住,最後要說一句,索引並不總是最好的工具,只有當索引説明提高查詢速度帶來的好處大於其帶來的額外工作時,索引才是有效的。對於非常小的表,簡單的全資料表掃描更高效。對於中到大型的表,索引就非常有效。對於超大型的表,建立和維護索引的代價隨之增長,這時候其他技術也許更有效,比如分區表。最後的最後,explain後再提測是一種美德。

特定類型查詢優化

1.優化COUNT()查詢

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

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

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

2.優化關聯查詢

在大資料場景下,表與表之間通過一個冗余欄位來關聯,要比直接使用JOIN有更好的性能。如果確實需要使用關聯查詢的情況下,需要特別注意的是:

確保ON和USING字句中的列上有索引。在創建索引的時候就要考慮到關聯的順序。當表A和表B用列c關聯的時候,如果優化器關聯的順序是A、B,那麼就不需要在A表的對應列上創建索引。沒有用到的索引會帶來額外的負擔,一般來說,除非有其他理由,只需要在關聯順序中的第二張表的相應列上創建索引(具體原因下文分析)。

確保任何的GROUP BY和ORDER BY中的運算式只涉及到一個表中的列,這樣MySQL才有可能使用索引來優化。

要理解優化關聯查詢的第一個技巧,就需要理解MySQL是如何執行關聯查詢的。當前MySQL關聯執行的策略非常簡單,它對任何的關聯都執行嵌套迴圈關聯操作,即先在一個表中迴圈取出單條資料,然後在嵌套迴圈到下一個表中尋找匹配的行,依次下去,直到找到所有表中匹配的行為為止。然後根據各個表匹配的行,返回查詢中需要的各個列。

太抽象了?以上面的示例來說明,比如有這樣的一個查詢:

SELECT A.xx,B.yy

FROM A INNER JOIN B USING(c)

WHERE A.xx IN (5,6)

假設MySQL按照查詢中的關聯順序A、B來進行關聯操作,那麼可以用下面的偽代碼表示MySQL如何完成這個查詢:

outer_iterator = SELECT A.xx,A.c FROM A WHERE A.xx IN (5,6);

outer_row = outer_iterator.next;

while(outer_row) {

inner_iterator = SELECT B.yy FROM B WHERE B.c = outer_row.c;

inner_row = inner_iterator.next;

while(inner_row) {

output[inner_row.yy,outer_row.xx];

inner_row = inner_iterator.next;

}

outer_row = outer_iterator.next;

}

可以看到,最外層的查詢是根據A.xx列來查詢的,A.c上如果有索引的話,整個關聯查詢也不會使用。再看內層的查詢,很明顯B.c上如果有索引的話,能夠加速查詢,因此只需要在關聯順序中的第二張表的相應列上創建索引即可。

3.優化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;

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

4.優化UNION

MySQL處理UNION的策略是先創建臨時表,然後再把各個查詢結果插入到臨時表中,最後再來做查詢。因此很多優化策略在UNION查詢中都沒有辦法很好的時候。經常需要手動將WHERE、LIMIT、ORDER BY等字句“下推”到各個子查詢中,以便優化器可以充分利用這些條件先優化。

除非確實需要伺服器去重,否則就一定要使用UNION ALL,如果沒有ALL關鍵字,MySQL會給臨時表加上DISTINCT選項,這會導致整個臨時表的資料做唯一性檢查,這樣做的代價非常高。當然即使使用ALL關鍵字,MySQL總是將結果放入臨時表,然後再讀出,再返回給用戶端。雖然很多時候沒有這個必要,比如有時候可以直接把每個子查詢的結果返回給用戶端。

結語

理解查詢是如何執行以及時間都消耗在哪些地方,再加上一些優化過程的知識,可以幫助大家更好的理解MySQL,理解常見優化技巧背後的原理。希望本文中的原理、示例能夠幫助大家更好的將理論和實踐聯繫起來,更多的將理論知識運用到實踐中。

其他也沒啥說的了,給大家留兩個思考題吧,可以在腦袋裡想想答案,這也是大家經常掛在嘴邊的,但很少有人會思考為什麼?

有非常多的程式師在分享時都會拋出這樣一個觀點:盡可能不要使用存儲過程,存儲過程非常不容易維護,也會增加使用成本,應該把業務邏輯放到用戶端。既然用戶端都能幹這些事,那為什麼還要存儲過程?

JOIN本身也挺方便的,直接查詢就好了,為什麼還需要視圖呢?

參考資料

薑承堯 著;MySQL技術內幕-InnoDB存儲引擎;機械工業出版社,2013

Baron Scbwartz 等著;甯海元 周振興等譯;高性能MySQL(第三版); 電子工業出版社, 2013

由 B-/B+樹看 MySQL索引結構

https://segmentfault.com/a/1190000004690721

1、具有1-5工作經驗的,面對目前流行的技術不知從何下手,需要突破技術瓶頸的可以加群。

2、在公司待久了,過得很安逸,但跳槽時面試碰壁。需要在短時間內進修、跳槽拿高薪的可以加群。

3、如果沒有工作經驗,但基礎非常扎實,對java工作機制,常用設計思想,常用java開發框架掌握熟練的,可以加群。

4、覺得自己很牛B,一般需求都能搞定。但是所學的知識點沒有系統化,很難在技術領域繼續突破的可以加群。

5. 群號:高級架構群 651013434備註好資訊!

6.阿裡Java高級架構師免費直播講解知識點,分享知識,多年工作經驗的梳理和總結,帶著大家全面、科學地建立自己的技術體系和技術認知!

那麼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的不斷發展,優化器使用的優化策略也在不斷的進化,這裡僅僅介紹幾個非常常用且容易理解的優化策略,其他的優化策略,大家自行查閱吧。

查詢執行引擎

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

返回結果給用戶端

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

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

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

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

用戶端向MySQL伺服器發送一條查詢請求

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

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

MySQL根據執行計畫,調用存儲引擎的API來執行查詢

將結果返回給用戶端,同時緩存查詢結果

性能優化建議

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

1Scheme設計與資料類型優化

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

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

通常來說把可為NULL的列改為NOT NULL不會對性能提升有多少説明,只是如果計畫在列上創建索引,就應該將該列設置為NOT NULL。

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

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

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

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

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

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

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

2創建高性能索引

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

接下來將向你展示一系列創建高性能索引的策略,以及每條策略其背後的工作原理。但在此之前,先瞭解與索引相關的一些演算法和資料結構,將有助於更好的理解後文的內容。

3索引相關的資料結構和演算法

通常我們所說的索引是指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

怎麼理解這兩個特徵?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也提供了類似于平衡二叉樹的旋轉功能。當Leaf Page已滿但其左右兄弟節點沒有滿的情況下,B+Tree並不急於去做拆分操作,而是將記錄移到當前所在頁的兄弟節點上。通常情況下,左兄弟會被先檢查用來做旋轉操作。就比如上面第二個示例,當插入70的時候,並不會去做頁拆分,而是左旋操作。

左旋操作

通過旋轉操作可以最大限度的減少頁分裂,從而減少索引維護過程中的磁片的I/O操作,也提高索引維護效率。需要注意的是,刪除節點跟插入節點類似,仍然需要旋轉和拆分操作,這裡就不再說明。

高性能策略

通過上文,相信你對B+Tree的資料結構已經有了大致的瞭解,但MySQL中索引是如何組織資料的存儲呢?以一個簡單的示例來說明,假如有如下 資料表:

CREATE TABLE People(

last_name varchar(50) not null,

first_name varchar(50) not null,

dob date not null,

gender enum(`m`,`f`) not null,

key(last_name,first_name,dob)

);

對於表中每一行資料,索引中包含了last_name、first_name、dob列的值,下圖展示了索引是如何組織資料存儲的。

索引如何組織資料存儲,來自:高性能MySQL

可以看到,索引首先根據第一個欄位來排列順序,當名字相同時,則根據第三個欄位,即出生日期來排序,正是因為這個原因,才有了索引的“最左原則”。

1、MySQL不會使用索引的情況:非獨立的列

“獨立的列”是指索引列不能是運算式的一部分,也不能是函數的參數。比如:

select * from where id + 1 = 5

我們很容易看出其等價於 id = 4,但是MySQL無法自動解析這個運算式,使用函數是同樣的道理。

2、首碼索引

如果列很長,通常可以索引開始的部分字元,這樣可以有效節約索引空間,從而提高索引效率。

3、多列索引和索引順序

在多數情況下,在多個列上建立獨立的索引並不能提高查詢性能。理由非常簡單,MySQL不知道選擇哪個索引的查詢效率更好,所以在老版本,比如MySQL5.0之前就會隨便選擇一個列的索引,而新的版本會採用合併索引的策略。舉個簡單的例子,在一張電影演員表中,在actor_id和film_id兩個列上都建立了獨立的索引,然後有如下查詢:

select film_id,actor_id from film_actor where actor_id = 1 or film_id = 1

老版本的MySQL會隨機選擇一個索引,但新版本做如下的優化:

select film_id,actor_id from film_actor where actor_id = 1

union all

select film_id,actor_id from film_actor where film_id = 1 and actor_id <> 1

當出現多個索引做相交操作時(多個AND條件),通常來說一個包含所有相關列的索引要優於多個獨立索引。

當出現多個索引做聯合操作時(多個OR條件),對結果集的合併、排序等操作需要耗費大量的CPU和記憶體資源,特別是當其中的某些索引的選擇性不高,需要返回合併大量資料時,查詢成本更高。所以這種情況下還不如走全資料表掃描。

因此explain時如果發現有索引合併(Extra欄位出現Using union),應該好好檢查一下查詢和表結構是不是已經是最優的,如果查詢和表都沒有問題,那只能說明索引建的非常糟糕,應當慎重考慮索引是否合適,有可能一個包含所有相關列的多列索引更適合。

前面我們提到過索引如何組織資料存儲的,從圖中可以看到多列索引時,索引的順序對於查詢是至關重要的,很明顯應該把選擇性更高的欄位放到索引的前面,這樣通過第一個欄位就可以過濾掉大多數不符合條件的資料。

索引選擇性是指不重複的索引值和資料表的總記錄數的比值,選擇性越高查詢效率越高,因為選擇性越高的索引可以讓MySQL在查詢時過濾掉更多的行。唯一索引的選擇性是1,這時最好的索引選擇性,性能也是最好的。

理解索引選擇性的概念後,就不難確定哪個欄位的選擇性較高了,查一下就知道了,比如:

SELECT * FROM payment where staff_id = 2 and customer_id = 584

是應該創建(staff_id,customer_id)的索引還是應該顛倒一下順序?執行下麵的查詢,哪個欄位的選擇性更接近1就把哪個欄位索引前面就好。

select count(distinct staff_id)/count(*) as staff_id_selectivity,

count(distinct customer_id)/count(*) as customer_id_selectivity,

count(*) from payment

多數情況下使用這個原則沒有任何問題,但仍然注意你的資料中是否存在一些特殊情況。舉個簡單的例子,比如要查詢某個使用者組下有過交易的使用者資訊:

select user_id from trade where user_group_id = 1 and trade_amount > 0

MySQL為這個查詢選擇了索引(user_group_id,trade_amount),如果不考慮特殊情況,這看起來沒有任何問題,但實際情況是這張表的大多數資料都是從老系統中遷移過來的,由於新老系統的資料不相容,所以就給老系統遷移過來的資料賦予了一個預設的用戶組。這種情況下,通過索引掃描的行數跟全資料表掃描基本沒什麼區別,索引也就起不到任何作用。

推廣開來說,經驗法則和推論在多數情況下是有用的,可以指導我們開發和設計,但實際情況往往會更複雜,實際業務場景下的某些特殊情況可能會摧毀你的整個設計。

4、避免多個範圍條件

實際開發中,我們會經常使用多個範圍條件,比如想查詢某個時間段內登錄過的用戶:

select user.* from user where login_time > '2017-04-01' and age between 18 and 30;

這個查詢有一個問題:它有兩個範圍條件,login_time列和age列,MySQL可以使用login_time列的索引或者age列的索引,但無法同時使用它們。

5、覆蓋索引

如果一個索引包含或者說覆蓋所有需要查詢的欄位的值,那麼就沒有必要再回表查詢,這就稱為覆蓋索引。覆蓋索引是非常有用的工具,可以極大的提高性能,因為查詢只需要掃描索引會帶來許多好處:

索引條目遠小於資料行大小,如果唯讀取索引,極大減少資料訪問量

索引是有按照列值順序存儲的,對於I/O密集型的範圍查詢要比隨機從磁片讀取每一行資料的IO要少的多

6、使用索引掃描來排序

MySQL有兩種方式可以生產有序的結果集,其一是對結果集進行排序的操作,其二是按照索引順序掃描得出的結果自然是有序的。如果explain的結果中type列的值為index表示使用了索引掃描來做排序。

掃描索引本身很快,因為只需要從一條索引記錄移動到相鄰的下一條記錄。但如果索引本身不能覆蓋所有需要查詢的列,那麼就不得不每掃描一條索引記錄就回表查詢一次對應的行。這個讀取操作基本上是隨機I/O,因此按照索引順序讀取資料的速度通常要比順序地全資料表掃描要慢。

在設計索引時,如果一個索引既能夠滿足排序,又滿足查詢,是最好的。

只有當索引的列順序和ORDER BY子句的順序完全一致,並且所有列的排序方向也一樣時,才能夠使用索引來對結果做排序。如果查詢需要關聯多張表,則只有ORDER BY子句引用的欄位全部為第一張表時,才能使用索引做排序。ORDER BY子句和查詢的限制是一樣的,都要滿足最左首碼的要求(有一種情況例外,就是最左的列被指定為常數,下面是一個簡單的示例),其它情況下都需要執行排序操作,而無法利用索引排序。

// 最左列為常數,索引:(date,staff_id,customer_id)

select staff_id,customer_id from demo where date = '2015-06-01' order by staff_id,customer_id

7、冗餘和重複索引

冗餘索引是指在相同的列上按照相同的順序創建的相同類型的索引,應當儘量避免這種索引,發現後立即刪除。比如有一個索引(A,B),再創建索引(A)就是冗餘索引。冗餘索引經常發生在為表添加新索引時,比如有人新建了索引(A,B),但這個索引不是擴展已有的索引(A)。

大多數情況下都應該儘量擴展已有的索引而不是創建新索引。但有極少情況下出現性能方面的考慮需要冗餘索引,比如擴展已有索引而導致其變得過大,從而影響到其他使用該索引的查詢。

8、刪除長期未使用的索引

定期刪除一些長時間未使用過的索引是一個非常好的習慣。

關於索引這個話題打算就此打住,最後要說一句,索引並不總是最好的工具,只有當索引説明提高查詢速度帶來的好處大於其帶來的額外工作時,索引才是有效的。對於非常小的表,簡單的全資料表掃描更高效。對於中到大型的表,索引就非常有效。對於超大型的表,建立和維護索引的代價隨之增長,這時候其他技術也許更有效,比如分區表。最後的最後,explain後再提測是一種美德。

特定類型查詢優化

1.優化COUNT()查詢

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

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

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

2.優化關聯查詢

在大資料場景下,表與表之間通過一個冗余欄位來關聯,要比直接使用JOIN有更好的性能。如果確實需要使用關聯查詢的情況下,需要特別注意的是:

確保ON和USING字句中的列上有索引。在創建索引的時候就要考慮到關聯的順序。當表A和表B用列c關聯的時候,如果優化器關聯的順序是A、B,那麼就不需要在A表的對應列上創建索引。沒有用到的索引會帶來額外的負擔,一般來說,除非有其他理由,只需要在關聯順序中的第二張表的相應列上創建索引(具體原因下文分析)。

確保任何的GROUP BY和ORDER BY中的運算式只涉及到一個表中的列,這樣MySQL才有可能使用索引來優化。

要理解優化關聯查詢的第一個技巧,就需要理解MySQL是如何執行關聯查詢的。當前MySQL關聯執行的策略非常簡單,它對任何的關聯都執行嵌套迴圈關聯操作,即先在一個表中迴圈取出單條資料,然後在嵌套迴圈到下一個表中尋找匹配的行,依次下去,直到找到所有表中匹配的行為為止。然後根據各個表匹配的行,返回查詢中需要的各個列。

太抽象了?以上面的示例來說明,比如有這樣的一個查詢:

SELECT A.xx,B.yy

FROM A INNER JOIN B USING(c)

WHERE A.xx IN (5,6)

假設MySQL按照查詢中的關聯順序A、B來進行關聯操作,那麼可以用下面的偽代碼表示MySQL如何完成這個查詢:

outer_iterator = SELECT A.xx,A.c FROM A WHERE A.xx IN (5,6);

outer_row = outer_iterator.next;

while(outer_row) {

inner_iterator = SELECT B.yy FROM B WHERE B.c = outer_row.c;

inner_row = inner_iterator.next;

while(inner_row) {

output[inner_row.yy,outer_row.xx];

inner_row = inner_iterator.next;

}

outer_row = outer_iterator.next;

}

可以看到,最外層的查詢是根據A.xx列來查詢的,A.c上如果有索引的話,整個關聯查詢也不會使用。再看內層的查詢,很明顯B.c上如果有索引的話,能夠加速查詢,因此只需要在關聯順序中的第二張表的相應列上創建索引即可。

3.優化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;

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

4.優化UNION

MySQL處理UNION的策略是先創建臨時表,然後再把各個查詢結果插入到臨時表中,最後再來做查詢。因此很多優化策略在UNION查詢中都沒有辦法很好的時候。經常需要手動將WHERE、LIMIT、ORDER BY等字句“下推”到各個子查詢中,以便優化器可以充分利用這些條件先優化。

除非確實需要伺服器去重,否則就一定要使用UNION ALL,如果沒有ALL關鍵字,MySQL會給臨時表加上DISTINCT選項,這會導致整個臨時表的資料做唯一性檢查,這樣做的代價非常高。當然即使使用ALL關鍵字,MySQL總是將結果放入臨時表,然後再讀出,再返回給用戶端。雖然很多時候沒有這個必要,比如有時候可以直接把每個子查詢的結果返回給用戶端。

結語

理解查詢是如何執行以及時間都消耗在哪些地方,再加上一些優化過程的知識,可以幫助大家更好的理解MySQL,理解常見優化技巧背後的原理。希望本文中的原理、示例能夠幫助大家更好的將理論和實踐聯繫起來,更多的將理論知識運用到實踐中。

其他也沒啥說的了,給大家留兩個思考題吧,可以在腦袋裡想想答案,這也是大家經常掛在嘴邊的,但很少有人會思考為什麼?

有非常多的程式師在分享時都會拋出這樣一個觀點:盡可能不要使用存儲過程,存儲過程非常不容易維護,也會增加使用成本,應該把業務邏輯放到用戶端。既然用戶端都能幹這些事,那為什麼還要存儲過程?

JOIN本身也挺方便的,直接查詢就好了,為什麼還需要視圖呢?

參考資料

薑承堯 著;MySQL技術內幕-InnoDB存儲引擎;機械工業出版社,2013

Baron Scbwartz 等著;甯海元 周振興等譯;高性能MySQL(第三版); 電子工業出版社, 2013

由 B-/B+樹看 MySQL索引結構

https://segmentfault.com/a/1190000004690721

1、具有1-5工作經驗的,面對目前流行的技術不知從何下手,需要突破技術瓶頸的可以加群。

2、在公司待久了,過得很安逸,但跳槽時面試碰壁。需要在短時間內進修、跳槽拿高薪的可以加群。

3、如果沒有工作經驗,但基礎非常扎實,對java工作機制,常用設計思想,常用java開發框架掌握熟練的,可以加群。

4、覺得自己很牛B,一般需求都能搞定。但是所學的知識點沒有系統化,很難在技術領域繼續突破的可以加群。

5. 群號:高級架構群 651013434備註好資訊!

6.阿裡Java高級架構師免費直播講解知識點,分享知識,多年工作經驗的梳理和總結,帶著大家全面、科學地建立自己的技術體系和技術認知!