MySQL索引原理这话题,看着像个基础八股文,其实里面水挺深。我当年刚带团队时,就吃过一次大亏:一个运营后台的列表页,数据量刚过百万,接口直接超时,数据库CPU被打满。当时第一反应是加配置、加缓存,结果排查下来,发现就是一条SQL没走索引,全表扫了近一千万行。加了索引之后,查询从2.7秒掉到30毫秒,连SQL都不用改。那个瞬间我意识到,不懂索引原理,连排查问题的方向都是错的。
这篇内容我会从索引的本质讲起,一层层拆到InnoDB的B+树结构,再结合聚簇索引、回表、索引失效这些高频面试点,最后落到EXPLAIN实战和索引运维经验上。不堆概念,尽量用大白话和真实场景说清楚。适合刚入门的开发,也适合写过一两年SQL但没系统理过索引原理的人。
1. MySQL索引到底是什么:一次慢查询事故给我的教训
1.1 索引不是“优化神药”,它本质上是一棵有序树
很多人把索引理解成“给字段加个目录”,这个类比方向是对的,但不够准确。书的目录只是告诉你内容在第几页,而MySQL索引要解决的,是在海量数据里快速定位到目标行,同时还得支持高效的插入、删除和更新。
打个比方。你有一本没有页码、也没有目录的字典,要在里面找一个字,只能从第一页翻到最后一页。如果这本字典有一千页,运气好可能翻十页就找到了,运气差就得翻九百多页。这种查找方式,在数据库里就叫全表扫描。复杂度是O(n),数据量翻倍,耗时基本也翻倍。
索引做的事情,就是给字典加上“拼音目录”和“部首目录”。你不用一页页翻,而是先查目录,锁定大概位置,再跳到具体区域精确查找。这样查询成本从O(n)降到了O(log n)级别。别小看这个log,同样的千万级数据,全表扫描可能要走几百万次磁盘IO,走索引树可能只要二十多次磁盘IO。
但是这里面有个关键点:索引是要付出代价的。它需要额外的磁盘空间去存储树结构,每次插入、更新、删除时,除了维护数据本身,还要维护索引树。这就是典型的时间换空间、空间换查询速度的思路。所以“多建索引一定好”这个想法,是完全错误的。
1.2 一张表最多能建多少个索引?无脑添加不可取
MySQL理论上每张表允许建16个索引,每个索引最多包含16个列。但这个数字看看就好,现实里一张表超过五六个索引,写入性能就会肉眼可见地下降。
我见过最离谱的情况,是有人把一张只有二十几个字段的表,建了将近十个索引。理由是“前端啥字段都要排序,啥字段都要过滤”,结果导入数据时一小时才导完几十万条,因为每条插入都要维护十棵索引树,每个索引都要做一次或多次磁盘写入。
更讽刺的是,这些索引里真正被查询用到的,也就两三个。剩下那些索引纯粹是在“健身”——锻炼磁盘和CPU。判断索引有没有用,最直接的方法是看慢查询日志和EXPLAIN里的实际使用情况,而不是拍脑袋觉得“某个字段以后可能要用”。
我个人在建索引时有一个习惯:一张OLTP表,索引数量控制在五个以内,能用联合索引解决的,绝不多建单列索引。后面章节我会专门讲联合索引的设计思路。
2. InnoDB引擎下的索引底层:B+树、聚簇索引与非聚簇索引
2.1 为什么MySQL偏偏选了B+树
面试八股文的经典问题:为什么InnoDB用B+树,不用二叉树、红黑树或者哈希索引?
先说结论:因为InnoDB的数据是存储在磁盘上的,而磁盘IO的代价比内存访问高好几个数量级。所以在设计索引时,核心目标不是“比较次数更少”,而是“磁盘IO次数更少”。
二叉树和红黑树的问题在于树太高。一个亿级数据量的二叉树,树高可能接近33层。每读一层节点,就要一次磁盘IO,那就得读33次磁盘。而B+树是“矮胖”结构,一个节点能存很多个key,同样亿级数据,B+树的高度通常只有3到4层。这意味着查找一个数据,最多3到4次磁盘IO就能定位到目标。
B树和B+树的区别也是一个高频考点。B树的内节点和叶子节点都存数据,而B+树的所有数据都放在叶子节点,内节点只存索引值。这样做有两个好处:
- 内节点不存数据,就能存更多的key,树自然更矮,IO次数更少。
- B+树的叶子节点之间用链表串起来,天然支持范围查询和排序。只要找到范围起点,沿着链表往后扫就行。B树要实现范围查询就很麻烦,每个节点都要回溯。
哈希索引的查询速度其实是O(1),比B+树的log n还要快。但它只支持等值查询,不支持范围查询、排序、前缀匹配。对MySQL这种通用数据库来说,范围查询是刚需,所以哈希索引只能做辅助,比如自适应哈希索引。
2.2 聚簇索引与非聚簇索引:回表到底在回什么
这是索引原理里最容易绕晕的概念,但只要搞清楚了索引叶子节点到底存的是啥,瞬间就通了。
InnoDB的聚簇索引,就是把数据行本身放在索引的叶子节点上。一张表只有一个聚簇索引,通常就是主键索引。表数据按照主键的顺序物理存储,这有点像电话簿——按姓氏拼音排列,你找到姓,就翻到了对应的联系方式。
如果没有显式定义主键,InnoDB会找第一个非空唯一索引作为聚簇索引。如果也没有,InnoDB会隐藏一个6字节的row_id作为聚簇索引。
非聚簇索引,也叫二级索引或辅助索引。它的叶子节点存的是索引列的值 + 主键值,不是完整的数据行。所以当你通过二级索引查到数据时,先拿到主键值,还要再回聚簇索引里查一次完整行,这个过程就叫“回表”。
举个例子。表结构如下:
CREATE TABLE user ( id INT PRIMARY KEY, name VARCHAR(50), age INT, INDEX idx_name (name) );当执行SELECT * FROM user WHERE name = '张三'时,MySQL先走idx_name索引,找到name为“张三”的叶子节点,里面存的是主键值1。然后拿主键1再去聚簇索引里搜,找到整行数据,返回结果。这一步额外的查找,就是回表。
回表不是没代价的,它意味着至少两次B+树搜索。能不能避免?能,这就是下面要讲的覆盖索引。
2.3 辅助索引与覆盖索引:用空间换查询速度的关键手段
既然二级索引会回表,那能不能让查询的字段全部包含在索引里,这样压根不需要回表?
能。这种情况就叫“覆盖索引”。比如把上面那条SQL改成:
SELECT id, name FROM user WHERE name = '张三';id和name这两个字段,都在idx_name这个二级索引的叶子节点里。MySQL直接扫索引树就能拿到结果,不需要回聚簇索引。从执行计划里看,Extra列会显示Using index。
覆盖索引的价值不只是省一次查询,更关键的是二级索引的叶子节点只存索引列和主键,比聚簇索引存整行数据要小得多。同样大小的缓冲池(Buffer Pool),能装下的索引页更多,IO次数自然更少。这也是为什么很多大厂做SQL优化时,喜欢用“覆盖索引”去改造慢SQL。
但覆盖索引不是万能的。索引列越多,索引体积越大,写入开销也越高。如果为了覆盖所有查询字段,把select里的十几列全塞进索引,那插入一条数据的成本会高到怀疑人生。覆盖索引用在高频查询、字段少的SQL上收益最大。
3. 索引分类与创建策略:从单列索引到联合索引
3.1 索引分类大扫盲:主键、唯一、普通、全文
MySQL索引大致分四类,在实际建表建索引时很常用。我做了一个表方便对照:
| 索引类型 | 特点 | 常见使用场景 |
|---|---|---|
| 主键索引 | 唯一且非空,一张表只有一个,聚簇索引 | 每张表都应该有主键 |
| 唯一索引 | 值不能重复,允许有多个,可以是NULL | 手机号、身份证号、业务单号 |
| 普通索引 | 只加速查询,不限制值重复 | 高频查询的普通字段 |
| 全文索引 | 针对文本内容做分词匹配 | 文章内容搜索、长文本LIKE查询 |
主键索引和唯一索引的区别值得多说一句:主键是物理层面的排序依据,唯一索引是逻辑层面的约束。唯一索引也会自动创建索引树,所以像“订单号”“用户手机号”这种需要唯一性约束的业务字段,加唯一索引是一举两得——既保证数据不重复,又提升查询速度。
全文索引在MySQL 5.7之后支持了中文分词插件(ngram),但真要做复杂全文搜索、相关度排序,还是建议上Elasticsearch专门的搜索引擎。MySQL全文索引更适合轻量级场景,比如一个小型博客站的文章标题、摘要搜索。
3.2 最左前缀原则:联合索引的灵魂
联合索引是面试高频考点,也是实际优化里性价比最高的手段。它遵循最左前缀原则:查询条件里必须包含联合索引最左边的列,索引才会生效。
举个例子。建立联合索引(city, age, name),它实际建立的索引树,先按city排序,同一city再按age排序,同一city且同一age再按name排序。所以下面这条SQL能用到索引:
SELECT * FROM user WHERE city = '北京' AND age = 25;因为查询条件从最左边的city开始,逐步向右匹配。但如果单独用age或name作为条件,比如:
SELECT * FROM user WHERE age = 25;这条SQL用不上联合索引,因为索引树的第一层排序是city,没有city条件,MySQL不知道从哪个city分支找age。
那“跳过中间列”行不行?比如:
SELECT * FROM user WHERE city = '北京' AND name = '张三';这里city能用索引,但name用不了。因为索引树的排序规则是同city下按age排序,而不是直接按name排序。所以MySQL只是通过city把范围缩小了,然后在范围内一条条过滤name。这种情况叫“索引截断”,EXPLAIN里能明显看到key_len变小了。
设计联合索引时,一定把区分度高、最常作为过滤条件的字段放最左边,然后逐步考虑排序字段。别把区分度极低的字段(比如性别)往联合索引前面塞,否则索引会臃肿,效果也很差。
3.3 创建索引的SQL与操作要点
创建索引的标准SQL:
-- 建表时创建 CREATE TABLE user ( id INT PRIMARY KEY, city VARCHAR(20), age INT, name VARCHAR(50), INDEX idx_city_age_name (city, age, name) ); -- 表已存在时添加 ALTER TABLE user ADD INDEX idx_city_age (city, age); -- 删除索引 DROP INDEX idx_city_age ON user;有几个实操要点常年踩坑,务必留意:
第一,区分度。区分度越大越好,计算公式是COUNT(DISTINCT 字段) / COUNT(*)。如果一个字段100万行只有“男”“女”两个值,区分度就是0.000002,这种字段建索引收益极低。反过来,主键区分度是1,索引效果最强。
第二,字段长度。索引列太长,一个数据页能存放的索引条目就少,树会变高,IO次数增加。比如一个VARCHAR(255)的字段,如果业务只需要前10个字符来区分,建议使用前缀索引:
ALTER TABLE user ADD INDEX idx_email_prefix (email(20));第三,NULL值。尽量给字段定义NOT NULL。索引列允许NULL时,查询条件要用IS NULL或IS NOT NULL,索引在部分场景下不可用,而且NULL值在索引里处理也更复杂。
第四,频繁更新的字段不适合建索引。索引列每次更新都涉及索引树调整,更新越频繁,索引维护成本越高。一张表如果有一个字段每小时被批量更新几十万次,给它建索引就是给自己挖坑。
4. 索引失效场景与EXPLAIN实战排查
4.1 常见索引失效场景与原因速查表
索引失效是面试题重灾区,也是工作中最让人头疼的问题。我梳理了一份速查表,每条都是实际SQL里踩过的坑:
| 场景 | 原因 | 正确处理 |
|---|---|---|
| 违反最左前缀原则 | 联合索引没从最左列开始 | 调整查询条件或索引顺序 |
| 对索引列使用函数 | 如WHERE DATE(create_time) = '2024-01-01' | 改为create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00' |
| 隐式类型转换 | 字符串字段和数字比较 | 统一参数类型,或字段类型与参数匹配 |
| LIKE以%开头 | 无法利用B+树有序性 | 尽量避免,可考虑全文索引或ES |
| OR连接非索引列 | 只要有一个条件没索引,整个语句可能不走索引 | 拆成两个查询用UNION ALL合并 |
| 对索引列做运算 | 如WHERE id + 1 = 5 | 改为WHERE id = 4 |
函数处理和隐式类型转换是最隐蔽的,因为SQL语句看起来很正常,EXPLAIN出来却是全表扫描。
举一个隐式类型转换的经典例子。表里字段是VARCHAR类型,存储手机号,SQL写成:
SELECT * FROM user WHERE phone = 13812345678;这条SQL里,phone是VARCHAR,右边是数字,MySQL会把phone转成数字再比较。一旦对索引列做了转换,索引就失效了。正确写法是给参数加引号:
SELECT * FROM user WHERE phone = '13812345678';4.2 EXPLAIN读法:type、key、rows、Extra四个字段怎么组合分析
排查索引是否生效,EXPLAIN是首选工具。用法很简单:
EXPLAIN SELECT ... FROM ... WHERE ...;执行结果会返回一列信息,重点看四个字段:
type:访问类型,从上到下性能从差到好依次是ALL(全表扫描)、index(全索引扫描)、range(范围扫描)、ref(非唯一索引等值匹配)、eq_ref(唯一索引等值匹配)、const/system(主键或唯一索引定位单行)。看到ALL就该警惕,多半是索引失效。
key:实际用到的索引名。如果是NULL,说明没走任何索引。
rows:预计扫描的行数,只是个估算值,但数量级参考意义很大。同样的SQL,rows从一百万降到一千,基本就是命中索引了。
Extra:里最能说明问题的几个值——
Using filesort:表示需要额外文件排序,常见于ORDER BY没走索引。加了合适的联合索引后,这个值会消失,排序直接在索引顺序上完成。Using temporary:用了临时表,常见于GROUP BY/去重,性能极差。Using index:覆盖索引,这是理想态。Using where:在存储引擎层过滤后再回表或返回。如果是Using where + Using index,说明用了覆盖索引。
4.3 实测案例:一条SQL从全表扫描到命中索引的调优记录
这是之前线上真实调优过的一条SQL。表结构大概长这样:
CREATE TABLE order_info ( id BIGINT PRIMARY KEY, user_id BIGINT, order_no VARCHAR(32), status TINYINT, create_time DATETIME );慢查询日志里抓到这条SQL,执行耗时接近3秒:
SELECT * FROM order_info WHERE status = 1 ORDER BY create_time DESC LIMIT 20;EXPLAIN结果显示type=ALL,rows=800万,Extra里有Using filesort。也就是说,这条SQL把800万行全扫了一遍,然后在内存里做文件排序,最后取20条。这是最典型的“慢但不出错”的SQL。
优化思路分两步。
第一步,先加一个普通索引(status, create_time),因为查询条件是status等值,排序是create_time,联合索引可以直接覆盖这两个需求。加完索引后EXPLAIN变type=ref,rows降到几十万,Extra里的Using filesort消失了,查询时间降到200毫秒左右。
第二步,进一步压性能。因为语句是SELECT *,从二级索引拿到主键后还要回表取整行数据。虽然200毫秒已经可以接受,但如果把SELECT字段改成只取必要字段,并且在联合索引里加上这些字段,就能变成Using index覆盖索引,查询时间还能再压一半。
这个案例里有一个核心的调优思路:索引不光是让WHERE快,还要让GROUP BY、ORDER BY、DISTINCT也快。与其追求“查询总是能用索引”,不如在设计联合索引时,把过滤、排序、分组字段整体塞进一个索引,让一条SQL从执行到排序都不产生额外开销。
5. 经验心得与常见问题排雷
5.1 哪些情况下索引反而会拖慢性能
索引不是银弹,有些场景下建索引反而是负优化。
第一,高频写入的大表。像订单流水、日志记录这类每秒写入成千上万行的表,每多一个索引,就意味着每次插入要多维护一棵B+树。索引数量过多时,写入耗时成倍增长。这类表通常更看重写入吞吐量,索引设计一定要克制。
第二,超大文本字段。对TEXT、BLOB这种大字段直接建索引,索引体积会爆炸,而且大多数场景下也不会真的用过这种索引做过滤。真要搜长文本,用全文索引或者外部搜索引擎。
第三,小表。只有几百行数据的表,全表扫描一次的成本可能比走索引还低。MySQL优化器碰到这种情况,会直接放弃索引选择ALL。这种表建索引纯属浪费磁盘空间。
第四,低区分度+高重复率字段。比如状态字段就“成功/失败/处理中”三种值,建索引后MySQL发现扫描大量行才能拿到结果,可能干脆就不走了。这个前面提到过,是区分度问题。
5.2 大批量导入数据时的索引处理技巧
有经验的数据开发都有一个共识:向一张已有索引的大表批量灌数据,先删索引,导完再重建,速度能快好几倍。
原因很简单,插入一条数据时,如果表上有多个索引,MySQL每插一行就要维护一整棵索引树的顺序,还要处理分页分裂的额外开销。在高并发导入场景下,这个成本会被无限放大。先把索引删掉,让数据只按聚簇索引顺序落地,插入就是顺序写,速度起飞。数据导完后再一次性建索引,索引树的构建也比逐条维护高效得多。
这套操作有一个前提:导入过程中不能有线上查询在跑,否则先删索引会导致查询全表扫描。如果条件不允许停服,至少要把新表建成无索引状态,导入完成后再改名上线。
5.3 利用自增主键减少页分裂
这也是InnoDB聚簇索引最常见的坑。如果主键是UUID这类随机字符串,插入新行时,新数据的主键值不是顺序递增的,可能落在已有数据区间中间。这样InnoDB需要移动大量数据来为新值腾位置,频繁引发“页分裂”,不仅导入慢,还会产生大量索引碎片,占磁盘空间。
而自增主键是严格递增的,新行总是追加到B+树最右侧,几乎不触发页分裂。这也是为什么我一直建议,InnoDB表默认用自增整型主键,哪怕业务上没有合适的自然主键,也要加一列自增id。
如果非要用UUID当主键,可以考虑改成顺序UUID,或者用雪花算法生成趋势递增的数字ID,都能显著减少页分裂问题。
5.4 SHOW INDEX,几分钟摸清一张表的索引家底
接手一张老表或者排查性能问题时,第一件事不是看业务代码,而是先看索引长什么样:
SHOW INDEX FROM order_info;这个命令会列出表上所有索引,包括索引名、列名、索引顺序、基数(Cardinality)等关键信息。基数代表索引列有多少个不同值,数值越大代表区分度越好。如果发现某个索引的Cardinality相对于表行数极小,那这个索引大概率是在“养老”。
总结一句我自己的工作习惯:索引设计不是一次性的。业务SQL在变,数据规模在涨,索引也需要定期用慢查询日志和EXPLAIN做“体检”。回头看看那些线上慢SQL,大多数不是SQL写法有多复杂,而是索引压根没设计对。把索引原理吃透了,再去看优化方案,整个思路都会清晰很多。