1. 程式人生 > >MySQL之索引(三)

MySQL之索引(三)

聚簇索引

聚簇索引並不是一種單獨的索引型別,而是一種資料儲存方式。具體的細節依賴於其實現方式,但InnoDB的聚簇索引實際上在同一個結構中儲存了B-Tree索引和資料行。當表有聚簇索引時,它的資料行實際上存放在索引的葉子頁(leaf page)中。術語“聚簇”表示資料行和相鄰的鍵值緊湊地儲存在一起。因為無法同時把資料行存放在兩個不同的地方,所以一個表只能有一個聚簇索引。

因為是儲存引擎負責實現索引,因此不是所有的儲存引擎都支援聚簇索引。圖1-3展示了聚簇索引中的記錄是如何存放的。注意到,葉子頁包含了行的全部資料,但是節點頁只包含了索引列。在這個案例中,索引列包含的是整數值。 

圖1-3   聚簇索引的資料分佈

一些資料庫伺服器允許選擇哪個索引作為聚簇索引,但MySQL內建的儲存引擎尚未支援這一點。InnoDB將通過主鍵聚集資料,這也就是說圖1-3中的“被索引的列”就是主鍵列。如果沒有定義主鍵,InnoDB會選擇一個唯一的非空索引代替。如果沒有定義主鍵,InnoDB會選擇一個唯一的非空索引代替。如果沒有這樣的索引,InnoDB會隱式定義一個主鍵來作為聚簇索引。InnoDB只聚集在同一個頁面中的記錄。包含相鄰鍵值的頁面可能會相距甚遠。

聚集的資料有一些重要的優點:

  • 可以把相關資料儲存在一起。例如實現電子郵箱時,可以根據使用者ID來聚集資料,這樣只需要從磁碟讀取少數的資料頁就能獲取某個使用者的全部郵件。如果沒有使用聚簇索引,則每封郵件都可能導致一次磁碟I/O。
  • 資料訪問更快。聚簇索引將索引和資料儲存在同一個B-Tree中,因此從聚簇索引中獲取資料通常比非聚簇索引中查詢要快。
  • 使用覆蓋索引掃描的查詢可以直接使用頁節點的主鍵值。

聚簇索引也有一些些缺點:

  • 聚簇資料最大限度的提高了I/O密集型應用的效能,但如果資料全部都放在記憶體中,則訪問的順序就沒有那麼重要了,聚簇索引也就沒有那麼優勢了。
  • 插入速度嚴重依賴於插入順序。按照主鍵的順序插入是載入資料到InnoDB表中速度最快的方式。但如果不是按照主鍵順序載入資料,那麼在載入完成後最好使用OPTIMIZE TABLE命令重新組織一下表。
  • 更新聚簇索引列的代價很高,因為會強制InnoDB將每個被更新的行移動到新的位置。
  • 基於聚簇索引的表在插入新行,或者主鍵被更新導致需要移動行的時候,可能面臨“頁分裂”的問題。當行的主鍵值要求必須將這一行插入到某個已滿的頁中時,儲存引擎會將該頁分裂成兩個頁面來容納該行,這就是一次分裂操作。頁分裂會導致表佔用更多的磁碟空間。
  • 聚簇索引可能導致全表掃描變慢,尤其是行比較稀疏,或者由於頁分裂導致資料儲存不連續的時候。
  • 二級索引(非聚簇索引)可能比想象的要更大,因為在二級索引的葉子節點包含了引用行的主鍵列。
  • 二級索引訪問需要兩次索引查詢,而不是一次。

最後一點可能讓人有些疑惑,為什麼二級索引需要兩次索引查詢?答案在於二級索引中儲存的“行指標”的實質。要記住,二級索引葉子節點儲存的不是指向行的物理位置的指標,而是行的主鍵值。這意味著通過二級索引查詢行,儲存引擎需要找到二級索引的葉子節點獲得對應的主鍵值,然後根據這個值去聚簇索引中查詢到對應的行。這裡做了重複的工作:兩次B-Tree查詢而不是一次。對於InnoDB,自適應雜湊索引能夠減少這樣的重複工作。

InnoDB和MyISAM的資料分佈對比

聚簇索引和非聚簇索引的資料分佈有區別,以及對應的主鍵索引和二級索引的資料分佈也有區別。來看看InnoDB和MyISAM是如何儲存下面這張表的:

CREATE TABLE layout_test (
   col1 int NOT NULL,
   col2 int NOT NULL,
   PRIMARY KEY(col1),
   KEY(col2)
);

  

假設主鍵的值位於1---10,000之間,且按隨機順序插入,然後用OPTIMIZE TABLE進行優化。換句話說,資料在磁碟上的儲存方式已經最優,但行的順序是隨機的。列col2的值是從1~100之間隨機賦值,所以會存在許多重複的值。

MyISAM的資料分佈非常簡單,按照資料插入的順序儲存在磁碟上,如圖1-4所示:

圖1-4   MyISAM表layout_test的資料分佈

在行的旁邊顯示了行號,從0開始遞增,因為行是定長的,所以MyISAM可以從表的開頭跳過所需的位元組找到需要的行。這種分佈方式很容易建立索引。下面顯示的一系列圖,隱藏了頁的物理細節,只顯示索引中的“節點”,索引中的每個葉子節點包含“行號”。圖1-5顯示了表的主鍵。

圖1-5   MyISAM表layout_test的主鍵分佈

這裡忽略了一些細節,例如前一個B-Tree節點有多少個內部節點,不過這並不影響對非聚簇儲存引擎的基本資料分佈的理解。

那col2列上的索引又會如何呢?有什麼特殊的嗎?回答是否定的:它和其他索引沒有什麼區別。圖1-6顯示了col2列上的索引。

圖1-6   MyISAM表layout_test的col2列索引的分佈

事實上,MyISAM中主鍵索引和其他索引在結構上沒有什麼不同。主鍵索引就是一個名為PRIMARY的唯一非空索引。

因為InnoDB支援聚簇索引,所以使用非常不同的方式儲存同樣的資料。InnoDB以如圖1-7所示的方式儲存資料。

圖1-7   InnoDB表layout_test的主鍵分佈

乍看上去,圖1-7和圖1-5沒有什麼不同,但仔細觀察,還是會注意到該圖顯示了整個表,而不是隻有索引。因為在InnoDB中,聚簇索引“就是”表,所以不像MyISAM那樣需要獨立的行儲存。聚簇索引的每個葉子節點都包含了主鍵值、事務ID、用於事務和MVCC的回滾指標以及所有的剩餘列。如果主鍵是一個列字首索引,InnoDB也會包含完整的主鍵列和剩下的其他列。

還有一點和MyISAM的不同是,InnoDB的二級索引和聚簇索引很不相同。InnoDB二級索引的葉子節點中儲存的不是“行指標”,而是主鍵值,並以此作為指向行的“指標”。這樣的策略減少了當出現行移動或者資料頁分裂時二級索引的維護工作。使用主鍵值當作指標會讓二級索引佔用更多的空間,換來的好處是,InnoDB在移動行時無需更新二級索引中的這個“指標”。

圖1-8顯示了表的col2索引。每一個葉子節點都包含了索引列(這裡是col2),緊接著是主鍵值(col1)。圖1-8展示了B-Tree的葉子節點結構,但我們故意省略了非葉子節點這樣的細節。InnoDB的非葉子節點包含了索引列和一個指向下級節點的指標(下一級可以是非葉子節點,也可以是葉子節點)。這對聚簇索引和二級索引都適用。

圖1-8   InnoDB表layout_test的二級索引分佈

圖1-9是描述InnoDB和MyISAM如何存放表的抽象圖,從圖1-9可以看出InnoDB和MyISAM儲存資料和索引的區別。

圖1-9   聚簇和非聚簇表對比圖

覆蓋索引

通常大家會根據查詢的WHERE條件來建立合適的索引,不過這只是索引優化的一個方面。設計優秀的索引應該考慮到整個查詢,而不單單是WHERE條件部分。索引確實是一種查詢資料的高效方式,但是MySQL也可以使用索引來直接獲取列的資料,這樣就不需要讀取資料行。如果索引的葉子節點中已經包含要查詢的資料,那麼還有什麼必要回表查詢呢?如果一個索引包含(或者說覆蓋)所有需要查詢的欄位的值,我們就稱之為“覆蓋索引”。

覆蓋索引是非常有用的工具,能極大地提高效能。因為它只需掃描索引無需回表,會帶來以下好處:

  • 索引條目通常遠小於資料行大小,所以如果只需要讀取索引,那MySQL就會極大地減少資料訪問量。這對快取的負載非常重要,因為這種情況下響應時間大部分花費在資料拷貝上。覆蓋索引對於I/O密集型的應用也有幫助,因為索引比資料更小,更容易全部放入記憶體中(這對於MyISAM尤其正確,因為MyISAM能壓縮索引以變得更小)。
  • 因為索引是按照列值順儲存的(至少在單個頁內是如此),所以對於I/O密集型的範圍查詢會比隨機從磁碟讀取每一行資料的I/O要少得多。對於某些儲存引擎,例如MyISAM和Percona XtraDB,甚至可以通過OPTIMIZE命令使得索引完全順序排列,這讓簡單的範圍查詢能使用完全順序的索引訪問。
  • 一些儲存引擎如MyISAM在記憶體中只快取索引,資料則依賴於作業系統來快取,因此要訪問資料需要一次系統呼叫。這可能會導致嚴重的效能問題,尤其是那些系統呼叫佔了資料訪問中的最大開銷的場景。
  • 由於InnoDB的聚簇索引,覆蓋索引對InnoDB表特別有用。InnoDB的二級索引在葉子節點中儲存了行的主鍵值,所以如果二級主鍵能夠覆蓋查詢,則可以避免對主鍵索引的二次查詢。

在所有這些場景中,在索引中滿足查詢的成本一般比查詢行要小得多。

不是所有型別的索引都可以稱為覆蓋索引。覆蓋索引必須要儲存索引列的值,而雜湊索引、空間索引和全文索引等都不儲存索引列的值,所以MySQL只能使用B-Tree索引做覆蓋索引。另外,不同的儲存引擎實現覆蓋索引的方式也不同,而且不是所有的引擎都支援覆蓋索引。

當發起一個被索引覆蓋的查詢時,在EXPLAIN的Extra列可以看到“Using index”的資訊。例如,表sakila.inventory有一個多列索引(store_id,film_id)。MySQL如果只需訪問這兩列,就可以使用這個索引做覆蓋索引,如下所示:

mysql> EXPLAIN SELECT store_id, film_id FROM inventory\G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: inventory
   partitions: NULL
         type: index
possible_keys: NULL
          key: idx_store_id_film_id
      key_len: 3
          ref: NULL
         rows: 4581
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning (0.00 sec)

  

索引覆蓋查詢還有很多陷阱可能會導致無法實現優化。MySQL查詢優化器會在執行查詢前判斷是否有一個索引能進行覆蓋。假設索引覆蓋了WHERE條件中的欄位,但不是整個查詢涉及的欄位。如果條件為假,MySQL 5.5和更早的版本也總是會回表獲取資料行,儘管並不需要這一行且最終會被過濾掉。

在大多數儲存引擎中,覆蓋索引只能覆蓋那些只訪問索引中部分列的查詢。不過,可以更進一步優化InnoDB。回想一下,InnoDB的二級索引的葉子節點包含了主鍵的值,這意味著InnoDB的二級索引可以有效的利用這些“額外”的主鍵列來覆蓋查詢。

例如,sakila.actor使用InnoDB儲存引擎,並在last_name欄位有二級索引,雖然該索引的列不包括主鍵actor_id,但也能夠用於對actor_id做覆蓋查詢:

mysql> EXPLAIN SELECT actor_id, last_name  FROM sakila.actor WHERE last_name = 'HOPPER'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: actor
   partitions: NULL
         type: ref
possible_keys: idx_actor_last_name
          key: idx_actor_last_name
      key_len: 137
          ref: const
         rows: 2
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning (0.00 sec)

  

使用索引掃描來做排序

MySQL有兩種方式可以生成有序的結果:通過排序操作;或者按索引順序掃描;如果EXPLAIN出來type列的值為“index”,則說明MySQL使用索引掃描來做排序。

掃描索引本身是很快的,因為只需從一條索引記錄移動到緊接著的下一條記錄。但如果索引不能覆蓋查詢所需的全部列,那就不得不每掃描一條索引記錄就都回表查詢一次對應的行。這基本上都是隨機I/O,因此按索引順序讀取的速度通常要比順序地全表掃描慢,尤其是在I/O密集型的工作負載時。

MySQL可以同時使用同一個索引既滿足查詢,又滿足排序。因此,如果可能,設計索引是應該儘可能
地同時滿足這兩個任務,這樣是最好的。

只有當索引的列順序和order by子句的順序完全一致,並且所有列的排序方向(倒序或升序)都一樣時,MySQL才能使用索引來對結果做排序,如果查詢需要關聯多張表,則只有當ORDER BY子句引用的欄位全部為第一個表時,才能使用索引做排序。ORDER BY子句和查詢型查詢的限制是一樣的:需要滿足索引的最左字首的要求,否則mMySQL都需要執行排序操作,而無法使用索引排序。

有一種情況下ORDER BY子句可以不滿足索引的最左字首的要求,就是前導列為常量的時候。如果WHERE子句或JOIN子句中對這些列指定了常量,就可以“彌補”索引的不足。

例如,Sakila示例資料庫的表rental在列(rental_date、inventory_id、customer_id)上有名為rental_date的索引。MySQL可以使用rental_date索引為下面的查詢做排序,從EXPLAIN中可以看到沒有出現檔案排序操作:

mysql> EXPLAIN SELECT rental_id, staff_id FROM rental
    -> WHERE rental_date = '2005-05-25'
    -> ORDER BY inventory_id, customer_id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: rental
   partitions: NULL
         type: ref
possible_keys: rental_date
          key: rental_date
      key_len: 5
          ref: const
         rows: 1
     filtered: 100.00
        Extra: Using index condition
1 row in set, 1 warning (0.00 sec)

  

即使ORDER BY子句不滿足索引的最左字首的要求,也可以用於查詢排序,這是因為索引的第一列被指定為一個常數。

下面這個查詢可以利用索引排序,是因為查詢為索引的第一列提供了常量條件,而是用第二列進行排序,將兩列組合在一起,就形成了索引的最左字首:

mysql> EXPLAIN SELECT rental_id, staff_id FROM rental
    -> WHERE rental_date = '2005-05-25'
    -> ORDER BY inventory_id DESC\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: rental
   partitions: NULL
         type: ref
possible_keys: rental_date
          key: rental_date
      key_len: 5
          ref: const
         rows: 1
     filtered: 100.00
        Extra: Using where
1 row in set, 1 warning (0.00 sec)

  

下面是一些不能使用索引做排序的查詢。

這個查詢的ORDER BY子句中引用了一個不在索引中的列:

mysql> EXPLAIN SELECT rental_id, staff_id FROM rental
    -> WHERE rental_date > '2005-05-25'
    -> ORDER BY inventory_id, customer_id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: rental
   partitions: NULL
         type: ALL
possible_keys: rental_date
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 16005
     filtered: 50.00
        Extra: Using where; Using filesort
1 row in set, 1 warning (0.00 sec)

  

這個查詢在索引列的第一列上是範圍條件,所以MySQL無法使用索引的其餘列:

mysql> EXPLAIN SELECT rental_id, staff_id FROM rental
    -> WHERE rental_date > '2005-05-25'
    -> ORDER BY inventory_id, customer_id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: rental
   partitions: NULL
         type: ALL
possible_keys: rental_date
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 16005
     filtered: 50.00
        Extra: Using where; Using filesort
1 row in set, 1 warning (0.00 sec)

  

 

這個查詢在inventory_id列上有多個等於條件。對於排序萊說,這也屬於一種範圍查詢:

mysql> EXPLAIN SELECT rental_id, staff_id FROM rental
    -> WHERE rental_date = '2005-05-25' AND inventory_id IN(1,2)
    -> ORDER BY customer_id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: rental
   partitions: NULL
         type: range
possible_keys: rental_date,idx_fk_inventory_id
          key: rental_date
      key_len: 8
          ref: NULL
         rows: 2
     filtered: 100.00
        Extra: Using index condition; Using filesort
1 row in set, 1 warning (0.00 sec)

  

下面這個例子理論上是可以使用索引進行關聯排序的,但由於優化器在優化時將film_actor表當作關聯的第二張表,所以實際上無法使用索引:

mysql> EXPLAIN SELECT actor_id, title FROM film_actor
    -> INNER JOIN film USING(film_id) ORDER BY actor_id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film
   partitions: NULL
         type: index
possible_keys: PRIMARY
          key: idx_title
      key_len: 767
          ref: NULL
         rows: 1000
     filtered: 100.00
        Extra: Using index; Using temporary; Using filesort
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
   partitions: NULL
         type: ref
possible_keys: idx_fk_film_id
          key: idx_fk_film_id
      key_len: 2
          ref: sakila.film.film_id
         rows: 5
     filtered: 100.00
        Extra: Using index
2 rows in set, 1 warning (0.01 sec)