1. 程式人生 > 資訊 >育碧公佈《刺客信條:英靈殿》路線圖:兩次更新,兩項免費活動

育碧公佈《刺客信條:英靈殿》路線圖:兩次更新,兩項免費活動

  索引(在MySQL中也叫做“鍵(key)”)是儲存引擎用於快速找到記錄的一種資料結構。 這是索引的基本功能,除此之外,本章還將討論索引其他一些方面有用的屬性。

  索引對於良好的效能非常關鍵。尤其是當表中的資料量越來越大時,索引對效能的影響 愈發重要。在資料量較小且負載較低時,不恰當的索引對效能的影響可能還不明顯,但當資料量逐漸增大時,效能則會急劇下降

  不過,索引卻經常被忽略,有時候甚至被誤解,所以在實際案例中經常會遇到由糟糕索 引導致的問題。這也是我們把索引優化放在了靠前的章節,甚至比查詢優化還靠前的原因。索引優化應該是對査詢效能優化最有效的手段了。索引能夠輕易將查詢效能提高几個數量級,“最優”的索引有時比一個“好的”索引效能要好兩個數量級。建立一個真正“最優”的索引經常需要重寫查詢,所以,本章和下一章的關係非常緊密。

1.索引基礎

  要理解MySQL中索引是如何工作的,最簡單的方法就是去看看一本書的“索引”部分: 如果想在一本書中找到某個特定主題,一般會先看書的“索引”,找到對應的頁碼。

  在MySQL中,儲存引擎用類似的方法使用索引,其先在索引中找到對應值,然後根據匹配的索引記錄找到對應的資料行。假如要執行下面的査詢:

mysql> SELECT first_name FROM sakila.actor WHERE actor_id = 5;

  如果在actor_id列上建有索引,則MySQL將使用該索引找到actor_id為5的行,也就是說,MySQL先在索引上按值進行査找,然後返回所有包含該值的資料行。

  索引可以包含一個或多個列的值。如果索引包含多個列,那麼列的順序也十分重要,因 為MySQL只能高效地使用索引的最左字首列。建立一個包含兩個列的索引,和建立兩個只包含一列的索引是大不相同的,下面將詳細介紹。

  如果使用的是ORM,是否還需要關心索引?

  簡而言之:是的,仍然需要理解索引,即使是使用物件關係對映(ORM)工具。

  ORM工具能夠生產符合邏輯的、合法的查詢(多數時候),除非只是生成非常基本的查詢(例如僅是根據主鍵查詢),否則它很難生成適合索引的查詢。無論是多麼複雜的ORM工具,在精妙和複雜的索引面前都是“浮雲”。讀完本章後面的內容以後,你就會同意這個觀點的!很多時候,即使是查詢優化技術專家也很難兼顧到各種情況,更別說ORM了。

1.1 索引的型別

  索引有很多種型別,可以為不同的場景提供更好的效能。在MySQL中,索引是在儲存引擎層而不是伺服器層實現的。所以,並沒有統一的索引標準:不同儲存引擎的索引的工作方式並不一樣,也不是所有的儲存引擎都支援所有型別的索引。即使多個儲存引擎支援同一種類型的索引,其底層的實現也可能不同。

  下面我們先來看看MySQL支援的索引型別,以及它們的優點和缺點。

  B-Tree索引

  當人們談論索引的時候,如果沒有特別指明型別,那多半說的是B-Tree索引,它使用B-Tree資料結構來儲存資料。大多數MySQL引擎都支援這種索引。Archive引擎是一個例外:5.1之前Archive不支援任何索引,直到5.1才開始支援單個自增列(AUT0_INCREMENT)的索引。

  我們使用術語“B-Tree”,是因為MySQL在CREATETABLE和其他語句中也使用該關鍵字。

  不過,底層的儲存引擎也可能使用不同的儲存結構,例如,NDB叢集儲存引擎內部實際 上使用了T-Tree結構儲存這種索引,即使其名字是BTREE;InnoDB則使用的是B+Tree,各種資料結構和演算法的變種不在這裡的討論範圍之內。

  儲存引擎以不同的方式使用B-Tree索引,效能也各有不同,各有優劣。例如,MyISAM使用字首壓縮技術使得索引更小,但InnoDB則按照原資料格式進行儲存。再如MyISAM索引通過資料的物理位置引用被索引的行,而InnoDB則根據主鍵引用被索引的行。

  B-Tree通常意味著所有的值都是按順序儲存的,並且每一個葉子頁到根的距離相同。圖 5-1展示了B-Tree索引的抽象表示,大致反映了InnoDB索引是如何工作的。MyISAM使用的結構有所不同,但基本思想是類似的。

  B-Tree索引能夠加快訪問資料的速度,因為儲存引擎不再需要進行全表掃描來獲取需要的資料,取而代之的是從索引的根節點(圖示並未畫出)開始進行搜尋。根節點的槽中存放了指向子節點的指標,儲存引擎根據這些指標向下層査找。通過比較節點頁的值和要査找的值可以找到合適的指標進入下層子節點,這些指標實際上定義了子節點頁中值的上限和下限。最終儲存引擎要麼是找到對應的值,要麼該記錄不存在。

  葉子節點比較特別,它們的指標指向的是被索引的資料,而不是其他的節點頁(不同引擎的“指標”型別不同)。圖5-1中僅繪製了一個節點和其對應的葉子節點,其實在根節點和葉子節點之間可能有很多層節點頁。樹的深度和表的大小直接相關。

  B-Tree對索引列是順序組織儲存的,所以很適合査找範圍資料。例如,在一個基於文字域的索引樹上,按字母順序傳遞連續的值進行査找是非常合適的,所以像“找出所有以I到K開頭的名字”這樣的査找效率會非常髙。

  假設有如下資料表:

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列的值,圖5-2顯示了該索引是如何組織資料的儲存的。

  請注意,索引對多個值進行排序的依據是CREATETABLE語句中定義索引時列的順序。看一下最後兩個條目,兩個人的姓和名都一樣,則根據他們的出生日期來排列順序。

  可以使用B-Tree索引的查詢型別。B-Tree索引適用於全鍵值、鍵值範圍或鍵字首査找。其中鍵字首查詢只適用於根據最左字首的査找。前面所述的索引對如下型別的査詢有效。

  全值匹配

全值匹配指的是和索引中的所有列進行匹配,例如前面提到的索引可用於查詢姓名 為CubaAllen、出生於 1960-01-01 的人。

  匹配最左字首

前面提到的索引可用於査找所有姓為Allen的人,即只使用索引的第一列。

  匹配列字首

也可以只匹配某一列的值的開頭部分。例如前面提到的索引可用於査找所有以J開頭的姓的人。這裡也只使用了索引的第一列。

  匹配範圍值

例如前面提到的索引可用於査找姓在Allen和Barrymore之間的人。這裡也只使用了索引的第一列。

  精確匹配某一列並範圍匹配另外一列

前面提到的索引也可用於査找所有姓為Allen,並且名字是字母K開頭(比如Kim、Karl等)的人。即第一列Jlast_name全匹配,第二列first_name範圍匹配。

  只訪問索引的查詢

B-Tree通常可以支援“只訪問索引的査詢”,即査詢只需要訪問索引,而無須訪問 資料行。後面我們將單獨討論這種“覆蓋索引”的優化。

  因為索引樹中的節點是有序的,所以除了按值査找之外,索引還可以用於査詢中的ORDERBY操作(按順序査找)。一般來說,如果B-Tree可以按照某種方式査找到值,那麼也可以按照這種方式用於排序。所以,如果ORDERBY子句滿足前面列出的幾種査詢型別,則這個索引也可以滿足對應的排序需求。

  下面是一些關於B-Tree索引的限制:

  • 如果不是按照索引的最左列開始査找,則無法使用索引。例如上面例子中的索引無法用於査找名字為Bill的人,也無法査找某個特定生日的人,因為這兩列都不是最左資料列。類似地,也無法査找姓氏以某個字母結尾的人。
  • 不能跳過索引中的列。也就是說,前面所述的索引無法用於査找姓為Smith並且在某個特定日期出生的人。如果不指定名(first_name),則MySQL只能使用索引的第一列。
  • 如果查詢中有某個列的範圍查詢,則其右邊所有列都無法使用索引優化査找。例如有査WHERElast_name=’Smith'ANDfirst_nameLIKE'J%’ANDdob=‘1976-12-23’,這個査詢只能使用索引的前兩列,因為這裡LIKE是一個範圍條件(但是伺服器可以把其餘列用於其他目的)。如果範圍査詢列值的數量有限,那麼可以通過使用多個等於條件來代替範圍條件。在本章的索引案例學習部分,將演示一個詳細的案例。

  到這裡讀者應該可以明白,前面提到的索引列的順序是多麼的重要:這些限制都和索引 列的順序有關。在優化效能的時候,可能需要使用相同的列但順序不同的索引來滿足不同型別的査詢需求。也有些限制並不是B-Tree本身導致的,而是MySQL優化器和儲存引擎使用索引的方式導致的,這部分限制在未來的版本中可能就不再是限制了。

  雜湊索引

  雜湊索引(hashindex)基於雜湊表實現,只有精確匹配索引所有列的査詢才有效。對 於每一行資料,儲存引擎都會對所有的索引列計算一個雜湊碼(hashcode),雜湊碼是一個較小的值,並且不同鍵值的行計算出來的雜湊碼也不一樣。雜湊索引將所有的雜湊碼儲存在索引中,同時在雜湊表中儲存指向每個資料行的指標。

  在MySQL中,只有Memory引擎顯式支援雜湊索引。這也是Memory引擎表的預設索引型別,Memory引擎同時也支援B-Tree索引。值得一提的是,Memory引擎是支援非唯一雜湊索引的,這在資料庫世界裡面是比較與眾不同的。如果多個列的雜湊值相同,索引會以連結串列的方式存放多個記錄指標到同一個雜湊條目中。

  下面來看一個例子。假設有如下表:

CREATE TABLE testhash (
   fname VARCHAR(50) NOT NULL,
   lname VARCHAR(50) NOT NULL,
   KEY USING HASH(fname)
) ENGINE=MEMORY;

  表中包含如下資料:

mysql> SELECT * FROM testhash;
+--------+-----------+
| fname  | lname     |
+--------+-----------+
| Arjen  | Lentz     |
| Baron  | Schwartz  |
| Peter  | Zaitsev   |
| Vadim  | Tkachenko |
+--------+-----------+

  假設索引使用假想的雜湊函式f(),它返回下面的值(都是示例資料,非真實資料):

f('Arjen')= 2323
f('Baron')= 7437
f('Peter')= 8784
f('Vadim')= 2458

  則雜湊索引的資料結構如下:

槽(Slot)    值(Value)
2323           指向第1行的指標
2458           指向第4行的指標
7437           指向第2行的指標
8784           指向第3行的指標

  注意每個槽的編號是順序的,但是資料行不是。現在,來看如下査詢:

mysql> SELECT lname FROM testhash WHERE fname='Peter';

  MySQL先計算'Peter'的雜湊值,並使用該值尋找對應的記錄指標。因為f('Peter')=8784,所以MySQL在索引中查詢8784,可以找到指向第3行的指標,最後一步是比較第三行的值是否為'Peter',以確保就是要査找的行。

  因為索引自身只需儲存對應的雜湊值,所以索引的結構十分緊湊,這也讓雜湊索引査找的速度非常快。然而,雜湊索引也有它的限制:

  • 雜湊索引只包含雜湊值和行指標,而不儲存欄位值,所以不能使用索引中的值來避免讀取行。不過,訪問記憶體中的行的速度很快,所以大部分情況下這一點對效能的影響並不明顯。
  • 雜湊索引資料並不是按照索引值順序儲存的,所以也就無法用於排序。
  • 雜湊索引也不支援部分索引列匹配査找,因為雜湊索引始終是使用索引列的全部內容來計算雜湊值的。例如,在資料列(A,B)上建立雜湊索引,如果查詢只有資料列A,則無法使用該索引。
  • 雜湊索引只支援等值比較査詢,包括=、IN()、<=> (注意 <> 和 <=> 是不同的操作)。也不支援任何範圍査詢,例如WHEREprice> 100。
  • 訪問雜湊索引的資料非常快,除非有很多雜湊衝突(不同的索引列值卻有相同的雜湊值)。當出現雜湊衝突的時候,儲存引擎必須遍歷連結串列中所有的行指標,逐行進行比較,直到找到所有符合條件的行。
  • 如果雜湊衝突很多的話,一些索引維護操作的代價也會很高。例如,如果在某個選擇性很低(雜湊衝突很多)的列上建立雜湊索引,那麼當從表中刪除一行時,儲存引擎需要遍歷對應雜湊值的連結串列中的每一行,找到並刪除對應行的引用,衝突越多,代價越大。

  因為這些限制,雜湊索引只適用於某些特定的場合。而一旦適合雜湊索引,則它帶來的 效能提升將非常顯著。舉個例子,在資料倉庫應用中有一種經典的“星型”schema,需要關聯很多査找表,雜湊索引就非常適合査找表的需求。

  除了Memory引擎外,NDB叢集引擎也支援唯一雜湊索引,且在NDB叢集引擎中作用非常特殊,但這不屬於這裡講解的範圍。

  InnoDB引擎有一個特殊的功能叫做“自適應雜湊索引(adaptivehashindex)”。當InnoDB注意到某些索引值被使用得非常頻繁時,它會在記憶體中基於B-Tree索引之上再建立一個雜湊索引,這樣就讓B-Tree索引也具有雜湊索引的一些優點,比如快速的雜湊査找。這是一個完全自動的、內部的行為,使用者無法控制或者配置,不過如果有必要,完全可以關閉該功能。

  建立自定義雜湊索引。如果儲存引擎不支援雜湊索引,則可以模擬像InnoDB—樣建立 雜湊索引,這可以享受一些雜湊索引的便利,例如只需要很小的索引就可以為超長的鍵建立索引。

  思路很簡單:在B-Tree基礎上建立一個偽雜湊索引。這和真正的雜湊索引不是一回事, 因為還是使用B-Tree進行査找,但是它使用雜湊值而不是鍵本身進行索引査找。你需要做的就是在査詢的WHERE子句中手動指定使用雜湊函式。

  下面是一個例項,例如需要儲存大量的URL,並需要根據URL進行搜尋査找。如果使用B-Tree來儲存URL,儲存的內容就會很大,因為URL本身都很長。正常情況下會有如下査詢:

mysql> SELECT id FROM url WHERE url="http://www.mysql.com";

  若刪除原來URL列上的索引,而新增一個被索引的url_crc列,使用CRC32做雜湊,就 可以使用下面的方式査詢:

mysql> SELECT id FROM url WHERE url="http://www.mysql.com"
    ->    AND url_crc=CRC32("http://www.mysql.com");

  這樣做的效能會非常高,因為MySQL優化器會使用這個選擇性很高而體積很小的基於url_crc列的索引來完成査找(在上面的案例中,索引值為1560514994)。即使有多個記錄有相同的索引值,査找仍然很快,只需要根據雜湊值做快速的整數比較就能找到索引條目,然後一一比較返回對應的行。另外一種方式就是對完整的URL字串做索引,那樣會非常慢。

  這樣實現的缺陷是需要維護雜湊值。可以手動維護,也可以使用觸發器實現。下面的案 例演示了觸發器如何在插入和更新時維護url_crc列。首先建立如下表:

CREATE TABLE pseudohash (
   id int unsigned NOT NULL auto_increment,
   url varchar(255) NOT NULL,
   url_crc int unsigned NOT NULL DEFAULT 0,
   PRIMARY KEY(id)
);

  然後建立觸發器。先臨時修改一下語句分隔符,這樣就可以在觸發器定義中使用分號:

DELIMITER //
 
CREATE TRIGGER pseudohash_crc_ins BEFORE INSERT ON pseudohash FOR EACH ROW BEGIN
SET NEW.url_crc=crc32(NEW.url);
END;
//
 
CREATE TRIGGER pseudohash_crc_upd BEFORE UPDATE ON pseudohash FOR EACH ROW BEGIN
SET NEW.url_crc=crc32(NEW.url);
END;
//
 
DELIMITER ;

  剩下的工作就是驗證一下觸發器如何維護雜湊索引:

mysql> INSERT INTO pseudohash (url) VALUES ('http://www.mysql.com');
mysql> SELECT * FROM pseudohash;
+----+----------------------+------------+
| id | url                  | url_crc    |
+----+----------------------+------------+
|  1 | http://www.mysql.com | 1560514994 |
+----+----------------------+------------+
mysql> UPDATE pseudohash SET url='http://www.mysql.com/' WHERE id=1;
mysql> SELECT * FROM pseudohash;
+----+---------------------- +------------+
| id | url                   | url_crc    |
+----+---------------------- +------------+
|  1 | http://www.mysql.com/ | 1558250469 |
+----+---------------------- +------------+

  如果採用這種方式,記住不要使用SHA1()和MD5()作為雜湊函式。因為這兩個函式計算 出來的雜湊值是非常長的字串,會浪費大量空間,比較時也會更慢。SHA1()和MD5()是強加密函式,設計目標是最大限度消除衝突,但這裡並不需要這樣髙的要求。簡單雜湊函式的衝突在一個可以接受的範圍,同時又能夠提供更好的效能。

  如果資料表非常大,CRC32()會出現大量的雜湊衝突,則可以考慮自己實現一個簡單的 64位雜湊函式。這個自定義函式要返回整數,而不是字串。一個簡單的辦法可以使用MD5()函式返回值的一部分來作為自定義雜湊函式。這可能比自己寫一個雜湊演算法的效能要差,不過這樣實現最簡單:

mysql> SELECT CONV(RIGHT(MD5('http://www.mysql.com/'), 16), 16, 10) AS HASH64;
+---------------------+
| HASH64              |
+---------------------+
| 9761173720318281581 |
+---------------------+

  處理雜湊衝突。當使用雜湊索引進行査詢的時候,必須在WHERE子句中包含常量值:

mysql> SELECT id FROM url WHERE url_crc=CRC32("http://www.mysql.com")
    ->    AND url="http://www.mysql.com";

  一旦出現雜湊衝突,另一個字串的雜湊值也恰好是1560514994,則下面的查詢是無法 正確工作的。

mysql> SELECT id FROM url WHERE url_crc=CRC32("http://www.mysql.com");

  因為所謂的“生日悖論”,出現雜湊衝突的概率的增長速度可能比想象的要快得多。CRC32()返回的是32位的整數,當索引有93 000條記錄時出現衝突的概率是1%。例如我們將/usr/share/dict/words中的詞匯入資料表並進行CRC32()計算,最後會有98 569行。這就已經出現一次雜湊衝突了,衝突讓下面的查詢返回了多條記錄:

mysql> SELECT word, crc FROM words WHERE crc = CRC32('gnu');
+---------+------------+
| word    | crc        |
+---------+------------+
| codding | 1774765869 |
| gnu     | 1774765869 |
+---------+------------+

  正確的寫法應該如下:

mysql> SELECT word, crc FROM words WHERE crc = CRC32('gnu')AND word = 'gnu';
+------+------------+
| word | crc        |
+------+------------+
| gnu  | 1774765869 |
+------+------------+

  要避免衝突問題,必須在WHERE條件中帶入雜湊值和對應列值。如果不是想査詢具體值,例如只是統計記錄數(不精確的),則可以不帶入列值,直接使用CRC32()的雜湊值査詢即可。還可以使用如FNV64()函式作為雜湊函式,這是移植自PerconaServer的函式,可以以外掛的方式在任何MySQL版本中使用,雜湊值為64位,速度快,且衝突比CRC32()要少很多。

  空間資料索引(R_Tree)

  MyISAM表支援空間索引,可以用作地理資料儲存。和B-Tree索引不同,這類索引無須字首查詢。空間索引會從所有維度來索引資料。査詢時,可以有效地使用任意維度來組合査詢。必須使用MySQL的GIS相關函式如MBRCONTAINS()等來維護資料。MySQL的GIS支援並不完善,所以大部分人都不會使用這個特性。開源關係資料庫系統中對GIS的解決方案做得比較好的是PostgreSQL的PostGIS。

  全文索引

  全文索引是一種特殊型別的索引,它査找的是文字中的關鍵詞,而不是直接比較索引中 的值。全文搜尋和其他幾類索引的匹配方式完全不一樣。它有許多需要注意的細節,如停用詞、詞幹和複數、布林搜尋等。全文索引更類似於搜尋引擎做的事情,而不是簡單的WHERE條件匹配。

  在相同的列上同時建立全文索引和基於值的B-Tree索引不會有衝突,全文索引適用於MATCHAGAINST操作,而不是普通的WHERE條件操作。

  其他索引類別

  還有很多第三方的儲存引擎使用不同型別的資料結構來儲存索引。例如TokuDB使用分 形樹索引(fractaltreeindex),這是一類較新開發的資料結構,既有B-Tree的很多優點,也避免了B-Tree的一些缺點。如果通讀完本章,可以看到很多關於InnoDB的主題,包括聚簇索引、覆蓋索引等。多數情況下,針對InnoDB的討論也都適用於TokuDB。

  ScaleDB使用Patricia tries(這個詞不是拼寫錯誤),其他一些儲存引擎技術如InfiniDB和Infobright則使用了一些特殊的資料結構來優化某些特殊的査詢。

2.索引的優點

  索引可以讓伺服器快速地定位到表的指定位置。但是這並不是索引的唯一作用,到目前 為止可以看到,根據建立索引的資料結構不同,索引也有一些其他的附加作用。

  最常見的B-Tree索引,按照順序儲存資料,所以MySQL可以用來做ORDERBY和GROUPBY操作。因為資料是有序的,所以B-Tree也就會將相關的列值都儲存在一起。最後,因為索引中儲存了實際的列值,所以某些査詢只使用索引就能夠完成全部査詢。據此特性,總結下來索引有如下三個優點:

    1.索引大大減少了伺服器需要掃描的資料量。

    2.索引可以幫助伺服器避免排序和臨時表。

    3.索引可以將隨機I/O變為順序I/O。

  “索引”這個主題完全值得單獨寫一本書,如果想深入理解這部分內容,強烈建議閱讀由TapioLahdenmaki和MikeLeach編寫的Relational Database Index Design and the Optimizers(Wiley出版社)一書,該書詳細介紹瞭如何計算索引的成本和作用、如何評估査詢速度、如何分析索引維護的代價和其帶來的好處等。

  Lahdenmaki和Leach在書中介紹瞭如何評價一個索引是否適合某個査詢的“三星系統” (three-starsystem):索引將相關的記錄放到一起則獲得一星;如果索引中的資料順序和査找中的排列順序一致則獲得二星;如果索引中的列包含了査詢中需要的全部列則獲得“三星”。後面將會介紹這些原則。

  討論:索引是最好的解決方案嗎?

  索引並不總是最好的工具。總的來說,只有當索引幫助儲存引擎快速查詢到記錄帶來的好處大於其帶來的額外工作時,索引才是有效的。對於非常小的表,大部分情況下簡單的全表掃描更高效。對於中到大型的表,索引就非常有效。但對於特大型的表,建立和使用索引的代價將隨之增長。這種情況下,則需要一種技術可以直接區分出查詢需要的一組資料,而不是一條記錄一條記錄地匹配。例如可以使用分割槽技術。

  如果表的數量特別多,可以建立一個元資料資訊表,用來查詢需要用到的某些特性。例如執行那些需要聚合多個應用分佈在多個表的資料的查詢,則需要記錄“哪個使用者的資訊儲存在哪個表中”的元資料,這樣在查詢時就可以直接忽略那些不包含指定使用者資訊的表。對於大型系統,這是一個常用的技巧。事實上,Infobright就是使用類似的實現。對於TB級別的資料,定位單條記錄的意義不大,所以經常會使用塊級別元資料技術來替代索引。

3.高效能的索引策略

  正確地建立和使用索引是實現高效能査詢的基礎。前面已經介紹了各種型別的索引及其 對應的優缺點。現在一起來看看如何真正地發揮這些索引的優勢。

  高效地選擇和使用索引有很多種方式,其中有些是針對特殊案例的優化方法,有些則是 針對特定行為的優化。使用哪個索引,以及如何評估選擇不同索引的效能影響的技巧,則需要持續不斷地學習。接下來的幾個小節將理解如何髙效地使用索引。

3.1 獨立的列

  我們通常會看到一些査詢不當地使用索引,或者使得MySQL無法使用已有的索引。如果査詢中的列不是獨立的,則MySQL就不會使用索引。“獨立的列”是指索引列不能是表示式的一部分,也不能是函式的引數。

  例如,下面這個査詢無法使用actor_id列的索引:

mysql> SELECT actor_id FROM sakila.actor WHERE actor_id +1=5;

  憑肉眼很容易看出WHERE中的表示式其實等價於actor_id= 4,但是MySQL無法自動 解析這個方程式。這完全是使用者行為。我們應該養成簡化WHERE條件的習慣,始終將索引列單獨放在比較符號的一側。

  下面是另一個常見的錯誤:

mysql> SELECT ... WHERE TO_DAYS(CURRENT_DATE) - TO_DAYS(date_col) <= 10;

3.2 字首索引和索引選擇性

  有時候需要索引很長的字元列,這會讓索引變得大且慢。一個策略是前面提到過的模擬 雜湊索引。但有時候這樣做還不夠,還可以做些什麼呢?

  通常可以索引開始的部分字元,這樣可以大大節約索引空間,從而提高索引效率。但這樣也會降低索引的選擇性。索引的選擇性是指,不重複的索引值(也稱為基數,cardinality)和資料表的記錄總數(#T)的比值,範圍從1/#T到1之間。索引的選擇性越髙則査詢效率越高,因為選擇性高的索引可以讓MySQL在査找時過濾掉更多的行。唯一索引的選擇性是1,這是最好的索引選擇性,效能也是最好的。

  一般情況下某個列字首的選擇性也是足夠高的,足以滿足査詢效能。對於BLOB、TEXT或者很長的VARCHAR型別的列,必須使用字首索引,因為MySQL不允許索引這些列的完整長度。

  訣竅在於要選擇足夠長的字首以保證較高的選擇性,同時又不能太長(以便節約空間)。 字首應該足夠長,以使得字首索引的選擇性接近於索引整個列。換句話說,字首的“基數”應該接近於完整列的“基數”。

  為了決定字首的合適長度,需要找到最常見的值的列表,然後和最常見的字首列表進行 比較。在示例資料庫Sakila中並沒有合適的例子,所以我們從表city中生成一個示例表,這樣就有足夠的資料進行演示:

CREATE TABLE sakila.city_demo(city VARCHAR(50) NOT NULL);
INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city;
-- Repeat the next statement five times:
INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city_demo;
-- Now randomize the distribution (inefficiently but conveniently):
UPDATE sakila.city_demo
   SET city = (SELECT city FROM  sakila.city ORDER BY RAND() LIMIT 1);

  現在我們有了示例資料集。資料分佈當然不是真實的分佈;因為我們使用了RAND(),所以你的結果會與此不同,但對這個練習來說這並不重要。首先,我們找到最常見的城市列表:

mysql> SELECT COUNT(*) AS cnt, city
    -> FROM sakila.city_demo GROUP BY city ORDER BY cnt DESC LIMIT 10;
+-----+----------------+
| cnt | city           |
+-----+----------------+
|  65 | London         |
|  49 | Hiroshima      |
|  48 | Teboksary      |
|  48 | Pak Kret       |
|  48 | Yaound         |
|  47 | Tel Aviv-Jaffa |
|  47 | Shimoga        |
|  45 | Cabuyao        |
|  45 | Callao         |
|  45 | Bislig         |
+-----+----------------+

  注意到,上面每個值都出現了 45〜65次。現在査找到最頻繁出現的城市字首,先從3 個字首字母開始:

mysql> SELECT COUNT(*) AS cnt, LEFT(city, 3) AS pref
    -> FROM sakila.city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 10;
+-----+------+
| cnt | pref |
+-----+------+
| 483 | San  |
| 195 | Cha  |
| 177 | Tan  |
| 167 | Sou  |
| 163 | al-  |
| 163 | Sal  |
| 146 | Shi  |
| 136 | Hal  |
| 130 | Val  |
| 129 | Bat  |
+-----+------+

  每個字首都比原來的城市出現的次數更多,因此唯一字首比唯一城市要少得多。然後我 們增加字首長度,直到這個字首的選擇性接近完整列的選擇性。經過實驗後發現字首長度為7時比較合適

mysql> SELECT COUNT(*) AS cnt, LEFT(city, 7) AS pref
    -> FROM sakila.city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 10;
+-----+---------+
| cnt | pref    |
+-----+---------+
|  70 | Santiag |
|  68 | San Fel |
|  65 | London  |
|  61 | Valle d |
|  49 | Hiroshi |
|  48 | Teboksa |
|  48 | Pak Kre |
|  48 | Yaound  |
|  47 | Tel Avi |
|  47 | Shimoga |
+-----+---------+

  計算合適的字首長度的另外一個辦法就是計算完整列的選擇性,並使字首的選擇性接近 於完整列的選擇性。下面顯示如何計算完整列的選擇性:

mysql> SELECT COUNT(DISTINCT city)/COUNT(*) FROM sakila.city_demo;
+-------------------------------+
| COUNT(DISTINCT city)/COUNT(*) |
+-------------------------------+
|                        0.0312 |
+-------------------------------+

  通常來說(儘管也有例外情況),這個例子中如果字首的選擇效能夠接近0.031,基本上就可用了。可以在一個査詢中針對不同字首長度進行計算,這對於大表非常有用。下面給出瞭如何在同一個査詢中計算不同字首長度的選擇性:

mysql> SELECT COUNT(DISTINCT LEFT(city, 3))/COUNT(*) AS sel3,
    ->    COUNT(DISTINCT LEFT(city, 4))/COUNT(*) AS sel4,
    ->    COUNT(DISTINCT LEFT(city, 5))/COUNT(*) AS sel5,
    ->    COUNT(DISTINCT LEFT(city, 6))/COUNT(*) AS sel6,
    ->    COUNT(DISTINCT LEFT(city, 7))/COUNT(*) AS sel7
    -> FROM sakila.city_demo;
+--------+--------+--------+--------+--------+
| sel3   | sel4   | sel5   | sel6   | sel7   |
+--------+--------+--------+--------+--------+
| 0.0239 | 0.0293 | 0.0305 | 0.0309 | 0.0310 |
+--------+--------+--------+--------+--------+

  査詢顯示當前綴長度到達7的時候,再增加字首長度,選擇性提升的幅度已經很小了。

  只看平均選擇性是不夠的,也有例外的情況,需要考慮最壞情況下的選擇性。平均選擇 性會讓你認為字首長度為4或者5的索引已經足夠了,但如果資料分佈很不均勻,可能就會有陷阱。如果觀察字首為4的最常出現城市的次數,可以看到明顯不均勻:

mysql> SELECT COUNT(*) AS cnt, LEFT(city, 4) AS pref
    -> FROM sakila.city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 5;
+-----+------+
| cnt | pref |
+-----+------+
| 205 | San  |
| 200 | Sant |
| 135 | Sout |
| 104 | Chan |
|  91 | Toul |
+-----+------+

  如果字首是4個位元組,則最常出現的字首的出現次數比最常出現的城市的出現次數要大 很多。即這些值的選擇性比平均選擇性要低。如果有比這個隨機生成的示例更真實的資料,就更有可能看到這種現象。例如在真實的城市名上建一個長度為4的字首索引,對於以“San”和“New”開頭的城市的選擇性就會非常糟糕,因為很多城市都以這兩個詞開頭。

  在上面的示例中,已經找到了合適的字首長度,下面演示一下如何建立字首索引:

mysql> ALTER TABLE sakila.city_demo ADD KEY (city(7));

  字首索引是一種能使索引更小、更快的有效辦法,但另一方面也有其缺點:MySQL無法使用字首索引做ORDERBY和GROUPBY,也無法使用字首索引做覆蓋掃描。

  一個常見的場景是針對很長的十六進位制唯一ID使用字首索引。在前面的章節中已經討論了很多有效的技術來儲存這類ID資訊,但如果使用的是打包過的解決方案,因而無法修改儲存結構,那該怎麼辦?例如使用vBulletin或者其他基於MySQL的應用在儲存網站的會話(SESSION)時,需要在一個很長的十六進位制字串上建立索引。此時如果採用長度為8的字首索引通常能顯著地提升效能,並且這種方法對上層應用完全透明。

  提示:有時候字尾索引(suffixindex)也有用途(例如,找到某個域名的所有電子郵件地址)。MysQL原生並不支援反向索引,但是可以把字串反轉後儲存,並於此建立字首索引。可以通過觸發器來維護這種索引。參考之前節中“建立自定義雜湊索引”部分的相關內容。

3.3 多列索引

  很多人對多列索引的理解都不夠。一個常見的錯誤就是,為每個列建立獨立的索引,或 者按照錯誤的順序建立多列索引。

  下一小節單獨討論索引列的順序問題。先來看第一個問題,為每個列建立獨立的索引,從SHOWCREATETABLE中很容易看到這種情況:

CREATE TABLE t (
  c1 INT,
  c2 INT,
  c3 INT,
  KEY(c1),
  KEY(c2),
  KEY(c3)
);

  這種索引策略,一般是由於人們聽到一些專家諸如“把WHERE條件裡面的列都建上索引”這樣模糊的建議導致的。實際上這個建議是非常錯誤的。這樣一來最好的情況下也只能是“一星”索引,其效能比起真正最優的索引可能差幾個數量級。有時如果無法設計一個“三星”索引,那麼不如忽略掉WHERE子句,集中精力優化索引列的順序,或者建立一個全覆蓋索引。

  在多個列上建立獨立的單列索引大部分情況下並不能提髙MySQL的査詢效能。MySQL5.0和更新版本引入了一種叫“索引合併”(indexmerge)的策略,一定程度上可以使用表上的多個單列索引來定位指定的行。更早版本的MySQL只能使用其中某一個單列索引,然而這種情況下沒有哪一個獨立的單列索引是非常有效的。例如,表film_actor在欄位film_id和actor_id上各有一個單列索引。但對於下面這個査詢WHERE條件,這兩個單列索引都不是好的選擇:

mysql> SELECT film_id, actor_id FROM sakila.film_actor
    -> WHERE actor_id = 1 OR film_id = 1;

  在老的MySQL版本中,MySQL對這個査詢會使用全表掃描。除非改寫成如下的兩個查詢UNION的方式:

mysql> SELECT film_id, actor_id FROM sakila.film_actor WHERE actor_id = 1
    -> UNION ALL
    -> SELECT film_id, actor_id FROM sakila.film_actor WHERE film_id = 1
    ->    AND actor_id <> 1;

  但在MySQL5.0和更新的版本中,査詢能夠同時使用這兩個單列索引進行掃描,並將結果進行合併。這種演算法有三個變種:0R條件的聯合(union),AND條件的相交(intersection),組合前兩種情況的聯合及相交。下面的査詢就是使用了兩個索引掃描的聯合,通過EXPLAIN中的Extra列可以看到這點:

mysql> EXPLAIN SELECT film_id, actor_id FROM sakila.film_actor
    -> WHERE actor_id = 1 OR film_id = 1\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
         type: index_merge
possible_keys: PRIMARY,idx_fk_film_id
          key: PRIMARY,idx_fk_film_id
      key_len: 2,2
          ref: NULL
         rows: 29
        Extra: Using union(PRIMARY,idx_fk_film_id); Using where

  MySQL會使用這類技術優化複雜査詢,所以在某些語句的Extra列中還可以看到巢狀操作。

  索引合併策略有時候是一種優化的結果,但實際上更多時候說明了表上的索引建得很糟糕:

  • 當出現伺服器對多個索引做相交操作時(通常有多個AND條件),通常意味著需要一個包含所有相關列的多列索引,而不是多個獨立的單列索引。
  • 當伺服器需要對多個索引做聯合操作時(通常有多個OR條件),通常需要耗費大量CPU和記憶體資源在演算法的快取、排序和合並操作上。特別是當其中有些索引的選擇性不高,需要合併掃描返回的大量資料的時候。
  • 更重要的是,優化器不會把這些計算到“査詢成本”(cost)中,優化器只關心隨機頁面讀取。這會使得査詢的成本被“低估”,導致該執行計劃還不如直接走全表掃描。這樣做不但會消耗更多的CPU和記憶體資源,還可能會影響査詢的併發性,但如果是單獨執行這樣的査詢則往往會忽略對併發性的影響。通常來說,還不如像在MySQL4.1或者更早的時代一樣,將査詢改寫成UNION的方式往往更好。

  如果在EXPLAIN中看到有索引合併,應該好好檢査一下查詢和表的結構,看是不是已經是最優的。也可以通過引數optimizer_switch來關閉索引合併功能。也可以使用IGNOREIMDEX提示讓優化器忽略掉某些索引。

3.4 選擇合適的索引列順序

  我們遇到的最容易引起困惑的問題就是索引列的順序。正確的順序依賴於使用該索引的 査詢,並且同時需要考慮如何更好地滿足排序和分組的需要(順便說明,本節內容適用於B-Tree索引;雜湊或者其他型別的索引並不會像B-Tree索引一樣按順序儲存資料)。

  在一個多列B-Tree索引中,索引列的順序意味著索引首先按照最左列進行排序,其次是第二列,等等。所以,索引可以按照升序或者降序進行掃描,以滿足精確符合列順序的ORDERBY、GROUPBY和DISTINCT等子句的査詢需求。

  所以多列索引的列順序至關重要。在Lahdenmaki和Leach的“三星索引”系統中,列順序也決定了一個索引是否能夠成為一個真正的“三星索引”。在本章的後續部分我們將通過大量的例子來說明這一點。

  對於如何選擇索引的列順序有一個經驗法則:將選擇性最高的列放到索引最前列。這個 建議有用嗎?在某些場景可能有幫助,但通常不如避免隨機IO和排序那麼重要,考慮問題需要更全面(場景不同則選擇不同,沒有一個放之四海皆準的法則。這裡只是說明,這個經驗法則可能沒有你想象的重要)。

  當不需要考慮排序和分組時,將選擇性最髙的列放在前面通常是很好的。這時候索引的 作用只是用於優化WHERE條件的査找。在這種情況下,這樣設計的索引確實能夠最快地過濾出需要的行,對於在WHERE子句中只使用了索引部分字首列的查詢來說選擇性也更高。然而,效能不只是依賴於所有索引列的選擇性(整體基數),也和査詢條件的具體值有關,也就是和值的分佈有關。這和前面介紹的選擇字首的長度需要考慮的地方一樣。可能需要根據那些執行頻率最高的查詢來調整索引列的順序,讓這種情況下索引的選擇性最髙。

  以下面的査詢為例:

SELECT * FROM payment WHERE staff_id = 2 AND customer_id = 584;

  是應該建立一個(staff_id,customer_id)索引還是應該顛倒一下順序?可以跑一些査詢來確定在這個表中值的分佈情況,並確定哪個列的選擇性更高。先用下面的查詢預測一下,看看各個WHERE條件的分支對應的資料基數有多大:

mysql> SELECT SUM(staff_id = 2), SUM(customer_id = 584) FROM payment\G
*************************** 1. row ***************************
     SUM(staff_id = 2): 7992
SUM(customer_id = 584): 30

  根據前面的經驗法則,應該將索引列customer_id放到前面,因為對應條件值的customer_id數量更小。我們再來看看對於這個customer_id的條件值,對應的staff_id列的選擇性如何:

mysql> SELECT SUM(staff_id = 2) FROM payment WHERE customer_id = 584\G
*************************** 1. row ***************************
SUM(staff_id = 2): 17

  這樣做有一個地方需要注意,査詢的結果非常依賴於選定的具體值。如果按上述辦法優 化,可能對其他一些條件值的査詢不公平,伺服器的整體效能可能變得更糟,或者其他某些査詢的執行變得不如預期。

  如果是從諸如pt-query-digest這樣的工具的報告中提取“最差”査詢,那麼再按上述辦法選定的索引順序往往是非常髙效的。如果沒有類似的具體査詢來執行,那麼最好還是按經驗法則來做,因為經驗法則考慮的是全域性基數和選擇性,而不是某個具體査詢:

mysql> SELECT COUNT(DISTINCT staff_id)/COUNT(*) AS staff_id_selectivity, COUNT(DISTINCT customer_id)/COUNT(*) AS customer_id_selectivity, COUNT(*)  FROM payment\G
*************************** 1. row ***************************
   staff_id_selectivity: 0.0001
customer_id_selectivity: 0.0373
               COUNT(*): 16049

  customer_id的選擇性更高,所以答案是將其作為索引列的第一列:

mysql>  ALTER TABLE payment ADD KEY(customer_id, staff_id);

  當使用字首索引的時候,在某些條件值的基數比正常值高的時候,問題就來了。例如, 在某些應用程式中,對於沒有登入的使用者,都將其使用者名稱記錄為“guset”,在記錄使用者行為的會話(session)表和其他記錄使用者活動的表中“guest”就成為了一個特殊使用者ID。一旦査詢涉及這個使用者,那麼和對於正常使用者的査詢就大不同了,因為通常有很多會話都是沒有登入的。系統賬號也會導致類似的問題。一個應用通常都有一個特殊的管理員賬號,和普通賬號不同,它並不是一個具體的使用者,系統中所有的其他使用者都是這個使用者的好友,所以系統往往通過它向網站的所有使用者傳送狀態通知和其他訊息。這個賬號的巨大的好友列表很容易導致網站出現伺服器效能問題。

  這實際上是一個非常典型的問題。任何的異常使用者,不僅僅是那些用於管理應用的設計 糟糕的賬號會有同樣的問題;那些擁有大量好友、圖片、狀態、收藏的使用者,也會有前面提到的系統賬號同樣的問題。

  下面是一個我們遇到過的真實案例,在一個使用者分享購買商品和購買經驗的論壇上,這 個特殊表上的査詢執行得非常慢:

mysql> SELECT COUNT(DISTINCT threadId) AS COUNT_VALUE
    -> FROM Message
    -> WHERE (groupId = 10137) AND (userId = 1288826) AND (anonymous = 0)
    -> ORDER BY priority DESC, modifiedDate DESC

  這個査詢看似沒有建立合適的索引,所以客戶諮詢我們是否可以優化。EXPLAIN的結果 如下:

id: 1
select type:  SIMPLE
table: Message
type: ref
key: ix_groupId_userId 
key_len: 18
ref: const,const
rows:  1251162
Extra:  Using where

  MySQL為這個査詢選擇了索引(groupId,userId),如果不考慮列的基數,這看起來是 一個非常合理的選擇。但如果考慮一下userID和groupID條件匹配的行數,可能就會有不同的想法了:

mysql> SELECT COUNT(*), SUM(groupId = 101B7),
-> SUM(userId = 1288826), SUM(anonymous = 0)
-> FROM Message\G
************************** 1. row *************************
count(*): 4142217 
sum(groupId = 10137) : 4092654 
sum(userId = 1288826): 1288496 
sum(anonymous = 0)   : 4141934

  從上面的結果來看符合組(groupId)條件幾乎滿足表中的所有行,符合使用者(userid)條件的有130萬條記錄——也就是說索引基本上沒什麼用。因為這些資料是從其他應用中遷移過來的,遷移的時候把所有的訊息都賦予了管理員組的使用者。這個案例的解決辦法是修改應用程式程式碼,區分這類特殊使用者和組,禁止針對這類使用者和組執行這個査詢。

  從這個小案例可以看到經驗法則和推論在多數情況是有用的,但要注意不要假設平均情況下的效能也能代表特殊情況下的效能,特殊情況可能會摧毀整個應用的效能。

  最後,儘管關於選擇性和基數的經驗法則值得去研究和分析,但一定要記住別忘了WHERE子句中的排序、分組和範圍條件等其他因素,這些因素可能對査詢的效能造成非常大的影響。

3.5 聚簇索引

  聚簇索引並不是一種單獨的索引型別,而是一種資料儲存方式。具體的細節依賴於其實現方式,但InnoDB的聚簇索引實際上在同一個結構中儲存了B-Tree索引和資料行。

  當表有聚簇索引時,它的資料行實際上存放在索引的葉子頁(leafpage)中。術語“聚簇”表示資料行和相鄰的鍵值緊湊地儲存在一起。因為無法同時把資料行存放在兩個不同的地方,所以一個表只能有一個聚簇索引(不過,覆蓋索引可以模擬多個聚簇索引的情況,本章後面將詳細介紹)。

  因為是儲存引擎負責實現索引,因此不是所有的儲存引擎都支援聚簇索引。本節我們主 要關注InnoDB,但是這裡討論的原理對於任何支援聚簇索引的儲存引擎都是適用的。

  圖5-3展示了聚簇索引中的記錄是如何存放的。注意到,葉子頁包含了行的全部資料,但是節點頁只包含了索引列。在這個案例中,索引列包含的是整數值。

  一些資料庫伺服器允許選擇哪個索引作為聚簇索引,但直到mysql5.6之前,還沒有任何一個MySQL內建的儲存引擎支援這一點。InnoDB將通過主鍵聚集資料,這也就是說圖5-3中的“被索引的列”就是主鍵列。

  如果沒有定義主鍵,InnoDB會選擇一個唯一的非空索引代替。如果沒有這樣的索引,InnoDB會隱式定義一個主鍵來作為聚簇索引。InnoDB只聚集在同一個頁面中的記錄。包含相鄰鍵值的頁面可能會相距甚遠。

  聚簇主鍵可能對效能有幫助,但也可能導致嚴重的效能問題。所以需要仔細地考慮聚簇 索引,尤其是將表的儲存引擎從InnoDB改成其他引擎的時候(反過來也一樣)。

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

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

  如果在設計表和查詢時能充分利用上面的優點,那就能極大地提升效能。同時,聚簇索 引也有一些缺點:

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

  最後一點可能讓人有些疑惑,為什麼二級索引需要兩次索引査找?答案在於二級索引中 儲存的“行指標”的實質。要記住,二級索引葉子節點儲存的不是指向行的物理位置的指標,而是行的主鍵值。

  這意味著通過二級索引査找行,儲存引擎需要找到二級索引的葉子節點獲得對應的主鍵 值,然後根據這個值去聚簇索引中査找到對應的行。這裡做了重複的工作:兩次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,按照隨機順序插入並使用OPTIMIZETABLE命令做 了優化。換句話說,資料在磁碟上的儲存方式已經最優,但行的順序是隨機的。列col2的值是從1〜100之間隨機賦值,所以有很多重複的值。

  MyISAM的資料分佈。MyISAM的資料分佈非常簡單,所以先介紹它。MyISAM按照資料插入的順序儲存在磁碟上,如圖5-4所示。

  在行的旁邊顯示了行號,從0開始遞增。因為行是定長的,所以MyISAM可以從表的開頭跳過所需的位元組找到需要的行(MyISAM並不總是使用圖5-4中的“行號”,而是根據定長還是變長的行使用不同策略)。

  這種分佈方式很容易建立索引。下面顯示的一系列圖,隱藏了頁的物理細節,只顯示索 引中的“節點”,索引中的每個葉子節點包含“行號”。圖5-5顯示了表的主鍵。

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

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

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

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

  第一眼看上去,感覺該圖和前面的圖5-5沒有什麼不同,但再仔細看細節,會注意到該 圖顯示了整個表,而不是隻有索引。因為在InnoDB中,聚簇索引“就是”表,所以不像MyISAM那樣需要獨立的行儲存。

  聚簇索引的每一個葉子節點都包含了主鍵值、事務ID、用於事務和MVCC的回滾指標以及所有的剩餘列(在這個例子中是col2)。如果主鍵是一個列字首索引,InnoDB也會包含完整的主鍵列和剩下的其他列。

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

  圖5-8顯示了示例表的col2索引。每一個葉子節點都包含了索引列(這裡是col2),緊接著是主鍵值(col1)。

  圖5-8展示了B-Tree的葉子節點結構,但我們故意省略了非葉子節點這樣的細節。InnoDB的非葉子節點包含了索引列和一個指向下級節點的指標(下一級節點可以是非葉子節點,也可以是葉子節點)。這對聚簇索引和二級索引都適用。

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

  如果還沒有理解聚簇索引和非聚簇索引有什麼區別、為何有這些區別及這些區別的重要 性,也不用擔心。隨著學習的深入,尤其是學完本章剩下的部分以及下一章以後,這些問題就會變得越發清楚。這些概念有些複雜,需要一些時間才能完全理解。

  在InnoDB表中按主鍵順序插入行

  如果正在使用InnoDB表並且沒有什麼資料需要聚集,那麼可以定義一個代理鍵(surrogatekey)作為主鍵,這種主鍵的資料應該和應用無關,最簡單的方法是使用AUTO_INCREMENT自增列。這樣可以保證資料行是按順序寫入,對於根據主鍵做關聯操作的效能也會更好。

  最好避免隨機的(不連續且值的分佈範圍非常大)聚簇索引,特別是對於I/O密集型的應用。例如,從效能的角度考慮,使用UUID來作為聚簇索引則會很糟糕:它使得聚簇索引的插入變得完全隨機,這是最壞的情況,使得資料沒有任何聚集特性。

  為了演示這一點,我們做如下兩個基準測試。第一個使用整數ID插入userinfo表:

CREATE TABLE userinfo (
   id              int unsigned NOT NULL AUTO_INCREMENT,
   name            varchar(64) NOT NULL DEFAULT '',
   email           varchar(64) NOT NULL DEFAULT '',
   password        varchar(64) NOT NULL DEFAULT '',
   dob             date DEFAULT NULL,
   address         varchar(255) NOT NULL DEFAULT '',
   city            varchar(64) NOT NULL DEFAULT '',
   state_id        tinyint unsigned NOT NULL DEFAULT '0',
   zip             varchar(8) NOT NULL DEFAULT '',
   country_id      smallint unsigned NOT NULL DEFAULT '0',
   gender          ('M','F')NOT NULL DEFAULT 'M',
   account_type    varchar(32) NOT NULL DEFAULT '',
   verified        tinyint NOT NULL DEFAULT '0',
   allow_mail      tinyint unsigned NOT NULL DEFAULT '0',
   parrent_account int unsigned NOT NULL DEFAULT '0',
   closest_airport varchar(3) NOT NULL DEFAULT '',
   PRIMARY KEY (id),
   UNIQUE  KEY email (email),
   KEY     country_id (country_id),
   KEY     state_id (state_id),
   KEY     state_id_2 (state_id,city,address)
) ENGINE=InnoDB

  注意到使用了自增的整數ID作為主鍵。

  第二個例子是userinfo_uuid表。除了主鍵改為UUID,其餘和前面的userinfo表完全相同。

CREATE TABLE userinfo_uuid ( 
    uuid varchar(36) NOT NULL,
    ...

  我們測試了這兩個表的設計。首先,我們在一個有足夠記憶體容納索引的伺服器上向這兩 個表各插入100萬條記錄。然後向這兩個表繼續插入300萬條記錄,使索引的大小超過伺服器的記憶體容量。表5-1對測試結果做了比較。

5-1:InnoDB表插入資料的測試結果
表名 行數 時間(秒) 索引大小(MB)
userinfo 1000000 137 342
userinfo_uuid 1000000 180 544
userinfo 3000000 1233 1036
userinfo_uuid 3000000 4525 1707

  為了明白為什麼會這樣,來看看往第一個表中插入資料時,索引發生了什麼變化。圖5-10顯示了插滿一個頁面後繼續插入相鄰的下一個頁面的場景。注意到向UUID主鍵插入行不僅花費的時間更長,而且索引佔用的空間也更大。這一方 面是由於主鍵欄位更長;另一方面毫無疑問是由於頁分裂和碎片導致的。

  如圖5-10所示,因為主鍵的值是順序的,所以InnoDB把每一條記錄都儲存在上一條記錄的後面。當達到頁的最大填充因子時(InnoDB預設的最大填充因子是頁大小的15/16,留出部分空間用於以後修改),下一條記錄就會寫入新的頁中。一旦資料按照這種順序的方式載入,主鍵頁就會近似於被順序的記錄填滿,這也正是所期望的結果(然而,二級索引頁可能是不一樣的)。

  對比一下向第二個使用了UUID聚簇索引的表插入資料,看看有什麼不同,圖5-11顯示 了結果。

  因為新行的主鍵值不一定比之前插入的大,所以InnoDB無法簡單地總是把新行插入到索引的最後,而是需要為新的行尋找合適的位置——通常是已有資料的中間位置——並且分配空間。這會增加很多的額外工作,並導致資料分佈不夠優化。下面是總結的一些缺點:

  • 寫入的目標頁可能已經刷到磁碟上並從快取中移除,或者是還沒有被載入到快取中,InnoDB在插入之前不得不先找到並從磁碟讀取目標頁到記憶體中。這將導致大量的隨機I/O。
  • 因為寫入是亂序的,InnoDB不得不頻繁地做頁分裂操作,以便為新的行分配空間。頁分裂會導致移動大量資料,一次插入最少需要修改三個頁而不是一個頁。
  • 由於頻繁的頁分裂,頁會變得稀疏並被不規則地填充,所以最終資料會有碎片。

  在把這些隨機值載入到聚簇索引以後,也許需要做一次OPTIMIZETABLE來重建表並優化頁的填充。

  從這個案例可以看出,使用InnoDB時應該儘可能地按主鍵順序插入資料,並且儘可能 地使用單調增加的聚簇鍵的值來插入新行。

  討論:順序的主鍵什麼時候會造成更壞的結果?

  對於高併發工作負載,在InnoDB中按主鍵順序插入可能會造成明顯的爭用。主鍵的上界會成為“熱點”。因為所有的插入都發生在這裡,所以併發插入可能導致間隙鎖競爭。另一個熱點可能是AUTO_INCREMENT鎖機制;如果遇到這個問題,則可能需要考慮重新設計表或者應用,或者更改innodb autoinc_lock_mode配置。如果你的伺服器版本還不支援innodb autoinc_lock_mode引數,可以升級到新版本的InnoDB,可能對這種場景會工作得更好。

3.6 覆蓋索引

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

  覆蓋索引是非常有用的工具,能夠極大地提高效能。考慮一下如果査詢只需要掃描索引 而無須回表,會帶來多少好處:

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

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

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

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

mysql> EXPLAIN SELECT store_id, film_id FROM sakila.inventory\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: inventory
         type: index
possible_keys: NULL
          key: idx_store_id_film_id
      key_len: 3
          ref: NULL
         rows: 4673
        Extra: Using index

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

  來看看為什麼會發生這樣的情況,以及如何重寫査詢以解決該問題。從下面的査詢開始:

mysql> EXPLAIN SELECT * FROM products WHERE actor='SEAN CARREY'
    -> AND title like '%APOLLO%'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: products
         type: ref
possible_keys: ACTOR,IX_PROD_ACTOR
          key: ACTOR
      key_len: 52
          ref: const
         rows: 10
        Extra: Using where

  這裡索引無法覆蓋該査詢,有兩個原因:

  • 沒有任何索引能夠覆蓋這個査詢。因為査詢從表中選擇了所有的列,而沒有任何索引覆蓋了所有的列。不過,理論上MySQL還有一個捷徑可以利用:WHERE條件中的列是有索引可以覆蓋的,因此MySQL可以使用該索引找到對應的actor並檢査title是否匹配,過濾之後再讀取需要的資料行。
  • MySQL不能在索引中執行LIKE操作。這是底層儲存引擎API的限制,MySQL5.5和更早的版本中只允許在索引中做簡單比較操作(例如等於、不等於以及大於)。MySQL能在索引中做最左字首匹配的LIKE比較,因為該操作可以轉換為簡單的比較操作,但是如果是萬用字元開頭的LIKE査詢,儲存引擎就無法做比較匹配。這種情況下,MySQL伺服器只能提取資料行的值而不是索引值來做比較。

  也有辦法可以解決上面說的兩個問題,需要重寫査請並巧妙地設計索引。先將索引擴充套件 至覆蓋三個資料列(artist,title,prod_id),然後按如下方式重寫査詢:

mysql> EXPLAIN SELECT *
    -> FROM products
    ->    JOIN (
    ->       SELECT prod_id
    ->       FROM products
    ->       WHERE actor='SEAN CARREY' AND title LIKE '%APOLLO%'
    ->    ) AS t1 ON (t1.prod_id=products.prod_id)\G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: <derived2>
               ...omitted...
*************************** 2. row ***************************
           id: 1
  select_type: PRIMARY
        table: products
               ...omitted...
*************************** 3. row ***************************
           id: 2
  select_type: DERIVED
        table: products
         type: ref
possible_keys: ACTOR,ACTOR_2,IX_PROD_ACTOR
          key: ACTOR_2
      key_len: 52
          ref:
         rows: 11
        Extra: Using where; Using index

  我們把這種方式叫做延遲關聯(deferredjoin),因為延遲了對列的訪問。在査詢的第一階段MySQL可以使用覆蓋索引,在FROM子句的子査詢中找到匹配的prod_id,然後根據這些prod_id值在外層査詢匹配獲取需要的所有列值。雖然無法使用索引覆蓋整個査詢,但總算比完全無法利用索引覆蓋的好。

  這樣優化的效果取決於WHERE條件匹配返回的行數。假設這個products表有100萬行, 我們來看一下上面兩個査詢在三個不同的資料集上的表現,每個資料集都包含100萬行:

    1.第一個資料集,SeanCarrey出演了 30 000部作品,其中有20 000部的標題中包含了Apollo。

    2.第二個資料集,SeanCarrey出演了 30 000部作品,其中40部的標題中包含了Apollo。

    3.第三個資料集, SeanCarrey出演了 50部作品,其中10部的標題中包含了Apollo。 使用上面的三種資料集來測試兩種不同的査詢,得到的結果如表5-2所示。

表5_2:索引覆蓋查詢和非覆蓋查詢的測試結果
資料集 原查詢 優化後的查詢
示例1 每秒5次査詢 每秒5次査詢
示例2 每秒7次査詢 每秒35次査詢
示例3 每秒2 400次査詢 每秒2 000次査詢

  下面是對結果的分析:

  • 在示例 1 中,查詢返回了一個很大的結果集,因此我們看不到優化的效果。大部分時間用於讀取和傳送資料。
  • 示例 2,其中第二個條件過濾後只返回了很少的結果集,優化的效果非常明顯:這這個資料集上效能提高了5倍,優化後的查詢效率來自只需要讀取 40 完整行,而不是第一個查詢中的 30,000。
  • 示例 3 顯示了子查詢效率低下的情況。索引過濾後留下的結果集非常小,以至於子查詢比從表中讀取所有資料更昂貴。

  在大多數儲存引擎中,索引只能涵蓋訪問屬於索引一部分的列的查詢。然而,InnoDB 實際上可以更進一步地進行這種優化。回想一下 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. 行 **************** *********** 
           id: 1 
  select_type: SIMPLE 
        table: actor 
         type: ref 
possible_keys: idx_actor_last_name 
          key: idx_actor_last_name 
      key_len: 137 
          ref: const 
         rows: 2 
        Extra: Using where;Using index

  討論:未來MySQL版本的改進

  上面提到的很多限制都是由於儲存引擎API設計所導致的,目前的API設計不允許MySQL將過濾條件傳到儲存引擎層。如果MySQL在後續版本能夠做到這一點,則可以把查詢傳送到資料上,而不是像現在這樣只能把資料從儲存引擎拉到伺服器層,再根據查詢條件過濾。MySQL5.6之後版本包含了在儲存引擎API上所做的一個重要的改進,其被稱為“索引條件推送(indexconditionpushdown)”。這個特性將大大改善現在的查詢執行方式,如此一來上面介紹的很多技巧也就不再需要了。

3.7 使用索引掃描來做排序

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

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

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

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

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

  例如,Sakila示例資料庫的表rental在列(rental_date,inventory_id,customer_id) 上有名為rental_date的索引。

CREATE TABLE rental (
   ...
   PRIMARY KEY (rental_id),
   UNIQUE KEY rental_date (rental_date,inventory_id,customer_id),
   KEY idx_fk_inventory_id (inventory_id),
   KEY idx_fk_customer_id (customer_id),
   KEY idx_fk_staff_id (staff_id),
   ...
);

  MySQL可以使用rental_date索引為下面的查詢做排序,從EXPLAIN中可以看到沒有出現檔案排序(filesort)操作:

mysql> EXPLAIN SELECT rental_id, staff_id FROM sakila.rental
    -> WHERE rental_date = '2005-05-25'
    -> ORDER BY inventory_id, customer_id\G
*************************** 1. row ***************************
         type: ref
possible_keys: rental_date
          key: rental_date
         rows: 1
        Extra: Using where

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

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

... WHERE rental_date = '2005-05-25' ORDER BY inventory_id DESC;

  下面這個査詢也沒問題,因為ORDERBY使用的兩列就是索引的最左字首:

... WHERE rental_date > '2005-05-25' ORDER BY rental_date, inventory_id;

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

  • 下面這個査詢使用了兩種不同的排序方向,但是索引列都是正序排序的:
... WHERE rental_date = '2005-05-25' ORDER BY inventory_id DESC, customer_id ASC;
  • 下面這個查詢的ORDERBY子句中引用了一個不在索引中的列:
... WHERE rental_date = '2005-05-25' ORDER BY inventory_id, staff_id;
  • 下面這個査詢的WHERE和ORDERBY中的列無法組合成索引的最左字首:
... WHERE rental_date = '2005-05-25' ORDER BY customer_id;
  • 下面這個査詢在索引列的第一列上是範圍條件,所以MySQL無法使用索引的其餘列:
... WHERE rental_date > '2005-05-25' ORDER BY inventory_id, customer_id;
  • 這個査詢在inventory_id列上有多個等於條件。對於排序來說,這也是一種範圍査詢:
... WHERE rental_date = '2005-05-25' AND inventory_id IN(1,2) ORDER BY customer_id;

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

mysql> EXPLAIN SELECT actor_id, title FROM sakila.film_actor
    -> INNER JOIN sakila.film USING(film_id) ORDER BY actor_id\G
+------------+----------------------------------------------+
| table      | Extra                                        |
+------------+----------------------------------------------+
| film       | Using index; Using temporary; Using filesort |
| film_actor | Using index                                  |
+------------+----------------------------------------------+

  使用索引做排序的一個最重要的用法是當査詢同時有ORDERBY和LIMIT子句的時候。後面會具體介紹這些內容。

3.8 壓縮(字首壓縮)索引

  MyISAM使用字首壓縮來減少索引的大小,從而讓更多的索引可以放入記憶體中,這在某 些情況下能極大地提髙效能。預設只壓縮字串,但通過引數設定也可以對整數做壓縮。

  MyISAM壓縮每個索引塊的方法是,先完全儲存索引塊中的第一個值,然後將其他值和 第一個值進行比較得到相同字首的位元組數和剩餘的不同字尾部分,把這部分儲存起來即可。例如,索引塊中的第一個值是“perform”,第二個值是“performance”,那麼第二個值的字首壓縮後儲存的是類似“7,ance”這樣的形式。MyISAM對行指標也採用類似的字首壓縮方式。

  壓縮塊使用更少的空間,代價是某些操作可能更慢。因為每個值的壓縮字首都依賴前面 的值,所以MyISAM査找時無法在索引塊使用二分査找而只能從頭開始掃描。正序的掃描速度還不錯,但是如果是倒序掃描——例如ORDERBYDESC——就不是很好了。所有在塊中査找某一行的操作平均都需要掃描半個索引塊。

  測試表明,對於CPU密集型應用,因為掃描需要隨機査找,壓縮索引使得MyISAM在索引査找上要慢好幾倍。壓縮索引的倒序掃描就更慢了。壓縮索引需要在CPU記憶體資源與磁碟之間做權衡。壓縮索引可能只需要十分之一大小的磁碟空間,如果是I/O密集型應用,對某些査詢帶來的好處會比成本多很多。可以在CREATETABLE語句中指定PACK_KEYS引數來控制索引壓縮的方式。

3.9 冗餘和重複索引

  MySQL允許在相同列上建立多個索引,無論是有意的還是無意的。MySQL需要單獨維護重複的索引,並且優化器在優化査詢的時候也需要逐個地進行考慮,這會影響效能。

  重複索引是指在相同的列上按照相同的順序建立的相同型別的索引。應該避免這樣建立 重複索引,發現以後也應該立即移除。

  有時會在不經意間建立了重複索引,例如下面的程式碼:

CREATE TABLE test (
   ID INT NOT NULL PRIMARY KEY,
   A  INT NOT NULL,
   B  INT NOT NULL,
   UNIQUE(ID),
   INDEX(ID)
) ENGINE=InnoDB;

  一個經驗不足的使用者可能是想建立一個主鍵,先加上唯一限制,然後再加上索引以供查 詢使用。事實上,MySQL的唯一限制和主鍵限制都是通過索引實現的,因此,上面的寫法實際上在相同的列上建立了三個重複的索引。通常並沒有理由這樣做,除非是在同一列上建立不同型別的索引來滿足不同的査詢需求。

  冗餘索引和重複索引有一些不同。如果建立了索引(A,B),再建立索引(A)就是冗餘索引, 因為這只是前一個索引的字首索引。因此索引(A,B)也可以當作索引(A)來使用(這種冗餘只是對B-Tree索引來說的)。但是如果再建立索引(B,A),則不是冗餘索引,索引(B)也不是,因為B不是索引(A,B)的最左字首列。另外,其他不同型別的索引(例如雜湊索引或者全文索引)也不會是B-Tree索引的冗餘索引,而無論覆蓋的索引列是什麼。

  冗餘索引通常發生在為表新增新索引的時候。例如,有人可能會增加一個新的索引(AB) 而不是擴充套件已有的索引(A)。還有一種情況是將一個索引擴充套件為(A,ID),其中ID是主鍵,對於InnoDB來說主鍵列已經包含在二級索引中了,所以這也是冗餘的。

  大多數情況下都不需要冗餘索引,應該儘量擴充套件已有的索引而不是建立新索引。但也有 時候出於效能方面的考慮需要冗餘索引,因為擴充套件已有的索引會導致其變得太大,從而影響其他使用該索引的査詢的效能。

  例如,如果在整數列上有一個索引,現在需要額外增加一個很長的VARCHAR列來擴充套件該索引,那效能可能會急劇下降。特別是有査詢把這個索引當作覆蓋索引,或者這是MyISAM表並且有很多範圍査詢(由於MyISAM的字首壓縮)的時候。

  考慮一下前面“在InnoDB中按主鍵順序插入行”一節提到的userinfo表。這個表有1000 000行,對每個state_id值大概有20 000條記錄。在state_id列有一個索引對下面的査詢有用,假設査詢名為Q1 :

mysql> SELECT count(*) FROM userinfo WHERE state_id=5;

  一個簡單的測試表明該査詢的執行速度大概是每秒115次(QPS)。還有一個相關查詢需 要檢索幾個列的值,而不是隻統計行數,假設名為Q2:

mysql> SELECT state_id, city, address FROM userinfo WHERE state_id=5;

  對於這個査詢,測試結果QPS小於10(這裡使用了全記憶體的案例,如果表逐漸變大,導致工作負載變成I/O密集型時,效能測試結果差距會更大。對於C0UNT()查詢,覆蓋索引效能提升100倍也是很有可能的。)。提升該査詢效能的最簡單辦法就是擴充套件索引為 (state_id,city,address),讓索引能覆蓋査詢:

mysql> ALTER TABLE userinfo DROP KEY state_id,
    ->    ADD KEY state_id_2 (state_id, city, address);

  索引擴充套件後,Q2執行得更快了,但是Q1卻變慢了。如果我們想讓兩個査詢都變得更快, 就需要兩個索引,儘管這樣一來原來的單列索引是冗餘的了。表5-3顯示這兩個査詢在不同的索引策略下的詳細結果,分別使用MyISAM和InnoDB儲存引擎。注意到只有state_id_2索引時,InnoDB引擎上的査詢Q1的效能下降並不明顯,這是因為InnoDB沒有使用索引壓縮。

表5-3:使用不同索引策略的SELECT查詢的QPS測試結果
只有state_id 只有state_id_2 只有state_id和state_id_2
MyISAM, Q1 114.96 25.40 112.19
MyISAM, Q2 9.97 16.34 16.37
InnoDB, Q1 108.55 100.33 107.97
InnoDB, Q2 12.12 28.04 28.06

  有兩個索引的缺點是索引成本更高。表5-4顯示了向表中插入100萬行資料所需要的時間。

5-4:在使用不同索引策略時插入100萬行資料的速度
只有state_id 同時有state_id和state_id_2
InnoDB,對兩個索引都有足夠的內容 80秒 136秒
MyISAM,只有一個索引有足夠的內容 72秒 470秒

  可以看到,表中的索引越多插入速度會越慢。一般來說,增加新索引將會導致INSERT、UPDATE、DELETE等操作的速度變慢,特別是當新增索引後導致達到了記憶體瓶頸的時候。

  解決冗餘索引和重複索引的方法很簡單,刪除這些索引就可以,但首先要做的是找出這樣的索引。可以通過寫一些複雜的訪問INF0RMATI0N_SCHEMA表的査詢來找,不過還有兩個更簡單的方法。可使用ShlomiNoach的commmon_schema中的一些檢視來定位,是一系列可以安裝到伺服器上的常用的儲存和檢視。這比自己編寫査詢要快而且簡單。另外也可以使用Percona Toolkit中的pt-duplicate-key-checker,該工具通過分析表結構來找出冗餘和重複的索引。對於大型伺服器來說,使用外部的工具可能更合適些;如果伺服器上有大量的資料或者大量的表,査詢INF0RMATI0N_SCHEMA表可能會導致效能問題。

  在決定哪些索引可以被刪除的時候要非常小心。回憶一下,在前面的InnoDB的示例表 中,因為二級索引的葉子節點包含了主鍵值,所以在列(A)上的索引就相當於在(A,ID)上的索引。如果有像WHERE A = 5 ORDER BY ID這樣的査詢,這個索引會很有作用。但如果將索引擴充套件為(A,B),則實際上就變成了(A,B,ID),那麼上面査詢的ORDER BY子句就無法使用該索引做排序,而只能用檔案排序了。所以,建議使用Percona工具箱中的pt-upgrade工具來仔細檢査計劃中的索引變更。

3.10 未使用的索引

  除了冗餘索引和重複索引,可能還會有一些伺服器永遠不用的索引。這樣的索引完全是 累贅,建議考慮刪除。有兩個工具可以幫助定位未使用的索引。最簡單有效的辦法是在PerconaServer或者MariaDB中先開啟userstates伺服器變數(預設是關閉的),然後讓伺服器正常執行一段時間,再通過査詢INF0RMATI0N_SCHEMA.INDEX_STATISTICS就能査到每個索引的使用頻率。

  另外,還可以使用PerconaToolkit中的pt-index-usage,該工具可以讀取查詢日誌,並對日誌中的每條査詢進行EXPLAIN操作,然後打印出關於索引和査詢的報告。這個工具不僅可以找出哪些索引是未使用的,還可以瞭解査詢的執行計劃一一例如在某些情況有些類似的査詢的執行方式不一樣,這可以幫助你定位到那些偶爾服務質量差的査詢,優化它們以得到一致的效能表現。該工具也可以將結果寫入到MySQL的表中,方便查詢結果。

3.11 索引和鎖

  索引可以讓查詢鎖定更少的行。如果你的查詢從不訪問那些不需要的行,那麼就會鎖定 更少的行,從兩個方面來看這對效能都有好處。首先,雖然InnoDB的行鎖效率很髙,記憶體使用也很少,但是鎖定行的時候仍然會帶來額外開銷;其次,鎖定超過需要的行會增加鎖爭用並減少併發性。

  InnoDB只有在訪問行的時候才會對其加鎖,而索引能夠減少InnoDB訪問的行數,從而減少鎖的數量。但這隻有當InnoDB在儲存引擎層能夠過濾掉所有不需要的行時才有效。如果索引無法過濾掉無效的行,那麼在InnoDB檢索到資料並返回給伺服器層以後,MySQL伺服器才能應用WHERE子句。這時已經無法避免鎖定行了:InnoDB已經鎖住了這些行,到適當的時候才釋放。在MySQL5.1和更新的版本中,InnoDB可以在伺服器端過濾掉行後就釋放鎖,但是在早期的MySQL版本中,InnoDB只有在事務提交後才能釋放鎖。

  通過下面的例子再次使用資料庫Sakila很好地解釋了這些情況:

mysql> SET AUTOCOMMIT=0;
mysql> BEGIN;
mysql> SELECT actor_id FROM sakila.actor WHERE actor_id < 5
    ->    AND actor_id <> 1 FOR UPDATE;
+----------+
| actor_id |
+----------+
|        2 |
|        3 |
|        4 |
+----------+

  這條查詢僅僅會返回2〜4之間的行,但是實際上獲取了 1〜4之間的行的排他鎖。InnoDB會鎖住第1行,這是因為MySQL為該査詢選擇的執行計劃是索引範圍掃描:

mysql> EXPLAIN SELECT actor_id FROM sakila.actor
    -> WHERE actor_id < 5 AND actor_id <> 1 FOR UPDATE;
+----+-------------+-------+-------+---------+--------------------------+
| id | select_type | table | type  | key     | Extra                    |
+----+-------------+-------+-------+---------+--------------------------+
|  1 | SIMPLE      | actor | range | PRIMARY | Using where; Using index |
+----+-------------+-------+-------+---------+--------------------------+

  換句話說,底層儲存引擎的操作是“從索引的開頭開始獲取滿足條件actor_id< 5的記錄”,伺服器並沒有告訴InnoDB可以過濾第1行的WHERE條件。注意到EXPLAIN的Extra列出現了“Usingwhere”,這表示MySQL伺服器將儲存引擎返回行以後再應用WHERE過濾條件。

  下面的第二個査詢就能證明第1行確實已經被鎖定,儘管第一個査詢的結果中並沒有這 個第1行。保持第一個連線開啟,然後開啟第二個連線並執行如下査詢:

mysql> SET AUTOCOMMIT=0;
mysql> BEGIN;
mysql> SELECT actor_id FROM sakila.actor WHERE actor_id = 1 FOR UPDATE;

  這個查詢將會掛起,直到第一個事務釋放第1行的鎖。這個行為對於基於語句的複製的正常執行來說是必要的。

  就像這個例子顯示的,即使使用了索引,InnoDB也可能鎖住一些不需要的資料。如果不能使用索引査找和鎖定行的話問題可能會更糟糕,MySQL會做全表掃描並鎖住所有的行,而不管是不是需要。

  關於InnoDB、索引和鎖有一些很少有人知道的細節:InnoDB在二級索引上使用共享 (讀)鎖,但訪問主鍵索引需要排他(寫)鎖。這消除了使用覆蓋索引的可能性,並且使得SELECTFORUPDATE比LOCKINSHAREMODE或非鎖定査詢要慢很多。

4.索引案例學習

  理解索引最好的辦法是結合示例,所以這裡準備了一個索引的案例。

  假設要設計一個線上約會網站,使用者資訊表有很多列,包括國家、地區、城市、性別、 眼睛顏色,等等。網站必須支援上面這些特徵的各種組合來搜尋使用者,還必須允許根據使用者的最後線上時間、其他會員對使用者的評分等對使用者進行排序並對結果進行限制。如何設計索引滿足上面的複雜需求呢?

  出人意料的是第一件需要考慮的事情是需要使用索引來排序,還是先檢索資料再排序。使用索引排序會嚴格限制索引和査詢的設計。例如,如果希望使用索引做根據其他會員對使用者的評分的排序,則WHERE條件中的ageBETOEEN18AND25就無法使用索引。如果MySQL使用某個索引進行範圍查詢,也就無法再使用另一個索引(或者是該索引的後續欄位)進行排序了。如果這是很常見的WHERE條件,那麼我們當然就會認為很多査詢需要做排序操作(例如檔案排序filesort)。

4.1 支援多種過濾條件

  現在需要看看哪些列擁有很多不同的取值,哪些列在WHERE子句中出現得最頻繁。在有 更多不同值的列上建立索引的選擇性會更好。一般來說這樣做都是對的,因為可以讓MySQL更有效地過濾掉不需要的行。

  country列的選擇性通常不高,但可能很多査詢都會用到。sex列的選擇性肯定很低,但也會在很多査詢中用到。所以考慮到使用的頻率,還是建議在建立不同組合索引的時候將(sex,country)列作為字首。

  但根據傳統的經驗不是說不應該在選擇性低的列上建立索引的嗎?那為什麼這裡要將兩個選擇性都很低的欄位作為索引的字首列?我們的腦子壞了?

  我們的腦子當然沒壞。這麼做有兩個理由:第一點,如前所述幾乎所有的査詢都會用到sex列。前面曾提到,幾乎每一個査詢都會用到sex列,甚至會把網站設計成每次都只能按某一種性別搜尋使用者。更重要的一點是,索引中加上這一列也沒有壞處,即使査詢沒有使用sex列也可以通過下面的“訣竅”繞過。

  這個“訣竅”就是:如果某個査詢不限制性別,那麼可以通過在査詢條件中新增AND SEXIN(‘m’,’f’)來讓MySQL選擇該索引。這樣寫並不會過濾任何行,和沒有這個條件時返回的結果相同。但是必須加上這個列的條件,MySQL才能夠匹配索引的最左字首。這個“訣轉”在這類場景中非常有效,但如果列有太多不同的值,就會讓IN()列表太長,這樣做就不行了。

  這個案例顯示了一個基本原則:考慮表上所有的選項。當設計索引時,不要只為現有的 査詢考慮需要哪些索引,還需要考慮對査詢進行優化。如果發現某些査詢需要建立新索引,但是這個索引又會降低另一些査詢的效率,那麼應該想一下是否能優化原來的查詢。應該同時優化査詢和索引以找到最佳的平衡,而不是閉門造車去設計最完美的索引。

  接下來,需要考慮其他常見WHERE條件的組合,並需要了解哪些組合在沒有合適索引的 情況下會很慢。(sex,country,age)上的索引就是一個很明顯的選擇,另外很有可能還需要(sex,country,region,age)和(sex,country,region,city,age)這樣的組合索引。

  這樣就會需要大量的索引。如果想盡可能重用索引而不是建立大量的組合索引,可以使用前面提到的IN()的技巧來避免同時需要(sex,country,age)和(sex,country,region,age)的索引。如果沒有指定這個欄位捜索,就需要定義一個全部國家列表,或者國家的全部地區列表,來確保索引字首有同樣的約束(組合所有國家、地區、性別將會是一個非常大的條件)。

  這些索引將滿足大部分最常見的搜尋査詢,但是如何為一些生僻的搜尋條件(比如has_pictures、eyecolor、haircolor和education)來設計索引呢?這些列的選擇性高、使用也不頻繁,可以選擇忽略它們,讓MySQL多掃描一些額外的行即可。另一個可選的方法是在age列的前面加上這些列,在査詢時使用前面提到過的IN()技術來處理捜索時沒有指定這些列的場景。

  你可能已經注意到了,我們一直將age列放在索引的最後面。age列有什麼特殊的地方 嗎?為什麼要放在索引的最後?我們總是儘可能讓MySQL使用更多的索引列,因為査詢只能使用索引的最左字首,直到遇到第一個範圍條件列。前面提到的列在WHERE子句中都是等於條件,但是age列則多半是範圍査詢(例如査找年齡在18〜25歲之間的人)。

  當然,也可以使用IN()來代替範圍査詢,例如年齡條件改寫為IN(18, 19, 20, 21,22, 23, 24, 25),但不是所有的範圍査詢都可以轉換。這裡描述的基本原則是,儘可能將需要做範圍査詢的列放到索引的後面,以便優化器能使用盡可能多的索引列。

  前面提到可以在索引中加入更多的列,並通過IN()的方式覆蓋那些不在WHERE子句中的列。但這種技巧也不能濫用,否則可能會帶來麻煩。因為每額外增加一個IN()條件,優化器需要做的組合都將以指數形式增加,最終可能會極大地降低查詢效能。考慮下面的WHERE子句:

WHERE eye_color   IN('brown','blue','hazel')
   AND hair_color IN('black','red','blonde','brown')
   AND sex        IN('M','F')

  優化器則會轉化成4x3x2 = 24種組合,執行計劃需要檢査WHERE子句中所有的24種組合。對於MySQL來說,24種組合並不是很誇張,但如果組合數達到上千個則需要特別小心。老版本的MySQL在IN()組合條件過多的時候會有很多問題。査詢優化可能需要花很多時間,並消耗大量的記憶體。新版本的MySQL在組合數超過一定數量後就不再進行執行計劃評估了,這可能會導致MySQL不能很好地利用索引。

4.2 避免多個範圍條件

  假設我們有一個last_online列並希望通過下面的査詢顯示在過去幾週上線過的使用者:

WHERE  eye_color   IN('brown','blue','hazel')
   AND hair_color  IN('black','red','blonde','brown')
   AND sex         IN('M','F')
   AND last_online > DATE_SUB(NOW(), INTERVAL 7 DAY)
   AND age         BETWEEN 18 AND 25

  這個査詢有一個問題:它有兩個範圍條件,last_online列和age列,MySQL可以使用last_online列索引或者age列索引,但無法同時使用它們。

  如果條件中只有last_online而沒有age,那麼我們可能考慮在索引的後面加上last_online列。這裡考慮如果我們無法把age欄位轉換為一個IN()的列表,並且仍要求對於同時有last_online和age這兩個維度的範圍査詢的速度很快,那該怎麼辦?答案是,很遺憾沒有一個直接的辦法能夠解決這個問題。但是我們能夠將其中的一個範圍查詢轉換為一個簡單的等值比較。為了實現這一點,我們需要事先計算好一個active列,這個欄位由定時任務來維護。當用戶每次登入時,將對應值設定為1,並且將過去連續七天未曾登入的使用者的值設定為0。

  這個方法可以讓MySQL使用(active,sex,country,age)索引。active列並不是完全精確的,但是對於這類査詢來說,對精度的要求也沒有那麼高。如果需要精確資料,可以把last_online列放到WHERE子句,但不加入到索引中。這和本章前面通過計算URL雜湊值來實現URL的快速查詢類似。所以這個査詢條件沒法使用任何索引,但因為這個條件的過濾性不髙,即使在索引中加入該列也沒有太大的幫助。換個角度來說,缺乏合適的索引對該査詢的影響也不明顯。

  到目前為止,我們可以看到:如果使用者希望同時看到活躍和不活躍的使用者,可以在查詢中使用IN()列表。我們已經加入了很多這樣的列表,但另外一個可選的方案就只能是為不同的組合列建立單獨的索引。至少需要建立如下的索引:(active,sex,country,age), (active,country,age), (sex,country,age)和(country,age)。這些索引對某個具體的査詢來說可能都是更優化的,但是考慮到索引的維護和額外的空間佔用的代價,這個可選方案就不是一個好策略了。

  在這個案例中,優化器的特性是影響索引策略的一個很重要的因素。如果未來版本的MySQL能夠實現鬆散索引掃描,就能在一個索引上使用多個範圍條件,那也就不需要為上面考慮的這類査詢使用IN()列表了。

  討論:什麼是範圍條件

  從EXPLAIN的輸出很難區分MySQL是要查詢範圍值,還是查詢列表值。EXPLAIN使用同樣的詞“range”來描述這兩種情況。例如,從type列來看,MySQL會把下面這種查詢當作是“range”型別:

mysql> EXPLAIN SELECT actor_id FROM sakila.actor
    -> WHERE actor_id > 45\G
************************* 1. row *************************
           id: 1
  select_type: SIMPLE
        table: actor
         type: range

  但是下面這條查詢呢?

mysql> EXPLAIN SELECT actor_id FROM sakila.actor
    -> WHERE actor_id IN(1, 4, 99)\G
************************* 1. row *************************
           id: 1
  select_type: SIMPLE
        table: actor
         type: range

  從EXPLAIN的結果是無法區分這兩者的,但可以從值的範圍和多個等於條件來得出不同。在我們看來,第二個查詢就是多個等值條件查詢。

  我們不是挑剔:這兩種訪問效率是不同的。對於範圍條件查詢,MySQL無法再使用範圍列後面的其他索引列了,但是對於“多個等值條件查詢”則沒有這個限制。

4.3 優化排序

  在這個學習案例中,最後要介紹的是排序。使用檔案排序對小資料集是很快的,但如果 一個査詢匹配的結果有上百萬行的話會怎樣?例如如果WHERE子句只有sex列,如何排序?

  對於那些選擇性非常低的列,可以增加一些特殊的索引來做排序。例如,可以建立(sex,rating)索引用於下面的査詢:

mysql> SELECT <cols> FROM profiles WHERE sex='M' ORDER BY rating LIMIT 10;

  這個査詢同時使用了ORDERBY和LIMIT,如果沒有索引的話會很慢。

  即使有索引,如果使用者介面上需要翻頁,並且翻頁翻到比較靠後時査詢也可能非常慢。 下面這個查詢就通過ORDERBY和LIMIT偏移量的組合翻頁到很後面的時候:

mysql> SELECT <cols> FROM profiles WHERE sex='M' ORDER BY rating LIMIT 100000, 10;

  無論如何建立索引,這種査詢都是個嚴重的問題。因為隨著偏移量的增加,MySQL需要花費大量的時間來掃描需要丟棄的資料。反正規化化、預先計算和快取可能是解決這類查詢的僅有策略。一個更好的辦法是限制使用者能夠翻頁的數量,實際上這對使用者體驗的影響不大,因為使用者很少會真正在乎搜尋結果的第10 000頁。

  優化這類索引的另一個比較好的策略是使用延遲關聯,通過使用覆蓋索引査詢返回需要的主鍵,再根據這些主鍵關聯原表獲得需要的行。這可以減少MySQL掃描那些需要丟棄的行數。下面這個査詢顯示瞭如何高效地使用(sex,rating)索引進行排序和分頁:

mysql> SELECT <cols> FROM profiles INNER JOIN (
    ->    SELECT <primary key cols> FROM profiles
    ->    WHERE x.sex='M' ORDER BY rating LIMIT 100000, 10
    -> ) AS x USING(<primary key cols>);

5.維護索引和表

  即使用正確的型別建立了表並加上了合適的索引,工作也沒有結束:還需要維護表和索 引來確保它們都正常工作。維護表有三個主要的目的:找到並修復損壞的表,維護準確的索引統計資訊,減少碎片。

5.1 找到並修復損壞的表

  表損壞(corruption)是很糟糕的事情。對於MyISAM儲存引擎,表損壞通常是系統崩潰導致的。其他的引擎也會由於硬體問題、MySQL本身的缺陷或者作業系統的問題導致索引損壞。

  損壞的索引會導致查詢返回錯誤的結果或者莫須有的主鍵衝突等問題,嚴重時甚至還會 導致資料庫的崩潰。如果你遇到了古怪的問題——例如一些不應該發生的錯誤——可以嘗試執行CHECKTABLE來檢査是否發生了表損壞(注意有些儲存引擎不支援該命令;而有些引擎則支援以不同的選項來控制完全檢查表的方式)。CHECKTABLE通常能夠找出大多數的表和索引的錯誤。

  可以使用REPAIRTABLE命令來修復損壞的表,但同樣不是所有的儲存引擎都支援該命令。如果儲存引擎不支援,也可通過一個不做任何操作(no-op)的ALTER操作來重建表,例如修改表的儲存引擎為當前的引擎。下面是一個針對InnoDB表的例子:

mysql> ALTER TABLE innodb_tbl ENGINE=INNODB;

  此外,也可以使用一些儲存引擎相關的離線工具,例如myisamchk或者將資料匯出一份,然後再重新匯入。不過,如果損壞的是系統區域,或者是表的“行資料”區域,而不是索引,那麼上面的辦法就沒有用了。在這種情況下,可以從備份中恢復表,或者嘗試從損壞的資料檔案中儘可能地恢復資料。

  如果InnoDB引擎的表出現了損壞,那麼一定是發生了嚴重的錯誤,需要立刻調査一下 原因。InnoDB—般不會出現損壞。InnoDB的設計保證了它並不容易被損壞。如果發生損壞,一般要麼是資料庫的硬體問題例如記憶體或者磁碟問題(有可能),要麼是由於資料庫管理員的錯誤例如在MySQL外部操作了資料檔案(有可能),抑或是InnoDB本身的缺陷(不太可能)。常見的類似錯誤通常是由於嘗試使用rsync備份InnoDB導致的。不存在什麼査詢能夠讓InnoDB表損壞,也不用擔心暗處有“陷阱”。如果某條査詢導致InnoDB資料的損壞,那一定是遇到了bug,而不是査詢的問題。

  如果遇到資料損壞,最重要的是找出是什麼導致了損壞,而不只是簡單地修復,否則很有可能還會不斷地損壞。可以通過設定innodb_force_recovery引數進入InnoDB的強制恢復模式來修復資料,更多細節可以參考MySQL手冊。另外,還可以使用開源的InnoDB資料恢復工具箱(InnoDBDataRecoveryToolkit)直接從InnoDB資料檔案恢復出資料(下載地址: http://www.percona.com/software/mysql-innodb-data-recovery-tools/)。

5.2 更新索引統計資訊

  MySQL的査詢優化器會通過兩個API來了解儲存引擎的索引值的分佈資訊,以決定如何使用索引。第一個API是records_in_range(),通過向儲存引擎傳入兩個邊界值獲取在這個範圍大概有多少條記錄。對於某些儲存引擎,該介面返回精確值,例如MyISAM;但對於另一些儲存引擎則是一個估算值,例如InnoDB。

  第二個API是info(),該介面返回各種型別的資料,包括索引的基數(每個鍵值有多少條記錄)。

  如果儲存引擎向優化器提供的掃描行數資訊是不準確的資料,或者執行計劃本身太複雜 以致無法準確地獲取各個階段匹配的行數,那麼優化器會使用索引統計資訊來估算掃描行數。MySQL優化器使用的是基於成本的模型,而衡量成本的主要指標就是一個査詢需要掃描多少行。如果表沒有統計資訊,或者統計資訊不準確,優化器就很有可能做出錯誤的決定。可以通過執行ANALYZETABLE來重新生成統計資訊解決這個問題。

  每種儲存引擎實現索引統計資訊的方式不同,所以需要進行ANALYZETABLE的頻率也因不同的引擎而不同,每次執行的成本也不同:

  • Memory引擎根本不儲存索引統計資訊。
  • MyISAM將索引統計資訊儲存在磁碟中,ANALYZETABLE需要進行一次全索引掃描來計算索引基數。在整個過程中需要鎖表。
  • 直到MySQL5.5版本,InnoDB也不在磁碟儲存索引統計資訊,而是通過隨機的索引訪問進行評估並將其儲存在記憶體中。

  可以使用SHOWINDEXFROM命令來査看索引的基數(Cardinality)。例如:

mysql> SHOW INDEX FROM sakila.actor\G
*************************** 1. row ***************************
       Table: actor
  Non_unique: 0
    Key_name: PRIMARY
Seq_in_index: 1
 Column_name: actor_id
   Collation: A
 Cardinality: 200
    Sub_part: NULL
      Packed: NULL
        Null:
  Index_type: BTREE
     Comment:
*************************** 2. row ***************************
       Table: actor
  Non_unique: 1
    Key_name: idx_actor_last_name
Seq_in_index: 1
 Column_name: last_name
   Collation: A
 Cardinality: 200
    Sub_part: NULL
      Packed: NULL
        Null:
  Index_type: BTREE
     Comment:

  這個命令輸出了很多關於索引的資訊,在MySQL手冊中對上面每個欄位的含義都有詳細的解釋。這裡需要特別提及的是索引列的基數(Cardinality),其顯示了儲存引擎估算索引列有多少個不同的取值。在MySQL5.0和更新的版本中,還可以通過INF0RMATI0N_SCHEMA.STATISTICS表很方便地査詢到這些資訊。例如基於INF0RMATI0N_SCHEMA的表,可以編寫一個査詢給出當前選擇性比較低的索引。需要注意的是,如果伺服器上的庫表非常多,則從這裡獲取元資料的速度可能會非常慢,而且會給MySQL帶來額外的壓力。

  InnoDB的統計資訊值得深入研究。InnoDB引擎通過抽樣的方式來計算統計資訊,首先 隨機地讀取少量的索引頁面,然後以此為樣本計算索引的統計資訊。在老的InnoDB版本中,樣本頁面數是8,新版本的InnoDB可以通過引數innodb_stats_sample_pages來設定樣本頁的數量。設定更大的值,理論上來說可以幫助生成更準確的索引資訊,特別是對於某些超大的資料表來說,但具體設定多大合適依賴於具體的環境。

  InnoDB會在表首次開啟,或者執行ANALYZETABLE,抑或表的大小發生非常大的變化 (大小變化超過十六分之一或者新插入了 20億行都會觸發)的時候計算索引的統計資訊。

  InnoDB在開啟某些INF0RMATI0N_SCHEMA表,或者使用SHOWTABLESTATUS和SHOW INDEX,抑或在MySQL客戶端開啟自動補全功能的時候都會觸發索引統計資訊的更新。如果伺服器上有大量的資料,這可能就是個很嚴重的問題,尤其是當I/O比較慢的時候。客戶端或者監控程式觸發索引資訊取樣更新時可能會導致大量的鎖,並給伺服器帶來很多的額外壓力,這會讓使用者因為啟動時間漫長而沮喪。只要SHOWINDEX檢視索引統計資訊,就一定會觸發統計資訊的更新。可以關閉innodb_stats_on_metadata引數來避免上面提到的問題。

  如果使用Percona版本,使用的就是XtraDB引擎而不是原生的InnoDB引擎,那麼可以 通過innodb_stats_auto_update引數來禁止通過自動取樣的方式更新索引統計資訊,這時需要手動執行ANALYZETABLE命令來更新統計資訊。如果某些査詢執行計劃很不穩定的話,可以用該辦法固化査詢計劃。我們當初引入這個引數也正是為了解決一些客戶的這種問題。

  如果想要更穩定的執行計劃,並在系統重啟後更快地生成這些統計資訊,那麼可以使用 系統表來持久化這些索引統計資訊。甚至還可以在不同的機器間遷移索引統計資訊,這樣新環境啟動時就無須再收集這些資料。在Percona5.1版本和官方的5.6版本都已經加入這個特性。在Percona版本中通過innodb_use_sys_stats_table引數可以啟用該特性,官方5.6版本則通過innodb_analyze_is_persistent引數控制。

  一旦關閉索引統計資訊的自動更新,那麼就需要週期性地使用ANALYZETABLE來手動更 新。否則,索引統計資訊就會永遠不變。如果資料分佈發生大的變化,可能會出現一些很糟糕的執行計劃。

5.3 減少索引和資料的碎片

  B-Tree索引可能會碎片化,這會降低査詢的效率。碎片化的索引可能會以很差或者無序的方式儲存在磁碟上。

  根據設計,B-Tree需要隨機磁碟訪問才能定位到葉子頁,所以隨機訪問是不可避免的。 然而,如果葉子頁在物理分佈上是順序且緊密的,那麼査詢的效能就會更好。否則,對於範圍査詢、索引覆蓋掃描等操作來說,速度可能會降低很多倍;對於索引覆蓋掃描這一點更加明顯。

  表的資料儲存也可能碎片化。然而,資料儲存的碎片化比索引更加複雜。有三種類型的 資料碎片。

  行碎片(Rowfragmentation)

這種碎片指的是資料行被儲存為多個地方的多個片段中。即使査詢只從索引中訪問一行記錄,行碎片也會導致效能下降。

  行間碎片(Intra-rowfragmentation)

行間碎片是指邏輯上順序的頁,或者行在磁碟上不是順序儲存的。行間碎片對諸如全表掃描和聚簇索引掃描之類的操作有很大的影響,因為這些操作原本能夠從磁碟上順序儲存的資料中獲益。

  剩餘空間碎片(Freespacefragmentation)

剩餘空間碎片是指資料頁中有大量的空餘空間。這會導致伺服器讀取大量不需要的資料,從而造成浪費。

  對於MyISAM表,這三類碎片化都可能發生。但InnoDB不會出現短小的行碎片;InnoDB會移動短小的行並重寫到一個片段中。

  可以通過執行OPTIMIZETABLE或者匯出再匯入的方式來重新整理資料。這對多數儲存引擎都是有效的。對於一些儲存引擎如MyISAM,可以通過排序演算法重建索引的方式來消除碎片。老版本的InnoDB沒有什麼消除碎片化的方法。不過最新版本InnoDB新增了“線上”新增和刪除索引的功能,可以通過先刪除,然後再重新建立索引的方式來消除索引的碎片化。

  對於那些不支援OPTIMIZETABLE的儲存引擎,可以通過一個不做任何操作(no-op)的ALTERTABLE操作來重建表。只需要將表的儲存引擎修改為當前的引擎即可:

mysql> ALTER TABLE <table> ENGINE=<engine>;

  對於開啟了expand_fast_index_creation引數的PerconaServer,按這種方式重建表, 則會同時消除表和索引的碎片化。但對於標準版本的MySQL則只會消除表(實際上是聚簇索引)的碎片化。可用先刪除所有索引,然後重建表,最後重新建立索引的方式模擬PerconaServer的這個功能。

  應該通過一些實際測量而不是隨意假設來確定是否需要消除索引和表的碎片化。Percona的XtraBackup有個--stats引數以非備份的方式執行,而只是列印索引和表的統計情況,包括頁中的資料量和空餘空間。這可以用來確定資料的碎片化程度。另外也要考慮資料是否已經達到穩定狀態,如果你進行碎片整理將資料壓縮到一起,可能反而會導致後續的更新操作觸發一系列的頁分裂和重組,這會對效能造成不良的影響(直到資料再次達到新的穩定狀態)。

6.總結

  通過本章可以看到,索引是一個非常複雜的話題!MySQL和儲存引擎訪問資料的方式, 加上索引的特性,使得索引成為一個影響資料訪問的有力而靈活的工作(無論資料是在磁碟中還是在記憶體中)。

  在MySQL中,大多數情況下都會使用B-Tree索引。其他型別的索引大多隻適用於特殊的目的。如果在合適的場景中使用索引,將大大提高査詢的響應時間。本章將不再介紹更多這方面的內容了,最後值得總的回顧一下這些特性以及如何使用B-Tree索引。

  在選擇索引和編寫利用這些索引的査詢時,有如下三個原則始終需要記住:

    1.單行訪問是很慢的。特別是在機械硬碟儲存中(SSD的隨機I/O要快很多,不過這一點仍然成立)。如果伺服器從儲存中讀取一個數據塊只是為了獲取其中一行,那麼就浪費了很多工作。最好讀取的塊中能包含儘可能多所需要的行。使用索引可以建立位置引用以提升效率。

    2.按順序訪問範圍資料是很快的,這有兩個原因。第一,順序I/O不需要多次磁碟尋道,所以比隨機I/O要快很多(特別是對機械硬碟)。第二,如果伺服器能夠按需要順序讀取資料,那麼就不再需要額外的排序操作,並且GROUPBY査詢也無須再做排序和將行按組進行聚合計算了。

    3.索引覆蓋査詢是很快的。如果一個索引包含了査詢需要的所有列,那麼儲存引擎就不需要再回表査找行。這避免了大量的單行訪問,而上面的第1點已經寫明單行訪問是很慢的。

  總的來說,編寫査詢語句時應該儘可能選擇合適的索引以避免單行査找、儘可能地使用 資料原生順序從而避免額外的排序操作,並儘可能使用索引覆蓋査詢。這與本章開頭提到的Lahdenmaki和Leach的書中的“三星”評價系統是一致的。

  如果表上的每一個查詢都能有一個完美的索引來滿足當然是最好的。但不幸的是,要這 麼做有時可能需要建立大量的索引。還有一些時候對某些査詢是不可能建立一個達到“三星”的索引的(例如査詢要按照兩個列排序,其中一個列正序,另一個列倒序)。這時必須有所取捨以建立最合適的索引,或者尋求替代策略(例如反正規化化,或者提前計算彙總表等)。

  理解索引是如何工作的非常重要,應該根據這些理解來建立最合適的索引,而不是根據 一些諸如“在多列索引中將選擇性最高的列放在第一列”或“應該為WHERE子句中出現的所有列建立索引”之類的經驗法則及其推論。

  那如何判斷一個系統建立的索引是合理的呢? 一般來說,我們建議按響應時間來對査詢進行分析。找出那些消耗最長時間的査詢或者那些給伺服器帶來最大壓力的査詢,然後檢査這些査詢的schema、SQL和索引結構,判斷是否有查詢掃描了太多的行,是否做了很多額外的排序或者使用了臨時表,是否使用隨機I/O訪問資料,或者是有太多回表査詢那些不在索引中的列的操作。

  如果一個査詢無法從所有可能的索引中獲益,則應該看看是否可以建立一個更合適的索 引來提升效能。如果不行,也可以看看是否可以重寫該査詢,將其轉化成一個能夠高效利用現有索引或者新建立索引的査詢,其它章節介紹。

  如果根據之前介紹的基於響應時間的分析不能找出有問題的査詢呢?是否可能有我們 沒有注意到的“很糟糕”的査詢,需要一個更好的索引來獲取更高的效能? 一般來說,不可能。對於診斷時抓不到的査詢,那就不是問題。但是,這個查詢未來有可能會成為問題,因為應用程式、資料和負載都在變化。如果仍然想找到那些索引不是很合適的査詢,並在它們成為問題前進行優化,則可以使用pt-query-digest的査詢審査“review”功能,分析其EXPLAIN出來的執行計劃。

作者:小家電維修

相見有時,後會無期。