☰
SQL优化实战:读懂执行计划与索引原理,根治慢查询
2026/10/21 16:03:26 网站建设 项目流程

从事数据库开发和运维十来年,我见过太多的性能事故:半夜一个报表把整个实例拖死,线上接口突然从 50ms 涨到 5s,加了一堆硬件发现毫无改善。冷静下来一查,绝大多数时候问题都出在 SQL 语句本身,而不是数据库参数、服务器配置或者中间件。这大概是这一行最反直觉的地方——你花大价钱加的 CPU、内存、SSD,可能被一条写法糟糕的 SQL 轻松抵消。所以这个标题我特别想展开聊:SQL 优化不是背一堆口诀,而是要学会读执行计划、懂成本模型、理解索引原理,再用这些知识去精准改写查询。这篇文章我会从原理、实际拆解和面试应对三个维度来写,内容尽量贴近我平时排查问题和指导新人的真实思路,希望对正在做应用开发、DBA、或者准备面试的同学都有帮助。

1. 慢SQL排查的真相:性能瓶颈往往在查询本身

1.1 一个典型场景:加机器解决不了的问题

之前在某电商业务的后台系统遇到过一个问题:一张积累了上亿条数据的订单明细表,每天凌晨跑一次对账报表,SQL 执行时间从最初的几分钟恶化到接近一小时,最后直接超时失败。当时团队的第一反应是扩容,把数据库实例从 4 核升到 16 核,存储从 SATA 换到 NVMe SSD,结果执行时间只快了一点点,依然无法在业务要求的窗口内跑完。

后来我用 EXPLAIN 看了那条 SQL,发现核心问题非常朴素——那条对账查询在联表时,驱动表选错了,导致内部循环执行了上亿次小查询;再加上其中一个关联字段的类型不一致,索引完全失效,每一次关联都在做全表扫描级别的隐式转换。这些问题单靠硬件根本看不出来,也修不了。加机器只能掩盖症状,稍微再涨一点数据量就会原形毕露。

我讲这个故事是想强调一个观点:在数据库性能优化这个领域,“堆硬件”是最不值得优先尝试的路线。SQL 优化的第一性原理,是让数据库以最小的代价获取需要的数据,这个代价主要就是磁盘 I/O、内存读写和 CPU 计算。你写的每一行 SQL,本质上是在告诉优化器你想要什么;但你用什么样的句式、什么样的关联条件、什么样的过滤顺序,会极大影响优化器最终选择的执行路径。很多性能瓶颈的“锅”,数据库不背,得从 SQL 本身找。

1.2 成本模型:优化器到底在算什么账

要说清楚为什么 SQL 写法会影响性能,得先建立一个最小化的成本概念。绝大多数数据库(MySQL、PostgreSQL、Oracle 等)在生成执行计划时,都会估算各个候选方案的“成本”,然后选成本最小的那一条。成本主要由几个部分组成:

  • 从磁盘读取数据页的 I/O 成本,这是大头;
  • 遍历行数据并检查过滤条件的 CPU 成本;
  • 排序、分组、聚合这类内存/临时文件操作的成本;
  • 网络传输成本,虽然占比往往较小,但 SELECT 大量列时也会放大。

举个例子,一条最简单的SELECT * FROM orders WHERE status = 'PAID'。假如 orders 表有 1000 万行,其中满足条件的行只有 10 万行,优化器面前有两条路:一条是全表扫描,从头到尾读聚簇索引的每一个数据页,然后逐行过滤;另一条是在 status 字段上有二级索引,先扫索引找到 10 万行对应的主键,再通过主键回表读取完整行数据。

数据库怎么选?它看得看 status 字段的“选择性”,也就是满足条件行数占总行数的比例。如果只有 1%,走索引基本上稳赚;如果 status 的分布不均匀,90% 的行都是 'PAID',那优化器很可能会放弃索引,直接全表扫描,因为回表次数实在太多了。这就是为什么有时候你确认字段上有索引,却发现执行计划里根本没用到——不是索引坏了,是优化器算了笔账,认为走索引不划算。

理解这层之后,SQL 优化的方向就清晰了:尽量让数据库的估算结果接近真实情况,并且尽量构造出“索引比全表便宜”的条件。具体来说就是:过滤条件要足够的筛选性、关联字段类型要一致、统计信息要新、查询要尽量只取需要的行和列。

1.3 SQL 优化的三大黄金目标

我一般把 SQL 优化的目标压缩成三句话,排查问题的时候反复对照,很少跑偏:

  • 减少扫描的数据量:能走索引就不全表扫,能用覆盖索引就不回表,能用 LIMIT 就尽早用。数据量下降一个数量级,执行时间往往也下降一个数量级。
  • 减少计算量:避免在索引列上做函数运算、隐式类型转换,避免 SELECT * 把不需要的大字段(TEXT、BLOB)拉到内存;对那些只需要两个列的计算,别让数据库去拖 30 个列。
  • 减少不必要的排序、临时表和锁等待:ORDER BY、GROUP BY、DISTINCT、窗口函数都可能在临时表或排序文件中运行。能利用索引天然有序性让数据库省去排序,就不要让它额外排序。

这三个目标不是孤立的,它们之间经常互相影响。比如 SELECT 不需要的大字段,会导致本来可以“索引覆盖”的计划被迫回表;回表一多,排序和临时表的数据量也跟着上来。所以做 SQL 优化,不是只看某一条 index 有没有命中,而是整体看这条 SQL 的完整执行链路,从扫描、关联、过滤到聚合排序,每一步都问一句:这一步能不能再省一点?

2. 执行计划是手术刀:EXPLAIN 不会读,优化无从谈起

2.1 执行计划中最值得盯的四类信息

我见过不少开发者的“EXPLAIN 阅读”,是看一眼有没有出现ALL(全表扫描),有就回来问“怎么让它走索引”。这远远不够。EXPLAIN 真正的价值是让你理解优化器每一步在干什么、处理了多少行、用了什么策略。我习惯重点看四类信息:

第一是type / access method,表达的是每一步的访问方式。常见类型从好到坏大致是:system > const > eq_ref > ref > range > index > ALL。ALL是全表扫描,index是扫描整个二级索引树(一般也比全表扫好一点,但如果是大索引依然有压力),range走的是范围扫描,ref是等值匹配返回多行。看到ALL和index,基本就可以断定这一步有优化空间。

第二是rows,也就是优化器预估这一步会读取的行数。这个数字和真实情况差距很大时要特别小心,通常说明统计信息过期或者过滤条件写法让优化器无法准确估算。我调 SQL 时有个惯性动作:先看每一行执行步骤的 rows,把“预估行数”贴着“实际目标数据量”去对比,如果预估明显离谱,再往下看统计信息和条件表达式。

第三是key / possible_keys和Extra。possible_keys 是候选索引,key 是最终选用的索引。Extra 是宝藏区,里面会出现Using where、Using index、Using filesort、Using temporary、Using join buffer这些关键词。Using filesort说明这次执行计划里带了额外的排序,Using temporary则说明用了临时表,这两个都值得警惕;反过来,Using index是好事,表示这一步只扫索引就拿到了所有需要的数据。

第四是关联顺序。多表 JOIN 时看最上面的表是驱动表,下面的表是被驱动表。驱动表的行数 × 对被驱动表每次查找的成本,基本决定了整个 JOIN 的消耗。被驱动表的关联字段如果没有索引,每一次匹配都是全表扫描,整个连接的成本就是驱动表行数乘以全表扫描的成本,这种“笛卡尔爆炸”的组合特别容易出现。

我通常用下面这个表给团队做快速自查:

关注点关键字段/内容危险信号常见解决方案
访问方式typeALL、index 出现在大表上优化过滤条件,检查统计信息
预估行数rows比其他步骤大几个数量级看看这条选择的过滤条件是否有索引可用
索引选用key该命中没命中;或用错索引检查索引列是否被函数包裹、类型是否一致
排序与临时表ExtraUsing filesort、Using temporary让排序字段走索引,减少临时表数据量
连接策略连接顺序驱动表过大、被驱动表无索引调整关联条件,用小的结果集做驱动

2.2 三种典型的高成本操作

执行计划里我见得最多的“高成本操作”有三类,每类都有比较典型的 SQL 特征。

第一类是嵌套循环连接(Nested Loop Join)放大。很多数据库在没有更好的连接策略时,会采用嵌套循环:先取驱动表的每一行,再去被驱动表里找匹配的行。这本身没什么问题,问题通常出在“被驱动表关联字段没有索引入口”。如果驱动表只有 1 万行,被驱动表有 1000 万行且关联字段无索引,那计划就会执行 1 万次全表扫描,一亿次级别的比较就出来了。我看到这种执行计划,脑子里第一反应是:要么在关联字段上补索引,要么想办法先缩减驱动表的行数,务必让破局点出现在第一层。

第二类是排序和分组导致的临时文件。而且这类问题特别隐蔽,经常和索引分布有关。比如SELECT region, SUM(amount) FROM orders GROUP BY region ORDER BY SUM(amount) DESC,如果 orders 表没有复合索引能同时覆盖 region 和 amount,优化器一般会先把数据按 region 分组聚合,再对聚合结果排序。数据量大时,这个中间结果会写进磁盘临时文件,性能比内存排序慢几个数量级。解决方向通常有两个:一是设计覆盖索引让分组排序走索引;二是如果业务可以接受近似统计,考虑减少数据范围或预聚合表。

第三类是SELECT 大量列 + 回表叠加。执行计划里明明走了二级索引,但 Select 列表里存在索引没有覆盖的列,数据库只能每命中一行就回一次聚簇索引。如果命中的行数有几百万,那“走索引”这件事本身也已经很痛苦了。所以我在设计索引时会刻意考虑“查询列是不是也能放进索引里”,尽量减少回表次数。

2.3 优化器为什么可能选错计划:统计信息与计划缓存

一个经常让开发者困惑的问题是:索引明明存在,可 EXPLAIN 显示没走,或者走了但表现更差。答案大多藏在一个词里——统计信息。

数据库的优化器不是神,它做决策依赖的是表的行数、数据分布、索引基数这些统计信息。如果表的统计信息没有及时更新,优化器就会按旧的数据分布做估算,可能把一个 1% 选择性的等值查询错估成 70%,从而放弃索引选择全表扫描。这在大批量写入、频繁删除、数据增长剧烈的表上尤其常见。我在实际维护中会设置周期性的统计信息更新任务,尤其关注那些单次批量写入超过全表 10% 的表,写完必刷新统计信息。

另一个容易被忽略的是“参数化与计划缓存”对优化器的影响。部分数据库会把通用查询缓存成执行计划,如果同一个查询第一次执行时的参数分布比较极端(比如第一次传的 dept_id 匹配 90% 行),它缓存下来的执行计划可能对后续更多数场景不友好。这种情况虽然不常见,但一旦遇到,就需要考虑查询级别的「重新编译」,或者把查询拆成几种参数模式分别优化。

3. 索引:最大的收益和最隐蔽的失效陷阱

3.1 复合索引的字段顺序:最左前缀原则

索引是 SQL 优化最重要的杠杆,但多数人只是知道“查询慢就加索引”,并不清楚索引该怎么设计。这里最核心的概念是复合索引和最左前缀原则。

一条查询SELECT * FROM orders WHERE status = 'PAID' AND created_at >= '2024-01-01' ORDER BY created_at,如果我建一个(status, created_at)复合索引,查询时优化器可以先用 status 的等值条件精确定位到一小段索引范围,再在索引内部按 created_at 做范围扫描,而且排序也能直接从索引中获得,连额外排序都省了。如果调整成(created_at, status),因为 created_at 是范围条件,它后面的 status 列在索引匹配时基本“用不上”等值定位,可能变成冗余。

所以设计复合索引时我的排序习惯是:等值条件列放前面,范围条件列放中间,ORDER BY 的列放最后或考虑让其天然有序。为什么要这样排?本质上是在索引二叉树上尽可能先利用高选择性的等值条件缩小范围,再处理范围条件。范围条件会消耗掉后续列的有序性,这是一个比很多口诀都更深一层的理解。

3.2 索引失效的四大套路

设计好索引只是第一步,更重要的是别让写出的 SQL 把索引“废掉”。以下四种套路我几乎每周都能在代码 review 里见到:

  • 在索引列上做函数运算:WHERE DATE(created_at) = '2024-01-01',即使 created_at 上有索引,一旦套了 DATE 函数,优化器就无法直接使用索引树的有序性,只能全表扫描后逐个计算。正确写法是WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'。
  • 隐式类型转换:最常见的是 varchar 字段和数字比较。WHERE user_id = 1234567890123,但 user_id 定义成了 varchar,数据库会把每个 varchar 值转换成数字再比较,索引同时失效。解决方法是让参数类型与字段类型严格一致,不要在 SQL 里依赖数据库的隐式转换。
  • 前导模糊匹配:LIKE '%关键字%'无法利用 B+ 树索引的前缀匹配能力,必然全表扫描。如果业务真的需要中间匹配,要么考虑全文索引,要么考虑前缀索引或其他检索系统。
  • OR 拼接导致 Index Merge 失效:WHERE a = 1 OR b = 2,即使 a 和 b 分别有索引,也只有部分数据库能做 index merge;更多情况下优化器会退化为全表扫描。可以考虑改造为UNION ALL分支查询,让每个分支各自走索引。

3.3 覆盖索引与回表成本

覆盖索引是相对进阶但收益极高的优化策略。它的意思很简单:查询所需的全部列,都包含在某个索引的叶子节点里,这样数据库扫描完索引就能得到结果,完全不用回表。

举一个真实场景:某个用户维度的权限表,核心是按 user_id 查 role_id 和 tenant_id。建一个(user_id, role_id, tenant_id)复合索引后,查询SELECT role_id, tenant_id FROM user_roles WHERE user_id = ?可以走“覆盖索引扫描”,连聚簇索引的数据页都不碰。只要访问路径集中在二级索引树上,I/O 和响应时间都会显著下降。

我在设计这类索引时也会警惕过度优化:覆盖索引不是把所有列都塞进去,而是要针对高频查询列做平衡。索引本身也要占用存储,并且每次写入都要更新,塞进太多宽字段(比如 200 字符的备注列)反而会让索引膨胀、写入变慢。一般建议覆盖索引用在核心高频路径上,低频、宽表的查询不要硬凑。

3.4 不是索引越多越好

很多系统是“慢就加索引”,最后索引几十个,查询的速度没见多好,写入速度先掉下来了。这里面有几笔账容易被人忽略:

  • 每次 INSERT/UPDATE/DELETE 都要同步维护该表上的每一个二级索引。索引越多,写入放大越严重,锁竞争和 I/O 压力也随之上升。
  • 索引占用的存储不是免费的,二级索引的叶子节点和聚簇索引一样要落盘,大量冗余索引会抬高备份和扩容成本。
  • 优化器面对可选索引太多的情况,可能选错索引,反而更慢。候选索引每个都要评估,优化器本身的计算开销也在涨。

我一般建议同事用“实用主义”思路建索引:先看慢查询日志和真实业务的高频查询,挑出前十个核心查询,为它们设计少量精良的复合索引,让一个索引尽量服务多条相似查询;而不是看到一个 WHERE 条件就加一个单列索引。定期用工具检查重复索引(两棵索引树具有相同前缀列)并清理掉冗余项,是很有价值的日常工作。

4. 那些踩过的SQL反模式:改造前后对比

4.1 SELECT * 的连锁反应

我理解很多人写 SELECT * 是因为懒,或者怕漏字段。但在生产环境,SELECT * 的代价往往比想象中大得多。

  • 它会把不需要的大字段(比如 TEXT 类型描述、JSON 配置)也拉到内存和网络中,导致 I/O 和网络带宽被无谓消耗。
  • 它会破坏覆盖索引的效果。本来一个索引就能覆盖查询,SELECT * 因为你还要所有列,数据库只能回表取数。
  • 在分库分表或微服务拆分的场景,SELECT * 还会加剧上下游接口的耦合,表结构一变,你可能被迫返回更多字段。

优化实践很简单:列出明确字段,至少在 OLTP 核心链路上做到按需查询。比如订单列表只需要订单号、状态、创建时间,就不要把整个大订单记录拖出来。

4.2 COUNT、IN 与 EXISTS 的选择误区

很多人喜欢用NOT IN来排除子查询中的结果,但当一个子查询可能返回 NULL 时,NOT IN 的行为会非常反直觉——如果子查询结果里有 NULL,整个条件会变成 UNKNOWN,查询返回空结果。这也是面试题里经常埋的坑。稳妥一些的做法是使用NOT EXISTS,同时保证关联字段有索引。

反过来,如果 IN 后面的子查询结果集很大,有些数据库优化器会把IN转换成 semi-join,这种转换本身很高效,不会出现“一行一查”的极端情况。但前提是子查询有索引可走、过滤条件能提前缩小结果集。此时的坑更多出在“先写一个海量子查询,再在外面包层 WHERE”这种写法上,数据库优化器如果不够智能,可能没法把外层条件下推到子查询里面。

我在这个点上的实操建议是:先看执行计划再决定写法。不要人云亦云“IN 一定慢、EXISTS 一定快”,很多情况下优化器会把它们改写成等价执行计划,真正的差别来自统计信息和索引。多花十分钟做个实验,比背一百条经验可靠。

4.3 OR 拼接与索引合并的陷阱

之前遇到过一条慢 SQL,长这样:

SELECT * FROM activity_log WHERE user_id = 123 OR device_id = 'XYZ-123'

单独看 user_id 和 device_id 都有索引,但 MySQL 的旧版本优化器面对这种情况并没有很好的 index merge 策略,执行计划直接就全表扫描了。改造后:

SELECT * FROM activity_log WHERE user_id = 123 UNION ALL SELECT * FROM activity_log WHERE device_id = 'XYZ-123' AND user_id <> 123

两个分支各自走索引,再把结果合并。这条 SQL 上线后耗时从 3 秒降到几十毫秒。但要注意,UNION ALL 之后的结果可能包含重复行,所以要根据业务语义决定是否需要用 UNION 去重(成本会高一些)。这类“拆分支”的写法适合 OR 两边的条件选择性和结果集差异很大时使用;如果两个条件重合度高,或者表的整体数据量不大,改写收益不明显。

4.4 深分页与数据倾斜

分页查询是另一个高频事故点。LIMIT 1000000, 20这句 SQL 看起来简单,实际上数据库要从头数到第 100 万条,再丢掉前面的 100 万条,最后只返回 20 条。随着页码增大,查询会越来越慢。

一种改造思路是“延迟关联”:先只查主键做分页,再用主键回表拿完整行。比如:

SELECT t.* FROM orders t JOIN (SELECT id FROM orders WHERE status = 'PAID' ORDER BY created_at DESC LIMIT 1000000, 20) tmp ON t.id = tmp.id

第二种思路是“游标分页”:记住上一页最后一条记录的排序字段值,下一页直接用WHERE created_at < ? ORDER BY created_at DESC LIMIT 20,对用户来说体验几乎一样,但对数据库来说每一页都只扫描 20 行,稳定且可预测。这也是我推荐给多数业务系统的做法,尤其适合移动端信息流那种无限滑动的场景。

数据倾斜是另一个容易爆雷的点。比如按某个城市分组统计订单量,结果那个城市的订单量是其他城市的上百倍,分组聚合这个组的数据会让整个查询在单线程级别的聚合或排序阶段长时间卡住。遇到这种场景,我会先看业务能不能接受对超大户做特殊路径处理:对热点分组单独查询,其他分组走正常聚合;或者采用分层聚合、异步预计算等方案,避免让数据库在实时路径硬扛。

5. 面试中的SQL优化:如何回答得既有深度又不啰嗦

5.1 面试官问“你如何优化 SQL”时,真正想听什么

这个问题的考察点往往不是“你有没有背过索引优化十条”,而是你面对一个线上性能问题时,是否有完整的排查思路。面试官想听的是你的“方法论”:先看什么、再看什么、怎么确认根因、怎么验证改进效果。

所以我不建议把答案组织成“加索引、避免 select *、避免 in”这样碎片式的清单。一个更好的回答结构是:讲一个你自己处理过的真实慢查询案例,按排查链路一步步展开,顺便在一个步骤里带出索引、执行计划、统计信息等关键词。

5.2 一个标准答题框架:从慢查询到索引调整

假设面试官给了你一条查询:

SELECT * FROM user_order_detail WHERE user_id = ? AND order_date BETWEEN ? AND ? ORDER BY amount DESC

一个漂亮的回答走法可以是这样:

  1. 先说“我会先用 EXPLAIN 看执行计划”,确认是 type=ALL 全表扫描,还是走了某个索引但仍有回表。
  2. 把目光聚焦到 WHERE 的等值条件 user_id 和范围条件 order_date 上,分析索引候选应该是(user_id, order_date)还是(user_id, order_date, amount)。这里可以提到最左前缀:等值条件放前面,范围条件放后面。
  3. 看查询列,发现 SELECT *,所以就算有复合索引也要大量回表。如果业务允许,把 SELECT * 改成明确字段,让复合索引变成覆盖索引。
  4. ORDER BY amount 如果跟索引字段顺序不匹配,可能引发 Using filesort;适当调整索引列顺序,让索引天然有序。
  5. 最后解释如何验证:慢查询日志确认耗时下降,用 EXPLAIN 对比行数和 Extra 的变化。

这种回答的优点是全程有“为什么”:为什么这样排索引、为什么回表多、为什么 filesort 有额外成本。面试官会觉得你是真做过优化,而不是只会背八股。

5.3 加分项:主动说出容易出现二次问题的细节

在面试回答里,能够主动补充下面这类“进阶细节”会明显加分:

  • 索引设计不是一劳永逸,数据量增长或统计信息老化后,执行计划可能漂移,要定期刷新统计信息。
  • 加了索引后写入性能会下降,需要权衡高频写入和低频查询的关系。
  • 复合索引要警惕范围条件 “吃掉”后续列的有序性。
  • 遇到极端场景不要迷信“单条 SQL 搞定一切”,必要时可以拆 SQL、用汇总表、改推荐路径。

有一次我在面试聊到一个分页极限问题,候选人脱口而出“这种情况应该改业务设计,不一定要在数据库层面硬扛”。我当时就挺认可这个判断。SQL 优化不只是写一条漂亮的 SQL,也是一种全局的系统观:知道哪些问题应该在索引层面解决,哪些问题应该换一种查询方式,哪些问题底层设计就不该这么查。能把这一点自然表达出来,比单纯罗列优化技巧更能让人记住你。

如果再让我聊几点实战经验,我特别想提的是“优化要建立在精确测量之上”。不要靠感觉说“这条 SQL 慢了”,先打开慢查询日志,把用户等待时间、CPU 时间、锁等待时间逐项拆开;不要靠猜说“这个字段该建索引”,先确认选择性和执行计划。另外,在一个大批量数据环境里,任何写操作(尤其是建索引)都尽量安排在业务低峰期,而且要小步验证,避免把一条从 1 秒优化到 50ms 的 SQL,变成另一个新的性能事故。最后送你一个我用了很久的小技巧:每次 SQL 上线前,把它的 EXPLAIN 结果存进一个文档,后面再出性能问题,回头对比执行计划的变化,定位速度能快很多倍。希望你下次遇到慢 SQL,第一反应不是加配置,而是打开执行计划,顺着成本和行数一路看下去。

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

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

立即咨询