☰
SQL优化别只盯着索引?从执行计划到表结构设计全解读
2026/10/11 20:43:47 网站建设 项目流程

说实话,干了这么多年的数据库开发与性能排查,我见过太多“加索引万能论”的场面。慢SQL一出现,第一步永远是“加个索引试试”,不行就“强制索引”,再不行就说“索引没生效”。每次看到这种操作,我都想说一句:SQL优化如果只会加索引,那你离真正的优化还差得远。

这条标题肯定戳中了不少人的心。索引确实是SQL优化里最常用、见效最快的手段之一,但它从来不是银弹。一个查询慢,根因可能藏在执行计划里、SQL写法里、表结构设计里,甚至藏在优化器自己的“错误判断”里。这些场景下,加索引不仅没用,有时候还会帮倒忙。这篇内容我打算结合实际排查经验,把SQL优化的完整思路拆开讲一遍:怎么看执行计划、怎么改SQL、怎么做表结构层面的治本优化,最后再复盘一个真实案例,把从“慢查询”到“快查询”的全过程走一遍。适合后端开发、DBA、数据开发和对数据库性能有要求的同学参考,你应该能拿走不少能直接落地的思路。

1. 只会加索引,说明你还没看懂SQL优化的全貌

1.1 为什么“加索引”成了默认答案

索引这个概念太深入人心了。几乎所有SQL优化的入门文章都在讲索引,什么主键索引、普通索引、联合索引、覆盖索引,好像只要把索引加到位,性能问题就都解决了。再加上很多同学第一次接触优化时,遇到的第一个案例就是“全表扫描加索引后秒变走索引”,这种正向反馈一旦建立,就很容形成路径依赖。

这种路径依赖的结果,就是见到慢SQL不问缘由,先看一眼有没有索引,没有就加,有就删了重建。有一次我帮某开发同学排查一条三秒多的查询,他已经在关键字段上加了索引,但执行计划显示还是在走全表扫描。当时他的第一反应是“索引是不是失效了”,于是打算把索引删了重新建。实际上问题根本不在这里,后面我会讲这个案例的具体原因。

还有一个让我比较无语的现象:不少人对“索引会拖慢写入”没有概念。在业务读多写少的场景下,加几个索引问题不大,但在高频写入的流水表上,每加一个索引就意味着每一次INSERT、UPDATE都要多维护一份索引数据。索引不是免费的,它占用存储空间,消耗写入性能,还会让优化器在做执行计划时多一层选择成本。如果只靠加索引去解决所有问题,迟早会给自己挖坑。

1.2 慢SQL的根因远不止“没走索引”

一条SQL跑得慢,本质上只有两种可能:一种是它需要处理的数据量太大,另一种是它处理数据的方式效率太低。索引能解决的是后者的部分问题,但远不是全部。

我总结了一下实际项目里常见的慢查询根因,大概有这么几类:

  • 执行计划本身选错了路径,比如两张大表做JOIN,优化器选错了驱动表,导致扫描行数爆炸。
  • SQL写法有问题,比如在WHERE条件中对列做了函数计算,导致索引无法用于范围匹配;又比如用隐式类型转换,让字段索引被“无视”掉。
  • 表结构设计不合理,业务上高频查询需要的数据散落在很多张表里,每次都要做复杂的关联和聚合。
  • 统计信息不准确,优化器拿着错误的数据做判断,明明有更优路径却看不到,选了全表扫描。
  • 数据倾斜或锁等待,这种其实已经不是SQL本身的问题了,但表面上看就是一条SQL卡住不动。

这五类原因里,只有第一类和索引关系比较大,第二类和索引沾边但需要用对方式,后三类根本不是“加索引”能解决的,得靠改写SQL、调整表结构、维护统计信息,甚至改业务逻辑来处理。

1.3 什么时候加索引才是正确的动作

我不反对加索引,我反对的是不加思考地加索引。什么样的情况下加索引是合理甚至必须的动作?我一般会按这个顺序判断:

确认这条SQL的WHERE、JOIN、ORDER BY、GROUP BY涉及的高选择性字段确实缺少合适的索引。高选择性指的是字段的重复率低,比如用户ID、订单号、手机号,这种字段建索引的意义最大。

执行计划里表现出明显的类型退化,比如type是ALL(全表扫描),而对应字段上又有索引时,优先排查为什么没用上,再决定要不要新建索引。

索引能带来数量级提升而不是百分之二三十的“毛毛雨”。如果一个索引只让查询快了20%,但对写入的影响很大,那我会非常谨慎地考虑是否值得。

结论很简单:索引是SQL优化工具箱里的一把好工具,但你不能每次不管问题是什么都掏出这把扳手。真正的优化思路,是先搞清楚SQL为什么慢,再选择用什么手段去解决。

2. 第一步不是优化SQL,而是看懂执行计划

2.1 EXPLAIN关键字段怎么读

我见过不少同学对执行计划的态度是“看一眼type是不是ALL就完了”,这是远远不够的。EXPLAIN输出里每一个字段都有它的意义,但最关键的是下面这几个。

type字段,显示的是访问类型,从好到差大致是:system、const、eq_ref、ref、range、index、ALL。如果一张表几百万行数据,type是ALL,那大概率是要命的。range意味着只扫描一个范围的索引,这种通常还可以接受;index表示全索引扫描,虽然没用全表,但也好不到哪去;const和eq_ref是走主键或唯一索引查找时的高效状态,这种通常很快。

key字段是实际用到的索引,这个要重点看。还有一个容易忽略的字段是rows,它表示优化器预估要扫描的行数。很多经验不足的同学只看type,不看rows,其实这两个必须放在一起看。比如同样都是range,一个是预估扫描几百行,一个是预估扫描几百万行,这差别太大了。

Extra字段也含金量十足。它会出现很多信息,其中我最关心的就是Using filesort(文件排序)、Using temporary(用临时表)和Using index(覆盖索引扫)。Using filesort和Using temporary都是性能隐患,说明执行过程中MySQL可能要把结果集放在内存或磁盘里做排序、去重、分组,一旦数据量大,就会瞬间把内存打满,甚至落到磁盘上,性能就会断崖式下跌。

2.2 用一个字段快速判断“纸面计划”是否靠谱

MySQL 8.0之后,EXPLAIN ANALYZE是一个非常逆天的工具,它会真实地执行SQL,然后输出每一步的实际执行时间、实际扫描行数和实际返回行数。这意味着我们不再需要靠估算和猜,直接看真实数据即可。

我给大家演示一个怎么看的过程,假设我们有这样一条SQL:

EXPLAIN ANALYZE SELECT o.order_id, u.user_name, o.amount FROM tb_order o JOIN tb_user u ON o.user_id = u.id WHERE o.status = 1 ORDER BY o.created_time DESC LIMIT 100;

部分输出会像这样(版本不同输出格式略有差异):

-> Limit: 100 row(s) (cost=... rows=...) -> Sort: o.created_time DESC (actual time=... rows=100 loops=1) -> Nested loop inner join (...) -> Filter: (o.status = 1) (cost=... rows=...) -> Table scan on o (actual time=... rows=...) -> Single-row index lookup on u using PRIMARY KEY (id=o.user_id)

从这个输出就能直接看出来:MySQL是先全表扫了tb_order,过滤status=1,然后再拿每一行去tb_user表做主键回表查询,最后对结果做了排序。如果这里全表扫描的行数是几百万,那这个排序步骤的耗时就不可能低。

看完EXPLAIN ANALYZE的输出,其实我们心里就有底了:如果全表扫描了百万行,但最终只返回100行,那问题出在第一步——扫描范围太大。这个时候你就知道该把工作重心放在“减少第一步扫描的数据量”上,而不是去纠结别的。

2.3 执行计划里的“看得见”和“看不见”

执行计划像一个故事,但这个故事有些部分是真实的,有些部分是讲故事的人(优化器)自己脑补的。优化器选择执行路径时依赖的是统计信息,如果统计信息不更新,它脑补出来的数据可能和真实情况相差很远。

我遇到过一个典型案例:某订单表里有几百万行历史数据,但最近三个月的数据其实只有几十万行。由于统计信息一直没有更新,优化器认为全表扫描的代价比走索引小,于是每次查询都在做全表扫描。这个场景下就算你把索引加到完美也白搭,关键动作是执行ANALYZE TABLE命令让优化器“看见”真实的数据分布,或者考虑用分区表来缓解统计信息偏差的问题。

所以读执行计划时,心里要有个数:预估行数是“信一半”的状态,真正动手优化前,最好确认一下统计信息是否新鲜。

3. 不靠加索引也能大幅提速的SQL改写技巧

3.1 EXISTS与IN的取舍,别被老黄历忽悠了

很多教程说“子查询用EXISTS更快,因为只关心是否存在”,还有一句口诀“小表驱动大表”。这话在老版本MySQL里确实有道理,但在8.0版本里,优化器已经具备自动子查询重写的能力,很多情况下EXISTS和IN会被转换成同一种执行计划。

但有一点仍然很重要:当关联字段上没有索引时,EXISTS通常会比IN更稳。原因在于EXISTS是“只要找到一条满足条件的行就立刻停止”,而IN往往需要把子查询的结果集完全物化后,再去和外层表做匹配。如果子查询产生的中间结果集特别大,IN就可能占用大量临时表空间。

举个例子,我们要查“已下单的用户列表”,一种写法是:

-- IN写法 SELECT * FROM tb_user WHERE user_id IN ( SELECT DISTINCT user_id FROM tb_order WHERE status = 1 );

另一种是:

-- EXISTS写法 SELECT * FROM tb_user u WHERE EXISTS ( SELECT 1 FROM tb_order o WHERE o.user_id = u.user_id AND o.status = 1 );

如果tb_order表的数据量很大、user_id选择性又不高,EXISTS走的是“逐行去子查询里判断”,只要能找到一条命中的记录就返回,逻辑上更省;而IN会先把所有满足status=1的user_id查出来,做一次去重排序,再与tb_user匹配。当中间结果集膨胀到几十万行时,IN的性能会明显劣化。

当然现在优化器很聪明,不一定每次都按字面逻辑执行,但在我实际测试下来,EXISTS在某些数据分布下确实能稳定地避免中间大集合的物化问题。我的建议是:不要迷信优化器,线上出现组合场景时,两边都写一下,用EXPLAIN去看实际计划,别用耳朵去听传说。

3.2 UNION与UNION ALL之间的“性能陷阱”

UNION会对结果集做去重,而去重的代价是排序或哈希。两段各返回一万行的小查询,UNION也许无感,但一旦变成各返回百万行,去重操作会带来巨大的临时表开销。而大部分业务场景根本不在意结果集是否完全去重,或者说业务本身已经保证了两个集合之间没有重叠。

因此,能用UNION ALL就不要用UNION是一条底线经验。如果你确实需要去重,那么考虑一下去重逻辑是不是可以在业务侧用其他方式规避掉,比如加一个记录来源字段的过滤条件,从源头上消除重复。

3.3 WHERE条件里那些“隐形杀手”

这是我最想强调的一块,也是很多人加索引无效的根本原因。MySQL的索引本质上是一种有序的数据结构,它的高效依赖于“能够按索引的顺序去直接定位数据”。而一旦在索引列上做了函数运算、算术运算或者隐式类型转换,优化器就没办法再利用索引的原生顺序了。

常见的写法坑有这么几个:

对索引列使用函数,比如WHERE DATE(create_time) = '2024-01-01',这会导致create_time字段无法使用索引。应该改写成范围条件:WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'。

对索引列做隐式类型转换,比如字段是varchar类型手机号,过滤时写成WHERE mobile = 13800138000,MySQL会把字段类型转换成数字去比较,索引就失效了。应该写成WHERE mobile = '13800138000',让类型匹配。

对索引列做表达式运算,比如WHERE price * 0.8 > 100,这个写法没有任何办法能用到price上的索引。可以先计算出阈值:WHERE price > 100 / 0.8。

这些改写的核心思路是:把索引列独立在比较符号的一侧,让索引列本身不被任何函数或计算包裹。这条原则几乎能解决一半的“加了索引但没用上”问题。

3.4 LIMIT深分页的优化,靠索引不如靠“换思路”

分页也是重灾区。新手最常见的写法是:

SELECT * FROM tb_order ORDER BY id DESC LIMIT 100000, 20;

这条SQL在MySQL里真正的执行逻辑是:先按id从大到小扫描10万零20条,然后把前10万条丢掉,只返回最后的20条。也就是说,你越往后翻页,扫描的数据就越多,查询自然越来越慢。

优化方案之一就是给排序字段建索引,这当然有用,但只是治标。真正的根治思路是“键集分页”,也叫延迟分页或游标分页。核心做法是:把起始位置从“偏移量”改成“上一次获取到的最后一条记录的唯一键”。

-- 假设上一次翻页拿到的最大id是100000 SELECT * FROM tb_order WHERE id < 100000 ORDER BY id DESC LIMIT 20;

这个方法的好处是:无论你翻到第几页,SQL扫描的行数都只与LIMIT大小有关,而不是与偏移量有关。只要id是主键,走主键索引完成一次范围扫描,速度就非常稳。对于没有自增主键的表,可以用时间戳加唯一键的组合实现类似效果,但前提是业务允许有序游标分页。

3.5 JOIN优化,不只是加索引的事

JOIN慢除了关联字段没索引之外,还有一个被很多人忽略的因素:驱动表的选取。优化器一般会选择小表作为驱动表,但统计信息不准时可能选错。一旦选错,大表先行扫描,再把全量数据去和小表匹配,性能必然糟糕。

面对JOIN慢,我的检查顺序是:

  • 检查ON和WHERE条件中用到的关联字段是否有索引,且两端字段的字符集、排序规则是否一致。字段类型相同但字符集不同,也会导致索引失效。
  • 看EXPLAIN里的驱动表是不是小表,必要时可以用STRAIGHT_JOIN强制指定驱动顺序,但这招属于最后手段。
  • 考虑能否把JOIN改成子查询或拆成多次查询,在应用层做归并。有些场景下一条复杂JOIN拆成两条简单查询,性能反而更好,因为每条都能走独立的高效索引路径。

4. 治本:表结构设计与统计信息维护

4.1 反规范化设计,冗余字段并不可怕

很多人在设计表结构时把范式看得太重,什么都要第三范式,能拆就拆,结果业务查询时动不动就要关联三四张表。在OLTP系统里,这种过度规范化的设计往往是性能问题的根源。

举个例子,某订单查询页面需要展示订单的基本信息、用户名、所属渠道名。最“规范”的做法是在前端列表查询时JOIN用户表和渠道表。但如果这个页面是高频访问的,每次查询都去JOIN大表,优化空间完全被表结构锁死了。比较合理的做法是:在订单表里直接冗余user_name和channel_name两个字段。用户改名或渠道改名时,通过定时任务或异步消息更新订单冗余字段。

这样设计以后,列表查询从三表JOIN变成单表查询,性能会有数量级的提升。当然,冗余会带来数据一致性的成本,但绝大多数互联网业务对用户改名的生效时间有一定容忍度,最终一致就能满足需求。

4.2 统计信息过期,加索引也救不回来

统计信息这个点值得单独拿出来讲。MySQL优化器选择执行计划时有一个代价模型,它需要知道表有多少行、索引的区分度如何、字段值的分布情况。这些数据不是实时的,而是由定期采样维护的。当表数据发生剧烈变化但统计信息还没更新时,优化器就会拿着旧地图找新路,结果就是明明走索引更优,它偏选全表扫描。

我在实际项目中遇到过不止一次这样的情况:一个用了很久的SQL突然从0.1秒变成3秒,EXPLAIN一看,type=ALL,key是NULL。第一反应大概是“索引失效了”,但检查字段类型、字符集都没问题。最后发现是统计信息里面显示表只有几百行,而真实行数已经到几百万。执行一次ANALYZE TABLE tb_xxx之后,执行计划立刻恢复走索引,查询恢复到0.1秒。

所以当遇到“索引明明存在却不走索引”的情况,不要马上重造索引。先看看统计信息是否更新过,用SHOW TABLE STATUS看Rows字段与真实数据量对比,很多问题就能直接定位。

4.3 索引本身的“增删改查”

索引设计其实也是一门减法艺术。很多系统随着需求演进,字段和查询条件越来越多,索引也越加越多。到最后一张表十几二十个索引都是常态。但索引越多,写入越慢、存储越大、优化器选择越复杂。有些时候,两个联合索引存在重叠和冗余,完全可以合并成一个更通用的索引。

我建议做一次索引自查:

  • 对每个索引,确认是否有对应的高频SQL真正用到它,可以通过慢查询日志、performance_schema或者开启optimizer_trace去统计。
  • 检查是否存在前缀相同的冗余索引,比如已有idx_a_b(a, b),又建了idx_a(a),那idx_a基本是多余的。
  • 检查低区分度字段上的索引,比如is_deleted字段全是0,偶发几个1,这种索引价值非常低,却要白占写入开销。
  • 检查联合索引的字段顺序是否与查询模式匹配,最左前缀原则决定了一个联合索引能被哪些查询使用,字段顺序放错,索引的利用率会下降一大截。

索引的调整一定要基于真实查询频率,而不是“觉得未来可能用得上”。把未来可能用得上当作设计依据,是大量冗余索引的温床。

5. 完整案例复盘:一条慢SQL从分析到优化的全过程

5.1 现场现象

某订单管理系统上线半年后,运营同学反馈订单列表页打开要等三四秒。页面上的查询条件是:下单时间范围、订单状态、所属渠道,列表需要展示下单人姓名和订单金额,点击翻页到200页之后尤其卡顿。

初版SQL长这样:

SELECT o.id, o.order_no, o.amount, u.user_name FROM tb_order o JOIN tb_user u ON o.user_id = u.id WHERE o.status IN (1, 2, 3) AND o.channel_id = 1001 AND o.created_time >= '2024-01-01 00:00:00' AND o.created_time < '2024-10-01 00:00:00' ORDER BY o.created_time DESC LIMIT 1000, 20;

5.2 初步排查

先跑EXPLAIN,看到的信息让我眉头一皱:tb_order表type为range,key是idx_channel_status,rows预估只有两万,Extra出现了Using filesort,tb_user表倒是正常走了主键索引。表面上看,两万行也不算多,排序也不至于要三秒吧?但实际数据不是这样。

关键点在于:执行计划上的rows是优化器按统计信息估算出来的,而实际上channel_id=1001且status IN (1,2,3)这个组合在tb_order表里命中了60多万行。因为统计信息陈旧,优化器严重低估了这个范围的行数,然后还选择了先按channel_status这个联合索引取出60万行,再做排序、分页。60万行数据的filesort性能,就是这条慢SQL的直接来源。

另外还有一个隐蔽问题:LIMIT 1000, 20。就算排序不慢,MySQL也得把1000+20条全部取出来,丢掉前面1000条,翻页越深浪费越大。这两个问题叠加,查询自然就慢了。

5.3 优化动作

我没有急着给任何字段加索引,而是先把SQL拆开了看:

第一步,调整统计信息。先执行ANALYZE TABLE tb_order,让优化器拿到最新行数和区分度,这样EXPLAIN的结果才有参考价值。

第二步,改写深翻页。因为列表页是按created_time倒序展示的,而created_time本身又是一个范围条件,我决定采用游标分页,用上一页最后一条数据的created_time和id组合作为翻页边界:WHERE (o.created_time < :last_created_time OR (o.created_time = :last_created_time AND o.id < :last_id))。这是键集分页的通用写法,彻底干掉LIMIT大偏移量带来的浪费。

第三步,处理排序字段。当前排序是o.created_time DESC,而筛选条件是status IN (1,2,3)与channel_id=1001。要让排序不产生filesort,就得让联合索引的字段顺序与“等值条件在前、排序字段在后”匹配。一个相对合理的联合索引设计是idx_channel_status_created(channel_id, status, created_time)。很多同学会问:status是IN条件,能不能放在中间?这里有个细节:对于IN范围条件和已有索引,MySQL在8.0里支持一定程度的range优化,但最稳妥的方案是先过滤等值channel_id,再按status范围约束,created_time作为排序后缀。

5.4 优化效果与复盘感受

优化后再看EXPLAIN:tb_order表type降为range,key用上了新索引,rows从60万降到几百,Using filesort消失。实际查询时间从3.2秒降到0.05秒左右,翻到几百页也稳定在几十毫秒内。

这个案例里,最关键的不是某个单点动作,而是顺序。如果一开始就盲目加索引,很可能加了一个和已有idx_channel_status重合的索引,问题照样存在。真正的突破口有两个:一是用最新统计信息重新看待执行计划,二是用键集分页从算法层面消除偏移量浪费。这两个动作不需要任何一张物理新表,也不需要改动业务代码,却把性能提升了一个量级。

6. 常见问题速查表与我的踩坑经验

6.1 慢查询排查速查表

整理了一张我在日常工作中经常翻看的速查表,针对不同症状、可能原因、推荐动作做了对照,基本能覆盖80%的线上慢SQL排查场景。

现象可能原因推荐动作
明明有索引却不走索引统计信息过期执行ANALYZE TABLE刷新统计信息
明明有索引却不走索引索引列被函数包裹或隐式类型转换改写SQL,保证索引列独立
加了索引还是慢索引选择性低,命中大量数据考虑组合条件/联合索引,或换业务方案
深分页越来越慢LIMIT偏移量过大改用键集分页,以游标取代偏移量
JOIN查询极慢关联字段无索引或驱动表选错给关联字段加索引,检查字符集一致性,必要时强制小表驱动
ORDER BY有Using filesort索引字段顺序与排序不匹配调整联合索引设计,让排序走索引
数据量不大但查询很慢锁等待或大事务检查INNODB_TRX和processlist,定位锁源
查询偶尔快偶尔慢缓存失效后冷数据加载检查Buffer Pool配置和慢日志时间分布

这张表不是银弹,但能帮你把思路拉回正轨。绝大多数慢SQL问题不需要靠什么高深技巧,只要按“执行计划确认原因 → 选择对应手段 → 验证对比”的顺序走一遍。

6.2 几条只有踩过坑才能总结出来的经验

第一,优化SQL前先确认“你优化的是不是真正的瓶颈”。有一次某人让我帮忙看一条2秒的SQL,结果EXPLAIN显示扫描500行、无排序、无临时表,怎么看都该是毫秒级。后来才发现,问题不在SQL本身,而在业务代码里一个循环调用了100次这个SQL。优化SQL解决不了循环调用问题,要从架构层面处理。

第二,不要在生产环境直接执行ALTER TABLE加索引,尤其不要在大表上直接加。5.6之前的版本加索引会锁表,5.6之后虽然支持在线DDL,但复制延迟和磁盘IO的压力依然存在。我的习惯是:先在测试环境评估,再在业务低峰期执行,生产环境加完索引后观察一段时间的主从延迟。

第三,不要为了“看起来很专业”就过度优化。一张表只有几千行,就算全表扫描也就不到1毫秒,你花半小时去优化它,纯属浪费生命。优化SQL要分清主次,对高频访问的核心SQL,值得精益求精;对低频后台任务,保证正确性和可维护性比极致性能更重要。

第四,执行计划的“纸上谈兵”和真实执行还是有差别的,尤其在复杂SQL上。有条件的时候多用EXPLAIN ANALYZE去实测,它会告诉你实际扫描了多少行、实际耗时是多少,而不是让你对着预估值猜。真实数据往往能推翻你所有的假设。

第五,写SQL时尽量让“索引列独立”成为肌肉记忆。这个习惯一旦养成,很多索引失效的场景直接就不会发生。我见过的很多慢SQL,不是没加索引,而是从一开始写SQL的方式就没考虑过索引能不能用上。在写WHERE条件时多问自己一句:这里能不能直接走索引?养成这个反射,比掌握再多的优化技巧都值钱。

我个人实操下来的体会是:SQL优化从来不是某个单一技巧的堆砌,而是一套“用执行计划定位问题,用最小代价解决问题”的排查逻辑。索引是工具箱里的一把好用工具,但如果你手里只有锤子,眼中的一切问题就都变成了钉子。别再问“为什么加了索引还慢”了,先去看看执行计划里真正发生的那些事吧。

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

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

立即咨询