MySQL索引數據結構及算法原理

原文鏈接:http://blog.codinglabs.org/articles/theory-of-mysql-index.html

個人學習 引用自 http://blog.codinglabs.org/articles/theory-of-mysql-index.html


摘要

MySQL支持諸多存儲引擎,而各種存儲引擎對索引的支持也各不相同,因此MySQL數據庫支持多種索引類型,如BTree索引,哈希索引,全文索引等等。爲了避免混亂,本文將只關注於BTree索引

第一部分主要從數據結構及算法理論層面討論MySQL數據庫索引的數理基礎。

第二部分結合MySQL數據庫中MyISAM和InnoDB數據存儲引擎中索引的架構實現討論聚集索引、非聚集索引及覆蓋索引等話題。

第三部分根據上面的理論基礎,討論MySQL中高性能使用索引的策略。


數據結構及算法基礎

索引的本質

MySQL官方對索引的定義爲:索引(Index)幫助MySQL高效獲取數據數據結構。提取句子主幹,就可以得到索引的本質:索引是數據結構。

數據庫查詢是數據庫的最主要功能之一。我們都希望查詢數據的速度能儘可能的快,因此數據庫系統的設計者會從查詢算法的角度進行優化。最基本的查詢算法當然是順序查找(linear search),這種複雜度爲O(n)的算法在數據量很大時顯然是糟糕的,好在計算機科學的發展提供了很多更優秀的查找算法,例如二分查找(binary search)、二叉樹查找(binary tree search)等。如果稍微分析一下會發現,每種查找算法都只能應用於特定的數據結構之上,例如二分查找要求被檢索數據有序,而二叉樹查找只能應用於二叉查找樹上,但是數據本身的組織結構不可能完全滿足各種數據結構(例如,理論上不可能同時將兩列都按順序進行組織),所以,在數據之外,數據庫系統還維護着滿足特定查找算法的數據結構,這些數據結構以某種方式引用(指向)數據,這樣就可以在這些數據結構上實現高級查找算法。這種數據結構,就是索引。

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

雖然這是一個貨真價實的索引,但是實際的數據庫系統幾乎沒有使用二叉查找樹或其進化品種紅黑樹(red-black tree)實現的,原因會在下文介紹。

B-Tree和B+Tree

B-Tree

爲了描述B-Tree,首先定義一條數據記錄爲一個二元組[key, data],key爲記錄的鍵值,對於不同數據記錄,key是互不相同的;data爲數據記錄除key外的數據。那麼B-Tree是滿足下列條件的數據結構:

  • d爲大於1的一個正整數,稱爲B-Tree的度。
  • h爲一個正整數,稱爲B-Tree的高度。
  • 每個非葉子節點由n-1個key和n個指針組成,其中d<=n<=2d。
  • 每個葉子節點最少包含一個key和兩個指針,最多包含2d-1個key和2d個指針,葉節點的指針均爲null 。
  • 所有葉節點具有相同的深度,等於樹高h。
  • key和指針互相間隔,節點兩端是指針。
  • 一個節點中的key從左到右非遞減排列。
  • 所有節點組成樹結構。
  • 每個指針要麼爲null,要麼指向另外一個節點。
  • 如果某個指針在節點node最左邊且不爲null,則其指向節點的所有key小於v(key1),其中v(key1)爲node的第一個key的值。
  • 如果某個指針在節點node最右邊且不爲null,則其指向節點的所有key大於v(keym),其中v(keym)爲node的最後一個key的值。
  • 如果某個指針在節點node的左右相鄰key分別是keyi和keyi+1且不爲null,則其指向節點的所有key小於v(keyi+1)且大於v(keyi)。

圖2是一個d=2的B-Tree示意圖。

由於B-Tree的特性,在B-Tree中按key檢索數據的算法非常直觀:首先從根節點進行二分查找,如果找到則返回對應節點的data,否則對相應區間的指針指向的節點遞歸進行查找,直到找到節點或找到null指針,前者查找成功,後者查找失敗。B-Tree上查找算法的僞代碼如下:

    BTree_Search(node, key) {
        if(node == null) return null;
        foreach(node.key)
        {
            if(node.key[i] == key) return node.data[i];
                if(node.key[i] > key) return BTree_Search(point[i]->node);
        }
        return BTree_Search(point[i+1]->node);
    }
    data = BTree_Search(root, my_key);

關於B-Tree有一系列有趣的性質,例如一個度爲d的B-Tree,設其索引N個key,則其樹高h的上限爲logd((N+1)/2),檢索一個key,其查找節點個數的漸進複雜度爲O(logdN)。從這點可以看出,B-Tree是一個非常有效率的索引數據結構。

另外,由於插入刪除新的數據記錄會破壞B-Tree的性質,因此在插入刪除時,需要對樹進行一個分裂、合併、轉移等操作以保持B-Tree性質

 

B+Tree

B-Tree有許多變種,其中最常見的是B+Tree,例如MySQL就普遍使用B+Tree實現其索引結構。

與B-Tree相比,B+Tree有以下不同點

  • 每個節點的指針上限爲2d而不是2d+1。
  • 內節點不存儲data只存儲key不可能在非葉子結點命中
  • 非葉子結點相當於是葉子結點的索引(稀疏索引),葉子結點相當於是存儲
    (關鍵字)數據的數據層;
  • 更適合文件索引系統;

 

圖3是一個簡單的B+Tree示意。

 

爲什麼使用B-Tree(B+Tree)

一般來說,索引本身也很大,不可能全部存儲在內存中,因此索引往往以索引文件的形式存儲的磁盤上。這樣的話,索引查找過程中就要產生磁盤I/O消耗,相對於內存存取,I/O存取的消耗要高几個數量級,所以評價一個數據結構作爲索引的優劣最重要的指標就是在查找過程中磁盤I/O操作次數的漸進複雜度。換句話說,索引的結構組織要儘量減少查找過程中磁盤I/O的存取次數。

 

B-/+Tree索引的性能分析

先從B-Tree分析,根據B-Tree的定義,可知檢索一次最多需要訪問h個節點。數據庫系統的設計者巧妙利用了磁盤預讀原理,將一個節點的大小設爲等於一個頁,這樣每個節點只需要一次I/O就可以完全載入。爲了達到這個目的,在實際實現B-Tree還需要使用如下技巧:

每次新建節點時,直接申請一個頁的空間,這樣就保證一個節點物理上也存儲在一個頁裏,加之計算機存儲分配都是按頁對齊的,就實現了一個node只需一次I/O

B-Tree中一次檢索最多需要h-1次I/O(根節點常駐內存),漸進複雜度爲O(h)=O(logdN)

。一般實際應用中,出度d是非常大的數字,通常超過100,因此h非常小(通常不超過3)

綜上所述,用B-Tree作爲索引結構效率是非常高的。

 


MySQL索引實現

MyISAM索引實現

MyISAM引擎使用B+Tree作爲索引結構,葉節點的data域存放的是數據記錄的地址。下圖是MyISAM索引的原理圖:

這裏設表一共有三列,假設我們以Col1爲主鍵,則圖8是一個MyISAM表的主索引(Primary key)示意。可以看出MyISAM的索引文件僅僅保存數據記錄的地址。在MyISAM中,主索引和輔助索引(Secondary key)在結構上沒有任何區別,只是主索引要求key是唯一的,而輔助索引的key可以重複。如果我們在Col2上建立一個輔助索引,則此索引的結構如下圖所示:

同樣也是一顆B+Tree,data域保存數據記錄的地址。因此,MyISAM中索引檢索的算法爲首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,則取出其data域的值,然後以data域的值爲地址,讀取相應數據記錄。

MyISAM的索引方式也叫做“非聚集”的,之所以這麼稱呼是爲了與InnoDB的聚集索引區分。


InnoDB索引實現

雖然InnoDB也使用B+Tree作爲索引結構,但具體實現方式卻與MyISAM截然不同。

第一個重大區別是InnoDB的數據文件本身就是索引文件。從上文知道,MyISAM索引文件和數據文件是分離的,索引文件僅保存數據記錄的地址。而在InnoDB中,表數據文件本身就是按B+Tree組織的一個索引結構,這棵樹的葉節點data域保存了完整的數據記錄。這個索引的key是數據表的主鍵,因此InnoDB表數據文件本身就是主索引。

圖10是InnoDB主索引(同時也是數據文件)的示意圖,可以看到葉節點包含了完整的數據記錄。這種索引叫做聚集索引。因爲InnoDB的數據文件本身要按主鍵聚集,所以InnoDB要求表必須有主鍵(MyISAM可以沒有),如果沒有顯式指定,則MySQL系統會自動選擇一個可以唯一標識數據記錄的列作爲主鍵,如果不存在這種列,則MySQL自動爲InnoDB表生成一個隱含字段作爲主鍵,這個字段長度爲6個字節,類型爲長整形。

第二個與MyISAM索引的不同是InnoDB的輔助索引data域存儲相應記錄主鍵的值不是地址。換句話說,InnoDB的所有輔助索引都引用主鍵作爲data域。例如,圖11爲定義在Col3上的一個輔助索引:

這裏以英文字符的ASCII碼作爲比較準則。聚集索引這種實現方式使得按主鍵的搜索十分高效,但是輔助索引搜索需要檢索兩遍索引:首先檢索輔助索引獲得主鍵,然後用主鍵到主索引中檢索獲得記錄。

瞭解不同存儲引擎的索引實現方式對於正確使用和優化索引都非常有幫助,例如知道了InnoDB的索引實現後,就很容易明白爲什麼不建議使用過長的字段作爲主鍵,因爲所有輔助索引都引用主索引,過長的主索引會令輔助索引變得過大。再例如,用非單調的字段作爲主鍵在InnoDB中不是個好主意,因爲InnoDB數據文件本身是一顆B+Tree,非單調的主鍵會造成在插入新記錄時數據文件爲了維持B+Tree的特性而頻繁的分裂調整,十分低效,而使用自增字段作爲主鍵則是一個很好的選擇。


索引使用策略及優化

MySQL的優化主要分爲結構優化(Scheme optimization)和查詢優化(Query optimization)

示例數據庫

最左前綴原理與相關優化

這裏先說一下聯合索引的概念。在上文中,我們都是假設索引只引用了單個的列,實際上,MySQL中的索引可以以一定順序引用多個列,這種索引叫做聯合索引,一般的,一個聯合索引是一個有序元組<a1, a2, …, an>,其中各個元素均爲數據表的一列,實際上要嚴格定義索引需要用到關係代數,但是這裏我不想討論太多關係代數的話題,因爲那樣會顯得很枯燥,所以這裏就不再做嚴格定義。另外,單列索引可以看成聯合索引元素數爲1的特例。

以employees.titles表爲例,下面先查看其上都有哪些索引:

SHOW INDEX FROM employees.titles;
    +--------+------------+----------+--------------+-------------+-----------+-------------+------+------------+
    | Table  | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Null | Index_type |
    +--------+------------+----------+--------------+-------------+-----------+-------------+------+------------+
    | titles |          0 | PRIMARY  |            1 | emp_no      | A         |        NULL |      | BTREE      |
    | titles |          0 | PRIMARY  |            2 | title       | A         |        NULL |      | BTREE      |
    | titles |          0 | PRIMARY  |            3 | from_date   | A         |      443308 |      | BTREE      |
    | titles |          1 | emp_no   |            1 | emp_no      | A         |      443308 |      | BTREE      |
    +--------+------------+----------+--------------+-------------+-----------+-------------+------+------------+

從結果中可以到titles表的主索引爲<emp_no, title, from_date>,還有一個輔助索引<emp_no>。爲了避免多個索引使事情變複雜(MySQL的SQL優化器在多索引時行爲比較複雜),這裏我們將輔助索引drop掉:

ALTER TABLE employees.titles DROP INDEX emp_no;

情況一:全列匹配。

EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND title='Senior Engineer' AND from_date='1986-06-26';
    +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
    | id | select_type | table  | type  | possible_keys | key     | key_len | ref               | rows | Extra |
    +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
    |  1 | SIMPLE      | titles | const | PRIMARY       | PRIMARY | 59      | const,const,const |    1 |       |
    +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+

很明顯,當按照索引中所有列進行精確匹配(這裏精確匹配指“=”或“IN”匹配)時,索引可以被用到。這裏有一點需要注意,理論上索引對順序是敏感的,但是由於MySQL的查詢優化器會自動調整where子句的條件順序以使用適合的索引,例如我們將where中的條件順序顛倒:效果是一樣的。

EXPLAIN SELECT * FROM employees.titles WHERE from_date='1986-06-26' AND emp_no='10001' AND title='Senior Engineer';
    +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
    | id | select_type | table  | type  | possible_keys | key     | key_len | ref               | rows | Extra |
    +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
    |  1 | SIMPLE      | titles | const | PRIMARY       | PRIMARY | 59      | const,const,const |    1 |       |
    +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+

情況二:最左前綴匹配。

EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001';
    +----+-------------+--------+------+---------------+---------+---------+-------+------+-------+
    | id | select_type | table  | type | possible_keys | key     | key_len | ref   | rows | Extra |
    +----+-------------+--------+------+---------------+---------+---------+-------+------+-------+
    |  1 | SIMPLE      | titles | ref  | PRIMARY       | PRIMARY | 4       | const |    1 |       |
    +----+-------------+--------+------+---------------+---------+---------+-------+------+-------+

當查詢條件精確匹配索引的左邊連續一個或幾個列時,如<emp_no>或<emp_no, title>,所以可以被用到,但是只能用到一部分,即條件所組成的最左前綴。上面的查詢從分析結果看用到了PRIMARY索引,但是key_len爲4,說明只用到了索引的第一列前綴。

情況三:查詢條件用到了索引中列的精確匹配,但是中間某個條件未提供。

EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND from_date='1986-06-26';
    +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
    | id | select_type | table  | type | possible_keys | key     | key_len | ref   | rows | Extra       |
    +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
    |  1 | SIMPLE      | titles | ref  | PRIMARY       | PRIMARY | 4       | const |    1 | Using where |
    +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+

時索引使用情況和情況二相同,因爲title未提供,所以查詢只用到了索引的第一列,而後面的from_date雖然也在索引中,但是由於title不存在而無法和左前綴連接,因此需要對結果進行掃描過濾from_date(這裏由於emp_no唯一,所以不存在掃描)。如果想讓from_date也使用索引而不是where過濾,可以增加一個輔助索引<emp_no, from_date>,此時上面的查詢會使用這個索引。除此之外,還可以使用一種稱之爲“隔離列”的優化方法,將emp_no與from_date之間的“坑”填上。

首先我們看下title一共有幾種不同的值:

    SELECT DISTINCT(title) FROM employees.titles;
    +--------------------+
    | title              |
    +--------------------+
    | Senior Engineer    |
    | Staff              |
    | Engineer           |
    | Senior Staff       |
    | Assistant Engineer |
    | Technique Leader   |
    | Manager            |
    +--------------------+

只有7種。在這種成爲“坑”的列值比較少的情況下,可以考慮用“IN”來填補這個“坑”從而形成最左前綴

    EXPLAIN SELECT * FROM employees.titles
    WHERE emp_no='10001'
    AND title IN ('Senior Engineer', 'Staff', 'Engineer', 'Senior Staff', 'Assistant Engineer', 'Technique Leader', 'Manager')
    AND from_date='1986-06-26';
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    | id | select_type | table  | type  | possible_keys | key     | key_len | ref  | rows | Extra       |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    |  1 | SIMPLE      | titles | range | PRIMARY       | PRIMARY | 59      | NULL |    7 | Using where |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

 這次key_len爲59,說明索引被用全了,但是從type和rows看出IN實際上執行了一個range查詢,這裏檢查了7個key。看下兩種查詢的性能比較:

    SHOW PROFILES;
    +----------+------------+-------------------------------------------------------------------------------+
    | Query_ID | Duration   | Query                                                                         |
    +----------+------------+-------------------------------------------------------------------------------+
    |       10 | 0.00058000 | SELECT * FROM employees.titles WHERE emp_no='10001' AND from_date='1986-06-26'|
    |       11 | 0.00052500 | SELECT * FROM employees.titles WHERE emp_no='10001' AND title IN ...          |
    +----------+------------+-------------------------------------------------------------------------------+

“填坑”後性能提升了一點。如果經過emp_no篩選後餘下很多數據,則後者性能優勢會更加明顯。當然,如果title的值很多,用填坑就不合適了,必須建立輔助索引。

情況四:查詢條件沒有指定索引第一列。

    EXPLAIN SELECT * FROM employees.titles WHERE from_date='1986-06-26';
    +----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
    | id | select_type | table  | type | possible_keys | key  | key_len | ref  | rows   | Extra       |
    +----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
    |  1 | SIMPLE      | titles | ALL  | NULL          | NULL | NULL    | NULL | 443308 | Using where |
    +----+-------------+--------+------+---------------+------+---------+------+--------+-------------+

由於不是最左前綴,索引這樣的查詢顯然用不到索引。

情況五:匹配某列的前綴字符串。

    EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND title LIKE 'Senior%';
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    | id | select_type | table  | type  | possible_keys | key     | key_len | ref  | rows | Extra       |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    |  1 | SIMPLE      | titles | range | PRIMARY       | PRIMARY | 56      | NULL |    1 | Using where |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

此時可以用到索引,如果通配符%不出現在開頭,則可以用到索引,但根據具體情況不同可能只會用其中一個前綴

情況六:範圍查詢。

    EXPLAIN SELECT * FROM employees.titles WHERE emp_no < '10010' and title='Senior Engineer';
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    | id | select_type | table  | type  | possible_keys | key     | key_len | ref  | rows | Extra       |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    |  1 | SIMPLE      | titles | range | PRIMARY       | PRIMARY | 4       | NULL |   16 | Using where |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

範圍列可以用到索引(必須是最左前綴),但是範圍列後面的列無法用到索引。同時,索引最多用於一個範圍列,因此如果查詢條件中有兩個範圍列則無法全用到索引。

    EXPLAIN SELECT * FROM employees.titles
    WHERE emp_no < '10010'
    AND title='Senior Engineer'
    AND from_date BETWEEN '1986-01-01' AND '1986-12-31';
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    | id | select_type | table  | type  | possible_keys | key     | key_len | ref  | rows | Extra       |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    |  1 | SIMPLE      | titles | range | PRIMARY       | PRIMARY | 4       | NULL |   16 | Using where |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

可以看到索引對第二個範圍索引無能爲力。這裏特別要說明MySQL一個有意思的地方,那就是僅用explain可能無法區分範圍索引和多值匹配,因爲在type中這兩者都顯示爲range。同時,用了“between”並不意味着就是範圍查詢,例如下面的查詢:

    EXPLAIN SELECT * FROM employees.titles
    WHERE emp_no BETWEEN '10001' AND '10010'
    AND title='Senior Engineer'
    AND from_date BETWEEN '1986-01-01' AND '1986-12-31';
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    | id | select_type | table  | type  | possible_keys | key     | key_len | ref  | rows | Extra       |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
    |  1 | SIMPLE      | titles | range | PRIMARY       | PRIMARY | 59      | NULL |   16 | Using where |
    +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

看起來是用了兩個範圍查詢,但作用於emp_no上的“BETWEEN”實際上相當於“IN”,也就是說emp_no實際是多值精確匹配。可以看到這個查詢用到了索引全部三個列。因此在MySQL中要謹慎地區分多值匹配和範圍匹配,否則會對MySQL的行爲產生困惑。

 

情況七:查詢條件中含有函數或表達式。

很不幸,如果查詢條件中含有函數或表達式,則MySQL不會爲這列使用索引(雖然某些在數學意義上可以使用)

    EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND left(title, 6)='Senior';
    +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
    | id | select_type | table  | type | possible_keys | key     | key_len | ref   | rows | Extra       |
    +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
    |  1 | SIMPLE      | titles | ref  | PRIMARY       | PRIMARY | 4       | const |    1 | Using where |
    +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+

InnoDB的主鍵選擇與插入優化

請永遠使用一個與業務無關的自增字段作爲主鍵。

InnoDB使用聚集索引,數據記錄本身被存於主索引(一顆B+Tree)的葉子節點上。這就要求同一個葉子節點內(大小爲一個內存頁或磁盤頁)的各條數據記錄按主鍵順序存放,因此每當有一條新的記錄插入時,MySQL會根據其主鍵將其插入適當的節點和位置,如果頁面達到裝載因子(InnoDB默認爲15/16),則開闢一個新的頁(節點)。

如果表使用自增主鍵,那麼每次插入新的記錄,記錄就會順序添加到當前索引節點的後續位置,當一頁寫滿,就會自動開闢一個新的頁。如下圖所示:

這樣就會形成一個緊湊的索引結構,近似順序填滿。由於每次插入時也不需要移動已有數據,因此效率很高,也不會增加很多開銷在維護索引上。

如果使用非自增主鍵(如果身份證號或學號等),由於每次插入主鍵的值近似於隨機,因此每次新紀錄都要被插到現有索引頁得中間某個位置

 

此時MySQL不得不爲了將新記錄插到合適位置而移動數據,甚至目標頁面可能已經被回寫到磁盤上而從緩存中清掉,此時又要從磁盤上讀回來,這增加了很多開銷,同時頻繁的移動、分頁操作造成了大量的碎片,得到了不夠緊湊的索引結構,後續不得不通過OPTIMIZE TABLE來重建表並優化填充頁面。

因此,只要可以,請儘量在InnoDB上採用自增字段做主鍵。

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