《高性能MySQL》讀后感——高性能的索引策略

引子

對(duì)于一條SQL,開發(fā)同學(xué)最先關(guān)心的啥? 我覺得還不到這個(gè)SQL在數(shù)據(jù)庫(kù)的執(zhí)行過程,而是這條SQL是否能盡快的返回結(jié)果,在SQL的生命周期里,每一個(gè)環(huán)節(jié)都有足夠的優(yōu)化空間,但是我們有沒有想過,SQL優(yōu)化的本質(zhì)是啥?終極目標(biāo)又是啥?其實(shí)優(yōu)化本質(zhì)上就是減少SQL對(duì)資源的消耗和依賴,正如數(shù)據(jù)庫(kù)優(yōu)化的終極目的是Do nothing in database一樣,SQL優(yōu)化的終極目的是Consume no resource。

數(shù)據(jù)庫(kù)資源有兩個(gè)特性:

  • 首先資源是有限的,大家都搶著用就會(huì)有瓶頸的,所以SQL的瓶頸可能是由資源緊張產(chǎn)生的。
  • 其次資源是有代價(jià)的,并且代價(jià)各異,比如內(nèi)存的時(shí)延100ns, SSD100us,SAS盤10ms,網(wǎng)絡(luò)更高,那么訪問CPU L1/L2/L3 cache的代價(jià)就比訪問內(nèi)存的要低,訪問內(nèi)存資源的代價(jià)要比訪問硬盤資源的代價(jià),所以SQL的瓶頸也可能是訪問了代價(jià)比較高的資源導(dǎo)致的。

現(xiàn)代計(jì)算機(jī)體系下,機(jī)器上粗粒度的資源就那么幾種,無非是CPU,內(nèi)存,硬盤,和網(wǎng)絡(luò)。那么我們來看下SQL需要消耗哪些資源:

  • 比較、排序、SQL解析、函數(shù)或邏輯運(yùn)算需要用到CPU;
  • 緩存數(shù)據(jù)訪問,臨時(shí)數(shù)據(jù)存放需要用到內(nèi)存;
  • 冷數(shù)據(jù)讀取,大數(shù)據(jù)量的排序和關(guān)聯(lián),數(shù)據(jù)寫入落盤,需要訪問硬盤;
  • SQL請(qǐng)求交互,結(jié)果集返回需要網(wǎng)絡(luò)資源。

那么SQL優(yōu)化思路自然是減少SQL的解析,減少?gòu)?fù)雜的運(yùn)算,減少數(shù)據(jù)處理的規(guī)模,減少對(duì)物理IO的依賴,減少服務(wù)器和客戶端的網(wǎng)絡(luò)交互, 本文的每一節(jié)都解決上面的一兩點(diǎn),索引策略的組合最大化提升SQL優(yōu)化性能:

  • 獨(dú)立的列: 減少SQL的解析
  • 前綴索引和索引選擇性: 減少數(shù)據(jù)處理的規(guī)模,減少對(duì)物理IO的依賴
  • 多列索引:減少對(duì)物理IO的依賴
  • 選擇和是的索引列順序: 減少數(shù)據(jù)處理的規(guī)模,減少對(duì)物理IO的依賴
  • 聚簇索引: 減少數(shù)據(jù)處理的規(guī)模,減少對(duì)物理IO的依賴
  • 覆蓋索引: 減少對(duì)物理IO的依賴
  • 使用索引掃描來做排序: 減少?gòu)?fù)雜的運(yùn)算
  • 返回必要的列: 減少對(duì)物理IO的依賴,減少服務(wù)器和客戶端的網(wǎng)絡(luò)交互

在學(xué)習(xí)MySQL索引之前,最好先學(xué)習(xí)MySQL索引背后的數(shù)據(jù)結(jié)構(gòu)及算法原理。

獨(dú)立的列

獨(dú)立的列是指索引列不能是表達(dá)式的一部分,也不能是函數(shù)的參數(shù)。

例如:下面這個(gè)查詢無法使用actor_id列的索引:

mysql> explain select actor_id from actor where actor_id + 1 = 5;
+----+-------------+-------+-------+---------------+---------------------+---------+------+------+--------------------------+
| id | select_type | table | type  | possible_keys | key                 | key_len | ref  | rows | Extra                    |
+----+-------------+-------+-------+---------------+---------------------+---------+------+------+--------------------------+
|  1 | SIMPLE      | actor | index | NULL          | idx_actor_last_name | 137     | NULL |  200 | Using where; Using index |
+----+-------------+-------+-------+---------------+---------------------+---------+------+------+--------------------------+

憑肉眼容易看出where的表達(dá)式其實(shí)等價(jià)于actor_id=4,但是MySQL無法自動(dòng)解析這個(gè)函數(shù)。所以應(yīng)該簡(jiǎn)化where條件:始終將索引列單獨(dú)放在比較符號(hào)的一側(cè),使用索引的正確寫法如下,此時(shí)使用主鍵索引:

mysql> explain select actor_id from actor where actor_id = 4;
+----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref   | rows | Extra       |
+----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+
|  1 | SIMPLE      | actor | const | PRIMARY       | PRIMARY | 2       | const |    1 | Using index |
+----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+

下面是另外一個(gè)常見的錯(cuò)誤:

mysql>select ...  where to_days(current_date)-to_days(date_col) <=10;

前綴索引和索引選擇性

有時(shí)候索引很長(zhǎng)的字符列,讓索引變得大且慢。通常索引開始的部分字符,可以大大節(jié)約索引空間,從而提高索引效率。但這樣也會(huì)降低索引的選擇性。 索引的選擇性是指:不重復(fù)的索引值(也稱為基數(shù),Cardinality)和數(shù)據(jù)表的記錄總數(shù)(#T)的比值,范圍是 1/#T ~ 1。索引的選擇性越高則查詢效率越高,因?yàn)檫x擇性高的索引讓MySQL在查找時(shí)過濾掉更多的行。唯一索引的選擇性是1,這是最好的索引選擇性,性能也是最好的。

一般情況下某個(gè)列前綴的選擇性如果足夠高,也是可以滿足查詢性能。對(duì)于BLOB、TEXT或者很長(zhǎng)的VARCHAR類型的列,必須使用前綴索引,因?yàn)镸ySQL不允許索引這些列的完整長(zhǎng)度。如下所示,varchar(4000)類型的comment列最多只能建前綴長(zhǎng)度為255的索引。

mysql> show create table shop;
+-------+-------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
+-------+-------------------------------------------------------------------------------------------------------------------------------+
| shop  | CREATE TABLE `demo` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '記錄ID',
  `comment` varchar(4000) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_comment` (`comment`(255))
) ENGINE=InnoDB AUTO_INCREMENT=20001 DEFAULT CHARSET=utf8              
|
+-------+-------------------------------------------------------------------------------------------------------------------------------+

訣竅在于選擇足夠長(zhǎng)的前綴以保證較高的選擇性,同時(shí)又不能太長(zhǎng)(以便節(jié)約空間)。前綴應(yīng)該足夠長(zhǎng),以使得前綴索引的選擇性接近于索引整個(gè)列。換句話說,前綴的”基數(shù)“應(yīng)該接近于完整列的”基數(shù)“。

為了決定前綴的合適長(zhǎng)度,需要找到最常見的值的列表,然后和最常見的前綴列表進(jìn)行比較。在示例數(shù)據(jù)Sakila沒有合適的例子,所以我們從表city生成一個(gè)示例表,生成足夠的數(shù)據(jù)用來演示:

mysql> CREATE TABLE city_demo (city VARCHAR(50) NOT NULL);

mysql> INSERT INTO city_demo(city) SELECT city from city;
Records: 600  Duplicates: 0  Warnings: 0

重復(fù)執(zhí)行下面的sql 五次:

mysql> insert into city_demo(city) select city from city_demo;
Records: 600  Duplicates: 0  Warnings: 0

執(zhí)行下面sql 隨機(jī)分布數(shù)據(jù):

mysql> update city_demo set city = (select city from city order by RAND() limit 1);
Rows matched: 19200  Changed: 19170  Warnings: 0

示例數(shù)據(jù)集分布是隨機(jī)生成的,與你的結(jié)果會(huì)有所不同,但是對(duì)于結(jié)論沒有影響。首先,我們找到最常見的城市列表:

mysql> select count(*) as cnt, city from city_demo group by city order by cnt desc limit 10;
+-----+-------------------+
| cnt | city              |
+-----+-------------------+
|  60 | London            |
|  49 | Skikda            |
|  48 | Izumisano         |
|  47 | Valle de Santiago |
|  47 | Tegal             |
|  46 | Goinia            |
|  46 | Tychy             |
|  46 | Idfu              |
|  46 | Clarksville       |
|  46 | Paarl             |
+-----+-------------------+

注意到,上面每個(gè)值都出現(xiàn)了46-60次,現(xiàn)在查找到最頻繁出現(xiàn)的城市前綴,先從3個(gè)前綴字母開始:

mysql> select count(*) as cnt,left(city,3) as pref from city_demo group by pref order by cnt desc limit 10;
+-----+------+
| cnt | pref |
+-----+------+
| 453 | San  |
| 195 | Cha  |
| 161 | Tan  |
| 157 | Sou  |
| 148 | Shi  |
| 146 | Sal  |
| 145 | al-  |
| 140 | Man  |
| 137 | Hal  |
| 134 | Bat  |
+-----+------+

每個(gè)前綴都比原來的城市出現(xiàn)的次數(shù)要多,因此唯一前綴比唯一城市要少得多。然后我們?cè)黾忧熬Y長(zhǎng)度,直到這個(gè)前綴的選擇性接近完整列的選擇性。經(jīng)過實(shí)驗(yàn)發(fā)現(xiàn)前綴長(zhǎng)度為7時(shí)最為合適:

mysql> select count(*) as cnt,left(city,7) as pref from city_demo group by pref order by cnt desc limit 10;
+-----+---------+
| cnt | pref    |
+-----+---------+
|  74 | Valle d |
|  70 | Santiag |
|  61 | San Fel |
|  60 | London  |
|  49 | Skikda  |
|  48 | Izumisa |
|  47 | Tegal   |
|  46 | Tychy   |
|  46 | Goinia  |
|  46 | Idfu    |
+-----+---------+

計(jì)算合適的前綴長(zhǎng)度的另外一個(gè)方法就是計(jì)算完整列的選擇性,并使前綴的選擇性接近于完整列的選擇性。下面展示計(jì)算完整列的選擇性:

mysql> select count(distinct city)/count(*) from city_demo;
+-------------------------------+
| count(distinct city)/count(*) |
+-------------------------------+
|                        0.0312 |
+-------------------------------+

通常來說(盡管也有例外情況),這個(gè)例子中如果前綴的選擇性能夠接近于0.031,基本上就可用了??梢栽谝粋€(gè)查詢中針對(duì)不同的前綴長(zhǎng)度進(jìn)行計(jì)算,這對(duì)于大表非常有用。下面給出了如何在同一個(gè)查詢中計(jì)算不同前綴長(zhǎng)度的選擇性:

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 |
+--------+--------+--------+--------+--------+

查詢顯示當(dāng)前綴長(zhǎng)度達(dá)7的時(shí)候,再增加前綴長(zhǎng)度,選擇性提升的幅度已經(jīng)很小,但是增加的前綴索引占用空間。

只看平均選擇性是不夠的,也有例外的情況,需要考慮最壞情況下的選擇性。平均選擇性會(huì)讓你認(rèn)為前綴長(zhǎng)度為4或者5的索引已經(jīng)足夠了,但如果數(shù)據(jù)分布很不均勻,可能就會(huì)有陷阱。如果觀察前綴為4的最常出現(xiàn)城市的次數(shù),可以看到明顯不均勻:

mysql> select count(*) as cnt,left(city,4) as pref from city_demo group by pref order by cnt desc limit 5;
+-----+------+
| cnt | pref |
+-----+------+
| 198 | Sant |
| 186 | San  |
| 124 | Sout |
| 106 | Toul |
| 102 | Chan |
+-----+------+

如果前綴是4個(gè)字節(jié),則最常出現(xiàn)的前綴的出現(xiàn)次數(shù)比最常出現(xiàn)的城市的出現(xiàn)次數(shù)要大很多。即這些值的選擇性比平均選擇性要低。如果有比這個(gè)隨機(jī)生成的示例更真實(shí)的數(shù)據(jù),就更有可能看到這種現(xiàn)象。例如在真實(shí)的城市名上建一個(gè)長(zhǎng)度為4的前綴索引,對(duì)于以“San”和“New”開頭的城市的選擇性就會(huì)非常糟糕,因?yàn)楹芏喑鞘卸家赃@兩個(gè)詞開頭。

在上面的示例中,已經(jīng)找到了合適的前綴長(zhǎng)度,下面演示一下如何創(chuàng)建前綴索引:

mysql>alter table city_demo add index idx_city(city(7));

前綴索引是一種能使索引更小更快的有效辦法,但另一方面也有其缺點(diǎn):MySQL無法使用前綴索引做ORDER BY和GROUP BY,也無法使用前綴索引做覆蓋掃描

有時(shí)候后綴索引(suffix index)也有用途(例如,找到某個(gè)域名的所有電子郵件地址)。MySQL原生并不支持反向索引,但是可以把字符串反轉(zhuǎn)后存儲(chǔ),并基于此建立前綴索引。可以通過觸發(fā)器來維護(hù)這種索引。

多列索引

很多人對(duì)多列索引的理解都不夠。一個(gè)常見的錯(cuò)誤就是,為每個(gè)列創(chuàng)建獨(dú)立的索引,或者按照錯(cuò)誤的順序創(chuàng)建多列索引。

先來看第一個(gè)問題,為每個(gè)列創(chuàng)建獨(dú)立的索引,從show create table 中很容易看到這種情況:

create talbe t (
        c1 int,
        c2 int,
        c3 int,
        key(c1),
        key(c2),
        key(c3)
);

這種索引策略,一般是人們聽到一些專家諸如“把where條件里面的列都建上索引”這樣模糊的建議導(dǎo)致的。實(shí)際上這個(gè)建議非常錯(cuò)誤。這樣一來最好的情況下也只能是“一星”索引(關(guān)于三星索引可以參考拙作《高性能MySQL》讀后感——B-Tree索引的三星索引說明),其性能比起真正最優(yōu)的索引可能差幾個(gè)數(shù)量級(jí)。有時(shí)如果無法設(shè)計(jì)一個(gè)“三星”索引,那么不如忽略掉where子句,集中精力優(yōu)化索引列的順序,或者創(chuàng)建一個(gè)全覆蓋索引。

在多個(gè)列上建立獨(dú)立的單列索引大部分情況下并不能提高M(jìn)ySQL的查詢性能。MySQL 5.0和更新的版本引入了一種叫”索引合并”(index merge)策略,一定程度上可以使用表上的多個(gè)單列索引來定位指定的行。

索引合并策略有時(shí)候是一種優(yōu)化的結(jié)果,但大多數(shù)時(shí)候說明表索引建得很糟糕:

  • 當(dāng)出現(xiàn)服務(wù)器對(duì)多個(gè)索引做相交操作時(shí)(通常有多個(gè)AND條件),通常意味著需要一個(gè)包含所有相關(guān)列的多列索引,而不是多個(gè)獨(dú)立的單列索引。
  • 當(dāng)服務(wù)器需要對(duì)多個(gè)索引做聯(lián)合操作時(shí)(通常有多個(gè)OR條件),通常需要耗費(fèi)大量CPU和內(nèi)存資源在算法的緩存、排序和合并操作上。特別是當(dāng)其中有些索引的選擇性不高,需要合并掃描返回的大量數(shù)據(jù)的時(shí)候。
  • 更重要的是,優(yōu)化器不會(huì)把這些計(jì)算到“查詢成本”(cost)中,優(yōu)化器只關(guān)心隨機(jī)頁面讀取。這使得查詢的成本被“低估”,導(dǎo)致該執(zhí)行計(jì)劃還不如直接走全表掃描。這樣做不但消耗更多的CPU和內(nèi)存資源,還可能影響查詢的并發(fā)性,但如果是單獨(dú)運(yùn)行這樣的查詢,則往往忽略對(duì)并發(fā)性的影響。

如果在explain中看到有索引合并,應(yīng)該好好檢查一下查詢和表的結(jié)構(gòu),看是不是已經(jīng)是最優(yōu)的。也可以通過參數(shù)optimizer_switch來關(guān)閉索引合并功能。也可以使用ignore index提示讓優(yōu)化器忽略掉某些索引。

選擇合適的索引列順序

我們遇到的最容易引起困惑的問題就是索引列的順序。正確的順序依賴于使用該索引的查詢,并且同時(shí)需要考慮如何更好地滿足排序和分組的需要(順便說明,本節(jié)內(nèi)容適用于B-Tree索引;哈?;蛘咂渌愋偷乃饕⒉粫?huì)像B-Tree索引一樣按順序存儲(chǔ)數(shù)據(jù))。

在一個(gè)多列B-Tree索引中,索引列的順序意味著索引首先按照最左列進(jìn)行排序,其次是第二列,等等。所以,索引可以按照升序或者降序進(jìn)行掃描,以滿足精確符合列順序order by,group by和distinct等子句的查詢需求。

所以多列索引的列順序至關(guān)重要。

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

當(dāng)不需要考慮排序和分組時(shí),將選擇性最高的列放在前面通常是最好的。這時(shí)候索引的作用只是用于優(yōu)化where條件的查找。在這種情況下,這樣設(shè)計(jì)的索引確實(shí)能夠最快的過濾出需要的行,對(duì)于在where子句中使用了索引部分前綴列的查詢來說選擇性也更高。然而,性能不只是依賴于所有索引列的選擇性(整體基數(shù)),和查詢條件的具體值也有關(guān)系,也就是和值的分布有關(guān)。這和前面介紹的選擇前綴的長(zhǎng)度需要考慮的地方一樣??赡苄枰鶕?jù)那些運(yùn)行頻率最高的查詢來調(diào)整索引列的順序,讓這種情況下索引的選擇性最高。

以下面的查詢?yōu)槔?/p>

select * from payment where staff_id=2 and customer_id=584;

是應(yīng)該創(chuàng)建一個(gè)(staff_id,customer_id)索引還是應(yīng)該顛倒一下順序?可以跑一些查詢來確定在這個(gè)表中值的分布情況,并確定哪個(gè)列的選擇性更高。先用下面的查詢預(yù)測(cè)一下,看看各個(gè)where條件的分支對(duì)應(yīng)的數(shù)據(jù)基數(shù)有多大:

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

根據(jù)前面的經(jīng)驗(yàn)法則,應(yīng)該將索引customer_id放到前面,因?yàn)閷?duì)應(yīng)條件值的customer_id數(shù)量更小。我們?cè)賮砜纯磳?duì)于這個(gè)customer_id的條件值,對(duì)應(yīng)的staff_id列的選擇性如何:

mysql> select sum(staff_id=2) from payment where customer_id=584\G
*************************** 1. row ***************************
sum(staff_id=2): 17

這樣做有一個(gè)地方需要注意,查詢的結(jié)果非常依賴于特定的具體值。如果按上述辦法優(yōu)化,可能對(duì)其他一些條件值的查詢不公平,服務(wù)器的整體性能可能變得更糟,或者其他某些查詢的運(yùn)行變得不如預(yù)期。

如果是從諸如pt-query-digest這樣的工具的報(bào)告中提取“最差”查詢,那么再按上述辦法選定的索引順序往往是非常高效的。如果沒有類似的具體查詢來運(yùn)行,那么最好按經(jīng)驗(yàn)法則來做,因?yàn)榻?jīng)驗(yàn)法則考慮的是全局基數(shù)和選擇性,而不是某個(gè)具體查詢:

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 index idx_cust_staff_id(customer_id,staff_id);

當(dāng)使用前綴索引的時(shí)候,在某些條件值的基數(shù)比正常值高的時(shí)候,問題就來了。例如,在某些應(yīng)用程序中,對(duì)于沒有登錄的用戶,都將其用戶名記錄為”guest”,在記錄用戶行為的會(huì)話表和其他記錄用戶活動(dòng)的表中”guest”就成為了一個(gè)特殊用戶ID。一旦查詢涉及這個(gè)用戶,那么和對(duì)于正常用戶的查詢就大不同了,因?yàn)橥ǔS泻芏鄷?huì)話都是沒有登錄的。系統(tǒng)賬號(hào)也會(huì)導(dǎo)致類似的問題。一個(gè)應(yīng)用通常都有一個(gè)特殊的管理員賬號(hào),和普通賬號(hào)不同,它并不是一個(gè)具體的用戶,系統(tǒng)中所有的其他用戶都是這個(gè)用戶的好友,所以系統(tǒng)往往通過它向網(wǎng)站的所有用戶發(fā)送狀態(tài)通知和其他消息。這個(gè)賬號(hào)的巨大的好友列表很容易導(dǎo)致網(wǎng)站出現(xiàn)服務(wù)器性能問題。

覆蓋索引

通常大家都會(huì)根據(jù)查詢的where條件來創(chuàng)建合適的索引,不過這只是索引優(yōu)化的一個(gè)方面。設(shè)計(jì)優(yōu)秀的索引應(yīng)該考慮到整個(gè)查詢,而不單單是where條件部分。索引確實(shí)是一種查找數(shù)據(jù)的高效方式,但是MySQL也可以使用索引來直接獲取列的數(shù)據(jù),這樣就不再索引讀取數(shù)據(jù)行。如果索引的葉子節(jié)點(diǎn)中已經(jīng)包含要查詢的數(shù)據(jù),那么還有什么必要再回表查詢呢?如果一個(gè)索引包含(或者說覆蓋)所有需要查詢的字段的值,我們就稱之為“覆蓋索引”。

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

  • 索引條目通常遠(yuǎn)小于數(shù)據(jù)行大小,如果只讀取索引,那么MySQL訪問更少數(shù)據(jù)量。這對(duì)緩存的負(fù)載非常重要,因?yàn)檫@種情況下響應(yīng)時(shí)間大部分花費(fèi)在數(shù)據(jù)拷貝上。覆蓋索引對(duì)于IO密集型的應(yīng)用也有幫助,因?yàn)樗饕葦?shù)據(jù)還小,更容易全部放入內(nèi)存中(這對(duì)于MyISAM尤其正確,因?yàn)镸yISAM能壓縮索引以變得更小)。
  • 索引按照列值順序存儲(chǔ)(至少在單個(gè)頁內(nèi)是如此),對(duì)于IO密集型的范圍查詢會(huì)比隨機(jī)從磁盤讀取每一行數(shù)據(jù)的IO要少得多。
  • 大多數(shù)據(jù)引擎能更好的緩存索引。比如MyISAM在內(nèi)存中只緩存索引,數(shù)據(jù)則依賴于操作系統(tǒng)來緩存,因此訪問數(shù)據(jù)多一次系統(tǒng)調(diào)用。
  • 覆蓋索引對(duì)于InnoDB表特別有用,因?yàn)镮nnoDB使用聚集索引組織數(shù)據(jù)。InnoDB的二級(jí)索引在葉子節(jié)點(diǎn)中保存行的主鍵值,如果二級(jí)索引能夠覆蓋查詢,則可以避免對(duì)主鍵索引的二次查詢。

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

對(duì)于索引覆蓋查詢(index-covered query),使用EXPLAIN時(shí),在Extra一列中看到“Using index”。例如,在sakila的inventory表中,有一個(gè)組合索引(store_id,film_id),對(duì)于只需要訪問這兩列的查詢,MySQL就可以使用覆蓋索引,如下:

mysql> EXPLAIN SELECT store_id, film_id FROM 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: 5341
        Extra: Using index

在大多數(shù)引擎中,只有當(dāng)查詢語句所訪問的列是索引的一部分時(shí),索引才會(huì)覆蓋。但是,InnoDB不限于此,InnoDB的二級(jí)索引在葉子節(jié)點(diǎn)中存儲(chǔ)了primary key的值。例如,sakila.actor表使用InnoDB,而且對(duì)于是last_name上有二級(jí)索引,所以索引能覆蓋那些訪問actor_id的查詢:

mysql> EXPLAIN SELECT actor_id, last_name FROM actor WHERE last_name = 'HOPPER'\G
*************************** 1. row ***************************
           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有兩種方式生成有序的結(jié)果:

  • 通過排序操作,Explain 的Extra 輸出“Using filesort”, MySQL使用文件排序;
  • 通過索引順序掃描,Explain的type列值為index,MySQL使用索引掃描排序(不要和Extra 列的“Using index”混淆)。

掃描索引本身很快,因?yàn)橹恍鑿囊粭l記錄移動(dòng)到緊接著的下一條記錄。但如果索引不能覆蓋查詢所需的全部列,就要每掃描一條索引記錄得回表查詢一次對(duì)應(yīng)的行。這基本上都是隨機(jī)IO,因此按索引順序讀取數(shù)據(jù)的速度通常比順序的全表掃描慢,尤其是在IO密集型。所以,設(shè)計(jì)索引時(shí)讓同一個(gè)索引既滿足排序,又用于查找行,避免隨機(jī)IO。

當(dāng)索引的列的順序和ORDER BY子句的順序完全一致,并且所有列的排序方向(倒序和正序)都一樣時(shí),MySQL才能使用索引來對(duì)結(jié)果做排序。如果查詢需要關(guān)聯(lián)多張表,則只有ORDER BY子句引用的字段全部為第一個(gè)表時(shí),才能使用索引來做排序。ORDER BY子句和Where查詢的限制是一樣的:需要滿足索引的最左前綴的要求。

當(dāng)MySQL不能使用索引進(jìn)行排序時(shí),就會(huì)利用自己的排序算法(快速排序算法)在內(nèi)存(sort buffer)中對(duì)數(shù)據(jù)進(jìn)行排序,如果內(nèi)存裝載不下,它會(huì)將磁盤上的數(shù)據(jù)進(jìn)行分塊,再對(duì)各個(gè)數(shù)據(jù)塊進(jìn)行排序,然后將各個(gè)塊合并成有序的結(jié)果集(實(shí)際上就是外排序,使用臨時(shí)表)。
對(duì)于filesort,MySQL有兩種排序算法。
(1)兩次掃描算法(Two passes)
實(shí)現(xiàn)方式是先將需要排序的字段和可以直接定位到相關(guān)行數(shù)據(jù)的指針信息取出,然后在設(shè)定的內(nèi)存(通過參數(shù)sort_buffer_size設(shè)定)中進(jìn)行排序,完成排序之后再次通過行指針信息取出所需的Columns。
注:該算法是4.1之前采用的算法,它需要兩次訪問數(shù)據(jù),尤其是第二次讀取操作會(huì)導(dǎo)致大量的隨機(jī)I/O操作。另一方面,內(nèi)存開銷較小。

(2)一次掃描算法(single pass)
該算法一次性將所需的Columns全部取出,在內(nèi)存中排序后直接將結(jié)果輸出。
注:從 MySQL 4.1 版本開始使用該算法。它減少了I/O的次數(shù),效率較高,但是內(nèi)存開銷也較大。如果我們將并不需要的Columns也取出來,就會(huì)極大地浪費(fèi)排序過程所需要的內(nèi)存。在 MySQL 4.1 之后的版本中,可以通過設(shè)置 max_length_for_sort_data 參數(shù)來控制 MySQL 選擇第一種排序算法還是第二種。當(dāng)取出的所有大字段總大小大于 max_length_for_sort_data 的設(shè)置時(shí),MySQL 就會(huì)選擇使用第一種排序算法,反之,則會(huì)選擇第二種。為了盡可能地提高排序性能,我們自然更希望使用第二種排序算法,所以在 Query 中僅僅取出需要的 Columns 是非常有必要的。

當(dāng)對(duì)連接操作進(jìn)行排序時(shí),如果ORDER BY僅僅引用第一個(gè)表的列,MySQL對(duì)該表進(jìn)行filesort操作,然后進(jìn)行連接處理,此時(shí),EXPLAIN輸出“Using filesort”;否則,MySQL必須將查詢的結(jié)果集生成一個(gè)臨時(shí)表,在連接完成之后進(jìn)行filesort操作,此時(shí),EXPLAIN輸出“Using temporary;Using filesort”。

當(dāng)前導(dǎo)列為常量時(shí),ORDER BY子句可以不滿足索引的最左前綴要求。例如,Sakila數(shù)據(jù)庫(kù)的表rental在列(rental_date,inventory_id,customer_id)上有名為rental_date的索引,如下表所示。

CREATE TABLE `rental` (
  `rental_id` int(11) NOT NULL AUTO_INCREMENT,
  `rental_date` datetime NOT NULL,
  `inventory_id` mediumint(8) unsigned NOT NULL,
  `customer_id` smallint(5) unsigned NOT NULL,
  `return_date` datetime DEFAULT NULL,
  `staff_id` tinyint(3) unsigned NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  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`),
  CONSTRAINT `fk_rental_customer` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`customer_id`) ON UPDATE CASCADE,
  CONSTRAINT `fk_rental_inventory` FOREIGN KEY (`inventory_id`) REFERENCES `inventory` (`inventory_id`) ON UPDATE CASCADE,
  CONSTRAINT `fk_rental_staff` FOREIGN KEY (`staff_id`) REFERENCES `staff` (`staff_id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=16050 DEFAULT CHARSET=utf8;

MySQL使用rental_date索引為下面的查詢排序,從EXPLAIN中看出沒有出現(xiàn)filesort

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
         type: ref
possible_keys: rental_date
          key: rental_date
      key_len: 8
          ref: const
         rows: 1
        Extra: Using where

即使ORDER BY 字句不滿足最左前綴索引,也可以用于查詢排序,因?yàn)樗饕牡谝涣斜恢付槌?shù)。

下面這個(gè)查詢可以利用索引排序,是因?yàn)椴樵優(yōu)樗饕牡谝涣刑峁┝顺A織l件,用第二列進(jìn)行排序,將兩列組合在一起,就形成了索引的最左前綴:
... where rental_date = '2005-05-25' order by inventory_id desc;
下面這個(gè)查詢也沒問題,因?yàn)閛rder by使用的就是索引的最左前綴:
... where rental_data > '2005-05-25' order by rental_date,inventory_id;

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

  • 下面這個(gè)查詢使用兩種不同的排序方向:
    ... where rental_date = '2005-05-25' order by inventory_id desc,customer_id asc;
  • 下面這個(gè)查詢的order by 子句中引用一個(gè)不在索引中的列(staff_id):
    ... where rental_date = '2005-05-25' order by inventory_id,staff_id;
  • 下面這個(gè)查詢的where 和 order by的列無法組合成索引的最左前綴:
    ... where rental_date = '2005-05-25' order by customer_id;
  • 下面這個(gè)查詢?cè)谒饕械牡谝涣惺欠秶鷹l件,所以MySQL無法使用索引的其余列:
    ... where rental_date > '2005-05-25' order by customer_id;
  • 這個(gè)查詢?cè)趇nventory_id列上有多個(gè)等于條件。對(duì)于排序來說,這也是一種范圍查詢:
    ... where rental_date = '2005-05-25' and inventory_id in(1,2) order by customer_id;

壓縮(前綴壓縮)索引

MyISAM使用前綴壓縮來減少索引的大小,讓更多的索引可以放入內(nèi)存中,這在某些情況下能極大地提高性能。默認(rèn)只壓縮字符串,但通過參數(shù)設(shè)置也可以對(duì)整數(shù)做壓縮。MyISAM壓縮每個(gè)索引塊的方法是,先完全保存索引塊中的第一個(gè)值,然后將其他值和第一個(gè)值進(jìn)行比較得到相同前綴的字節(jié)數(shù)和剩余的不同后綴部分,把這部分存儲(chǔ)起來即可。例如:索引塊中的第一個(gè)值是“perform”,第二個(gè)值是“performance”,那么第二個(gè)值的前綴壓縮后存儲(chǔ)的是類似“7,ance”這樣的形式。MyISAM對(duì)行指針也采用類似的前綴壓縮方式。

壓縮塊使用更少的空間,代價(jià)是某些操作可能更慢。因?yàn)槊總€(gè)值的壓縮前綴都依賴前面的值,所以MyISAM查找時(shí)無法在索引塊使用二分查找而只能從頭開始掃描。正序的掃描速度還不錯(cuò),但是如果是倒序掃描——例如ORDER BY DESC——就不是很好。所以在塊中查找某一行的操作平均都需要掃描半個(gè)索引塊。

測(cè)試表明,對(duì)于CPU密集型應(yīng)用,因?yàn)閽呙栊枰S機(jī)查找,壓縮索引使得MyISAM在索引查找上要慢好幾倍。壓縮索引的倒序掃描就更慢了。壓縮索引需要在CPU內(nèi)存資源與磁盤之間做權(quán)衡。壓縮索引可能只需要十分之一大小的磁盤空間,如果是IO密集型應(yīng)用,對(duì)某些查詢帶來的好處會(huì)比成本多很多。

可以在CREATE TABLE 語句中指定pack_keys參數(shù)來控制索引壓縮的方式。

冗余和重復(fù)索引

MySQL允許在相同列上創(chuàng)建多個(gè)索引,MySQL需要單獨(dú)維護(hù)重復(fù)的索引,并且優(yōu)化器在優(yōu)化查詢的時(shí)候需要逐個(gè)地進(jìn)行考慮,影響查詢性能。

重復(fù)索引是指在相同的列上按照相同的順序創(chuàng)建相同類型的索引。應(yīng)該避免這樣創(chuàng)建重復(fù)索引,發(fā)現(xiàn)以后也應(yīng)該立即移除。

如下面的代碼,創(chuàng)建一個(gè)主鍵,先加上唯一限制,然后再加上索引以供查詢使用。事實(shí)上,MySQL的唯一限制和主鍵限制都是通過索引實(shí)現(xiàn)的,因此,實(shí)際上在相同的列上創(chuàng)建了三個(gè)重復(fù)的索引。通常并沒有理由這樣做,除非在同一列上創(chuàng)建不同類型的索引來滿足不同的查詢需求。

create table test(
  id int not null primary key,
  a int not null,
  b int not null,
  unique(id),
  index(id)
) engine=InnoDB;

冗余索引和重復(fù)索引有一些不同。如果創(chuàng)建了索引(a,b),再創(chuàng)建索引(a)就是冗余索引,因?yàn)檫@只是前一個(gè)索引的前綴索引。因此索引(a,b)也可以當(dāng)作索引(a)來使用(這種冗余只是對(duì)B-Tree索引來說的)。但是如果再創(chuàng)建索引(b,a),則不是冗余索引,索引(b)也不是,因?yàn)閎不是索引(a,b)的最左前綴列。另外其他不同類型的索引(例如哈希索引或者全文索引)也不會(huì)是B-Tree索引的冗余索引,而無論覆蓋的索引列是什么。

冗余索引通常發(fā)生在為表添加新索引的時(shí)候。例如,有人可能會(huì)增加一個(gè)新的索引(a,b)而不是擴(kuò)展已有的索引(a)。還有一種情況是將一個(gè)索引擴(kuò)展為(a,id),其中id是主鍵,對(duì)于InnoDB來說主鍵列已經(jīng)包含在二級(jí)索引中了,所以這也是冗余的。

大多數(shù)情況下都不需要冗余索引,應(yīng)該盡量擴(kuò)展已有的索引而不是創(chuàng)建新索引。但也有時(shí)候出于性能方面的考慮需要冗余索引,因?yàn)閿U(kuò)展已有的索引會(huì)導(dǎo)致其變得太大,從而影響其他使用該索引的查詢的性能。

例如:如果在整數(shù)列上有一個(gè)索引,現(xiàn)在需要額外增加一個(gè)很長(zhǎng)的varchar列來擴(kuò)展該索引,那性能可能會(huì)急劇下降。特別是有查詢把這個(gè)索引當(dāng)作覆蓋索引,或者這是MyISAM表并且有很多范圍查詢(由于MyISAM的前綴壓縮)的時(shí)候。

舉例,MyISAM引擎,表userinfo有100W行記錄,每個(gè)state_id值大概2W行,在state_id列有一個(gè)索引對(duì)下面的查詢有用,假設(shè)查詢名為Q1:

mysql> select count(*) from userinfo where state_id=5;

查詢測(cè)試結(jié)果:QPS=115。還有一個(gè)相關(guān)查詢檢索幾個(gè)列的值,而不是統(tǒng)計(jì)行數(shù),假設(shè)名為Q2:

mysql> select state_id,city,address from userinfo where state_id=5;

查詢測(cè)試結(jié)果:QPS<10。提升該查詢的性能可以擴(kuò)展索引為為(state_id, city, address),讓索引覆蓋查詢:

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

如果把state_id索引擴(kuò)展為(state_id,city,address),那么第二個(gè)查詢的性能更快了,但是第一個(gè)查詢卻變慢了,如果要兩個(gè)查詢都快,那么就必須要把state_id列索引進(jìn)行冗余了。但如果是innodb表,不冗余state_id列索引對(duì)第一個(gè)查詢的影響并不明顯,因?yàn)閕nnodb沒有使用索引壓縮,

MyISAM和InnoDB表使用不同索引策略的查詢QPS測(cè)試結(jié)果(以下測(cè)試數(shù)據(jù)僅供參考):

只有state_id列索引 只有state_id_2索引 同時(shí)有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

上表結(jié)論:

  • 對(duì)于MyISAM引擎,把state_id擴(kuò)展為state_id_2(state_id,city,address),Q2的QPS更高,覆蓋索引起作用;但是Q1的QPS下降明顯,受MyISAM的前綴壓縮影響需要從索引塊頭開始掃描。
  • 對(duì)于InnoDB引擎,把state_id擴(kuò)展為state_id_2(state_id,city,address),Q2的QPS更高,覆蓋索引起作用;但是Q1的QPS下降不明顯,因?yàn)镮nnoDB沒有使用索引壓縮。
  • MyISAM引擎需要建state_id和state_id_2索引,才能保證Q1/Q2性能最佳;而InnoDB引擎只需state_id_2索引就能保證Q1/Q2性能最佳,從這里看出,索引壓縮也并不是最好的。

有兩個(gè)索引的缺點(diǎn)是索引成本更高,下表是在不同的索引策略時(shí)插入InnoDB和MyISAM表100W行數(shù)據(jù)的速度(以下測(cè)試數(shù)據(jù)僅供參考):

只有state_id列索引 同時(shí)有state_id和state_id_2
InnoDB, 對(duì)有兩個(gè)索引都有足夠的內(nèi)容的時(shí)候 80秒 136秒
MyISAM, 只有一個(gè)索引有足夠的內(nèi)容的時(shí)候 72秒 470秒

可以看到,不論什么引擎,索引越多,插入速度越慢,特別是新增索引后導(dǎo)致達(dá)到了內(nèi)存瓶頸的時(shí)候,所以,要避免冗余索引和重復(fù)索引。

在刪除索引的時(shí)候要非常小心:如果在InnoDB引擎表上有where a=5 order by id 這樣的查詢,那么索引(a)就會(huì)很有用,索引(a,b)實(shí)際上是(a,b,id)索引,這個(gè)索引對(duì)于where a=5 order by id 這樣的查詢就無法使用索引做排序,而只能使用文件排序(filesort)。
舉例說明,表shop表結(jié)構(gòu)如下:

CREATE TABLE `shop` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '記錄ID',
  `shop_id` int(11) NOT NULL COMMENT '商店ID',
  `goods_id` int(11) NOT NULL COMMENT '物品ID',
  `pay_type` tinyint(1) NOT NULL COMMENT '支付方式',
  `price` decimal(10,2) NOT NULL COMMENT '物品價(jià)格',
  `comment` varchar(4000) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `shop_id` (`shop_id`,`goods_id`),
  KEY `price` (`price`),
  KEY `pay_type` (`pay_type`),
  KEY `idx_comment` (`comment`(255))
) ENGINE=InnoDB AUTO_INCREMENT=20001 DEFAULT CHARSET=utf8 COMMENT='商店物品表'

如下情況,使用pay_type索引:

mysql> explain select * from shop where pay_type = 2 order by id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: shop
         type: ref
possible_keys: pay_type
          key: pay_type
      key_len: 1
          ref: const
         rows: 9999
        Extra: Using where

如下情況,雖然使用shop_id索引,但是無法使用索引做排序,EXPLAIN出現(xiàn)filesort:

mysql> explain select * from shop where shop_id = 2 order by id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: shop
         type: ref
possible_keys: shop_id
          key: shop_id
      key_len: 4
          ref: const
         rows: 1
        Extra: Using where; Using filesort

如下情況,當(dāng)WHERE 條件覆蓋索引shop_id的所有值時(shí),使用索引做排序,EXPLAIN沒有filesort:

mysql> explain select * from shop where shop_id = 2 and goods_id = 2 order by id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: shop
         type: const
possible_keys: shop_id
          key: shop_id
      key_len: 8
          ref: const,const
         rows: 1
        Extra: 

索引和鎖

索引可以讓查詢鎖定更少的行。如果你的查詢從不訪問那些不需要的行,那么就會(huì)鎖定更少的行,從兩個(gè)方面來看這對(duì)性能都有好處。首先,雖然InnoDB的行鎖效率很高,內(nèi)存使用也很少,但是鎖定行的時(shí)候仍然會(huì)帶來額外開銷;其次,鎖定超過需要的行會(huì)增加鎖爭(zhēng)用并減少并發(fā)性。

InnoDB只有在訪問行的時(shí)候才會(huì)對(duì)其加鎖,而索引能夠減少InnoDB訪問的行數(shù),從而減少鎖的數(shù)量。但這只有當(dāng)InnoDB在存儲(chǔ)引擎層能夠過濾掉所有不需要的行時(shí)才有效。如果索引無法過濾掉無效的行,那么在InnoDB檢索到數(shù)據(jù)并返回給服務(wù)器層以后,MySQL服務(wù)器才能應(yīng)用 where子句。這時(shí)已經(jīng)無法避免鎖定行了:InnoDB已經(jīng)鎖住這些行,到適當(dāng)?shù)臅r(shí)候才釋放。在MySQL5.1及更新的版本中,InnoDB可以在服務(wù)器端過濾掉行后就釋放鎖。

下面的例子再次使用Sakila很好的解釋這些情況:

圖1 索引和鎖(1)

這條查詢只返回2~4行數(shù)據(jù),實(shí)際上獲取1~4行排他鎖。InnoDB鎖住第1行,因?yàn)镸ySQL為該查詢選擇的執(zhí)行計(jì)劃是索引范圍掃描:

mysql> explain select actor_id from actor where actor_id < 5 and actor_id <> 1 FOR UPDATE\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: actor
         type: range
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 2
          ref: NULL
         rows: 3
        Extra: Using where; Using index

換句話說,底層存儲(chǔ)引擎的操作是“從索引的開頭獲取滿足條件 actor_id < 5 的記錄”,服務(wù)器并沒有告訴InnoDB可以過濾第1行的WHERE 條件。Explain的Extra出現(xiàn)“Using Where”表示MySQL服務(wù)器將存儲(chǔ)引擎返回行以后再應(yīng)用WHERE 過濾條件。

我們來證明第1行確實(shí)是被鎖定,保持這個(gè)終端鏈接不關(guān)閉,然后我們打開另一個(gè)終端。如圖2,這個(gè)查詢會(huì)掛起,直到第1個(gè)事務(wù)釋放第1行的鎖。


圖2 索引和鎖(2)

按照這個(gè)例子,即使使用索引,InnoDB也可能鎖住一些不需要的數(shù)據(jù)。如果不能使用索引查找和鎖定行的話,結(jié)果會(huì)更糟。MySQL會(huì)全表掃描并鎖住所有的行,而不管是不是需要。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容