優化背后的工作原理
MySQL邏輯架構
MySQL邏輯架構整體分為三層,最上層為客戶端層,并非MySQL所獨有,諸如:連接處理、授權認證、安全等功能均在這一層處理。
MySQL大多數核心服務均在中間這一層,包括查詢解析、分析、優化、緩存、內置函數(比如:時間、數學、加密等函數)。所有的跨存儲引擎的功能也在這一層實現:存儲過程、觸發器、視圖等。
最下層為存儲引擎,其負責MySQL中的數據存儲和提取。和Linux下的文件系統類似,每種存儲引擎都有其優勢和劣勢。中間的服務層通過API與存儲引擎通信,這些API接口屏蔽了不同存儲引擎間的差異。
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會生成對應的執行計劃,查詢執行引擎根據執行計劃給出的指令逐步執行得出結果。整個執行過程的大部分操作均是通過調用存儲引擎實現的接口來完成,這些接口被稱為handler API。查詢過程中的每一張表由一個handler實例表示。實際上,MySQL在查詢優化階段就為每一張表創建了一個handler實例,優化器可以根據這些實例的接口來獲取表的相關信息,包括表的所有列名、索引統計信息等。存儲引擎接口提供了非常豐富的功能,但其底層僅有幾十個接口,這些接口像搭積木一樣完成了一次查詢的大部分操作。
返回結果給客戶端
查詢執行的最后一個階段就是將結果返回給客戶端。即使查詢不到數據,MySQL仍然會返回這個查詢的相關信息,比如該查詢影響到的行數以及執行時間等等。
如果查詢緩存被打開且這個查詢可以被緩存,MySQL也會將結果存放到緩存中。
結果集返回客戶端是一個增量且逐步返回的過程。有可能MySQL在生成第一條結果時,就開始向客戶端逐步返回結果集了。這樣服務端就無須存儲太多結果而消耗過多內存,也可以讓客戶端第一時間獲得返回結果。需要注意的是,結果集中的每一行都會以一個滿足①中所描述的通信協議的數據包發送,再通過TCP協議進行傳輸,在傳輸過程中,可能對MySQL的數據包進行緩存然后批量發送。
小結
- 客戶端向MySQL服務器發送一條查詢請求
- 服務器首先檢查查詢緩存,如果命中緩存,則立刻返回存儲在緩存中的結果。否則進入下一階段
- 服務器進行SQL解析、預處理、再由優化器生成對應的執行計劃
- MySQL根據執行計劃,調用存儲引擎的API來執行查詢
- 將結果返回給客戶端,同時緩存查詢結果
性能優化建議
不要聽信你看到的關于優化的“絕對真理”,包括本文所討論的內容,而應該是在實際的業務場景下通過測試來驗證你關于執行計劃以及響應時間的假設。
Scheme設計與數據類型優化
選數據類型遵循小而簡單的原則
越小的數據類型通常會更快,占用更少的磁盤、內存,處理時需要的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執行大部分修改表結果操作的方法是用新的結構創建一個張空表,從舊表中查出所有的數據插入新表,然后再刪除舊表。尤其當內存不足而表又很大,而且還有很大索引的情況下,耗時更久。當然有一些奇技淫巧可以解決這個問題,有興趣可自行查閱。
創建高性能索引
通常我們所說的索引是指B-Tree索引,它是目前關系型數據庫中查找數據最為常用和有效的索引,大多數存儲引擎都支持這種索引。使用B-Tree這個術語,是因為MySQL在CREATE TABLE或其它語句中使用了這個關鍵字,但實際上不同的存儲引擎可能使用不同的數據結構,比如InnoDB就是使用的B+Tree。
B+Tree中的B是指balance,意為平衡。需要注意的是,B+樹索引并不能找到一個給定鍵值的具體行,它找到的只是被查找數據行所在的頁,接著數據庫會把頁讀入到內存,再在內存中進行查找,最后得到要查找的數據。
考慮一個問題,平衡二叉樹的查找效率還不錯,實現也非常簡單,相應的維護成本還能接受,為什么MySQL索引不直接使用平衡二叉樹?
隨著數據庫中數據的增加,索引本身大小隨之增加,不可能全部存儲在內存中,因此索引往往以索引文件的形式存儲的磁盤上。這樣的話,索引查找過程中就要產生磁盤I/O消耗,相對于內存存取,I/O存取的消耗要高幾個數量級。可以想象一下一棵幾百萬節點的二叉樹的深度是多少?如果將這么大深度的一顆二叉樹放磁盤上,每讀取一個節點,需要一次磁盤的I/O讀取,整個查找的耗時顯然是不能夠接受的。那么如何減少查找過程中的I/O存取次數?
一種行之有效的解決方法是減少樹的深度,將二叉樹變為m叉樹(多路搜索樹),而B+Tree就是一種多路搜索樹。理解B+Tree時,只需要理解其最重要的兩個特征即可:第一,所有的關鍵字(可以理解為數據)都存儲在葉子節點(Leaf Page),非葉子節點(Index Page)并不存儲真正的數據,所有記錄節點都是按鍵值大小順序存放在同一層葉子節點上。其次,所有的葉子節點由指針連接。如下圖為高度為2的簡化了的B+Tree。
怎么理解這兩個特征?MySQL將每個節點的大小設置為一個頁的整數倍(磁盤頁,局部性原理,提高I/O效率),也就是在節點空間大小一定的情況下,每個節點可以存儲更多的內結點,這樣每個結點能索引的范圍更大更精確。
所有的葉子節點使用指針鏈接的好處是可以進行區間訪問,比如上圖中,如果查找大于20而小于30的記錄,只需要找到節點20,就可以遍歷指針依次找到25、30。如果沒有鏈接指針的話,就無法進行區間查找。這也是MySQL使用B+Tree作為索引存儲結構的重要原因。
MySQL為何將節點大小設置為頁的整數倍?
這就需要理解磁盤的存儲原理。磁盤本身存取就比主存慢很多,在加上機械運動損耗(特別是普通的機械硬盤),磁盤的存取速度往往是主存的幾百萬分之一,為了盡量減少磁盤I/O,磁盤往往不是嚴格按需讀取,而是每次都會預讀,即使只需要一個字節,磁盤也會從這個位置開始,順序向后讀取一定長度的數據放入內存,預讀的長度一般為頁的整數倍。
頁是計算機管理存儲器的邏輯塊,硬件及OS往往將主存和磁盤存儲區分割為連續的大小相等的塊,每個存儲塊稱為一頁(許多OS中,頁的大小通常為4K)。主存和磁盤以頁為單位交換數據。當程序要讀取的數據不在主存中時,會觸發一個缺頁異常,此時系統會向磁盤發出讀盤信號,磁盤會找到數據的起始位置并向后連續讀取一頁或幾頁載入內存中,然后一起返回,程序繼續運行。
MySQL巧妙利用了磁盤預讀原理,將一個節點的大小設為等于一個頁,這樣每個節點只需要一次I/O就可以完全載入。
為了達到這個目的,每次新建節點時,直接申請一個頁的空間,這樣就保證一個節點物理上也存儲在一個頁里,加之計算機存儲分配都是按頁對齊的,就實現了讀取一個節點只需一次I/O。假設B+Tree的高度為h,一次檢索最多需要h-1次I/O(根節點常駐內存),復雜度O(h) = O(logmN)。實際應用場景中,M通常較大,常常超過100,因此樹的高度一般都比較小,通常不超過3。
高性能策略
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列的值,下圖展示了索引是如何組織數據存儲的。
可以看到,索引首先根據第一個字段來排列順序,當名字相同時,則根據第三個字段,即出生日期來排序,正是因為這個原因,才有了索引的“最左原則”。
1、MySQL不會使用索引的情況:非獨立的列
“獨立的列”是指索引列不能是表達式的一部分,也不能是函數的參數。2、前綴索引
如果列很長,通常可以索引開始的部分字符,這樣可以有效節約索引空間,從而提高索引效率。-
3、多列索引和索引順序
在多數情況下,在多個列上建立獨立的索引并不能提高查詢性能。理由非常簡單,MySQL不知道選擇哪個索引的查詢效率更好,所以在老版本,比如MySQL5.0之前就會隨便選擇一個列的索引,而新的版本會采用合并索引的策略。- 當出現多個索引做相交操作時(多個AND條件),通常來說一個包含所有相關列的索引要優于多個獨立索引。
- 當出現多個索引做聯合操作時(多個OR條件),對結果集的合并、排序等操作需要耗費大量的CPU和內存資源,特別是當其中的某些索引的選擇性不高,需要返回合并大量數據時,查詢成本更高。所以這種情況下還不如走全表掃描。
因此explain時如果發現有索引合并(Extra字段出現Using union),應該好好檢查一下查詢和表結構是不是已經是最優的。
前面我們提到過索引如何組織數據存儲的,從圖中可以看到多列索引時,索引的順序對于查詢是至關重要的,很明顯應該把選擇性更高的字段放到索引的前面,這樣通過第一個字段就可以過濾掉大多數不符合條件的數據。
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、使用索引掃描來排序
在設計索引時,如果一個索引既能夠滿足排序,又滿足查詢,是最好的。
特定類型查詢優化
優化COUNT()查詢
COUNT()可能是被大家誤解最多的函數了,它有兩種不同的作用,其一是統計某個列值的數量,其二是統計行數。統計列值時,要求列值是非空的,它不會統計NULL。如果確認括號中的表達式不可能為空時,實際上就是在統計行數。最簡單的就是當使用COUNT(*)時,并不是我們所想象的那樣擴展成所有的列,實際上,它會忽略所有的列而直接統計行數。
我們最常見的誤解也就在這兒,在括號內指定了一列卻希望統計結果是行數,而且還常常誤以為前者的性能會更好。但實際并非這樣,如果要統計行數,直接使用COUNT(*),意義清晰,且性能更好。
優化關聯查詢
在大數據場景下,表與表之間通過一個冗余字段來關聯,要比直接使用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上如果有索引的話,能夠加速查詢,因此只需要在關聯順序中的第二張表的相應列上創建索引即可。
優化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);
有時候如果可以使用書簽記錄上次取數據的位置,那么下次就可以直接從該書簽記錄的位置開始掃描,這樣就可以避免使用OFFSET,比如下面的查詢:
SELECT id FROM t LIMIT 10000, 10;
改為:
SELECT id FROM t WHERE id > 10000 LIMIT 10;
其他優化的辦法還包括使用預先計算的匯總表,或者關聯到一個冗余表,冗余表中只包含主鍵列和需要做排序的列。
優化UNION
MySQL處理UNION的策略是先創建臨時表,然后再把各個查詢結果插入到臨時表中,最后再來做查詢。因此很多優化策略在UNION查詢中都沒有辦法很好的時候。經常需要手動將WHERE、LIMIT、ORDER BY等字句“下推”到各個子查詢中,以便優化器可以充分利用這些條件先優化。
除非確實需要服務器去重,否則就一定要使用UNION ALL,如果沒有ALL關鍵字,MySQL會給臨時表加上DISTINCT選項,這會導致整個臨時表的數據做唯一性檢查,這樣做的代價非常高。當然即使使用ALL關鍵字,MySQL總是將結果放入臨時表,然后再讀出,再返回給客戶端。雖然很多時候沒有這個必要,比如有時候可以直接把每個子查詢的結果返回給客戶端。
Ref:
作者:CHEN川
鏈接:http://www.lxweimin.com/p/d7665192aaaf