☰
MySQL索引优化实战:从慢查询到B+树原理与设计
2026/9/28 13:17:51 网站建设 项目流程

1. 一次线上慢查询,把我逼到重新理解索引

先说个真实经历。去年年底我们有个订单列表接口,每天晚上8点到10点准时变慢,前端转圈转得用户直接在群里开喷。看了下慢查询日志,罪魁祸首就是一条按用户ID和创建时间范围分页的SQL,单次查询跑了1.8秒。表里数据量其实不大,也就三百多万行,但就是慢。

当时我第一反应是加索引,结果加完一测,效果有,但没达到预期。后来用EXPLAIN一看,发现MySQL明明走了索引,却还是回了几十万行表。那一刻我才意识到,很多人对索引的理解停留在"建了索引查询就快",但真正决定查询快慢的,是索引结构怎么组织数据、查询怎么利用索引、以及索引在什么情况下会失效。

这篇文章我不想写成那种堆概念的教学文档。我打算从实际场景出发,把InnoDB的索引原理、复合索引的最左前缀法则、索引失效的常见场景、ORDER BY排序优化,以及一套能落地的索引设计流程,全部串起来讲清楚。适合正在做MySQL性能调优的开发者,也适合那些准备面试前想系统补一遍索引知识的同学。看完之后,你至少能回答三个问题:索引为什么快、什么情况下索引会白建、遇到慢查询该怎么设计索引。

2. 索引的本质:空间换时间,但换得很有技术含量

2.1 没有索引时,数据库到底在做全表扫描

很多人以为全表扫描就是把表从头到尾读一遍,其实在实际存储中,InnoDB是按页组织数据的,每页默认16KB,数据行按主键顺序存在页里,页与页之间通过双向链表连接。全表扫描的本质是顺着这个链表把所有页读进内存,逐行判断条件是否满足。

300万行数据按每页大概存100行来算,就是3万个页,每个页16KB,总共约480MB。这480MB要被完整读一遍,才能找到目标行。如果这些页不在缓冲池里,就得走磁盘IO。哪怕按顺序预读的优化做得再好,这个IO量也摆在那里。

这就是为什么很多慢查询在大数据量下像老牛拉车——不是SQL写得有问题,而是它天生要面对这么大体量的数据搬运。索引的价值,就是通过一个远小于原表的额外数据结构,帮你把需要读取的数据范围大幅缩小。

2.2 索引怎么做到"目录式"定位

你可以把索引想象成一本书的目录。没有目录,查找一个关键词就得从第一页翻到最后一页;有目录,先看章节页码,定位到对应区域,再细翻那几页就够了。

MySQL里的索引,主流是B+树结构。B+树的内部节点只存索引键和子节点指针,数据全在叶子节点,叶子节点之间用指针串成有序链表。这样一来,范围查询只需要找到起点,然后沿着叶子链表顺序往后扫就行,不用每次都从根节点重新走。

以InnoDB为例,一张表就是一个聚簇索引,数据行本身就是索引的叶子节点。假设一行数据约200字节,一页16KB能放80行,300万行就需要3.75万个叶子页。但如果你在某个字段上建了二级索引,索引项只包含索引列和主键值,一个索引项可能才20字节左右,一页能放下800个索引项,同样是300万行,二级索引的叶子页只需要3750个左右。查询时先在二级索引上找到主键,再回表查数据,需要读的页比全表扫描少一个数量级。

这个对比就是索引最朴素的价值:用更少的数据读取量,换取更快的定位速度。

2.3 索引也不是白嫖的:写入代价和存储成本

但索引不是免费的午餐。每建一个索引,就意味着每次INSERT、UPDATE、DELETE都要额外维护这个索引结构——B+树的节点分裂、合并、页写入都是开销。索引太多,写入性能就会被拖累。同时索引也占磁盘空间。

我自己的经验是:一个写多读少的业务表,索引数量严格控制在5个以内;读多写少的表可以适当多一些,但也不是越多越好,因为查询优化器在多个索引可选时,评估成本也需要时间,偶尔还会选错。索引设计的本质就是在查询加速和写入维护之间找平衡。

注意:MySQL里最忌讳的一件事,是给表的每个字段都建索引。索引不是收藏品,每一个索引都是在用真金白银的写入开销换查询速度。

3. InnoDB的B+树:聚簇索引与非聚簇索引的分工

3.1 为什么偏偏是B+树,而不是哈希表或红黑树

聊索引原理,绕不开数据结构选型。哈希表做等值查询确实快,O(1)的复杂度,但一碰到范围查询就抓瞎,因为哈希把物理分布完全打乱,无法保证有序。红黑树是平衡二叉树,查找效率O(logN),但在数据量大的时候树高太高,每次查找都要走很多层节点,而且不擅长范围扫描——中序遍历才能拿到有序结果,效率差。

B+树之所以被MySQL选中,核心在于两点:第一,节点可以存储多个键值,树的高度被压得极低。InnoDB一页16KB,假设一个索引键占8字节,内部节点一页能存约1000个键,三层B+树就能存10亿行数据。这意味着从根到叶子最多走三次磁盘IO,效率极其稳定。第二,叶子节点构成了一个有序的双向链表,范围查询只需要定位起点然后线性往后读,天然适配SQL里的范围条件、排序操作。

3.2 聚簇索引:主键决定数据"住"在哪里

InnoDB里,表数据本身就是索引,这个索引就是聚簇索引,通常建立在主键上。聚簇索引的叶子节点存的是整行数据,也就是说,数据行的物理顺序由主键顺序决定。

这个设计带来的直接影响是:按主键等值或范围查询非常快,因为数据一次到位,不需要回表。但也带来一个问题——如果主键是随机字符串(比如UUID),新插入的数据会随机落在B+树的任意位置,导致频繁的页分裂和物理重排,不仅写入慢,还会产生大量碎片。

我自己带过的项目里,凡是主键用UUID的表,插入性能在大数据量下都明显劣于自增ID。这绝对不是玄学,而是结构所决定的。

3.3 二级索引:每次查询最多两次B+树搜索的秘密

非主键索引统称二级索引(也叫辅助索引)。它的叶子节点存的是索引列的值加上主键值,不包含完整行数据。当你用二级索引查询时,InnoDB先走一遍二级索引的B+树,找到匹配项,拿到主键,再用主键去聚簇索引里走一遍,找到完整数据行。

这个过程叫回表。一次完整查询最多两次B+树搜索,这个设计是故意的:二级索引独立于聚簇索引存在,不需要因为数据行的移动而重建全部索引,只需要更新主键位置的索引项。

回表到底是不是性能杀手,取决于二级索引筛选出的数据量占总行数的比例。如果查询命中了1%的行,回表1%是划算的;如果命中了30%的行,回表30%就非常亏,优化器在这种情况下可能会放弃索引,直接全表扫描。

这里引出一个经典优化策略:覆盖索引。如果查询的字段全部都在二级索引里已经存在,MySQL就不需要回表,直接在索引页里取数据返回。这就是为什么"select *"经常比"select 指定列"慢——因为select *几乎必然触发回表。

3.4 主键设计失误的代价:一个实际对比

我对比过两张结构相同、数据量相同的业务表,一张主键用自增ID,一张主键用UUID。插入100万条数据,自增ID的表耗时约为UUID表的三分之一。原因很简单:自增ID是顺序插入,新数据永远追加在B+树最右侧,页分裂概率极低;UUID完全乱序,每次插入都可能触发页分裂,叶子页不断拆分、重写,磁盘写的量翻了好几倍。

如果你无法避免使用UUID,也有缓解方案:把UUID转成有序的二进制格式(比如UUID_TO_BIN函数),或者用雪花ID这类趋势递增的分布式ID方案,把随机性降到最低。

4. 复合索引的最左前缀法则:组合索引的正确打开方式

4.1 复合索引是"排序后的列表",不是"多份索引"

复合索引(联合索引)不是给每个字段各建一个索引,而是把多个字段组合成一个索引项,先按第一个字段排序,第一个字段相同再按第二个字段排序,依此类推。

打个比方:复合索引就像一本先按姓氏拼音排、再按名字拼音排的通讯录。想查"张伟",你可以先定位到Z,再在上面定位到W;但如果你只知道"伟"这个名,没有姓氏,这本通讯录对你基本没用,因为你不知道去哪一段找。

这个特性决定了复合索引的查询能力是逐步衰减的,MySQL里管这个叫最左前缀原则。

4.2 最左前缀到底怎么理解:连续性和范围终结

最左前缀有两个关键点。第一,查询条件必须包含复合索引最左边的字段,才能走索引。第二,在遇到范围条件(>、<、BETWEEN、LIKE 'xx%')之前,等值匹配的字段可以完整利用索引;一旦某个字段用了范围查询,索引就只能定位到这个范围,后续字段就无法走索引了。

举个例子,索引(a, b, c):

  • WHERE a = 1 AND b = 2 AND c = 3:全部命中索引,最优
  • WHERE a = 1 AND b = 2:命中索引的前两列
  • WHERE b = 2 AND c = 3:a不在条件里,索引用不上,全表扫描
  • WHERE a = 1 AND c = 3:a用索引定位后,c无法走索引,需要在a的结果集里做过滤
  • WHERE a > 1 AND b = 2:a用了范围,b失去索引定位能力,只能在a范围内逐个判断

第二个场景值得多说一句。很多人以为"只要条件里有a,索引就能完全用上",实际上范围条件会截断索引的连续性。这就是为什么复合索引的字段顺序设计必须考虑实际查询用到的等值和范围条件,而不仅仅是"建了就行"。

4.3 复合索引字段顺序怎么排:等值优先、区分度高的放前面

基于上述原理,复合索引字段顺序的设计原则可以归纳为四条:

  1. 经常作为等值条件的字段放最前面,比如订单表里的user_id。
  2. 区分度高的字段优先于区分度低的字段。比如性别这种只有两个值的字段,放最前面几乎没用,因为即使靠它定位了,剩下的候选集仍然巨大。
  3. 经常作为范围条件的字段放到后面,尽量减少范围条件对后面字段的"截断伤害"。
  4. 尽量让常用查询能用同一个索引覆盖,减少冗余索引。

举个实操例子。订单表订单查询最常用的是user_id + status + create_time这个组合,其中user_id是等值,status是等值,create_time是范围。那么复合索引应该设计成(user_id, status, create_time),而不是(create_time, user_id, status)。因为user_id等值定位最精确,create_time作为范围放最后,即使截断了,前面两个等值已经过滤掉了绝大部分数据。

4.4 一个"建了索引却不走"的案例复盘

我之前排查过一个线上问题。表结构里有索引(a, b, c),SQL是WHERE a = 1 AND c = 3,EXPLAIN显示possible_keys里有这个索引,但实际只用了索引的一部分,rows扫描数还是很高。原因就是上面说的,c无法利用索引定位,MySQL只能在a的结果集里面逐条过滤c。

这个教训是:索引设计不是写出来就完事,必须用EXPLAIN验证每个查询是否真正用上了索引的全部潜力。很多开发者在建索引时只考虑"字段齐全",没有考虑"顺序合理",导致索引效果大打折扣,这是最常见的误区之一。

5. 索引失效大排查:明明有索引却走全表扫描,多半栽在这些细节里

5.1 索引失效的常见场景清单

我在评审代码时,检查索引失效已经成了固定动作。下面这个表是我实际排查中总结的高频场景,可以直接收藏当checklist:

场景示例原因
对索引列使用函数WHERE DATE(create_time) = '2024-01-01'函数破坏了索引有序性
隐式类型转换WHERE phone = 13812345678(phone是varchar)类型转换使索引失效
左边模糊匹配WHERE name LIKE '%张'B+树按前缀排序,无法定位
OR连接非索引列WHERE a = 1 OR b = 2(仅a有索引)无法同时用两个索引合并
联合索引未用最左字段WHERE b = 2(索引(a,b))最左前缀原则
索引列参与运算WHERE a + 1 = 100运算破坏索引列值
NOT IN / NOT EXISTS 数据占比大WHERE status NOT IN (1,2)优化器评估后放弃索引
数据分布本身不优性别字段建索引查"男"区分度低,优化器认为全表扫更快

5.2 函数操作和隐式类型转换:最隐蔽的两个坑

函数坑之所以隐蔽,是因为它在小数据量下表现不明显,表一大就原形毕露。解决办法是尽量把函数从索引列转移到常量列:把WHERE DATE(create_time) = '2024-01-01'写成WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00',既走索引,语义也更严谨。

隐式类型转换更阴险。比如phone字段是varchar,你写的参数是数字,MySQL会把字符串列转成数字再比较,相当于给列套了一个隐式CAST,索引就废了。判断方法很简单:EXPLAIN看type是不是ALL,同时注意参数和字段类型的匹配。规范做法是应用层保证参数类型和字段类型一致,必要时强制加引号。

5.3 LIKE、OR、NOT IN这些关键字,到底怎么用才靠谱

LIKE慢查询里最常见的写法是LIKE '%关键字%',这种模糊搜索在MySQL里天生和B+树合不来,因为B+树只能按前缀匹配。如果你业务确实需要全文模糊搜索,应该考虑全文索引或专门的搜索引擎,而不是硬扛。如果只是后缀模糊,可以考虑把字段值反转后建索引,配合LIKE '关键字%',这个技巧我在实际项目里用过,效果立竿见影。

OR的问题在于,如果两边条件里有一个字段没索引,整体就无法走索引。解决办法有两个:一是把OR拆成两个查询用UNION ALL合并;二是用UNION把有索引的查询单独拎出来。不过5.7之后优化器也能用索引合并,具体情况还是得EXPLAIN验证。

NOT IN和NOT EXISTS在大数据量下经常被优化器放弃索引,因为要扫描的数据太多,还不如全表扫。能用LEFT JOIN...IS NULL替代NOT EXISTS的场景,尽量用JOIN方案,优化空间更大。

5.4 用EXPLAIN验证每一步:type是首要观察指标

排查索引失效,EXPLAIN是第一步也是最重要的一步。重点看type列,从好到差排列:

  • system:系统表,极少见
  • const:主键或唯一索引等值查询,只匹配一行
  • eq_ref:被驱动表通过唯一索引等值匹配,JOIN场景最优
  • ref:非唯一索引等值匹配
  • range:索引范围扫描
  • index:全索引扫描,跟全表扫差不多,只是扫的是索引
  • ALL:全表扫描,最差

如果type是ALL,说明这个查询基本没有合理利用索引。再看key列确认实际使用了哪个索引,看key_len判断索引具体用到了几个字段,看rows评估扫描行数。EXPLAIN看多了,你对索引的直觉会非常准。

提示:导出慢查询日志,把慢SQL统一跑一遍EXPLAIN,你会发现80%的问题都集中在上面这些场景里。排查索引失效不是靠猜,而是靠EXPLAIN逐条验证。

6. ORDER BY与索引:filesort的代价和排序优化

6.1 filesort什么时候发生,代价有多大

MySQL的排序有两种方式:利用索引有序性直接返回,或者生成结果集后额外排序(filesort)。第二种方式尤其需要注意,因为它可能使用临时文件,排序过程还会占用临时表空间,数据量大时磁盘IO和内存开销都非常可观。

INDEX排序和FILESORT的分界线在于:ORDER BY的字段是否正好是已用索引的一部分,且顺序和排序方向与索引一致。举例:索引(a, b, c),ORDER BY a, b, c能走索引;ORDER BY b, c不能走(缺少a);ORDER BY a, b, c DESC如果索引是默认ASC,也不能直接倒着用(除非MySQL 8.0支持倒序索引)。

6.2 一个典型场景:分页深翻页的排序地狱

最经典的排序问题出现在分页上。比如ORDER BY create_time LIMIT 100000, 20,MySQL并不是只取最后20条,而是把前10万条全部找出来排序,再丢弃前10万条,最后返回20条。越是往后翻页,扫描和排序的数据量越大,性能断崖式下跌。

我之前优化过一个报表接口,翻到第100页时查询耗时超过5秒。排查发现就卡在这个深分页排序上。解决方案是改成"基于游标"的分页:记录上一页最后一条的create_time,查询条件加上WHERE create_time < :last_create_time,然后ORDER BY create_time LIMIT 20。这样每次查询只扫描目标范围的索引,不再处理前面的大量数据。

改造之后,无论翻多少页,查询时间都稳定在几十毫秒以内。这个方案在业务上需要前端配合改一下交互方式,换来的性能收益非常大。

6.3 排序场景的实战改造方向

如果你的业务确实需要ORDER BY,我给几个可以直接上手的建议:

  1. 让排序字段尽量包含在已使用的索引中,避免filesort。排序字段优先放索引末尾,且与WHERE条件的范围字段保持兼容。
  2. 如果复合索引包含范围条件,排序字段可能会被截断,此时考虑单独设计排序专用索引,比如(user_id, create_time)。
  3. 避免SELECT * + ORDER BY大字段的组合,排序结果集越小越好,尽量只select必要字段,减少临时表的数据量。
  4. 深分页场景坚决不用LIMIT偏移写法,用游标或基于排序键的条件分页。
  5. MySQL 8.0之后可以建倒序索引,对DESC排序很友好,升级到8.0后可以考虑。

7. 索引设计实战:从订单表反推一套能落地的索引方案

7.1 先收集高频查询,再设计索引

我见过太多人一上来就给表建一堆索引,好像集邮一样。我自己设计索引的第一步永远是翻应用日志和慢查询日志,把真实高频SQL列出来。然后按频率和耗时排优先级,优先处理高频且耗时的查询。

以订单表为例,假设高频查询有三类:

  1. 按user_id查订单列表,按create_time倒序分页
  2. 按order_no精确查单条订单
  3. 按user_id + status查待付款/已付款订单

这三类查询对应的索引设计是:

  • 索引A:(user_id, create_time DESC)。用户查看自己的订单,是典型的分页排序场景
  • 索引B:(order_no)唯一定位,因为order_no是唯一流水号
  • 索引C:(user_id, status)。覆盖按用户和状态筛选的需求

这三条索引加起来,基本能覆盖绝大多数订单查询场景。多余索引坚决不建,后面再加。

7.2 主键选择:自增ID还是业务唯一键

订单表的主键选择,我一般建议自增ID作为代理主键,业务上的唯一标识(order_no)单独建唯一索引。好处有两个:自增ID维持聚簇索引的写入顺序性,减少页分裂;订单号即使业务规则变化,也不影响表结构的物理布局。

有些系统喜欢直接用订单号做主键,看起来省了一条索引,但全局唯一订单号往往不是顺序递增的,写入时容易触发页分裂。而且订单号一般很长,二级索引的叶子节点都要冗余一份主键值,索引空间成倍增加。综合下来弊大于利。

7.3 冗余索引、重复索引和不可见索引

冗余索引是索引设计里最浪费钱的东西。比如你已经有索引(a, b),再建索引(a)就是冗余的,因为(a, b)已经能覆盖(a)的查询。平时我检查表结构时会用information_schema.statistics查询所有索引,把前缀相同的索引列出来逐个对比,冗余的及时删掉。

MySQL 8.0支持不可见索引用INVISIBLE关键字,这是一个特别好的灰度工具。当你怀疑某个索引没有用但不敢直接删的时候,可以先设为不可见,观察一段时间,确认没有业务依赖再物理删除。这个方法比直接删索引安全得多,推荐给你。

7.4 索引设计的原则沉淀

最后把我这些年做索引设计的原则沉淀成几条,虽然不是标准答案,但踩过很多坑后回头看,每一条都有血的教训在后面:

  1. 索引列尽量选区分度高的字段,区分度低于20%的字段单独建索引基本没用。
  2. 字符串字段过长的,用前缀索引(比如前10个字符)减少索引体积,但要验证前缀区分度是否够。
  3. 频繁更新的字段不要放索引首位,更新索引列的成本很高。
  4. 联合索引的字段数量控制在3个以内,超过3个的索引维护成本会明显增加。
  5. 每张表的索引数量控制在5个以内,必要时通过冗余字段反范式设计减少索引需求。

8. 一个真实的索引优化案例复盘:从1.8秒到30毫秒

8.1 优化前的完整情况

回到文章开头那个订单列表接口。原始SQL大概长这样:

SELECT * FROM orders WHERE user_id = 12345 AND create_time BETWEEN '2024-11-01 00:00:00' AND '2024-11-30 23:59:59' ORDER BY create_time DESC LIMIT 20;

表里有300万行数据,索引情况是:主键id、单列索引user_id。EXPLAIN的结果是type为ref,key为idx_user_id,rows显示有20多万行。

问题就看出来了:MySQL先通过user_id定位到该用户全部订单,20多万行,然后对这批数据做create_time排序,最后才取20条。虽然用了索引,但排序是filesort,代价巨大。

8.2 优化过程:调整索引字段顺序

分析之后发现,user_id等值过滤后数据量仍然太大,create_time排序不走索引,而且BETWEEN范围条件本身对排序还有影响。我的优化方案是重新设计复合索引:

ALTER TABLE orders DROP INDEX idx_user_id; ALTER TABLE orders ADD INDEX idx_user_create_time (user_id, create_time DESC);

把user_id放在第一位,等值匹配可以直接定位到该用户;create_time放在第二位,并且利用MySQL 8.0的倒序索引特性,让排序方向正好匹配ORDER BY DESC。排序直接走索引,filesort消失,排序阶段的数据量也大幅减少。

改完之后EXPLAIN显示type为ref,key为idx_user_create_time,key_len明显变长,type从ALL变成range或ref,Extra列对应的Using filesort消失。接口耗时从1.8秒降到30到40毫秒,效果立竿见影。

8.3 优化验证和效果数据

优化完成后,我做了三件验证工作:

  1. 压测验证:用同样的参数跑前后对比,P99耗时从1.8秒降到150毫秒以内,P50降到30到40毫秒。
  2. 慢查询观察:持续一周观察慢查询日志,该SQL再也没出现在慢查询Top列表里。
  3. 写入性能回检:确认新索引没有明显拖慢INSERT和UPDATE。表是读多写少的报表类业务,写入频率低,索引维护成本可接受。

这个案例很好地印证了文章的所有观点:索引不是建了就完事,字段顺序、索引结构、查询写法三者必须匹配。优化SQL或索引之前,先EXPLAIN看清楚当前执行计划,再对症下药。

8.4 复盘:哪些做法可以平移到其他项目

这套排查方法可以平移到任何MySQL项目:

  • 慢SQL出现,先EXPLAIN,看type、key、rows、Extra四个关键列。
  • Extra列出现Using filesort或Using temporary,优先考虑调整索引。
  • 索引字段顺序根据查询的等值、范围、排序条件排列。
  • 每次索引变更都做前后对比验证,而不是改完就丢。

9. 最后说一句心里话

MySQL索引这个东西,表面上是几个数据结构和几条优化规则,但实际上特别考验对真实业务的理解。我从最开始"背八股文式地知道最左前缀、回表、覆盖索引这些名词",到后来能从一个慢查询倒推出一套索引设计,中间靠的不是看更多文章,而是反反复复被线上故障教会做人。

如果你现在刚接触索引,看完这篇文章先别急着背结论。拿一张真实业务表,导出慢查询日志,跑EXPLAIN,亲手调一次索引,体感会完全不一样。如果你已经有几年经验,那我更希望你能从"为什么"的层面重新审视每一条所谓的最佳实践——比如主键为什么推荐自增,范围查询为什么截断索引,filesort为什么慢。把这些底层逻辑想透了,以后遇到任何性能问题,你都有一根清晰的思考主线。

最后再分享一个排查时特别好用的小技巧:把每条慢SQL的EXPLAIN结果保存下来,在索引调整前后对比着看。不要只看耗时变没变,要看执行计划的形状变没变。这个对比习惯,能帮你在复杂的索引场景里快速建立自己的排查直觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询