最近在读高性能mysql这本书,这一系列文章只为读书笔记
一、索引基础
索引是存储引擎用于快速查找记录的一种数据结构,所以索引本质就是数据结构,根据索引类型的不同,所对应的数据结构也不尽相同,索引类型对应的数据结构将在第二节介绍。索引可以包含一个或者多个列的值。如果索引包含多个列,那么列的顺序也十分重要,因为mysql只能高效的使用索引的最左前缀。创建一个包含两个列的索引和两个只包含一个列的索引是大不相同的。
二、索引类型
索引是在存储引擎层实现的,所以并没有统一的索引标准:不同的存储引擎的索引的工作方式不一样,也不是所有的存储引擎都支持所有类型的索引。即使多个存储引擎支持同一索引类型,其底层的实现方式也可能不一样。下面主要讨论的是InnoDB和MyISAM两种存储引擎支持的索引类型。
1、B-Tree索引
使用术语上叫“B-Tree”,实际上底层存储引擎可能使用不同的存储结构,比如,NDB集群存储引擎使用的是T-Tree(一种特殊的AVL树),而InnoDB和MyISAM使用的都是B+Tree。InnoDB和MyISAM虽然使用相同的存储结构,但是它们索引存储的数据以及使用方式并不相同。下图是建立B-Tree结构(技术实现上是B+Tree)上的索引:
B-Tree对索引列是顺序组织存储的,所以很适合范围查找数据。假设有如下数据表:
CREATE TABLE People(
last_name varchar(50) not null,
first_name varchar(50) not null,
birth date not null,
gender enum('m','f') not null,
key(last_name,first_name,birth)
);
对于表中的每一行数据,索引中包含了last_name,first_name和birth列的值。下图显示B-Tree索引是如何组织数据存储的:
我们注意到,索引对多个值进行排序的依据是CREATE TABLE语句中定义索引时的顺序。
B-Tree适用于全键值、键值范围或键前缀查找,其中键前缀查找只适用于根据最左前缀查找。上面的索引对如下类型的查询有效:
1)全值匹配
指的是和索引中的所有列进行匹配,例如上面的索引可用于查找姓名为Cuba Allen、出生于1960-01-01的人。
2)匹配最左前缀
上面的索引可用于查找所有last_name为Allen的人,即只使用索引的第一列。
3)匹配列前缀
也可以只匹配某一列的值的来头部分。例如上面的索引可用于查找last_name以J开头的人,这里也只用到了索引的第一列。
4)匹配范围值
例如上面的索引可用于查找last_name在Allen和Barrymore之间的人。这里也只用到了索引的第一列。
5)精确匹配某一列并范围匹配另外一列
上面的索引查找所有last_name是Allen,并且first_name 是字母K开头的人。即第一列last_name全匹配,第二列first_name范围匹配。
6)只访问索引的查询
B-Tree通常可以支持“只访问索引的查询”,即查询只需要访问索引,无需访问数据行,后面会专门讨论这种“覆盖索引的优化”。
因为索引树中的节点是有序的,所以除了按值查找之外,索引还可以用于查询中的ORDER BY 操作。一般来说,如果B-Tree可以按照某种方式查找值,那么也可以按照这种方式用于排序。
下面是关于B-Tree索引的限制:
1)如果不是按照索引的最左列开始查找,则无法使用索引。例如,上面的索引无法查找first_name是Bill的人,也无法查找某个特定生日的人,因为这两列都不是最左数据列。
2)不能跳过索引中的列。也就是说,上面的索引无法用于last_name是Smith、并且在某个特定日期出生的人,这里只能用到索引的第一列。
3)如果查询中有某个列的范围查询,则其右边的所有列都无法使用索引优化查询。列入查询WHERE last_name='Smith' AND first_name LIKE 'J%' AND birth='1976-12-23',这个查询只能用到索引的前两列,因为这里的LIKE是一个范围条件。如果范围查询列值的数量有限,可以使用多个等于条件来代替。
看到这里就会明白,索引的顺序很重要。在优化性能的时候,可能需要想到的列但是顺序不同的索引来满足不同的查询需求。
2、哈希索引
哈希索引是基于哈希表实现,只有精确匹配索引所有列的查询才有效。mysql只有Memory引擎显示支持哈希索引,这里不多做记录。值得注意的是InnoDB有一个特殊的额功能叫做“自适应哈希索引”,当InnoDB注意到某些索引只被引用的非常频繁,它会在内存中基于B-Tree之上再创建一个哈希索引(我的理解就是索引的缓存),这是一个完全自动的、内部的行为。如果有必要,用户可以关闭这个功能。
创建自定义的哈希索引。举个例子,要存储大量的URL,并需要根据URL精选搜索查找,如果用B-Tree来存储URL,存储的内容将会很大,因为URL本身都很长,一般情况下会有下面的查询:
SELECT id FROM url WHERE url="http://www.mysql.com";
若删除原来URL列上的索引,而新增一个被索引的url_crc列,使用CRC32做哈希,就可以使用下面方式查询:
SELECT id from url WHERE url="http://www.mysql.com" AND url_crc=CRC32("http://www.mysql.com");
这样做的性能会非常高,因为mysql优化器会使用这个选择性很高而体积很小的基于url_crc列的索引来完成查询。即使有多个记录有相同的索引值,查找仍然很快,只需要根据哈希值做快速的整数比较就能找到索引条目,然后一一比较数据行。这个哈希值的维护可以使用触发器也可以程序里面维护。
3、空间数据索引
只有MYISAM支持,可以用做地理数据存储。但是mysql支持的GIS不完善,所以使用的不多,开源数据库这方面做的比较好的是PostgreSQL的PostGIS。
4、全文索引
全文索引是中特殊类型的索引,它查找的是文本中的关键字,而不是直接比较索引中的值。全文索引更类似与搜索引擎做的事,而不是简单的WHERE条件匹配。MYISAM支持这种索引类型。
5、其它索引类型
还有很多其它存储引擎使用不同类型的数据结构来存储索引。例如,TokuDB使用分形树索引,ScaleDB使用的是Patricia tries,其它的存储引擎技术如InfiniDB和Infobright则使用了一些特殊的数据结构来优化某些特殊的查询。
思考:索引是最好的解决方案吗?索引并不总是最好的解决方案。总的来说,对于非常小的表,大部分情况下简单的全表扫描更有效(小表可能一次IO就把所有数据行载入到了内存,在内存即使做全表扫描也很快,而使用索引除了需要查找索引树,一般情况至少也需要一次IO);对于中到大型的表,索引就非常有效;而对于特大型的表,建立和使用索引的代价将随之增长,这种情况下,则需要一种技术直接区分除查询需要的一组数据,而不是一条记录一条记录的匹配。例如可以使用分区技术。
三、高性能索引策略
1、独立的列
如果查询中的列不是独立的,则mysql就不会使用索引。“独立的列”是指索引不能是表达式的一部分,也不能是函数的参数。列如下面这个查询就无法使用actor_id列的索引:
mysql> select actor_id from skali.actor where actor_id+1=5
我们很容易看的出where中的表达式其实就等价于actor_id=4,但是mysql无法自动解析这个方程式,所以我们应该养成简化where条件的习惯,始终将索引列单独放在比较一侧。下面是另一个常见的错误:
select ... where TO_DAYS(CURRENT_DATE)-TO_DAYS(date_col)<=10
2、前缀索引和索引选择性
有时候需要索引很长的列,这回让索引变得大且慢。一个策略是前面提到的通过模拟哈希索引,另一个策略就是索引的开始的部分字符,这样可以大大的节约索引空间,从而提高索引效率。但是这样也会降低索引的选择性。索引的选择性是指,不重复的索引值(也称为基数)和数据表的总记录数的比值(#T),范围从1/#T到1之间。索引的选择性越高则查询效率越高,因为选择性高的索引可以让mysql在查找时过滤掉更多的行。唯一索引的选择性是1,这是最好的索引选择性,性能也是最好的。
一般情况下某个列的前缀索引的选择性也是足够高的,足以满足查询的性能。对于BLOB、TEXT或,者很长的VARCHAR类型的列,必须使用前缀索引,因为mysql不允许索引这些列分全部长度。
前缀应该足够长,以使得前缀索引的选择性接近与索引整个列(换句话说,前缀的“基数”应该接近于完整列的“基数”),同时又不能太长(以便节约空间)。为了决定前缀合适的长度,需要找到最常见的值的列表,然后和最常见的前缀列表进行比较。下面以列子说明如何选择合适前缀长度:
首先,我们有最常见的城市列表:
注意到,上面每个值都出现了45~65次。现在查找最频繁出现的城市的前缀,首先从3字母开始:
每个前缀都比原来的城市出现次数更多,数据分布和索引完整列相差还比较大。然后我们增加长度,直到我们前缀的选择性接近完整列的选择性。经过实验后发现前缀长度为7时比较合适:
计算合适长度的另一种方法是计算完整列的选择性,并使前缀的选择性接近于完整列的选择性。下面显示如何计算完整列的选择性:
通常来说(也有列外情况),这个列子中如果前缀的选择性能够接近0.031,基本上就可用了。可以在一个查询中针对不同的长度进行计算(这对大表非常有用):
查询显示,当前缀长度达到7时,再增加前缀长度,选择性提升的幅度已经很小了。
前缀是一种能使索引更小,更快的有效方法,但另一方面也有其缺点:mysql无法使用前缀索引做ORDER BY和GROUP BY,也无法使用前缀索引做覆盖扫描。
提示:有时候后缀索引也有用途(例如,找到某个域名的所有电子邮件地址)。mysql原生不支持反向索引,但是可以把字符串反转后存储,并基于此建立前缀索引。
3、多列索引
很多人对多列索引的理解不够。一个常见的错误就是,为每个列创建独立的单列索引,或者按照错误的顺序创建多列索引。例如,有如下查询:
mysql> select ... where c1=1 and c2=2 and c3=3
然后创建索引把where条件里面的列都创建上索引:
这种索引的策略是错误的,在多个列上建立独立的索引大部分情况下并不能提高mysql的查询性能(在每个列的可选择性都很低的情况下)。mysql5.0和更新版引入了一种叫“索引合并”的策略,一定程度上可以使用表上的多个单列索引来定位指定的的行。
索引合并策略有时候是一种优化策略,但实际上更多时候说明了表上的索引建的很糟糕。如果在EXPLAIN看到了索引合并,应该好好检查一下查询和表结构,看是不是已经是最优的。可以通过参数optimizer_switch来关闭索引合并功能。也可以使用IGNORE INDEX提示让优化器忽略掉某些索引。
4、选择合适的索引列顺序
正确的顺序依赖使用该索引的查询,并且需要考虑如何更好的满足排序和分组的需求(本节内容适用于B-TREE索引;哈希和其他类型的索引并不会像B-TREE索引一样按顺序存储数据)。
对于如何选择索引的列顺序有一个经验法则:当不需要考虑排序和分组时,将选择性最高的列放到索引最前列通常是很好的。这个时候索引的作用只是用于优化WHERE条件的查找。在这种情况下,这样设计的确实能最快地过滤出需要的行,对于在WHERE子句中只使用了索引部分前缀列的查询来说选择性也更高。然而,性能不只是依赖于所有索引的选择性(整体基数),也和查询的具体值有关,也就是和值的具体分布有关。可能需要根据 那些运行频率最高的查询来调整列的顺序,让这种情况下索引的选择性最高。
最后,尽管关于选择性和基数的经验法则值得去研究和分析,但一定不要忘了WHERE子句中的排序、分组和范围条件等其他因素,这些因素可能对于查询的性能造成非常大的影响。
5、聚簇索引
聚簇索引并不是一种单独的索引类型,而是一种数据存储方式。具体的细节依赖于其实现方式,但InnoDB的聚簇索引实际上在同一个结构中保存了B-TREE索引和数据。
因为是存储引擎负责实现,因此不是所有的存储引擎都支持聚簇索引。这里我们主要关注的是InnoDB。但是讨论的原理对于任何支持聚簇索引的存储引擎也是适用的。下图展示聚簇索引是如何存放记录的:
InnoDB将通过主键聚集数据,这也就是说上图中“被索引的列”就是主键。
如果没有定义主键,InnoDB会选择一个唯一非空索引代替。如果没有这样的索引,InnoDB会隐式定义一个主键来作为聚簇索引。InnoDB只聚集在同一个页面中的记录。包含相邻键值的页面可能会相差很远。
聚集的数据有一些重要的优点:
1)可以把相关数据保存在一起。例如实现电子邮箱时,可以根据用户ID来聚集数据,这样只需要从磁盘读取少量的数据页就能获取牧歌用户的全部邮件。
2)数据访问更快。聚簇索引将索引和数据保存在同一个B-Tree中,因此从聚簇索引中获取数据通常比在非聚簇索引中获取数据更快
3)使用覆盖索引扫描的查询可以直接使用页节点中的主键值。
同时它也有一些缺点:
1)聚簇索引最大限度的提高了I/O密集型应用的性能,但如果数据全部存放在内存中,则访问的顺序就没那么重要了,聚簇索引也就没有什么优势了。
2)插入速度严重依赖于插入顺序。按照主键的顺序插入是加载数据到InnoDB表中速度最快的方式。但如果不是按照主键的顺序加载数据,那么加载完数据后最好使用OPTIMIZE TABLE命令重新组织一下表。
3)更新聚簇索引列的代价很高,因为为强制InnoDB将每个被更新的行移动到新的位置。
4)基于聚簇索引的表插入新行,或者主键被更新导致需要移动行的时候,可能面临“页分裂”的问题。当行的主键值要求必须将这一行插入到某个已满的页中时,存储引擎会将该页分裂成两个页面来容纳改行,这是一次页分裂操作。
5)聚簇索引可能导致全表扫描变慢,尤其是行比较稀疏,或者由于页分裂导致数据存储不连续的时候
6)二级索引(非聚簇索引)可能比想象中的更大,因为二级索引的叶子节点包含了引用行的主键。
7)二级索引访问需要两次索引查找,而不是一次。
所以在表使用InnoDB做存储引擎时,应使用自增的主键来作为聚集数据。最好避免随机的(不连续且值的分布范围非常大,比如UUID)聚簇索引,特别时对于I/O密集型应用。
注意:对于高并发的工作负载,在InnoDB中按主键顺序插入可能会导致明显的争用。主键的上届会成为“热点”,因为所有的插入都发生在这里,所有并发插入可能导致间隙锁竞争。另一个热点可能是AUTO_INCREMENT锁机制;如果遇到这个问题,则可能需要考虑重新设计表或者应用,或者更改innodb_autoinc_lock_mod配置,如果你的不支持这个参数,可以升级到新版本的innodb。
6、覆盖索引
如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”。覆盖索引是非常有用的工具,能够极大的提高性能。考虑一下如果查询只需要扫描索引而无须回表,通过会带来多少好处:
1)索引条目通常远小于数据行大小,所以如果只需要读取索引,那么mysql会极大的减少数据访问量。这对缓存的负载非常重要,因为这种情况响应时间大部分花在数据拷贝上。覆盖索引对于I/O密集型的应用也有帮助,因为所有比数据更小,更容易全部放入内存(这对MyISAM尤其正确,因为MyISAM能够压缩索引以使索引变得更小)。
2)因为索引是按照列值顺序存储的(至少在单个页内是如此),所以对于I/O密集型的范围查询会比随机从磁盘读取每一行数据的I/O要少得多。对于某些存储引擎,例如MyISAM和Perconca XtraDB,甚至可以通过命令使得索引完全顺序排列,这让简单的范围查询可以使用完全顺序的索引访问。
3)一些存储引擎如MyISAM在内存中只缓存索引,数据则依赖于操作系统来缓存,因此要访问数据需要一次系统调用。这可能导致严重的性能问题,尤其是那些系统调用占了数据访问中的最大开销的场景。
4)由于InnoDB的聚簇索引,覆盖索引对InnoDB特别有用。InnoDB的二级索引在叶子节点中保存行的主键值,所以如果二级主键能覆盖查询,则可以避免对主键索引的二次查询。
不是所有类型的索引都可以成为覆盖索引,Mysql只能使用B-Tree索引做覆盖索引。当发起一个被索引覆盖的查询(也叫索引覆盖查询)时,在EXPLAIN的Extra列可以看到“Using index”的信息。
索引覆盖查询还有很多陷阱可能会导致无法实现优化。来看看为什么会发生这样的情况,以及如何重写查询以解决该问题。从下面的查询开始:
这里索引无法覆盖查询,有两个原因:
1)没有任何索引能够覆盖这个查询。因为查询从表中选择了所有的列,而没有任何索引覆盖了所有的列。不过,理论上mysql还有一条捷径可以利用:where条件中的列是有索引可以覆盖的,因此mysql可以利用该索引找到对应的actor并检查title是否匹配,过滤之后再读取需要的数据行
2)mysql不能在索引中执行like操作,这是底层存储引擎API的限制
也有办法可以解决上面的问题,需要重写查询并巧妙的设计索引。先将索引扩展至覆盖三个数据列(artist,title,prod_id),然后按如下方式重写查询:
我们把这种方式叫做延迟关联,因为延迟了对列的访问。
7、使用索引扫描来做排序
mysql有两种方式可以生成有序的结果:通过排序操作;或者按索引顺序扫描;如果EXPLAIN出来的type列的值为“index”,则说明mysql使用的索引扫描来做排序。
扫描索引本身是很快的,因为只需要从一条记录移动到紧接着的下一条记录。但如果索引不能覆盖查询所需的列,那就不得不每扫描一条索引记录就都回表查询一次对应的行。这基本上是随机I/O,因此按索引顺序读取数据的速度通常要比顺序的全表扫描慢,尤其是在I/O密集型的工作负载时。
mysql可以使用同一个索引既满足排序,又用于查找行。因此,如果可能,设计索引时应尽可能的满足这两种任务。
只有当索引的列顺序和order by子句的顺序完全一致,并且所有列的排序方向都是一样时,mysql才能使用索引来对结果做排序。如果查询需要关联多张表,则只有当order by子句引用的字段全部为第一张表时,才能使用索引做排序。order by子句和查找型查询的限制是一样的:需要满足索引的最左前缀的要求;否则,mysql都需要执行排序操作,而无法利用索引排序。
有一种情况下order by子句可以不满足索引的最左前缀的要求,就是前导列为常量的时候。如果where子句或者join子句中对这些列指定了常量,就可以“弥补”索引的不足。例如:
如果对“rental_date”,"inventory_id","customer_id"按字段顺序建立了多列索引,则即使order by子句不满足最左前缀的要求,也可以用于查询排序。这是因为索引的第一列被指定为一个常数。
8、压缩(前缀压缩)索引
MyISAM使用前缀压缩来减少索引的大小,从而让更多的索引可以放入内存,这在某些情况下能极大的提高性能。默认值压缩字符串,但通过参数设置也可以对整数做压缩。
压缩使用更少的空间,待见是某些操作可能更慢。因为每个值的压缩前缀都依赖前面的值,所以MyISAM查找时无法在所言块中使用二分查找而只能从头开始扫描。正序的扫描速度还不错,但如果时倒序就是很好了。测试表明,对于cpu密集型应用,因为扫描需要随机查找,压缩索引使得MyISAM在索引查找上要慢好几倍。压缩索引的倒序扫描就更慢了。压缩索引需要在cpu内存资源与磁盘之间做平衡。压缩索引可能只需要十分之一大小的磁盘空间,如果是I/O密集型应用,对某些查询带来的好处会比成本多很多。
可以在create table语句中指定PACK_KEYS参数来控制索引压缩的方式。
9、冗余和重复索引
mysql允许在相同的列上创建多个索引,无论是有意的还是无意的。mysql需要单独维护重复的索引,并且优化器在优化查询的时候也需要逐个的考虑,这回影响性能。
重复索引是指在相同的列上按照相同的顺序创建的相同类型的额索引。应该避免这样创建索引,发现以后也应该立即移除。
10、索引和锁
索引可以让查询锁定更少的行。如果你的查询不访问那些不需要的行,那么就会锁定更少的行,从两个方面来看这对性能都有好处。
InnoDB只在访问行的时候才会对其加锁,而索引能减少InnoDB访问的行数,从而减少锁的数量。但这只有当InnoDB在存储引擎层能够过滤掉所有不需要的行时才有效。如果索引无法过滤掉无效的行,那么在InnoDB检索到数据并返回到服务器层以后,mysql服务器才能应用where子句。这是已经无法避免锁定行了:InnoDB已经锁住了这些行,到适当的时候才释放。通过下面的例子解释这些情况:
这条查询仅仅会返回2~4之间的行,但实际上获取了1~4之间的行的排他锁。InnoDB会锁住第一行,这是因为mysql为该查询选择的执行计划是索引范围扫描:
换句话说,底层存储引擎的操作是“从索引的开头开始获取满足条件actor_id<5的记录”,服务器并没有告诉InnoDB可以过滤第1行的where条件。注意到EXPLAIN的Extra列出现了“Using where”,这表示mysql服务器将存储引擎返回行以后再应用where过滤条件。