索引是提高关系型数据库查询性能的利器,但其并非银弹,必须精通其原理,才能发挥奇效。
MySQL把数据存储和查询操作抽象成了存储引擎。MySQL支持多种存储引擎,并且可以以表为粒度设置存储引擎。因为需要事务,所以InnoDB最常用。
为减少磁盘随机读取次数,InnoDB采用页而非行的粒度保存数据,即数据被分成若干页,以页为单位保存在磁盘。InnoDB的页大小默认16K。
页目录通过一个个槽把记录分成不同组。记录中最前面的小矩形数字,代表当前组的记录条数。
最小和最大的槽指向2个特殊的伪记录。
0 =》 最小记录
1 =》 4
2 =》 8
3 =》 12
4 =》 16
5 =》 20
6 =》 最大记录
有了槽,按主键搜索页内记录时,就能用二分查找,而无需从最小记录遍历整个页的记录链表。
比如要搜索主键(PK)=15的记录:
(0+6)/2=3
,指向记录12<15,所以从槽3后继续搜索(3+6)/2=4.5
取整4,槽4对应记录16>15,所以记录在槽3页目录就是最简单的索引,通过对记录进行一级分组来降低搜索的时间复杂度。
这样能够降低的时间复杂度数量级有限。当有无数个数据页来存储表数据时,我们就需要考虑如何建立合适索引,才能方便定位记录所在的页。
因此,InnoDB使用B+树,既可以保存实际数据,也可加速数据搜索,这就是聚簇索引。
如果把上图叶子节点下面方块中的省略号看作实际数据,那么它就是聚簇索引的示意图。由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个。
InnoDB会自动使用主键(唯一定义一条记录的单或多个字段)作为聚簇索引的索引键(若无主键,则选择第一个不包含NULL值的唯一列)。方框数字代表索引键的值,对聚簇索引,一般就是主键。
比如搜索PK=4数据,通过根节点中的索引可知数据在第一个记录指向的2号页,通过2号页的索引又可知道数据在5号页,5号页就是实际数据页,再通过二分查找页目录马上可以找到记录的指针。
为了实现非主键字段的快速搜索,就引出了二级索引,也叫作非聚簇索引、辅助索引。非聚簇索引也是B+树,如下:
非聚簇索引的叶子节点保存的不是实际数据,而是主键。
获得主键值后去聚簇索引获得数据行,就是回表。
假设该索引是针对用户名字段创建的,索引记录上面方块中的字母是用户名,按顺序形成链表。若要搜索用户名为b的数据,经过两次定位可以得出在数据页5中,查出所有主键为7和6,再拿这俩主键继续使用聚簇索引进行两次回表得到完整数据。
创建N个二级索引,就需要再创建N棵B+树,新增数据时不仅要修改聚簇索引,还需要修改这N个二级索引。
假设有如下表:
通过如下存储过程创建10万条测试数据,约140s。
再创建两个索引:
则创建10万条记录的耗时提高到154s。
页中的记录都是按照索引值从小到大的顺序存放的:
页分裂和合并,都会有I/O代价,且过程中可能产生死锁。
虽然二级索引不保存原始数据,但要保存索引列的数据,所以会占用更多的空间。
比如,person表创建了两个索引后,使用下面的SQL查看数据和索引占用的磁盘:
SELECT DATA_LENGTH, INDEX_LENGTH FROM information_schema.TABLES
WHERE TABLE_NAME='person'
结果显示,数据本身只占用了4.7M,而索引占用了8.4M。
二级索引不保存原始数据,通过索引找到主键后需要再查询聚簇索引,才能拿到想要的数据。
示例如下:
key=person_name_score_index
,表明走的是person_name_score_index
索引。
type=ref
,表明是二级索引的等值匹配,符合预期
再看如下SQL的执行计划:
Extra列多了一行Using index,说明直接查的二级索引,没有回表。
联合索引保存了多个索引列的值,对于页中的记录先按照字段1排序,若相同再按照字段2排序,如下:
图中叶子节点每一条记录的第1、2个方块是索引列的数据,第三个方块是记录的主键。若查询的是索引列索引或联合索引能覆盖的数据,则查询索引本身已经“覆盖”了需要的数据,无需再回表。这种情况也叫索引覆盖。
*
,而是SELECT必须字段,甚至可以考虑使用联合索引包含我们要搜索的字段,既能实现索引加速,又可避免回表。LIKE语句查询name后缀为name123的用户,type=ALL全表扫描
把百分号放到后面走前缀匹配:
索引中行数据按索引值排序,只能根据前缀进行比较。
若非要按后缀查询也能走索引,并且永远只是按后缀查询,可以把数据反过来存,用时再倒过来。
比如查询条件用到了LENGTH函数,肯定无法走索引,type=ALL全表扫描
同理,索引保存的是索引列的原始值,而非经过函数计算后的值。若需要针对函数调用还能走索引,只能保存一份函数变换后的值,然后重新针对这个计算列做索引。
虽然对name和score建了联合索引,但仅按score列查询无法走索引
因为在联合索引情况下,数据按照索引第一列排序,第一列数据相同时才会按第二列排序。若想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。若仅按第二列搜索,肯定无法走索引。
因为有查询优化器,所以name作为WHERE子句的第几个条件并不重要。
现在回答一开始的问题:
查询数据可直接在聚簇索引上进行全表扫描,也可走二级索引扫描后到聚簇索引回表。
MySQL如何确定走哪个方案?
MySQL在查询数据之前,会先对可能的方案做执行计划,然后依据成本决定走哪个执行计划。
包括IO成本和CPU成本:
全表扫描,就是把聚簇索引中的记录依次和给定的查询条件对比,把符合搜索条件的记录加入结果集的过程。
所以要计算全表扫描的代价需要两个信息:
MySQL是实时统计的这些信息吗?
不是的,MySQL维护了表的统计信息,可使用命令:
可见总行数100147行。里表不是只有10w行记录吗,为啥这里还多了147行?
因为MySQL的统计信息只是个估算。现在我们估算下CPU成本:
100147*0.2=20030
数据长度是5783552B。对于InnoDB,这就是聚簇索引占用空间,等于聚簇索引的页面数量 * 每个页面的大小
。InnoDB每个页16K,大概计算出页面数量是353,所以I/O成本是353。
综上,全表扫描总成本约20383。
现在,我要用下面的SQL
执行计划是全表扫描。但只要把create_time条件中的5点改为6点就变为走索引了,并且走的是create_time索引而不是name_score联合索引:
该实验可以得到如下结论:
因为MySQL是根据成本判断的。虽然表的统计信息不完全准确,但足够用于策略的判断。
不过,有时会因为统计信息的不准确或成本估算问题,实际开销会和MySQL统计出来的差距较大,导致MySQL选择错误的索引或是直接全表扫描,这就需要人工干预,使用强制索引。
强制走name_score索引:
EXPLAIN
SELECT *
FROM person
FORCE INDEX(name_score)
WHERE NAME >'name84059'
AND create_time>'2020-01-24 05:00:00'
MySQL会根据成本选择执行计划,通过EXPLAIN可以知道优化器最终会选择怎样的执行计划,但MySQL如何制定执行计划始终是一个黑盒。
有没有什么办法可以了解各种执行计划的成本,以及MySQL做出选择的依据?
MySQL 5.6及之后,可以使用optimizer trace查看优化器生成执行计划的整个过程。有了这个功能,我们不仅可以了解优化器的选择过程,更可以了解每一个执行环节的成本,然后依靠这些信息进一步优化查询。
SET optimizer_trace="enabled=on";
SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace="enabled=off";
对于按照create_time>'2020-01-24 05:00:00’条件走全表扫描的SQL,来分析OPTIMIZER_TRACE的执行结果。
person_name_score_index
对name84059条件进行索引扫描需扫描33918行,成本11872,所以未选择该方案
33918 = 查询二级索引的I/O成本和CPU成本 + 回表查询聚簇索引的I/O成本和CPU成本
person_create_time_index
进行索引扫描需要扫描35606行,成本是12462,也是因为成本未选择该方案把SQL中的create_time
条件从05:00改为06:00,再次分析OPTIMIZER_TRACE。这次执行计划选择的是走person_create_time_index
索引。因为是查询更晚时间的数据,走person_create_time_index
索引需要扫描的行数从35606减少到了27218。这次走这个索引的成本9526.6小于全表扫描的10103,更小于走name_score索引的30435:
考虑到索引的维护代价、空间占用和查询时回表的代价,不能认为索引越多越好。索引一定是按需创建的,并且要尽可能确保足够轻量。
一旦创建了多字段的联合索引,我们要考虑尽可能利用索引本身完成数据查询,减少回表。
不能认为建了索引就一定有效,对于后缀的匹配查询、查询中不包含联合索引的第一列、查询条件涉及函数计算等无法使用索引。
即使SQL本身符合索引使用条件,MySQL也会通过评估各种查询方式的代价,来决定是否走索引,走哪个索引。
尝试通过索引进行SQL性能优化时,请一定通过执行计划或实际的效果来确认索引是否能有效改善性能问题,否则增加了索引不但没解决性能问题,还增加了数据库增删改的负担。
对EXPLAIN结果困惑的,还可以利用optimizer_trace查看详细的执行计划,各个索引的成本是多少,看看到底怎么挑选出来的最终方案。
参考
- https://dev.mysql.com/doc/internals/en/optimizer-tracing.html