MySQL之索引(二)
高效能的索引策略
正確地建立和使用索引是實現高效能查詢的基礎。在MySQL之索引(一)這一章中我們介紹了各種型別的索引及其對應的優缺點。現在我們一起來看看如何真正地發揮這些索引的優勢。
獨立的列
我們通常會看到一些查詢不當地使用索引,或者使得MySQL無法使用已有的索引。如果查詢中的列不是獨立的,則MySQL就不會使用索引。“獨立的列”是指索引列不能是表示式的一部分,也不能是函式的引數。
例如,下面這個查詢無法使用actor_id列的索引:
mysql> SELECT actor_id FROM actor WHERE actor_id + 1 = 5;
很容易看出WHERE中的表示式其實等價於actor_id = 4,但是MySQL無法自動解析這個方程式。這完全是使用者行為。我們應該養成簡化WHERE條件的習慣,始終將索引列單獨放在比較符號的一側。
下面是另一個常見的錯誤:
SELECT date_col FROM actor WHERE TO_DAYS(CURRENT_DATE) – TO_DAYS(date_col) <= 10;
字首索引和索引的選擇性
有時候需要索引很長的字元列,這會讓索引變得大且慢。可以通過索引開始的部分字元,這樣可以大大節約索引空間,從而提高索引的效率。但這樣也會降低索引的選擇性。索引的選擇性是指,不重複的索引值(也稱為基數,cardinality)和資料表的記錄總數(#T)的比值,範圍從1/#T到1之間。索引的選擇性越高則查詢效率越高,因為選擇性高的索引可以讓MySQL在查詢時過濾掉更多的行。唯一索引的選擇性是1,這是最好的索引選擇性,效能也是最好的。
一般情況下某個列字首的選擇性也是足夠高的,足以滿足查詢效能。對於BLOB、TEXT或很長的VARCHAR型別的列,必須使用字首索引,即只對列的前面幾個字元進行索引,因為MySQL不允許索引這些列的完整長度。
訣竅在於要選擇足夠長的字首以保證較高的選擇性,同時又不能太長(以便節約空間)。字首應該足夠長,以使得字首索引的選擇性接近於索引的整個列。換句話說,字首的“基數”應該接近於完整的列的“基數”。
為了決定字首的合適長度,需要找到最常見的值的列表,然後和最常見的字首列表進行比較。在示例資料Sakila沒有合適的例子,所以我們從表city生成一個示例表,生成足夠的資料用來演示:
資料集下載:
mysql> CREATE TABLE city_demo (city VARCHAR(50) NOT NULL); Query OK, 0 rows affected (0.10 sec) mysql> INSERT INTO city_demo(city) SELECT city from city; Query OK, 600 rows affected (0.03 sec) Records: 600 Duplicates: 0 Warnings: 0
重複執行下面的SQL五次:
mysql> INSERT INTO city_demo(city) SELECT city FROM city_demo; Query OK, 600 rows affected (0.03 sec) Records: 600 Duplicates: 0 Warnings: 0
執行下面SQL隨機分佈資料:
mysql> UPDATE city_demo SET city = (SELECT city FROM city ORDER BY RAND() limit 1); Query OK, 19179 rows affected (16.88 sec) Rows matched: 19200 Changed: 19179 Warnings: 0
現在我們有了示例資料集。資料分佈當然不是真實的分佈,因為我們使用了RAND(),所以不同的人的結果各不相同,但這個並不重要。首先,我們找到最常見的城市列表:
mysql> SELECT COUNT(*) as cnt, city FROM city_demo GROUP BY city ORDER BY cnt DESC LIMIT 10; +-----+------------+ | cnt | city | +-----+------------+ | 59 | London | | 52 | Elista | | 49 | Kamyin | | 48 | Kolpino | | 48 | Tabuk | | 47 | al-Qatif | | 46 | Tegal | | 46 | Ambattur | | 46 | Lubumbashi | | 46 | Karnal | +-----+------------+ 10 rows in set (0.03 sec)
上面每個值都出現了46~59次。現在查詢最頻繁出現的城市字首,先從3個字首字母開始:
mysql> SELECT COUNT(*) as cnt, LEFT(city,3) AS pref FROM city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 10; +-----+------+ | cnt | pref | +-----+------+ | 449 | San | | 189 | Sal | | 182 | Cha | | 169 | al- | | 152 | Tan | | 149 | Sou | | 136 | Man | | 130 | Shi | | 128 | Bat | | 127 | Kam | +-----+------+ 10 rows in set (0.03 sec)
每個字首都比原來的城市出現的次數更多,因此唯一字首比唯一城市要少得多。然後我們增加字首的長度,直到這個字首的選擇性接近完整列的選擇性。經過實驗後發現字首長度為7時比較合適:
mysql> SELECT COUNT(*) AS cnt, LEFT(city,7) AS pref FROM city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 10; +-----+---------+ | cnt | pref | +-----+---------+ | 65 | San Fel | | 64 | Santiag | | 59 | London | | 59 | Valle d | | 52 | Elista | | 49 | Kamyin | | 48 | Kolpino | | 48 | Tabuk | | 47 | al-Qati | | 46 | Tegal | +-----+---------+ 10 rows in set (0.03 sec)
計算合適的字首長度的一個方法是計算完整列的選擇性,並使字首的選擇性接近於完整列的選擇性。下面是如何計算完整列的選擇性:
mysql> SELECT COUNT(DISTINCT city)/COUNT(*) FROM city_demo; +-------------------------------+ | COUNT(DISTINCT city)/COUNT(*) | +-------------------------------+ | 0.0312 | +-------------------------------+ 1 row in set (0.02 sec)
通常來說,這個例子中如果字首的選擇效能夠接近於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 city_demo; +--------+--------+--------+--------+--------+ | sel3 | sel4 | sel5 | sel6 | sel7 | +--------+--------+--------+--------+--------+ | 0.0239 | 0.0293 | 0.0305 | 0.0309 | 0.0310 | +--------+--------+--------+--------+--------+ 1 row in set (0.07 sec)
查詢顯示當前字首長度到達7的時候,再增加字首長度,選擇性提升的幅度已經很小了。
只看平均選擇性是不夠的,也有例外的情況,需要考慮最壞情況下的選擇性。平均選擇性會讓你認為字首長度為4或者5的索引已經足夠了,但如果資料分佈很不均勻,可能就會有陷阱。如果觀察字首為4的最常出現城市的次數,可以看到明顯不均勻:
mysql> SELECT COUNT(*) AS cnt, LEFT(city,4) AS pref FROM city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 5; +-----+------+ | cnt | pref | +-----+------+ | 194 | San | | 192 | Sant | | 117 | Sout | | 89 | Chan | | 87 | Toul | +-----+------+ 5 rows in set (0.03 sec)
如果字首是4個位元組,則最常出現的字首的出現次數比最常出現的城市的出現次數要大很多。即這些值的選擇性比平均選擇性要低。如果有比這個隨機生成的示例更真實的資料,就更有可能看到這種現象。例如在真實的城市名上建一個長度為4的字首索引,對於以“San”和“New”開頭的城市的選擇性就會非常糟糕,因為很多城市都以這兩個詞開頭。
在上面的示例中,已經找到了合適的字首長度,下面演示一下如何建立字首索引:
mysql> ALTER TABLE city_demo ADD INDEX idx_city(city(7)); Query OK, 0 rows affected (0.10 sec) Records: 0 Duplicates: 0 Warnings: 0
字首索引是一種能使索引更小更快的有效辦法,但另一方面也有其缺點:MySQL無法使用字首索引做ORDER BY和GROUP BY,也無法使用字首索引做覆蓋掃描。
多列索引
很多人對多列索引的理解都不夠。一個常見的錯誤就是,為每個列建立獨立的索引,或者按照錯誤的順序建立多列索引。先來看第一個問題,為每個列建立獨立的索引,從show create table 中很容易看到這種情況:
CREATE TABLE t ( c1 int, c2 int, c3 int, key(c1), key(c2), key(c3) );
這種索引策略,一般是人們聽到一些專家諸如“把where條件裡面的列都建上索引”這樣模糊的建議導致的。實際上這個建議非常錯誤。這樣一來最好的情況下也只能是“一星”索引,其效能比起真正最優的索引可能差幾個數量級。有時如果無法設計一個“三星”索引,那麼不如忽略掉where子句,集中精力優化索引列的順序,或者建立一個全覆蓋索引。
三星索引理論
Lahdenmaki和Leach的三星索引理論:
- 一星:索引將相關的記錄放到一起。
- 二星:索引中的資料順序和查詢中的排列順序一致。
- 三星:索引中的列包含了查詢中需要的全部列。
在多個列上建立獨立的單列索引大部分情況下並不能提高MySQL的查詢效能。MySQL5.0和更新的版本引入了一種叫“索引合併”(index merge)策略,一定程度上可以使用表上的多個單列索引來定位指定的行。更早版本的MySQL只能使用其中某一個單列索引,然而這種情況下沒有哪一個獨立的單列索引是非常有效的。例如,表film_actor在欄位film_id和actor_id上各有一個單列索引。但對於下面這個查詢WHERE條件,這兩個單列索引都不是好的選擇:
SELECT film_id,actor_id FROM film_actor WHERE actor_id =1 OR film_id =1;
在老的MySQL版本中,MySQL對這個查詢會使用全表掃描。除非改寫成如下的兩個查詢UNION方式:
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;
但在5.0和更新的版本中,查詢能夠同時使用這兩個單列索引進行掃描,並講結果進行合併。這種演算法有三個變種:OR條件的聯合(union);AND條件相交(intersection),組合前兩種情況的聯合及相交;下面的查詢就是使用了兩個索引掃描聯合,通過EXPLAIN中的Extra列可以看到這點:
mysql> EXPLAIN SELECT film_id,actor_id FROM film_actor WHERE actor_id =1 OR film_id =1\G; *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor partitions: NULL type: index_merge possible_keys: PRIMARY,idx_fk_film_id key: PRIMARY,idx_fk_film_id key_len: 2,2 ref: NULL rows: 29 filtered: 100.00 Extra: Using union(PRIMARY,idx_fk_film_id); Using where 1 row in set, 1 warning (0.00 sec)
MySQL會使用這類技術優化複雜查詢,所以在某些語句的Extra列中還可以看到巢狀操作。
索引合併策略有時候是一種優化的結果,但實際上更多時候說明了表上的索引建得很糟糕:
- 當出現伺服器對多個索引做相交操作(通常有多個AND條件),通常意味著需要一個包含所有相關列的多列索引,而不是多個獨立的單列索引。
- 當伺服器需要對多個索引做聯合操作時(通常有多個OR條件),通常需要耗費大量CPU和記憶體資源在演算法的快取、排列和合並操作上。特別是當其中有些索引的選擇性不高,需要合併掃描返回的大量資料的時候。
- 更重要的是,優化器不會把這些計算到“查詢成本”(cost)中,優化器只關心隨機頁面讀取。這會使得查詢的成本被“低估”,導致該計劃還不如直接走全表掃描。這樣做不但會消耗更多的CPU和記憶體資源,還可能會影響查詢的併發性,但如果是單獨執行這樣的查詢則往往會忽略對併發性的影響。通常來說,還不如像在MySQL4.1或者更早的時代一樣,將查詢改寫成UNION的方式往往更好。
如果在EXPLAIN中看到有索引合併,應該好好檢查一下查詢和表的結構,看是不是已經是最優的。也可以通過引數optimizer_switch來關閉索引合併功能。也可以使用IGNORE INDEX提示讓優化器忽略掉某些索引。
選擇合適的索引列順序
我們遇到的最容易引起困惑的問題就是索引列的順序。正確的順序依賴於使用該索引的查詢,並且同時需要考慮如何更好地滿足排序和分組的需要。
在一個多列B-Tree索引中,索引列的順序意味著索引首先按照最左列進行排序,其次是第二列,等等。所以,索引可以按照升序或者降序進行掃描,以滿足精確符合列順序的ORDER BY、GROUP BY和DISTINCT等子句的查詢需求。
所以多列索引的順序至關重要。在“三星索引”系統中,列順序也決定了一個索引是否能夠成為一個真正的“三星索引”。
對於如何選擇索引的列順序有一個經驗法則:將選擇性最高的列放到索引最前列。這個建議有用嗎?在某些場景可能有幫助,但通常不如避免隨機IO和排序那麼重要。
當不需要考慮排序和分組時,將選擇性最高的列放在前面通常是很好的。這時候索引的作用只是用於優化WHERE條件的查詢。在這種情況下,這樣設計的索引確實能夠最快地過濾出需要的行,對於WHERE子句中只使用了索引部分字首列的查詢來說選擇性也更高。然而,效能不只是依賴於所有索引列的選擇性(整體基數),也和查詢條件的具體值有關,也就是和值的分佈有關。這和選擇字首的長度需要考慮的地方一樣。可能需要根據那些執行頻率最高的查詢來調整索引列的順序,讓這種情況下索引的選擇性最高。
以下面的查詢為例:
mysql> 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 1 row in set (0.01 sec)
根據前面的經驗法則,應該將索引列custom_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 1 row in set (0.00 sec)
這樣做的一個地方需要注意,查詢的結果非常依賴於選定的具體指。如果按上述辦法優化,可能對其他一些條件值的查詢不公平,伺服器的整體效能可能變得更糟,或者其他某些查詢的執行變得不如預期。
如果是從諸如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 1 row in set (0.01 sec)
customer_id的選擇性更高,所以答案是將其作為索引列的第一列:
mysql> ALTER TABLE payment ADD KEY(customer_id, staff_id); Query OK, 0 rows affected (0.13 sec) Records: 0 Duplicates: 0 Warnings: 0
當使用字首索引的時候,在某些條件值的基數比正常值高的時候,問題就來了。例如,在某些應用程式中,對於沒有登入的使用者,都將其使用者名稱記錄為”guest”,在記錄使用者行為的會話表和其他記錄使用者活動的表中”guest”就成為了一個特殊使用者ID。一旦查詢涉及這個使用者,那麼和對於正常使用者的查詢就大不同了,因為通常有很多會話都是沒有登入的。系統賬號也會導致類似的問題。一個應用通常都有一個特殊的管理員賬號,和普通賬號不同,它並不是一個具體的使用者,系統中所有的其他使用者都是這個使用者的好友,所以系統往往通過它向網站的所有使用者傳送狀態通知和其他訊息。這個賬號的巨大的好友列表很容易導致網站出現伺服器效能問題。
這實際上是一個非常典型的問題。任何的異常使用者,不僅僅是那些用於管理應用的設計糟糕的賬號會有同樣的問題;那些擁有大量好友、圖片、狀態、收藏的使用者,也會有前面提到的系統賬號同樣的問題。