MySQL - 索引原理及其優化

前言

網上都說學會mysql需要學會兩個部分,索引和事務,其實在最近的Mysql學習過程中,我覺得應該是有三個部分的,索引,查詢,事務。其中的查詢主要是指查詢優化即編寫高效率的SQL語句。

本文記錄一下學習MySQL的索引過程中的一些知識.主要爲閱讀《高性能MySQL》的一些理解和擴展。

 

什麼是索引

索引是存儲引擎用於快速找到記錄的一種數據結構

這是MySQL官方對於索引的定義,可以看到索引是一種數據結構,那麼我們應該怎樣理解索引呢?一個常見的例子就是書的目錄。我們都已經養成了看目錄的習慣,拿到一本書時,我們首先會先去查看他的目錄,並且當我們要查找某個內容時,我們會在目錄中查找,然後找到該片段對應的頁碼,再根據相應的頁碼去書中查找。如果沒有索引(目錄)的話,我們就只能一頁一頁的去查找了。

在MySQL中,假設我們有一張如下記錄的表:

如果我們希望查找到年齡爲15的人的名字,在沒有索引的情況下我們只能遍歷所有的數據去做逐一的對比,那麼時間複雜度是O(n).

而如果我們在插入數據的過程中, 額外維護一個數組,將age字段有序的存儲.得到如下數組。

[10,15,18,20,21] 
 |  |  |  |  | 
[x1,x4,x2,x3,x5]

下面的x是模擬數據再磁盤上的存儲位置.這個時候如果我們需要查找15歲的人的名字.我們可以對蓋數組進行二分查找.衆所周知,二分查找的時間複雜度爲O(logn).查找到之後再根據具體的位置去獲取真正的數據.

PS:MySQL中的索引不是使用的數組,而是使用的B+樹(後面講),這裏用數組舉例只是因爲比較好理解。

 

索引能爲我們帶來什麼?

如上面所說,索引能幫助我們快速的查找到數據.其次因爲索引中的值是順序儲存,那麼可以幫助我們進行orderby操作.而且索引中也是存儲了真正的值的,因此有一些的查詢直接可以在索引中完成(也就是覆蓋索引的概念,後面會提到).

總結一下索引的優點就是(《高性能》書中總結的):

  • 減少查詢需要掃描的數據量(加快了查詢速度)
  • 減少服務器的排序操作和創建臨時表的操作(加快了groupby和orderby等操作)
  • 將服務器的隨機IO變爲順序IO(加快查詢速度).

索引有哪些缺點呢?

首先索引也是數據,也需要存儲,因此會帶來額外的存儲空間佔用.其次,在插入,更新和刪除操作的同時,需要維護索引,因此會帶來額外的時間開銷.

總結一下:

  • 索引佔用磁盤或者內存空間
  • 減慢了插入更新操作的速度

實際上,在一定數據範圍內(索引沒有超級多的情況下),建立索引帶來的開銷是遠遠小於它帶來的好處的,但是我們仍然要防止索引的濫用.

 

都有哪些類型的索引?

對於MySQL來說,在服務器層並不實現索引,而是交給存儲引擎來實現的,因此不同的存儲引擎實現的索引類型不太一樣.InnoDB作爲當前使用最爲廣泛的存儲引擎,使用的是B+樹索引,因此我們大部分時間提到的索引也都是指的它.

MySQL主要有以下幾種索引:

  • B-樹索引/B+樹索引
  • 哈希索引
  • 空間數據索引
  • 全文索引

本文只學習B-樹索引和B+樹索引.

 

B-樹索引和B+樹索引

這裏不會特別詳細的解釋B-樹和B+樹的數據結構原理,有興趣的小夥伴可以移步參考文章中的文章.或者通過google自行了解.

 

B-樹

B-樹是一棵多路平衡查找樹,對於一棵M階的B-樹有以下的性質:

  1. 根節點至少有兩個子女.
  2. 每個節點包含k-1個元素和k個孩子,其中m/2 <= k <= m.
  3. 每一個葉子節點都包含k-1個元素,其中m/2 <= k <= m.
  4. 所有的葉子節點位於同一層.
  5. 每個節點中的元素從小到大排列,那麼k-1個元素正好是k個孩子包含的值域的劃分.

這麼說可能會有一些難理解,可以將B-樹理解爲一棵更加矮胖的二叉搜索樹.

 

B+樹

B+樹是B-樹的進階版本,在B-樹的基礎上又做了如下的限制:

  1. 每個中間節點不保存數據,只用來索引,也就意味着所有非葉子節點的值都被保存了一份在葉子節點中.
  2. 葉子節點之間根據自身的順序進行了鏈接.

這樣可以帶來什麼好處呢?

  1. 中間節點不保存數據,那麼就可以保存更多的索引,減少數據庫磁盤IO的次數.
  2. 因爲中間節點不保存數據,所以每一次的查找都會命中到葉子節點,而葉子節點是處在同一層的,因此查詢的性能更加的穩定.
  3. 所有的葉子節點按順序鏈接成了鏈表,因此可以方便的話進行範圍查詢.

 

怎樣創建高性能的索引?

由於優化索引和優化查詢一般是分不開的,因此這一塊可能會包含部分的查詢優化內容.

 

前綴索引和索引選擇性

如果希望給一個很長的字符串上添加索引,那麼可以考慮使用前綴索引.在正式介紹前綴索引之前,我們先大概考慮一下索引的工作步驟,數據庫使用索引進行查找的時候,一般是如下幾步:

  1. 在索引的B+樹上找到對應的值,比如找到學校名稱爲卡塞爾學院的一條記錄,並且拿到這條數據在磁盤上的地址.
  2. 根據地址去磁盤上查找,拿到該條數據所有的值.

那麼假如在所有的學校名稱的值中,卡塞爾就可以唯一的標識這條數據,那麼用卡塞爾來做索引是否可以達到和卡塞爾學院做索引相同的效果?

答案是肯定的,而使用卡塞爾的話,是可以減少索引的大小到原來的60%的.這就是前綴索引的作用.

前綴索引: 在對一個比較長的字符串進行索引時,可以僅索引開始的一部分字符,這樣可以大大的節約索引空間,從而提高索引效率.但是這樣也會降低索引的選擇性.

索引的選擇性: 不重複的值/所有的值. 可以看出索引的選擇性爲0-1,最高的就是該列唯一,沒有重複值.所以唯一索引的效率是比較好的.

但是在一般情況下,較長的字符串的一些前綴的選擇性也是比較好的,這個我們可以算出來.使用下面的語句:

select 
    count(distinct left(school_name,3))/count(*) as sch3, 
    count(distinct left(school_name,4))/count(*) as sch4,
    count(distinct left(school_name,5))/count(*) as sch5,
    count(distinct school_name)/count(*) as original
from 
    user;

其中查找到的original就是原本的選擇性,sch3,sch4,sch5分別是取該列的前3,4,5個字符作爲索引的時候的選擇性.逐步增加這個數值,當選擇性與原來相差不大的時候,就是一個比較合適的前綴索引的長度.(一般情況下是這樣,但是也有例外,當數據極其不均勻時,這樣的前綴索引會在某個特殊的case上表現很差勁).

找到合適的長度之後,就可以創建一個前綴索引了:alter table user add index sch_pre3(`school(3)`)

注意:前綴索引和覆蓋索引是很難一起使用的,我今天早上剛試過,對索引的優化進行到這一步之後無功而返,具體的原因在下面介紹完覆蓋索引之後解釋.

 

聯合索引

一般我們都是有對多個列進行索引的需求的,因爲查詢的需求多種多樣.這個時候我們可以選擇建立多個獨立的索引或者建立一個聯合索引.大多數時候都是聯合索引更加合適一些.

假設我們要執行這個語句:select * from user where school_name = '卡塞爾' and age > 20,我們在schoolage上分別建立兩個獨立的索引,那麼我們預期這條查詢語句會命中兩個索引,但是使用explain命令查看會發現不一定.這是一個玄學的過程.個人沒有研究清楚.

從理論上來講,MySQL在5.0之後的版本里面對支持合併索引,也就是同時使用兩個索引,但是MySQL的優化器不一定這樣認爲,他可能會認爲,查詢兩次B+樹的代價高於查詢一次索引之後去數據表進行過濾,因此會選擇只用一個索引.(我在自己的5張表上做了類似此case的測試,結果都是隻使用了一個索引.)

創建聯合索引的語法:alter table user add index school_age(`school`,`age`).

使用聯合索引的時候,有一個非常重要的因素就是所有的索引列只可以進行最左前綴匹配,例如上面的school_age聯合索引,當僅使用age作爲查詢條件的時候是不能使用的,也就是說select * from user where age =20是不能命中上面的聯合索引的.

在不考慮任何查詢的情況下,我們應該講選擇性高的列放在聯合索引的前面,但是實際上我們更多的是通過查詢來反推索引,以使某個固定的查詢可以儘可能的命中索引以提高查詢速度.畢竟我們建立索引的目的也是爲了加快查詢的速度.

因此聯合索引的優化更多的是根據某個或者某些語句來優化的,不具備一個通用的法則。

 

最左前綴索引的原理

當數據列有序的時候,mysql可以使用索引,那麼假設我們建立了school_age索引,示例數據如下:

 

在這份數據中,school字段是完全有序的,索引school可以使用索引.

而從全表來看,age字段不是有序的,因此無法直接使用索引,那麼觀察一下數據表,在什麼時候age有序呢?在school進行定值匹配的時候,例如當school=b的時候,對於這三條數據而言,age是有序的,因此可以使用age索引.這就是最左前綴的原理.

此外,最左前綴索引只能使用一個範圍查詢,例如select * from user where school > aselect * from user where school = a and age > 12,都是可以命中索引的,但是select * from user where school > a and age > 12中,僅school可以命中索引,這也可以從上面得出結論.因爲當school是範圍匹配的時候,mysql無法確認age字段是否嚴格有序,比如 school的範圍匹配命中了b,c的四條數據,那麼age就不是有序的.無法使用後續的索引.

 

聚簇索引

聚簇索引不是一種索引類型,而是一種存儲數據的方式.Innodb的聚簇索引是在同一個數據結構中保存了索引和數據.

因爲數據真正的數據只能有一種排序方式,所以一個表上只能有一個聚簇索引.Innodb使用主鍵來進行聚簇索引,沒有主鍵的話就會選擇一個唯一的非空索引,如果還還沒有,innodb會選擇生成一個隱式的主鍵來進行聚簇索引.爲什麼innodb這麼執着的需要搞一個聚簇索引呢,因爲一個數據表中的數據總得有且只有一種排序方式來存儲在磁盤上,因此這是必須的.

這也是innodb推薦我們使用自增主鍵的原因,因爲自增主鍵自增且連續,在插入的時候只需要不斷的在數據後面追加即可.設想一下使用UUID來作爲主鍵,那麼每一次的插入操作,都需要找到當前主鍵在已排序的主鍵中的位置,然後插入,並且要移動該主鍵後的數據,以使得數據和主鍵保持相同的順序,這無疑是代價非常高的.

也是因爲這個原因,在其他索引的葉子節點中,存儲的”數據”其實不是該數據的真實物理地址,而是該數據的主鍵,查找到主鍵之後,再根據主鍵進行一次索引,拿到數據.

聚簇索引和非聚簇索引的區別可以用一個簡單的例子來說明:

當我們拿到一本書的時候,目錄就是主鍵,是一個聚簇索引,因爲在目錄中連續的內容,在正文中也是連續的,當我們想要查看迎着陽光盛大逃亡章節,只需要在目錄中找到它對應的頁面,比如459,然後去對應的頁碼查看正文即可.

而非聚簇索引呢,則類似於書後面的附錄專有名詞索引一樣(二級普通索引),當你查找邦達列夫的時候,附錄會告訴你,這個名詞出現在了迎着陽光盛大逃亡一節,然後你需要去目錄(主鍵索引)中再次查找到對應的頁碼.

 

覆蓋索引

當一個索引包含(或者說是覆蓋)需要查詢的所有字段的值時,我們稱之爲覆蓋索引.

設想有如下的查詢語句:

select 
  school_name,age
from  
  user
where 
  school_name = '金色鶯尾花'

這個語句根據學校名稱來查詢數據行的學校名稱和年齡,從上面的數據查詢的步驟我們可以知道,當在索引中找到要求的值的時候,還需要根據主鍵去進行一次索引,以拿到全部的數據,然後從其中挑選出需要的列,返回.但是現在索引中已經包含了所有的需要返回的列,那麼就不用進行回數據表查詢的操作了,此外索引的大小一般是遠遠小於真正的數據大小的,覆蓋索引可以極大的減少從磁盤加載數據的數量.

爲什麼前綴索引和覆蓋索引無法一起使用?

因爲前綴索引的目的是用前綴來代表真正的值,他們在選擇性上幾乎沒有區別,但是MySQL仍然無法判斷真正的數據是什麼,比如阿里巴巴阿里媽媽在前綴爲2的時候是一樣的,但是爲了確保你查詢阿里巴巴的時候不會出現阿里媽媽的內容,是需要回到數據表拿到數據再次進行一個精準匹配來進行過濾的.

因此,覆蓋索引無法和列前綴索引一起使用,這是我用一個早晨的時間測試得出的結論.

 

刪除掉冗餘和重複的索引

有一些索引是從未在查詢中使用過,卻白白增加數據插入時開銷的,對於這種索引我們應該及時的進行刪除.

比如在主鍵上再建立一個普通索引,無疑是毫無作用的.

還比如在有聯合索引school_age的情況下,再建立一個school的獨立索引,因爲索引的最左前綴匹配原則,school_age是完全可以命中對school的單獨查詢的,因此後者可以刪掉.

 

如何查看索引的一些相關信息?

索引信息

在mysql中可以使用show index from table_name來查看某個表上的索引,它將會有如下的輸出:

 

或者使用show create table table_name來查看建表語句,其中包含創建索引的語句.

 

索引大小

在5.0以後的版本中,我們可以通過查看information_schema.TABLES表中的數據來獲取更加詳細的數據.

該表各字段的含義如下表:

我們可以通過一些查詢語句來獲取詳細的信息,比如:

// 查看當前MySQL服務器所有索引的大小(以MB爲單位,默認是字節)
SELECT CONCAT(ROUND(SUM(index_length)/(1024*1024), 2), ' MB') AS 'Total Index Size' FROM TABLES
// 查看某一個庫的所有大小
SELECT CONCAT(ROUND(SUM(index_length)/(1024*1024), 2), ' MB') AS 'Total Index Size' FROM TABLES  WHERE table_schema = 'XXX';
// 查看某一個表的索引大小
SELECT CONCAT(ROUND(SUM(index_length)/(1024*1024), 2), ' MB') AS 'Total Index Size' FROM TABLES  WHERE table_schema = 'yyyy' and table_name = "xxxxx";  
// 彙總查看一個庫中的數據大小及索引大小
SELECT CONCAT(table_schema,'.',table_name) AS 'Table Name', CONCAT(ROUND(table_rows/1000000,4),'M') AS 'Number of Rows', CONCAT(ROUND(data_length/(1024*1024*1024),4),'G') AS 'Data Size', CONCAT(ROUND(index_length/(1024*1024*1024),4),'G') AS 'Index Size', CONCAT(ROUND((data_length+index_length)/(1024*1024*1024),4),'G') AS'Total'FROM information_schema.TABLES WHERE table_schema LIKE 'xxxxx';

對tables表的數據的所有查看方式都是可以的,其中還包含了一些表格本身的數據信息,但是因爲和本文的主題不符合,這裏就不舉例子了.

注意:上面的表格是有緩存的,當更新數據庫索引之後,最好執行`analyze table xxxx`,然後再進行查看.MySQL會在表格數據發生較大的變化時才更新此表(大小變化超過1/16或者插入20億行).

 

索引碎片

在索引的創建刪除過程中,不可避免的會產品索引碎片,當然還有數據碎片,我們可以通過執行optimize table xxx來重新整理索引及數據,對於不支持此命令的存儲引擎來說,可以通過一條無意義的alter語句來觸發整理,比如:將表的存儲引擎更換爲當前的引擎,alter table xxxx engine=innodb.

 

參考文章

書籍《高性能MySQL(第三版)》 B-樹 B+樹

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章