干这一行久了,你会发现一个特别有意思的现象:面试的时候"MySQL索引"人人都能聊两句,B+树、最左前缀、回表这些词张口就来;可真到了线上,一条慢SQL把数据库拖到CPU飙满、连接堆积,能快速定位并解决的人,反而少得可怜。这篇文章我就把自己这些年折腾MySQL索引的实战经验整理出来,从底层原理到执行计划,从失效场景到面试高频题,一次讲透。不管你是刚入门的开发,还是正在做性能调优的老手,这篇都能帮你在索引这件事上少走弯路。
1. 索引底层逻辑:为什么偏偏是B+树
要搞懂索引,先得明白索引到底解决什么问题。简单说,就是让MySQL从"全表翻一遍"变成"按目录直接翻到那一页"。没有索引的时候,InnoDB只能把整张表的数据页一个个读出来,逐行比对条件,这叫全表扫描。数据量小的时候无所谓,可一旦表里有个几百万行,全表扫描的磁盘IO和CPU开销直接能把数据库拖垮。
1.1 从二叉树到B+树的演进逻辑
用树形结构做查找,核心目的是减少磁盘IO次数。这里有个关键指标——树的高度。每查一层,就要读一次磁盘页(InnoDB默认页大小16KB),树越矮,IO越少。
先看二叉树,每个节点最多两个子节点,几百万数据的高度能到二十多层,二十多次磁盘IO,这在数据库场景下是无法接受的。红黑树虽然是平衡的,但本质上还是二叉树,高度依然下不来。B树的思路是多路复用,每个节点存多个key,多个子节点,同样数据量高度只有三四层。那为什么MySQL最终选了B+树而没用B树?关键在于两点:
第一,B+树的非叶子节点不带数据,只存索引键和指针。这意味着一个16KB的页能塞进去的key数量远多于B树,树更矮更胖。第二,B+树的叶子节点之间用链表串起来,并且按key有序排列。这个设计太聪明了——范围查询走到叶子节点后,直接沿着链表往下走就行,不用再回溯父节点重新遍历。日常业务里WHERE id BETWEEN 100 AND 500这种范围查询太常见了,B+树的这个特性让范围扫描效率极高。
1.2 InnoDB的聚簇索引与二级索引
InnoDB里,表本身就是按索引组织的,这句话一定要记住。每张InnoDB表都有一个聚簇索引(也叫主键索引),它的叶子节点直接存整行数据。如果你建表时没指定主键,InnoDB会找第一个非空的唯一索引作为聚簇索引;再没有,它会生成一个隐藏的rowid列。这就是为什么我强烈建议每张表都要显式指定主键,最好是自增id——这样数据在物理上按id顺序存储,插入性能高,也方便基于主键的查询。
二级索引(非聚簇索引)则不同,它的叶子节点存的是索引列的值和对应的主键值。通过二级索引查数据,会先找到主键,再回聚簇索引里捞整行,这个过程叫回表。搞明白这个逻辑,后面讲"覆盖索引"你就自然理解了——如果查询的列恰好都在二级索引里,那根本不需要回表,直接返回索引里的数据就行,速度直接起飞。
注意:如果表没有主键,二级索引叶子节点存的是隐藏的rowid,回表效率会更差。所以建表时一定显式指定主键,别偷懒。
2. 索引分类与选型:每种索引都有自己的脾气
MySQL索引的坑,十有八九是选型不对或者理解偏差造成的。我见过有人在status字段上建了普通索引,也见过有人把VARCHAR字段不加前缀就去做索引,结果索引体积巨大,效果还不如全表扫描。这块得好好捋一捋。
2.1 主键索引、唯一索引、普通索引与全文索引
主键索引刚才说了,聚簇索引,叶子节点存整行。唯一索引保证字段值不重复,底层还是B+树。这里要特别注意:唯一索引和普通索引在查询性能上其实差距不大,因为B+树查到第一个匹配值之后,对于唯一索引直接就停了,普通索引还得继续扫到下一个不匹配的key。这个差异在极端情况下存在,但日常你可感知不到。
全文索引则是另一套东西。它本质是倒排索引,不是B+树,主要用在文本搜索场景。MySQL的全文索引说实话功能相对基础,中文分词支持也不好。我实测下来,除非是极简单的场景,否则建议直接用Elasticsearch,别在MySQL里折腾全文索引。如果项目里用的是MongoDB,那边也有类似的概念,但MongoDB的索引模型和MySQL差异很大,函数索引、复合索引这些概念其实各家的实现逻辑都有区别,不要把MySQL的经验原封不动搬过去。
还有个小众的——哈希索引。Memory引擎支持,InnoDB里也有自适应哈希索引(这是InnoDB内部自动维护的,你没法手动建)。哈希索引的查找是O(1)的,但它不支持范围查询,也不支持排序,所以对等值查询有奇效,范围查询就废了。
2.2 联合索引与最左前缀原则
联合索引(复合索引)是日常开发里用得最多也最容易出错的。ALTER TABLE user ADD INDEX idx_age_name (age, name)表示先按age排序,age相同的再按name排序。所以查询条件必须从最左边的列开始用,并且要连续。这就是最左前缀原则。
具体到实操:
WHERE age = 20—— 能用到索引WHERE age = 20 AND name = '张三'—— 能用到索引WHERE name = '张三'—— 用不到索引,因为跳过了ageWHERE age > 18 AND name = '张三'—— 这种情况age能用索引,但name用不上,因为range之后索引就断了
这里有个很多老手都会犯迷糊的点:联合索引的字段顺序该谁放前面?我的原则是,区分度高的放前面,或者把等值查询频繁的字段放前面。比如按(status, create_time)建索引,status就两个值,区分度很低,但如果你业务里大量按status过滤,放前面也能极大缩小扫描范围。重要的不是生搬硬套"区分度优先",而是看你的实际查询模式。
2.3 覆盖索引与索引下推:两个性能利器
覆盖索引(Using index)是减少回表最有效的手段。当SQL查询的列全部都在索引里时,InnoDB直接从索引树返回结果,不用再回聚簇索引取数据。所以设计索引时可以顺手把高频查询的select字段加进联合索引里。
举个例子:表user有id, name, age, email列,假设你建了idx_name_age(name, age),查询SELECT name, age FROM user WHERE name = '张三'就是覆盖索引,Extra里会显示Using index。但如果你查SELECT name, email FROM user WHERE name = '张三',email不在索引里,就必须回表了。
索引下推(ICP,Index Condition Pushdown)是MySQL 5.6引入的优化。在没有ICP时,联合索引(name, age),查询WHERE name = '张三' AND age > 20,InnoDB只能根据name定位到第一个张三的记录,然后回表,再把age > 20的过滤掉。开启ICP后,age > 20这个过滤条件会被下推到索引层,在索引遍历时就先过滤掉不满足条件的记录,明显减少回表次数。这个优化默认是开启的,你只需要知道它的存在,然后尽量写能利用它的SQL就好。
3. 索引失效的六大场景:每一个都是血泪坑
索引建了,SQL也看着挺正常的,可执行计划一出来,全表扫描。这种情况我排查过无数次,最后发现基本都是下面这几个坑。我按出现频率排个序,你对照着自查。
3.1 函数操作与隐式类型转换
对索引列做函数操作,索引必废。WHERE DATE(create_time) = '2024-01-01',MySQL会对每行都调用DATE函数,索引在这时候就没意义了。正确写法是WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02',这样create_time本身参与比较,索引才能生效。
隐式类型转换是另一个极隐蔽的坑。表里phone字段是VARCHAR,存的是字符串,你写WHERE phone = 13800138000,MySQL会把字符串隐式转成数字去比较,索引直接失效。这里可以理解为:字段的类型是字符串,但比较的对象成了数字,MySQL必须把字段类型转换后才能比,一旦对字段做了转换操作,索引就废了。所以VARCHAR字段比较时,一定要写字符串字面量,比如WHERE phone = '13800138000'。
3.2 LIKE通配符与OR连接的坑
LIKE '%abc'和LIKE '%abc%'用不到索引,这大家都知道。因为通配符在开头,B+树没法从某个key直接开始扫描。但LIKE 'abc%'是可以的,因为B+树可以定位到前缀为abc的第一个记录,然后向后范围扫描。
OR连接的情况我见得太多了:WHERE name = '张三' OR status = 1,即使name和status都有单列索引,优化器也可能选择全表扫描,尤其是两个条件都区分度不高的时候。要破这个局,可以改写UNION ALL,让两个索引都能各自生效,然后合并结果。或者如果你了解MySQL 8.0的索引跳跃扫描特性,有些场景它能自动优化这种问题,但并不是所有情况都适用,改写SQL反而更可控。
3.3 优化器的"任性选择"
很多时候索引没失效,但优化器就是不选你建的索引,直接全表扫描。为什么?因为优化器基于统计信息算了一笔账:用索引要回表那么多次,可能比全表扫描还慢。这个判断经常出在数据分布不均匀的时候,比如性别字段建了索引,查询WHERE gender = 'male',如果male占了一半数据,优化器果断全表。这种情况你没法强行让优化器用索引,最好的办法是让索引覆盖查询——用覆盖索引把查询列都包进去,降低回表成本,优化器自然就选了。
4. 实操:从EXPLAIN读懂执行计划
纸上谈兵再多,不如跑一条EXPLAIN看看。EXPLAIN是MySQL提供的执行计划分析工具,用法就是在SQL前面加EXPLAIN关键字:EXPLAIN SELECT * FROM user WHERE age = 20。它会输出一行关键信息,把这行看明白了,SQL为什么慢你就心里有数了。
4.1 执行计划核心列速查
| 列名 | 含义 | 值得关注的取值 |
|---|---|---|
| type | 访问类型 | const > eq_ref > ref > range > index > ALL,性能从左到右依次变差 |
| key | 实际用到的索引 | NULL说明没走索引 |
| rows | 预估扫描行数 | 越小越好 |
| Extra | 额外信息 | Using index(覆盖索引)、Using filesort(文件排序)、Using temporary(临时表) |
type字段这列是全表扫描还是走索引的关键。ALL就是全表扫描,必须警惕。index是全索引扫描,比ALL好一点,但也扫了整棵索引树。range是范围扫描,ref是等值查询用到非唯一索引,eq_ref是唯一索引等值查询,const是主键或唯一索引等值查询,这是最高效的。我平时看执行计划,eyeball先横扫type,如果看到ALL且rows很大,直接开干。
Extra里出现Using filesort要特别注意——这意味着排序是在内存或磁盘上做的,当排序数据超过sort_buffer_size就会用到磁盘文件,性能断崖式下跌。解决办法是让排序字段和查询条件走同一个索引,因为B+树本身就是有序的。
4.2 一次真实慢查询的排查过程
之前有个线上接口特别慢,一查是这条SQL:
SELECT order_no, amount, status FROM orders WHERE create_time BETWEEN '2024-03-01' AND '2024-03-31' ORDER BY amount DESC;EXPLAIN结果:type=ALL,rows=180万,Extra里还有Using filesort。表里其实有create_time的单列索引,但优化器觉得按时间范围查出来70万行,再回表取amount排序,还不如全表扫一次划算,所以压根没用索引。
我的改法是建了一个联合索引idx_create_time_amount(create_time, amount)。由于B+树叶子节点是按(create_time, amount)排序的,ORDER BY amount也能直接从索引里按序读取,Using filesort立刻消失,走了range扫描,总耗时从2.8秒降到0.15秒。一个小改动,性能翻了几十倍。
4.3 大表加索引的正确姿势
给大表加索引是个高危操作,千万别在业务高峰期直接ALTER TABLE。虽然InnoDB 5.6之后就支持Online DDL了,加索引的过程中不阻塞读写,但加索引本身要在后台扫描整张表构建索引,CPU、磁盘IO、内存都有明显开销,主从延迟也会被放大。我的经验是:
- 先用
SHOW INDEX FROM table确认这张表现有索引情况,避免重复建索引 - 用
pt-online-schema-change工具在线变更,原理是先建一张新表,通过触发器同步增量数据,最后rename。风险更低,而且可以限流 - 如果数据量特别大(上亿行),建议分批次操作,或者在外围系统做双写,绝对不能在生产环境硬跑
5. 面试高频题与调优体系
索引是Java、后端开发面试几乎必考的一个点,我把这些年被问过的问题整理了一下,当然也包括我面试别人时候喜欢问的几个。这块弄明白了,你去找工作这一关基本就稳了。
5.1 经典问题速答
为什么用B+树而不用红黑树?磁盘IO次数取决于树的高度,红黑树是二叉树,高度高,几百万数据就要二十多次IO。B+树多路+非叶子节点不存数据,一页能存上千key,三到四层就能支撑千万级数据,查询只要三四次IO。
主键为什么建议用自增id而不是UUID?自增id在插入时是顺序追加,B+树叶子节点直接往右写就行;UUID是无序的,新数据可能插在中间,导致页面分裂、数据行搬迁,产生大量碎片,插入性能会急剧下降。这一点在热词里也有人提到主键索引,其实背后就是这个道理。
为什么不要在低区分度字段建索引?比如性别字段,一个值占了50%数据,索引的筛选能力有限,回表成本还高,优化器大概率不用它。区分的标准简单说就是COUNT(DISTINCT col) / COUNT(*),比例越接近1越好,低于10%的话就别折腾了。
联合索引和单列索引怎么选?我见过一张表建了七八个单列索引,结果一个都用不上。索引越多,写入越慢,磁盘占用越大。我的习惯是:能用一个联合索引撑起多个查询的,就不建多个单列索引。可以结合业务查询模型来设计,高频的查询条件组合优先建联合索引,而不是每个字段单独建。
5.2 慢查询排查的完整链路
排查性能问题,我有一套固定流程,可以拿给你参考。
先开慢查询日志,定位到底哪些SQL慢。
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; -- 超过1秒的记录 SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';拿到慢SQL后,EXPLAIN看执行计划,重点看type、key、rows、Extra四列。如果走了索引但还慢,可能就是索引没覆盖,有大量回表;或者数据量太大,单次IO太多,这时候考虑改写SQL或优化索引结构。
还有一种情况是明明数据量不大,但查询就是慢,这就要看是不是并发压力太大——比如连接数打满、锁等待严重。这时SHOW PROCESSLIST看当前会话都在干什么,如果是大量Waiting for table metadata lock,多半是有人手动改了表结构没提交,把后面所有查询都堵住了。
5.3 MySQL 8.0索引新特性
最后提一嘴8.0带来的几个好用的新特性,因为热词里也有人关注新版本的功能。
第一个是函数索引(Functional Index)。以前WHERE DATE(create_time) = '2024-01-01'不能用索引,8.0可以直接建INDEX idx_create_date ((DATE(create_time))),底层是用虚拟生成列实现的。这样函数查询也能走索引了。
第二个是降序索引(Descending Index)。8.0之前索引默认都是升序存储,ORDER BY col DESC虽然也能用索引,但要额外做反向扫描。8.0支持建降序索引,INDEX idx_col (col DESC),对排序查询的性能提升很明显。
第三个是不可见索引(Invisible Index)。你可以把某个索引设为不可见,测试它是否还需要,但又不删它。比如:
ALTER TABLE user ALTER INDEX idx_age INVISIBLE;这条操作非常实用,能在不改业务代码的情况下,快速验证某个索引是不是还有用。实测没问题再最终删除,特别安全。
提示:索引不是越多越好。每建一个索引,插入和更新时就要多维护一棵B+树,写放大是真实存在的。一个几千行的表,索引再优化也快不到哪去,这时候该考虑的是别的瓶颈。
写了这么多,说句掏心窝的话:索引优化的本质,是理解你的数据和你的查询模式。任何经验法则都是参考,最终一定要结合EXPLAIN里的真实数据说话。我每次改SQL前的习惯是,先把执行计划打出来看一遍,改完了再跑一遍对比rows和Extra的变化。这个习惯帮我避免了很多次"自以为优化了,实际上没变化"的尴尬。建议你把这套流程跑熟了,以后再遇到慢查询,心里就有底。