最近几年我处理过的慢查询工单里,十个里有八个最终都能归结到同一条:索引没用好。要么是压根没建索引,要么是建了索引但SQL写法让优化器根本用不上。很多人一遇到慢查询就急着EXPLAIN看执行计划,却忽略了最底层的那个问题——MySQL的索引到底是怎么把数据找出来的。
如果你不了解B+树是怎么组织的,那“创建优化”这件事基本只能靠猜。你很难理解为什么联合索引要讲究最左前缀,为什么对索引列套一层函数就会失效,为什么有时候MySQL放着索引不用偏偏去全表扫。这篇文章我就从B+树原理开始,把索引的存储结构、创建策略、慢查询定位这条链路串起来讲透,最后再用一个完整的慢查询优化案例收尾。不管是刚入门的新手,还是写了好几年SQL但没系统梳理过索引逻辑的同学,应该都能从中拿到点能直接用的东西。
1. 为什么是B+树:从磁盘IO的限制说起
1.1 索引不是“目录”这么简单
很多人第一次接触索引,听到的类比是“就像书的目录”,这句话方向对,但很容易造成误解。书的目录只告诉你内容在第几页,而数据库索引不仅要做“定位”,还要频繁应对范围查询、排序、去重、分组这类操作。如果你只把它当成一个快速定位的目录,就很难解释为什么索引能同时帮ORDER BY加速,为什么范围查询也能走索引,为什么删除和更新也会变慢。
真正理解索引,要从存储介质讲起。MySQL的InnoDB引擎数据落在磁盘上,磁盘随机读比内存慢好几个数量级,因此所有索引结构的设计目标只有一个:尽量减少磁盘IO次数。而减少IO最有效的办法,就是让查询路径上经过的“树的高度”尽量低。
1.2 哈希结构为什么不行
有一类索引结构是哈希索引,它在等值查询上非常快,理论上是O(1)的复杂度。但它的致命弱点是:数据按哈希值存放,顺序被打乱,一旦遇到范围查询、前缀匹配、排序,哈希索引就完全无能为力。
这也解释了为什么InnoDB默认的索引结构选择了树而不是哈希。实际业务里大部分查询并不只是 WHERE id = 1,更多的是 WHERE create_time BETWEEN ... AND ...,或者是 ORDER BY age LIMIT 10。哈希索引在这些场景下连参与的资格都没有。
1.3 B树和B+树的关键差异
B树和B+树名字很像,但内部设计差异十分关键。B树在每个节点上都存储数据,而B+树只在叶子节点存储数据,非叶子节点全部用来存放索引键和指针。
这意味着同样的页面大小(InnoDB默认16KB),B+树能容纳更多的索引键,树的层数更矮。三层B+树能存放上千万甚至上亿条记录的主键索引,而如果换成B树,因为非叶子节点占了大量空间存数据,树的高度会明显增加,每次查询多一层就意味着多一次磁盘IO。
B+树还有一个杀手级设计:叶子节点之间用链表串联,并且按照索引键排序。这让范围查询变得极其简单——找到第一个符合条件的记录后,顺着链表往后扫描就行,不需要反复从根节点重新遍历。B树没有这个叶子链表,范围查询只能中序遍历,效率差得多。
1.4 一次索引查询发生了什么
假设有一张用户表,主键id上有聚簇索引,执行 SELECT * FROM user WHERE id = 9527:
- MySQL定位到B+树根节点所在的页,把它读入内存。
- 在根节点里比较索引键,确认下一步进入哪个子节点。
- 逐层向下,直到命中叶子节点。
- 在叶子节点中找到id = 9527的记录,由于InnoDB的叶子节点就是整行数据,直接返回。
整个过程大概3到4次IO,而且由于根节点页在内存中常驻,实际IO通常只有1到2次。这比全表扫描从第一页翻到最后一页要高效得多。
理解了这种结构以后,你就能自己推导出很多结论:索引键越短,单个页能装下的键越多,树越矮,IO越少;索引的选择性越高,B+树剪枝越快,扫描范围越小。这些结论会直接指导后面的索引创建优化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 聚簇索引、二级索引与回表:索引内部怎么配合
2.1 聚簇索引:数据和索引长在一起
InnoDB的表本质上就是一棵以主键为索引键的B+树,这棵树被称为聚簇索引,也常叫主键索引。它的叶子节点存的不只是主键值,而是整行完整记录。也就是说,在InnoDB里“数据即索引,索引即数据”,不存在一份独立于索引之外的数据文件。
这个设计有个直接后果:如果你建表时没指定主键,InnoDB会偷偷找一列非空的唯一索引作为主键,实在找不到就生成一个隐藏的rowid。所以“表必须有主键”不是规范要求,而是存储引擎层面强制存在的。主动选择一个业务上稳定且有序的主键,远比让InnoDB自己弄一个隐藏列更可控。
2.2 二级索引的指向逻辑与回表开销
除了主键索引外,你自己创建的普通索引叫二级索引,也叫辅助索引。二级索引的叶子节点存的是两部分内容:索引键本身和对应的主键值。
这里就引出一个非常重要的操作——回表。比如执行 SELECT * FROM user WHERE phone = '13800138000',如果phone上有普通索引,查询会先在二级索引的B+树里找到phone对应的记录,拿到主键id,再拿着id去聚簇索引的B+树里查完整行。
问题在于,二级索引匹配到的可能不止一条记录。如果匹配到100条,就要回表100次,这100次大多数是随机IO,代价很高。所以看执行计划时,如果看到key确实用了,但rows很大,实际性能可能依然很差,瓶颈往往就出在大量回表上。
2.3 覆盖索引:查询列全部塞进索引
避免大量回表最有效的办法是使用覆盖索引。如果查询所需的列都包含在同一个二级索引中,InnoDB在二级索引的叶子节点里就能拿到所有需要的数据,完全不需要回表。
比如有联合索引 idx_age_name(age, name),执行 SELECT age, name FROM user WHERE age BETWEEN 20 AND 30,优化器会发现所需的age和name都在索引里,直接扫描二级索引的叶子链表就能返回结果,查询计划中Extra字段会出现“Using index”,这就是覆盖索引生效的标志。
实际优化时,我经常看到有人给一张宽表的所有字段都塞进索引,试图让所有查询都覆盖,这属于过犹不及。覆盖索引适合解决高频率、返回列固定的查询,不适合把所有场景都押在一个索引上。
2.4 索引下推:把过滤动作提前到索引层
索引下推是MySQL 5.6引入的优化,英文是Index Condition Pushdown,简称ICP,很多人面试被问过。它的核心思想是:在允许的情况下,把WHERE条件中能用索引列判断的部分,下推到存储引擎层,在二级索引遍历的时候就先过滤掉不符合条件的记录,减少回表次数。
拿联合索引 idx_age_name(age, name)举例,执行 SELECT * FROM user WHERE age > 20 AND name LIKE '张%':
- 没有ICP时,InnoDB只根据age定位到一批索引记录,然后每条都回表,到Server层再判断name是否符合。
- 有ICP时,InnoDB遍历二级索引的过程中,直接用name的LIKE条件把不匹配的记录过滤掉,只有真正符合条件的那几条才回表。
ICP实际改善非常明显,尤其当二级索引匹配范围内命中的记录数很多、但真正满足条件占比很低时,回表次数能砍掉一大截。执行计划Extra里出现“Using index condition”就说明走了这个优化。
3. 创建索引的优化策略:单列、联合、前缀与唯一
3.1 联合索引与最左前缀匹配原则
联合索引是日常优化里最重要的索引类型,但也是最容易用错的。你要知道,(a, b, c)联合索引在B+树里排序的优先级是:先按a排,a相同再按b排,b相同再按c排。
这意味着:
- 查询条件只要包含a,就能走索引,比如
WHERE a = 1。 - 条件包含a和b,也能走索引,比如
WHERE a = 1 AND b = 2。 - 条件包含a、b、c,当然能走,比如
WHERE a = 1 AND b = 2 AND c = 3。 - 条件只包含b,或者只包含c,都走不了这个联合索引,因为b的值是在a相同的区域里才有序的,单独看b,整个B+树里b是无序的。
这条规则就是最左前缀匹配。设计联合索引时的第一个原则很简单:把查询频率最高、区分度最好的列放在最左边。
3.2 联合索引内部的列顺序怎么定
很多人建联合索引时分不清谁放前面。我的习惯是先看等值查询,再看范围查询和排序字段。
举个例子,订单表经常执行 WHERE status = 1 AND create_time BETWEEN '2024-01-01' AND '2024-01-31' ORDER BY create_time。你可能会建 (create_time, status),但如果status的区分度很低,其实create_time和status都不算高区分度,关键是要让索引同时覆盖筛选和排序。这里我会更倾向于 (status, create_time),因为status是等值条件,放在最左边可以让B+树先把status = 1的区域定位出来,在这个区域内create_time天然有序,既能过滤又能排序。
等值条件放前面、范围条件放后面,原因是范围条件之后的索引列无法继续用于精确定位,只能用于ICP过滤。这个原则理解以后,联合索引设计基本就上路了。
3.3 前缀索引:长字符串字段的解药
对于邮箱、URL这类超长字符串列,如果直接对整列建索引,索引体积会非常大,B+树的层数也可能被推高。解决方案是前缀索引,只取字段的前N个字符建索引。
比如 ALTER TABLE user ADD INDEX idx_email_prefix(email(10))。这样索引体积小,B+树更矮,查询走索引的效率更高。但要注意:前缀索引的选择性必须足够高,否则就算走了索引,命中的行也太多,反而比全表扫描还慢。
怎么衡量?可以对比一下:
- 全列选择性:
COUNT(DISTINCT email) / COUNT(*) - 前缀选择性:
COUNT(DISTINCT LEFT(email, 10)) / COUNT(*)
我一般会从5开始递增测试,直到前缀选择性接近全列选择性,再选一个够用且不过长的值。前缀索引最大的弊端是无法用在覆盖索引和排序上,因为索引里存的不是完整值,所以只适合解决“长字段等值查询慢”的场景。
3.4 唯一索引与普通索引怎么选
唯一索引不仅能加速查询,还能保证业务上的唯一性约束,比如用户名、手机号、身份证号这类业务唯一字段。但代价是每次插入和更新都要额外做一次唯一性校验,在写入压力大的场景下,这个校验是有成本的。
我的经验是:如果业务本身已经在前置系统里保证了唯一性,数据库层还加不加唯一索引取决于你对数据质量的容忍度。电商订单号的场景,内置唯一索引非常有必要,因为重复订单号的代价远大于一次唯一性校验;但日志表、流水表就算了,本身也不应该设计业务唯一键。
3.5 冗余索引的排查与清理
冗余索引是最容易被忽视的浪费。最常见的情况是:先建了 (a, b)联合索引,后来有人又单独建了 a的索引。其实后者完全冗余,因为 (a, b)索引已经能覆盖所有用a做查询条件的场景。
排查思路很简单:列出表上所有索引和对应使用的SQL,逐个检查是否有重叠。看到 (a, b)和 (a)共存时,优先考虑删除单独的a索引。索引不是邮票,多建几张不升值,每一张都占用磁盘空间、拖慢写入。
4. 索引失效全清单:每种写法背后的优化器逻辑
4.1 对索引列做计算或函数操作
一种极其常见的写法是 WHERE YEAR(create_time) = 2024。如果create_time上建了索引,这个查询依然走不了。原因很简单:B+树的叶子节点存的是原始字段值,不是计算后的值。你想查YEAR(create_time) = 2024,MySQL无法直接利用有序的create_time去定位,只能把每一行的create_time都算一遍再比较。
我自己排查这类问题时,经常会看到 WHERE id + 5 > 100 这种写法,同样的问题。索引列一旦参与任何表达式运算,优化器就放弃索引。解决办法是把计算挪到等号另一半:WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01',或者把 id + 5 > 100改写成 id > 95。
4.2 隐式类型转换:一个引号引发的性能问题
手机号、身份证这类字段,建表时往往用varchar存储,但查询时可能随手写成了 WHERE phone = 13800138000,数字类型和字符串类型比较,MySQL需要把varchar转成数字进行比较,导致索引列上发生了隐式类型转换。
隐式转换的规则会影响索引是否可用。当索引列是字符串类型、传入的是数字时,MySQL会对索引列使用CAST函数,索引函数失效。当索引列是数字类型、传入的是字符串时,通常会把传入的字符串转成数字,索引还能用,但同样不推荐依赖这种隐式行为。最稳妥的做法是查询参数的类型和建表字段类型完全一致,手机号字段就用引号包起来。
4.3 LIKE前导模糊与find_in_set的特殊情况
LIKE '%关键字%' 因为前导通配符的存在,无法利用B+树的有序性定位,只能全表扫描。但 LIKE '关键字%' 是可以走索引的,因为B+树按索引键排序,前缀已知就能确定扫描起点。如果业务确实需要模糊搜索,一般建议引入全文索引或者专业搜索引擎,而不是硬扛LIKE。
还有一个常见问题:find_in_set('x', tags) 能不能走索引?答案是基本上不能。find_in_set查找的是逗号分隔字符串中是否包含某个值,它面对的是一个整体字符串而不是结构化字段。B+树索引要求你提供明确的前缀或完整值,无法从一堆逗号分隔的文本里直接定位。这也侧面说明一个设计问题:应该避免在单列里存逗号分隔数据,数据要存储拆分,不要贪图一张表结构简单。
4.4 OR、NULL和排序带来的性能陷阱
WHERE a = 1 OR b = 2这种条件,如果a和b只有一个是索引列,那么整个查询通常无法命中索引,因为优化器必须同时处理两边结果集。如果确实要两个条件都查,可以考虑 UNION ALL 拆开,或者让a和b都建成索引。
关于NULL是否走索引,MySQL是允许对IS NULL使用索引的,但规则不算稳定。如果列上NULL值占比极高,优化器算下来觉得全表扫描更便宜,也会选择不走索引。更重要的是统计信息失真的问题,InnoDB的索引统计信息是基于采样的,如果数据分布变化大而没来得及更新,优化器的判断就可能跑偏。此时执行 ANALYZE TABLE user 刷新统计信息,往往能解决一些“本来走索引突然不走了”的怪问题。
4.5 优化器主动放弃索引:有时候全表扫描更聪明
这是很多人不理解的情况:明明字段上有索引,数据量也大,为什么EXPLAIN显示是ALL?
因为优化器会根据数据分布估算成本。索引扫描需要:先读二级索引,再回表拿整行。如果查询条件命中行数占比很高,比如查询超过表数据的20%甚至30%,优化器认为回表成本已经高过了直接全表扫描,于是果断放弃索引。这时候你强行建索引或改SQL没有意义,反而该考虑改写查询逻辑,或者利用覆盖索引让回表代价降到最低。
5. 慢查询定位与执行计划:让MySQL告诉你瓶颈在哪
5.1 开启慢查询日志:从长期维度抓出问题SQL
排查慢查询不能只靠遇到问题再查,最好先把慢查询日志打开,让数据库持续记录,这样才能发现那些“平时不慢、高峰期突然慢”的隐患SQL。
常用配置如下:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
long_query_time表示超过多少秒记为慢查询,生产环境建议从1秒开始,太低会刷爆日志,太高又容易漏掉问题。log_queries_not_using_indexes用于记录那些没有使用索引的查询,哪怕它执行很快。这类SQL特别值得关注,因为随着数据量增长,它们就是未来的慢查询。
日志落盘后,可以用mysqldumpslow汇总统计:
bash复制mysqldumpslow -s at /var/lib/mysql/localhost-slow.log
按平均查询时间排序,你会一眼看到哪些SQL是“常驻慢查询”,值得优先优化。
5.2 EXPLAIN关键列逐个拆解
拿到慢SQL后,第一件事是执行 EXPLAIN SELECT ...,重点看几个列。
type是最需要关注的访问类型,从好到差大致是:system、const、eq_ref、ref、range、index、ALL。理论上最常见的性能红线是 ALL全表扫描和 index全索引扫描,出现这两个值就要警惕。
key列表示实际用到的索引,rows是估计扫描的行数,filtered是经过条件过滤后剩余行数的比例。Extra里的信息尤其重要:
Using index:覆盖索引,最理想。Using index condition:走了索引下推。Using where:存储引擎返回后Server层再过滤。Using filesort:ORDER BY没走索引,需要额外排序。Using temporary:使用了临时表,通常出现在GROUP BY或DISTINCT场景。
看到 Using filesort和 Using temporary时,优先考虑能否通过调整联合索引列顺序,让排序和分组直接复用索引顺序。
5.3 一次慢查询的完整优化记录
分享一个我实际处理的案例。有一张订单表order_record,数据量在800万左右,慢SQL长这样:
sql复制SELECT order_id, user_id, amount, status
FROM order_record
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20;
EXPLAIN显示用了一个单列索引 idx_status(status),type是ref,但Extra有 Using filesort,rows扫出来几十万行。也就是说,status=1的订单非常多,MySQL要用索引筛出所有status=1的行,再单独排序取20条,代价巨大。
针对这个查询,我改成了联合索引 idx_status_create_time(status, create_time)。这样B+树在status相同的区域内,create_time本身就是按倒序组织的,排序操作直接消失,Extra从Using filesort变成了空,LIMIT 20只需要沿着索引顺序取前20条,查询从几百毫秒降到个位数毫秒。
这个案例很好地说明了联合索引列顺序的重要性:筛选列的等值条件放前面,排序列放后面,索引顺序天然满足排序需求。这是慢查询优化里性价比极高的一招。
6. 索引不是免费的:写入放大、锁竞争与日志采集的牵制
6.1 每次写入背后,索引也在同步维护
很多人优化时容易陷入一个极端:把所有可能涉及的列全部建上索引,想着“反正查询快就行”。但索引是有维护成本的。
每次INSERT,不仅要往聚簇索引里插入记录,还要往每个二级索引的B+树里同步插入索引项;每次UPDATE,如果更新了索引列,对应索引项也要调整位置;每次DELETE,所有索引项也要跟着删除。这意味着索引越多,写入放大越严重。对于高并发写入的核心交易表,索引数量必须克制,通常单个表控制在5个以内比较稳妥,大量只读报表类型的表可以适当放宽。
6.2 高并发下的索引页竞争与日志采集干扰
这里要讲一个容易被忽略的现象。在高并发写入场景下,二级索引的B+树会不断发生页分裂和页合并,尤其是自增主键外的业务索引。页分裂需要申请新的数据页、调整指针,会产生临时锁竞争;如果同时开了通用日志或慢日志采集,日志落盘本身也要抢占IO和CPU资源,会让索引页的读取和缓存刷新变得更慢。
我实际遇到过一个问题:系统平时很稳定,后来为了排查某个问题开了通用日志,高峰期所有带索引的查询全面变慢。原因就是日志采集和索引扫描在IO路径上互相争用,最终导致buffer pool中索引页换入换出频繁。结论是:日志采集功能在排查期间开启没问题,但用完必须及时关闭,持续全量采集对生产环境的影响远比想象中大。
6.3 临时干预:USE INDEX和统计信息刷新
遇到某些SQL因为统计信息偏差,优化器选错了索引,除了改写SQL,还有两个临时手段。
第一个是 FORCE INDEX或 USE INDEX,直接在SQL里指定让MySQL使用某个索引:
sql复制SELECT order_id
FROM order_record
FORCE INDEX(idx_status_create_time)
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20;
这种方式适合“我知道用哪个索引一定对”的场景,但要注意这是临时干预,根本解决办法还是分析为什么优化器选错。通常是统计信息过旧或者索引设计本身有问题。
第二个是刷新统计信息:
sql复制ANALYZE TABLE order_record;
执行后会重新采样更新索引统计信息,有时候能让优化器恢复正确判断。注意,ANALYZE TABLE在InnoDB中主要是读取统计信息,不用长时间锁表,但在大表上依然建议放在低峰期操作。
说到最后,还是想提醒一点:索引优化不是一锤子买卖。业务的数据量在涨,查询模式在变,今天最优的索引设计,三个月后可能就是累赘。我自己的做法是每季度定期复查一次慢查询日志,把长期出现的 rows很大的SQL挑出来重新分析,结合执行计划决定是调整SQL、新建索引还是删除冗余索引。这套循环做下来,比任何一次性的优化都管用。
