1. 程式人生 > >MySQL邏輯架構及效能優化原理

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必須將對應表的所有快取都設定為失效。如果查詢快取非常大或者碎片很多,這個操作就可能帶來很大的系統消耗,甚至導致系統僵死一會兒。而且查詢快取對系統的額外消耗也不僅僅在寫操作,讀操作也不例外:


  1. 任何的查詢語句在開始之前都必須經過檢查,即使這條SQL語句永遠不會命中快取
  2. 如果查詢結果可以被快取,那麼執行完成後,會將結果存入快取,也會帶來額外的系統消耗

  基於此,我們要知道並不是什麼情況下查詢快取都會提高系統性能,快取和失效都會帶來額外消耗,只有當快取帶來的資源節約大於其本身消耗的資源時,才會給系統帶來效能提升。如果系統確實存在一些效能問題,可以嘗試開啟查詢快取,並在資料庫設計上做一些優化,比如:
  1. 多個小表代替一個大表,注意不要過度設計
  2. 批量插入代替迴圈單條插入
  3. 合理控制快取空間大小,一般來說其大小設定為幾十兆比較合適
  4. 可以通過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選擇錯誤的執行計劃,比如統計資訊不準確、不會考慮不受其控制的操作成本(使用者自定義函式、儲存過程)、MySQL認為的最優跟我們想的最優並不一樣(我們希望執行時間儘可能短,但MySQL值選擇它認為成本小的,但成本小並不意味著執行時間短)等等。
  MySQL的查詢優化器是一個非常複雜的部件,它使用了非常多的優化策略來生成一個最優的執行計劃:   1. 重新定義表的關聯順序(多張表關聯查詢時,並不一定按照SQL中指定的順序進行,但有一些技巧可以指定關聯順序)
   2. 優化MIN()和MAX()函式(找某列的最小值,如果該列有索引,只需要查詢B+Tree索引最左端,反之則可以找到最大值,具體原理見下文)
  3. 提前終止查詢(比如:使用Limit時,查詢到滿足數量的結果集後會立即終止查詢)
  4. 優化排序(在老版本MySQL會使用兩次傳輸排序,即先讀取行指標和需要排序的欄位在記憶體中對其排序,然後再根據排序結果去讀取資料行,而新版本採用的是單次傳輸排序,也就是一次讀取所有的資料行,然後根據給定的列排序。對於I/O密集型應用,效率會高很多)

查詢執行引擎

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

返回結果給客戶端

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

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

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

  回頭總結一下MySQL整個查詢執行過程,總的來說分為6個步驟:
  1. 客戶端向MySQL伺服器傳送一條查詢請求
  2. 伺服器首先檢查查詢快取,如果命中快取,則立刻返回儲存在快取中的結果。否則進入下一階段
  3. 伺服器進行SQL解析、預處理、再由優化器生成對應的執行計劃
  4. MySQL根據執行計劃,呼叫儲存引擎的API來執行查詢
  5. 將結果返回給客戶端,同時快取查詢結果

效能優化建議

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

表的設計與資料型別優化

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

  這裡總結幾個可能容易理解錯誤的技巧:
  1. 通常來說把可為NULL的列改為NOT NULL不會對效能提升有多少幫助,只是如果計劃在列上建立索引,就應該將該列設定為NOT NULL。
  2. 對整數型別指定寬度,比如INT(11),沒有任何卵用。INT使用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. 表的列不要太多。原因是儲存引擎的API工作時需要在伺服器層和儲存引擎層之間通過行緩衝格式拷貝資料,然後在伺服器層將緩衝內容解碼成各個列,這個轉換過程的代價是非常高的。如果列太多而實際使用的列又很少的話,有可能會導致CPU佔用過高。
  8.大表ALTER TABLE非常耗時,MySQL執行大部分修改表結果操作的方法是用新的結構建立一個張空表,從舊錶中查出所有的資料插入新表,然後再刪除舊錶。尤其當記憶體不足而表又很大,而且還有很大索引的情況下,耗時更久。

建立高效能索引

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

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

  通常我們所說的索引是指B-Tree索引,它是目前關係型資料庫中查詢資料最為常用和有效的索引,大多數儲存引擎都支援這種索引。使用B-Tree這個術語,是因為MySQL在CREATE TABLE或其它語句中使用了這個關鍵字,但實際上不同的儲存引擎可能使用不同的資料結構,比如InnoDB儲存引擎就是使用的B+Tree。

  B+Tree中的B是指balance,意為平衡。需要注意的是,B+樹索引並不能找到一個給定鍵值的具體行,它找到的只是被查詢資料行所在的頁,接著資料庫會把頁讀入到記憶體,再在記憶體中進行查詢,最後得到要查詢的資料。

  隨著資料庫中資料的增加,索引本身大小隨之增加,不可能全部儲存在記憶體中,因此索引往往以索引檔案的形式儲存的磁碟上。這樣的話,索引查詢過程中就要產生磁碟I/O消耗,相對於記憶體存取,I/O存取的消耗要高几個數量級。可以想象一下一棵幾百萬節點的二叉樹的深度是多少?如果將這麼大深度的一顆二叉樹放磁碟上,每讀取一個節點,需要一次磁碟的I/O讀取,整個查詢的耗時顯然是不能夠接受的。那麼如何減少查詢過程中的I/O存取次數?

  一種行之有效的解決方法是減少樹的深度,將二叉樹變為m叉樹(多路搜尋樹),而B+Tree就是一種多路搜尋樹。理解B+Tree時,只需要理解其最重要的兩個特徵即可:第一,所有的關鍵字(可以理解為資料)都儲存在葉子節點(Leaf Page),非葉子節點(Index Page)並不儲存真正的資料,所有記錄節點都是按鍵值大小順序存放在同一層葉子節點上。其次,所有的葉子節點由指標連線。如下圖為高度為3的簡化了的B+Tree。

  對B+樹可以進行兩種查詢運算:
  1. 從最小關鍵字起順序查詢
  2. 從根結點開始,進行隨機查詢
  在隨機查詢時,從根結點出發,與B樹的查詢方式相同,只不過即使在非終端結點上找到了待查的關鍵字,也不終止,而是繼續向下一直到達包含待查關鍵字的葉子結點。因此,在B+樹中,不管隨機查詢成功與否,每次隨機查詢都是走了一條從根到葉子結點的路徑。如果需要進行順序查詢,從最左側包含最小關鍵字的葉子結點出發,不經過分支結點(即非終端結點),沿著指向下一葉子結點的指標可遍歷所有的關鍵字。

  下圖展示了一種可能的索引方式。左邊是資料表,一共有兩列七條記錄,最左邊的是資料記錄的實體地址(注意邏輯上相鄰的記錄在磁碟上並不是一定物理相鄰的)。為了加快Col2的查詢,可以維護一個右邊所示的二叉查詢樹,每個節點分別包含索引鍵值和一個指向對應資料記錄實體地址的指標,這樣就可以運用二叉查詢在O(log2N)的複雜度內獲取到相應資料。

  資料庫索引採用B+樹的主要原因是B樹在提高了磁碟IO效能的同時並沒有解決元素遍歷的效率低下的問題。正是為了解決這個問題,B+樹應運而生。B+樹只要遍歷葉子節點就可以實現整棵樹的遍歷。而且在資料庫中基於範圍的查詢是非常頻繁的,而B樹不支援這樣的操作(或者說效率太低)。B+樹元素遍歷效率極高,B+樹的結構也特別適合帶有範圍的查詢。比如查詢學校18-22歲的學生人數,可以通過從根結點出發進行隨機查詢,找到第一個18歲的學生(此時到達了葉子結點),然後再在葉子結點出發順序查詢到符合範圍的所有記錄。

  為表設定索引是要付出代價的:一是增加了資料庫的儲存空間,二是在插入和修改資料時要花費較多的時間(因為索引也要隨之變動)。

高效能策略

  通過上文,相信你對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列的值,下圖展示了索引是如何組織資料儲存的。

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

  1、MySQL不會在“非獨立的列”上建立索引。 ”獨立的列”是指索引列不能是表示式的一部分,也不能是函式的引數。比如:

select * from where id + 1 = 5

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

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

  3、多列索引和索引順序
  在多數情況下,在多個列上建立獨立的索引並不能提高查詢效能。理由非常簡單,MySQL不知道選擇哪個索引的查詢效率更好,所以在老版本,比如MySQL 5.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條件,比如上例中如果改成where film_id = 1 and actor_id = 1),通常來說一個包含所有相關列的索引要優於多個獨立索引。
  - 當出現多個索引做聯合操作時(多個OR條件,如上例中查詢語句),對結果集的合併、排序等操作需要耗費大量的CPU和記憶體資源,特別是當其中的某些索引的選擇性不高,需要返回合併大量資料時,查詢成本更高。所以這種情況下還不如走全表掃描。
  因此explain時如果發現有索引合併(Extra欄位出現Using union),應該好好檢查一下查詢和表結構是不是已經是最優的,如果查詢和表都沒有問題,那隻能說明索引建的非常糟糕,應當慎重考慮索引是否合適,有可能一個包含所有相關列的多列索引更適合。
  前面我們提到過索引如何組織資料儲存的,從圖中可以看到多列索引時,多列索引的順序對於查詢是至關重要的,很明顯應該把選擇性更高的欄位放到索引的前面,這樣通過第一個欄位就可以過濾掉大多數不符合條件的資料。
  理解索引選擇性的概念後,就不難確定哪個欄位的選擇性較高了,查一下就知道了,比如:

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

  多數情況下使用這個原則沒有任何問題。

  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、冗餘和重複索引
  冗餘索引是指在相同的列上按照相同的順序建立的相同型別的索引,應當儘量避免這種索引,發現後立即刪除。比如有一個索引(A,B),再建立索引(A)就是冗餘索引。冗餘索引經常發生在為表新增新索引時,比如有人新建了索引(A,B),但這個索引不是擴充套件已有的索引(A)。
  大多數情況下都應該儘量擴充套件已有的索引而不是建立新索引。但有極少情況下出現效能方面的考慮需要冗餘索引,比如擴充套件已有索引而導致其變得過大,從而影響到其他使用該索引的查詢。

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

  關於索引這個話題打算就此打住,最後要說一句,索引並不總是最好的工具,只有當索引幫助提高查詢速度帶來的好處大於其帶來的額外工作時,索引才是有效的。對於非常小的表,簡單的全表掃描更高效。對於中到大型的表,索引就非常有效。對於超大型的表,建立和維護索引的代價隨之增長,這時候其他技術也許更有效,比如分割槽表。最後的最後,explain後再提測是一種美德。
  在工作中,我們用於捕捉效能問題最常用的就是開啟慢查詢,定位執行效率差的SQL,那麼當我們定位到一個SQL以後還不算完事,我們還需要知道該SQL的執行計劃,比如是全表掃描,還是索引掃描,這些都需要通過EXPLAIN去完成。EXPLAIN命令是檢視優化器如何決定執行查詢的主要方法。可以幫助我們深入瞭解MySQL的基於開銷的優化器,還可以獲得很多可能被優化器考慮到的訪問策略的細節,以及當執行SQL語句時哪種策略預計會被優化器採用。

總結

  理解查詢是如何執行以及時間都消耗在哪些地方,再加上一些優化過程的知識有助於更好的理解MySQL,理解常見優化技巧背後的原理。