MySQL索引的基础原理和创建原则
数据库作为存储和管理数据的核心技术,广泛应用于各类信息系统中。随着数据量的不断增长以及业务复杂性的提高,数据库的性能优化成为至关重要的任务。
其中,索引作为一种能够显著提升查询效率的数据结构,在数据库操作中扮演着关键角色。然而,许多开发人员和数据库管理员对索引的理解仅停留在表面,未能深入掌握其底层原理和适用场景,导致在实际应用中无法充分发挥索引的优势,甚至可能因不当使用而影响数据库性能。
本文将从索引的类型、实现方式、创建时机、优缺点以及相关的优化原则等方面进行详细阐述,揭示索引背后的核心原理,为数据库性能优化提供坚实的理论基础。
索引的类型
索引的分类可能有不同维度,这里不追求特别准确的分类,毕竟不是做学术,只要感性认识几种即可。
打开Navicat,尝试创建索引时会发现有4种索引类型可以选择:
全文索引
普通索引
空间索引
唯一索引
普通索引就可以组织树结构了,而唯一索引在普通索引的基础上还要求索引列不能重复。比如,假设我们给student表的name列加了唯一索引,如果表中已经存在”张三”,那么再次插入”张三”将会报错。
MySQL这种关系型数据库并不适合进行全文检索(考虑Elastic Search),所以全文索引一般很少使用。
至于空间索引,我也不知道是什么。
实际开发常用的索引只有普通索引和唯一索引,其他的可以不用理会(主键索引其实相当于唯一索引+非NULL)。
索引的实现方式
常见的索引实现方式有两种,通过B+树结构或hash算法实现。
特别注意,这里虽然写的是”BTREE”,但MySQL确实使用的是B+Tree。
这个概念,其实和上面“索引的类型”并不冲突。
比如,对于普通索引,我们可以使用B+树的结构组织索引,也可以使用hash算法实现。经过上一篇的学习,我们对B+树结构已经比较了解,所以这里单独聊一下hash索引。
所谓hash索引,其实就是利用哈希算法为索引列计算得到唯一的存储地址,一般来说这个地址是不会重复的(重复的情况被称为哈希冲突)。
举个燕十八老师说的例子:
在墙上装一根弹性永不衰变的弹簧,每次拿不同的物件把弹簧压到极限后放开,不同的物件最终落点会不同。比如你上回存了一本书,那么下次想要找到这本书时,只需要拿一本一模一样的书重新弹一下,即可在本次落点处找到上次那本书。
数据库hash索引的设计也类似,假设你要存入id=10086的数据,就需要通过hash算法对id进行计算,得到一个存储位置后写入数据。下次拿着id=10086查询时,只要按同样的算法再次计算,就能马上找到对应的数据,是不是很快呢~
需要注意的是,弹簧的例子用来比喻hash算法虽然挺形象的,但可能会让人误以为越重的物品落点越近,越轻的物品落点越远,进而得出结论:hash索引可以进行范围查找。
其实并不是如此。
hash算法有个比较显著的特征:即使源数据具备一定的相关性,经过hash映射后得到的结果也会变得“很散”,没有规律可循。回到之前的例子中,你可以理解为重量并不是影响书本最终落点的唯一因素,书的材质、形状等都占有一定比例,最终体现到空气阻力上导致落点的不规则。
不知道大家还是否记得,在JavaSE阶段接触HashMap时,很多人会发现put的顺序和get的顺序并不一定相同。比如put的顺序是1000克、500克、300克,而get的顺序却是500克、300克、1000克。也就是说,经过hash计算后,数据的相关性会大大减弱。
所以,当你希望查找500g~1000g的书本时,就无法利用边界值进行范围查找。而B+树叶子节点是有序链表,范围查询非常方便。
hash索引除了无法进行范围查找外,还不能进行模糊搜索。
hash算法本身代表着精确定位,依赖于计算的入参得出“唯一”的值,所以无法进行模糊匹配。比如,你给我”bravo”,我可以计算唯一的hash值,你给我”bra%”,我会以为这人就叫”bra%”,也计算一个值,而****这个值代表着”bra%”计算得到的落点,而不是”所有以bra开头的数据”的落点,显然是不对的。
但B+树可以进行模糊搜索,你可以姑且认为因为它会顺着树查找,并在装有数据的节点内调用类似Java的String#startWith()方法进行比较。
hash索引的优劣势
- 优势:速度非常快,只需一次计算即可得到地址,时间复杂度O(1),而B+树是O(logn)
- 劣势:不支持模糊查询、范围查询、索引排序(本身就是不规则的,如何利用索引排序呢)
最后,《实用小算法》中List转HashMap的操作其实就是借鉴了hash索引!
索引的创建
索引的创建时机一般有两处:
- 起初,建表时顺便建立索引
- 后期,修改表结构创建索引(一般都是这样,因为很难未卜先知,提前优化等于瞎优化)
比如,一开始就创建索引:
这张表有两个索引:主键索引、auditor_id普通索引。
主键索引并不属于上面介绍的4种索引类型之一,但所谓的Primary Key可以看做 唯一索引 + NOT NULL约束。
后期如果需要添加索引,可以通过两种方式:
- SQL语句
- Navicat图形界面
利用SQL语句添加索引:
1 | -- 1.添加PRIMARY KEY(主键索引) |
在本案例中,可以写:
1 | ALTER TABLE `moneywithdraw` ADD INDEX idx_auditor_id (`auditor_id`); |
利用Navicat图形界面创建单列索引:
利用Navicat图形界面创建联合索引:
哦,对了,数据量太大的表,不要自己随便加索引,搞不好会锁表哦…后面有机会再说。总之,你可以“懂索引”,但要“动索引”前,最好三思。
索引的好与坏
提到索引,很多人就会说:哦,索引能提高查询速度。一般这么说的人,可能学得还不错,但绝对还没有完全掌握索引的底层原理。
如果你认为索引的优势只是加快查询,那就太小看索引了。
索引的优势是:
加快查询速度(包括关联查询)
加快排序速度(ORDER BY)
加快分组速度(GROUP BY)
虽然加快排序、加快分组最终还是体现在加快查询速度上,但能主动意识到这一点算是一种突破。只有当你意识到索引能加快排序和分组,你才会在写ORDER BY和GROUP BY时有意识地利用索引分组和排序(最左匹配原则),从而写出更优的SQL。
索引的劣势:
创建索引是需要付出代价的,主要体现在维护成本、空间成本和回表成本。也就是说索引能提高查询效率,但往往会降低增删改的速度(字典新增几百个字,需要额外编排目录吧,要多占几页纸吧)
如果使用了联合索引,还需要考虑索引失效问题(下篇介绍联合索引)
太多的索引会增加查询优化器的选择时间(选择太多也麻烦)
建索引的原则
很多人觉得SQL优化才是重中之重,创建索引只需要一行代码即可,没什么大不了的。但现在你已经知道了索引的优势与劣势,你会明白“在合适的时候、合适的字段建立索引”是多么空泛的口号。创建索引的判断依据究竟是什么呢?
创建索引有4个大原则:
索引并不是越多越好,联合索引应该优于多个单列索引
索引应该建立在区分度高的字段上
尽量给查询频繁的字段创建索引,避免为修改频繁的字段创建索引
避免重复索引
第一个原则背后的原因是,实际上数据库一次查询只会选择一棵索引树(不包括回表),更专业的说法是每次查询只会选择一个执行计划。即使你给a,b,c,d,e,f,g…所有列都加了索引,SELECT xx, xxx FROM table WHERE …时,数据库也只会择优****选择一个执行计划进行查询。
需要注意的是,每建一个索引,就需要维护一棵索引树,所以索引绝对不是越多越好,不合适的索引会增加数据库的负担。比如,你已经搞了一个根据拼音查找汉字的目录,又想根据偏旁部首来,那没辙了,只能劳烦您自己再搞一个目录了。
看到这,你可能会反问:我靠,那MySQL也太笨了吧,为什么这么死心眼一次只利用一个索引?
比较粗浅的理由是:你根据拼音查完汉字以后,还会根据偏旁部首再查一遍吗?
比较正经的理由是:按我个人的理解,索引本身的出发点是“走完一遍索引后,数据库应该返回精确的结果或很小的结果集”,从成本上考虑,此时再走一遍索引还不如直接遍历结果集来得快。当然,要想一次索引就得到精确的结果集,着实需要下一番苦功夫。给哪个字段加索引好呢?我建议,应该尽可能给区分度高的字段添加索引。
什么是区分度很高?这就是建索引的第二个原则啦。比如,表中有100w学生数据,你如果在sex列加索引,那么根据sex大概只能过滤掉50w数据,剩下的结果集仍然很大,说明这个索引建得不太合适,区分度太低了。
第三个原则就是字面意思。比如一本字典根据内容编好目录以后,隔三差五地就有新词汇要往里面加,或者经常要修改汉字读音,一顿操作后必然要连累目录,只能重新编排啦。也就是说,为了保证目录能正确指向对应的汉字,每次增删改后都要额外多一个操作:重新修订目录。
总之要意识到索引在加快查询的同时几乎必然会对修改产生负担,所以创建索引并没有那么简单,它绝对是一门“平衡的艺术”。
第四个原则是,比如已经建立a索引,又建立index(a,b,c)联合索引,此时单列索引a就是冗余的,因为联合索引已经可以保证符合条件时会利用a索引。在物理存储上,a单列索引和index(a, b, c)是两个独立的B+树,重复的索引会增加维护成本。
以上四个原则,后面的内容还会重新提到。
MySQL常用引擎
MySQL的引擎有很多种,但最常听到的就MyISAM和InnoDB,而实际开发几乎99%选择使用InnoDB,而且MySQL5.6还是哪个版本以后默认引擎就从MyISAM变成了InnoDB,所以这里着重介绍InnoDB,简略介绍MyISAM。
对于两种引擎的介绍,可以看:存储引擎简介
这里主要想和大家讨论MyISAM和InnoDB在索引组织上的区别。大家应该都已经知道,MyISAM和InnoDB存储数据的方式是不同的。
MyISAM的每张表在存储时会分为3个文件:
表结构
表数据
索引
也就是说,表数据和索引是分别独立存储的。
而InnoDB的表数据在存储时只分为2个文件:
- 表结构
- 表数据+索引
需要注意的是,InnoDB所有表的数据和索引都在同一个文件里(见下一个小节)。
聚簇索引与非聚簇索引
对于BTREE索引而言,从数据的组织形式来看,索引又可以分为两大类:
- 聚簇索引
- 非聚簇索引
所谓聚簇索引,可以简单理解为索引和数据是“聚合”在一起的,而非聚簇索引的数据和索引是分开的。
根据InnoDB引擎的主键索引查询时无需回表,每一行完整的数据都直接挂在叶子节点下,可以直接返回。也就是说,对于InnoDB的主键索引而言,数据即索引,索引即数据。
MyISAM不是很重要,不提了。
InnoDB的索引也并不是都不需要回表,根据是否需要回表其实可以分为两类:主键索引、辅助索引(或者叫二级索引、普通索引)。
会什么要做这种区分呢?
假设一个场景:
新建一张表后,自然会产生主键索引。但后期发现name字段查询很频繁,于是加了name索引。
如果name索引也和主键索引一样挂着数据,那么两个索引数据就会重复。想象一下,现在磁盘中有一颗叫name的树和一棵叫id的树,一个以name为节点,一个以id为节点,相同的是最底层叶子节点都挂着完整的表数据。也就是说,磁盘中存了两份一模一样的student数据。且不说数据冗余,更新时还可能产生数据不一致(要同步数据,确保多张表的数据一致性)。
所以InnoDB的做法是,辅助索引只存储索引列+主键,必要时进行“回表”操作:
由于SELECT * FROM stu WHERE name=’bravo’中,查询的数据是*,也就是整行数据。而上面的辅助索引只存了主键+name,所以必须回表:拿着主键再去跑一遍主键索引,最终返回整行数据。
现在,我们可以给MyISAM和InnoDB的索引分类做个简单的总结:
MyISAM:非聚簇索引,需要回表
InnoDB:
- 聚簇索引:主键索引,叶子节点是表数据,不需要回表
- 非聚簇索引:辅助索引(唯一索引、普通索引),叶子节点是主键,必要时需要根据主键回表查询
InnoDB每张表只能有一个主键索引,辅助索引则可以有多个。表数据只有一份,挂在主键索引下面。
需要注意的是,如有可能,应该尽量避免回表。SQL优化的本质其实就是减少/减小磁盘IO,而回表必然会增加磁盘IO次数。
举个例子,假设某张表总共就两棵索引树:主键索引+name辅助索引,两棵树高度都是3。由于只有主键索引下才挂着表数据,所以对于SELECT * FROM table WHERE name=’xxx’来说,需要先走辅助索引取得id,再根据id走一遍主键索引。假设两棵树需要的数据都在第三层,那么这条SQL需要进行6次逻辑IO访问。而如果直接根据id查询,就可以直接走主键索引,IO次数为3。
所以,通常情况下辅助索引查询都是需要回表的,比主键索引查询多扫描一棵索引树(自身+主键索引),实际编写SQL时,应该尽量走主键索引。
那么,什么情况下辅助索引可以避免回表吗?
索引覆盖
索引覆盖这个名字,咋一听不知所云,所以很多初学者一直搞不明白什么意思,其实它最大的作用就是:避免回表。
下面通过一个案例来说明。
假设有个需求:前端需要支持根据用户名模糊搜索订单,而页面需要的字段如下。
id | productName | price | userName | userAge |
---|---|---|---|---|
1 | iphone12 | 5999 | bravo1988 | 18 |
一个可行的方案是:
在t_user表中根据name搜索用户,得到user_id、user_name、user_age
在t_order表中根据user_id查询订单
在内存中根据user_id匹配order和user数据后返回
由于t_user表此时的查询条件是user_name,为了加快t_user表的查询速度,可以给user_name加普通索引。但,这样真的好吗?我劝!不要犯这样的聪明,小聪明啊。
你要知道,此时我们从t_user表查询的可不止user_name,还有user_age和id。如果只是给user_name加了索引,那么此时磁盘中产生的索引树是这样的:
这棵树的非叶子节点是user_name,叶子节点是id,也就是说从这棵树上我们只能得到user_name和user_id,至于user_age,MySQL底层只能跳出name索引树,然后跑到隔壁主键索引获取。整个过程被称为回表,而回表意味着多跑一趟。
此时我们可以给user_name和user_age加一个联合索引,这样就能产生所谓的“索引覆盖”:
当辅助索引上的字段完全满足本次查询的列时,就是所谓的索引覆盖,这是一个好消息,意味着不需要回表,查询效率将会大大提高。这也是为什么SQL优化原则中经常会强调:尽量只取必要的字段,避免SELECT *(提高索引覆盖的几率,查询的字段越多,几率越低)。
即使目前表中只有两个字段且已经索引覆盖,也不要写SELECT *。因为后期随着业务扩展,这张表会新增其他字段,此时SELECT *将不再覆盖索引!
为了方便记忆,大家可以把索引覆盖理解为 索引的字段 >= 查询需要的字段。比如联合索引的字段是index(a,b,c),那么此时SELCT a, b就会发生索引覆盖,索引覆盖最大的好处是避免回表。
需要强调的是,覆盖索引和联合索引没有必然关系。比如我只给user_name加单索引,而我查询语句是
SELECT id, user_name FROM t_user WHERE name=’bravo’;
此时也是索引覆盖。所以,能否索引覆盖不取决于索引单方面,需要查询配合。
关于联合索引,我们放在下一篇介绍。