SQL 优化知识笔记:从索引、写法到排查路径
把面试里常被追问的 SQL 优化经验整理成一篇可反复翻看的复核型笔记,覆盖索引设计、SQL 写法、分页、排序、聚合与慢 SQL 排查,但不替代真实慢 SQL 的现场定位。
SQL 优化这件事,很容易在面试里被问成一个宽泛的问题:做过哪些 SQL 优化?如果只按“加索引、改写 SQL、看执行计划”这种关键词去回答,虽然不算错,但通常很难真正说明自己理解了什么。
我更想把这件事写成一份能反复回看的复盘笔记:把 SQL 优化里最常见、也最有复用价值的问题重新摊开,再按“为什么会慢、为什么这样改、具体怎么判断”的顺序整理出来。无论是复盘项目里的慢查询,还是准备面试,都可以顺着这个结构往回找。
这里先把边界说清:下面提到的索引、执行计划、Using filesort、Using temporary、回表等判断,主要都以 MySQL 的 InnoDB 引擎为例。不同数据库的优化器、执行计划字段和实现细节并不完全一样,所以更适合把它当成“以 MySQL 为主的一套经验框架”,而不是适用于所有数据库的统一定律。
先确认你要解决哪类 SQL 问题
| 你现在更需要什么 | 对应入口 | 为什么 |
|---|---|---|
| 想把索引、写法、分页、排序、N+1、Explain 放回一张复盘总图里 | 继续看本文 | 这正是本文的知识笔记角色 |
| 你正在处理一次真实慢 SQL,想沿线上排障顺序收敛 | MySQL 慢查询怎么定位:从执行计划到真实瓶颈 | 那篇适合按现场顺序把范围收窄 |
| 你主要卡在 Explain 字段不会读 | MySQL EXPLAIN 详解:type、rows、extra 到底怎么看 | 那篇更偏执行计划阅读 |
| 你已经确认问题在排序或深分页 | MySQL 里 order by 为什么会慢?一套分析思路讲清楚 或 MyBatis 分页为什么慢?深分页问题怎么优化 | 细分页会更快落地 |
这篇适合拿来做复盘总图
如果你想把索引、写法、分页、排序、N+1、Explain 放回一张总图里做复盘,这篇就很合适。
它不负责替你定位一次具体的慢 SQL 事故,也不替代 Explain、排序、分页这些细分页;它的价值在于把常见 SQL 优化动作重新收成一张总图,方便复盘、面试和查漏补缺。
一、先有个总判断:SQL 优化不是玄学,是在减少数据库做的无效工作
大多数慢 SQL,本质上都属于下面几种情况之一:
- 本来可以少扫描很多行,但没有利用到索引。
- 本来可以顺着已有顺序直接取数据,却额外做了排序或临时聚合。
- 本来只需要少量字段,却把整行甚至大字段都读出来了。
- 本来可以一次查完,却在业务层做成了多次往返。
- 本来该离线、缓存或汇总的计算,被放到了每次实时查询里。
所以优化 SQL 时,不要一上来就想着“加个索引看看”,而是问自己三个问题:
- 这条 SQL 真正慢在哪里,是扫描太多、排序太重,还是回表太多?
- 它慢的是单条执行时间,还是在高并发下放大了资源消耗?
- 这个问题应该在 SQL 层解决,还是应该退到缓存、汇总表、异步计算等更上层去解决?
后面的内容,基本都可以看成是在回答这三类问题。
二、索引优化:真正高频的 SQL,大多先死在索引没用对
索引不是“给字段都建上就行”,它更像一套有顺序的目录。数据库之所以能更快找到数据,不是因为索引神奇,而是因为索引让数据有序。
2.1 单列索引能解决一部分问题,但很多业务查询真正需要的是联合索引
最常见的业务查询并不是只按一个字段过滤,而是多个条件一起出现。
例如订单列表:
select id, order_no, status, amount, create_time
from orders
where user_id = 1001
and status = 'PAID'
order by create_time desc
limit 20;
如果只有单列索引:
idx_user_id(user_id)
idx_status(status)
idx_create_time(create_time)
数据库未必能把这三个索引优雅地拼起来。很多时候,它会选其中一个,然后剩下的过滤和排序继续做额外工作。
更合适的做法通常是建联合索引:
idx_user_status_ctime(user_id, status, create_time)
原因在于这条查询的真实意图其实是:
- 先定位某个用户的数据;
- 再缩小到某种状态;
- 最后按时间倒序拿最近一页。
联合索引把这三个动作串到了一棵有序结构里,数据库就不必先扫一大批数据,再额外做排序。
2.2 为什么联合索引要讲“最左前缀”
联合索引的顺序是有意义的。
索引 (user_id, status, create_time) 的底层含义,可以粗略理解成:
- 按
user_id排; - 同一个
user_id下再按status排; - 同一个
user_id + status下再按create_time排。
所以它最擅长的查询是:
where user_id = ?
where user_id = ? and status = ?
where user_id = ? and status = ? order by create_time desc
但如果你写:
where status = 'PAID'
它就不太能用好这个联合索引,因为你跳过了最左边的 user_id。数据库没法直接在这棵有序结构里快速缩小范围。
这也是为什么联合索引顺序不能随便拍脑袋。它不是“把常用字段都塞进去”,而是要让最常见查询路径顺着索引顺序走。
2.3 覆盖索引为什么常常有明显效果
看下面两个查询:
select id, order_no, status
from orders
where user_id = 1001
order by create_time desc
limit 20;
和:
select *
from orders
where user_id = 1001
order by create_time desc
limit 20;
如果索引里刚好能覆盖第一条查询所需的字段,那么数据库只查索引就够了,不需要再回到主键索引或数据页里取整行,这就叫覆盖索引。
而第二条 select * 基本一定会回表,因为你把所有字段都要了。
回表不是不能接受,但在大表和高并发下,少一次随机 IO 往往就能把延迟从几百毫秒拉到几十毫秒级。所以很多列表页、后台检索页优化时,往往会先把 select * 去掉,而不是先改业务。
三、SQL 写法优化:很多索引不是没建,而是被写法绕开了
项目里最常见的一类问题是:索引建了,但 SQL 还是慢。原因往往不是数据库“不聪明”,而是写法让索引没法发挥作用。
3.1 不要在索引列上做函数、计算或格式转换
例如:
select id, title
from article
where date(create_time) = '2026-03-20';
这类写法常见,但它的问题是:索引里存的是原始 create_time,不是 date(create_time) 的结果。数据库为了判断每一行是否匹配,往往只能对每行都算一遍函数。
更合适的写法是改成范围查询:
select id, title
from article
where create_time >= '2026-03-20 00:00:00'
and create_time < '2026-03-21 00:00:00';
这样数据库就能直接顺着索引做范围扫描。
同理,下面这些写法也需要警惕:
where amount * 100 > 5000
where status + 0 = 1
where cast(user_id as char) = '1001'
它们的共同问题都是:把列本身改造成了另一个值,而索引依赖的是列原始值的有序性。
3.2 隐式类型转换也会让索引效果变差
如果字段是字符串,你却拿数字去比;或者字段是数字,你拿字符串去比,数据库有时会帮你“自动转换”。但这种自动转换,往往会把本来可走索引的判断变成更重的比较。
例如手机号字段如果是 varchar:
where phone = 13800138000
最好改成:
where phone = '13800138000'
这类问题在项目里很隐蔽,因为 SQL 看起来能跑通,功能上也没错,但性能经常掉下去。
3.3 like 不是不能优化,但要知道它什么时候还能利用索引
下面这条常常还能利用索引:
where name like 'wang%'
因为数据库知道要从 wang 开头的那一段有序范围开始扫。
但下面这条通常就很难:
where name like '%yuan'
因为前缀不确定,数据库不知道从哪段有序范围开始找,只能更接近全扫描。
如果业务真的有大量模糊检索需求,通常要考虑全文索引、搜索引擎,或者专门的搜索字段设计,而不是指望普通 B+ 树索引解决所有模糊匹配。
四、排序、分组和分页:很多“查得慢”其实不是查,而是排序和丢弃
面试里最容易出案例的,往往就是列表页、报表页、后台检索页。这些页面常见的问题都集中在排序、聚合和分页。
4.1 为什么 order by 经常是慢查询的核心原因
如果数据库不能直接利用索引顺序输出结果,就要自己额外排一次。这时在 explain 的 Extra 里,最常见的信号通常是:
Using filesort
filesort 不一定真的写磁盘,但它代表发生了额外排序。数据量大时,排序本身就很重。
看一个例子:
select id, user_id, create_time, amount
from orders
where user_id = 1001
order by amount desc
limit 20;
如果索引是 (user_id, create_time),那它能帮助过滤 user_id,但对 order by amount desc 帮助不大,数据库就还要排序。
所以排序优化的核心思路通常是:
- 让排序字段尽量和过滤字段一起出现在联合索引里;
- 或者先缩小结果集,再做排序;
- 再不行就考虑是否真的要实时排序。
4.2 group by 为什么容易变成重操作
聚合不是魔法,它要先拿到数据,再把相同键归在一起计算。
例如:
select status, count(*)
from orders
where create_time >= '2026-03-01'
group by status;
如果时间范围很大,数据库仍然要扫描大量行。
如果分组字段和过滤字段都没有合适索引,就更容易在 Extra 里看到:
Using temporary- 有时还会伴随
Using filesort
这里要把两个信号分开理解:
Using temporary更偏向在说明数据库为了完成分组、去重或中间结果处理,额外使用了临时表;Using filesort更偏向在说明数据库做了额外排序。
它们经常同时出现,但含义并不完全一样。
这一类查询优化时,思路通常有三条:
- 先把过滤做好,缩小进入聚合的数据集;
- 给高频聚合场景配合适索引;
- 如果统计特别高频且数据量大,就考虑汇总表、宽表或离线任务。
报表类 SQL 很适合这样判断:如果每次实时算的代价已经高到不合理,就不要再强迫数据库每次临场发挥。
4.3 深分页为什么慢,不在于拿 20 条,而在于丢掉前面那一大堆
很多后台列表都会写成:
select id, title, create_time
from article
order by id desc
limit 100000, 20;
这条 SQL 真正慢的原因不是最后只返回 20 条,而是数据库通常得先扫到前面那 100020 条,再丢掉前 100000 条。
偏移量越大,浪费越多。
更常见的优化方式是游标分页:
select id, title, create_time
from article
where id < 987654
order by id desc
limit 20;
这种方式不是从“第几页”开始想,而是从“上一页最后一条记录之后继续往后取”来想,性能会稳定很多。
如果业务真的要保留页码跳转,也常见“先查 ID 再回表”的两段式写法,但底层思路还是一样:尽量不要让数据库为了一页数据白白扫描和丢弃太多内容。
五、Join 优化:表一多,问题就不只是索引,而是驱动方式和结果膨胀
Join 查询的优化重点一般有三件事:
- 关联字段有没有索引;
- 过滤动作能不能尽早发生;
- 会不会因为一对多关系把结果集膨胀得过大。
5.1 Join 字段没有索引,通常就是灾难的开始
例如:
select o.id, o.order_no, u.nickname
from orders o
join users u on o.user_id = u.id
where o.status = 'PAID';
如果 orders.user_id、users.id 没有适合的索引,关联过程就会很重。
哪怕 users.id 是主键已经有索引,也还要顺手确认这几件事:
orders.status有没有索引;- 过滤是不是先作用在
orders上; - 是否会因为关联过多列、过多表而导致中间结果变大。
5.2 “小表驱动大表”为什么常被提
本质上是想让关联时参与循环匹配的外层结果集更小。
例如下面这条查询:
select o.id, o.order_no, u.nickname
from orders o
join users u on o.user_id = u.id
where o.status = 'PAID';
如果 orders 里有几百万行,而 status = 'PAID' 只会筛出几千行,那么一种更理想的执行思路就是:
- 先把
orders里满足status = 'PAID'的那一小批记录筛出来; - 再拿这批记录的
user_id去users表里做关联查找。
这样数据库真正需要拿去做 join 匹配的外层结果集会小很多,后续的关联成本也更可控。
之所以常说“小表驱动大表”,其实是在表达一个很朴素的意思:参与外层循环匹配的那一侧,越小越好。因为在很多执行方式里,驱动表拿出一行,就要去另一张表里继续找一轮匹配;驱动侧越大,这个动作重复得就越多。
这里还需要把“驱动表”说得更具体一些。
在 join 执行里,所谓驱动表,指的是先被取出来、再拿它的每一行去另一张表里做匹配的那一侧;另一边则可以理解成被驱动表。
如果把上面的 SQL 粗略想成两层循环,它更像这样:
for 每一条满足条件的 orders 记录:
去 users 表里找 user_id 对应的用户
如果实际执行过程接近这个样子,那么:
orders就是驱动表;users就是被驱动表。
因为是 orders 先出来,再驱动对 users 的查找。
反过来,如果另一条 SQL 是:
select o.id, o.order_no, u.nickname
from users u
join orders o on o.user_id = u.id
where u.level = 'vip';
而实际执行过程是:
- 先从
users里筛出level = 'vip'的用户; - 再用每个用户的
id去orders里找记录;
那这时就是:
users是驱动表;orders是被驱动表。
所以判断谁驱动谁,不能只看 SQL 里谁写在前面,而要看数据库真正先访问谁。
在 MySQL 里最直接的方式就是看 EXPLAIN。它的结果通常一行对应一张表,粗略理解时可以先看 join 顺序:先访问的那张表,往往就是驱动表;后访问的那张表,往往就是被驱动表。
当然,优化器可能会重排 join 顺序,所以“SQL 书写顺序”和“实际执行顺序”并不一定一致。这也是为什么项目里讨论“小表驱动大表”时,最好配合 EXPLAIN 一起看,而不是只凭 SQL 文字顺序判断。
但这里的“小”,不要只按物理表总行数去死记。更实用的理解是:
- 先经过过滤后更小的那一侧;
- 更容易通过索引快速定位的那一侧;
- 能让后续关联次数明显变少的那一侧。
现代优化器会自己做很多选择,但你脑子里要有这个判断:Join 的问题不只是“有没有索引”,还包括“谁先过滤、谁后关联、谁在驱动谁”。
5.3 有些 Join 问题其实不是 SQL 本身慢,而是业务模型在放大数据
例如一张订单表 join 明细表后,一条订单会变成多条记录;再 join 商品表、优惠表,就可能继续膨胀。
这时常见的问题就不是“单条记录查不出来”,而是:
- 结果集变大;
- 排序和分页失真;
- 前端还得再去重;
- count 结果也变复杂。
所以有些列表查询直接 join 就够了,也有些场景会改成:
- 先查主列表;
- 再按主键批量补充从表信息;
- 或者预先做冗余字段。
这也是为什么 SQL 优化不只是数据库技巧,很多时候也是模型设计和接口设计问题。
六、N+1 查询:很多慢接口不是一条 SQL 慢,而是查太多次
N+1 查询是最典型的“单条 SQL 看起来都不慢,但接口整体很慢”的问题。
例如:
- 先查 100 条订单;
- 再循环 100 次分别查每条订单的用户信息。
业务代码可能长这样:
List<Order> orders = orderMapper.listRecent();
for (Order order : orders) {
User user = userMapper.findById(order.getUserId());
order.setUserName(user.getNickname());
}
数据库角度看,这不是一条慢 SQL,而是 101 次数据库往返。
问题在于:
- 连接池压力变大;
- 网络往返变多;
- 数据库 CPU 和 IO 都被重复消耗;
- 并发一上来就容易放大。
更合适的做法通常是:
- 一次 join 查出来;
- 或者先收集 user_id,再批量查用户;
- 或者把常用展示字段冗余到订单表里。
所以面试里如果被问到 SQL 优化,能主动提到 N+1,通常会显得更像做过真实系统维护的人。
七、慢 SQL 排查:别靠感觉,先让执行计划说话
真正做优化时,最怕的是一上来就凭经验乱改。慢 SQL 的排查最好形成固定路径。
7.1 排查先看 explain
最基本要看这几项:
type:访问方式,是否全表扫描;key:用了哪个索引;rows:大概要扫描多少行;Extra:有没有Using filesort、Using temporary等额外操作。
例如你看到:
type = allrows = 500000Extra = Using filesort
基本就能判断:这条 SQL 很可能既没有有效缩小范围,又还在额外排序。
7.2 把 SQL 放回业务场景里看
同一条 SQL,在不同场景下重要性完全不同。
比如一个每天跑一次的离线报表,单次 2 秒未必是问题;
但一个列表接口在核心链路上每秒调用几百次,哪怕单次 300ms,也会把整个系统拖下去。
所以排查时不要只盯单条执行时间,还要看:
- 这是核心接口还是后台任务?
- 它调用频率高不高?
- 它的慢会不会放大成线程堆积、连接池占满、上下游超时?
7.3 优化后再看真实指标变化
说自己做过 SQL 优化,最有说服力的方式不是“我会 explain”,而是:
- 优化前:平均 900ms,P95 1.4s;
- 优化后:平均 120ms,P95 220ms;
- rows 扫描量下降了一个数量级;
- 排序消失或回表次数显著下降。
面试时如果能把结论说成“从什么指标变成什么指标”,可信度会高很多。
八、几个常见案例,用来把前面的知识点串起来
案例一:订单列表慢,是因为联合索引顺序不对
原 SQL:
select id, order_no, amount, status, create_time
from orders
where user_id = 1001
and status = 'PAID'
order by create_time desc
limit 20;
原索引:
idx_user_id(user_id)
idx_status(status)
问题:
- 过滤条件分散在两个单列索引里;
- 排序字段
create_time没被联合进来; explain里rows很大,Extra出现Using filesort。
优化:
idx_user_status_ctime(user_id, status, create_time)
再把查询字段收窄,不用 select *。
结果:过滤和排序可以尽量沿着同一个索引完成,列表页响应时间通常会明显下降。
案例二:按天查询数据慢,是因为在时间列上用了函数
原 SQL:
select count(*)
from orders
where date(create_time) = '2026-03-20';
问题:
date(create_time)让索引失去原本的范围查找价值;- 数据量大时容易扫描很多行。
优化后:
select count(*)
from orders
where create_time >= '2026-03-20 00:00:00'
and create_time < '2026-03-21 00:00:00';
这是典型的“功能完全一样,但写法决定性能”的例子。
案例三:深分页慢,改成游标分页
原 SQL:
select id, title
from article
order by id desc
limit 100000, 20;
问题:
- 数据库为拿 20 条数据,得先处理前面 100000 条。
优化后:
select id, title
from article
where id < 900000
order by id desc
limit 20;
这类优化在后台管理系统里几乎是必备项。
案例四:接口慢不是因为单条 SQL 慢,而是 N+1
原逻辑:
- 查订单列表;
- 对每条订单再查用户昵称;
- 对每条订单再查支付信息。
问题:
- 单条 SQL 可能都只有几毫秒;
- 但接口整体执行了几百次数据库查询。
优化方向:
- 改 join;
- 或改批量查询;
- 或对展示字段做适度冗余。
这种问题在 ORM 使用较多的项目里尤其常见。
九、复盘和面试里最常被追问的几个问题
1. 面试里说“做过 SQL 优化”,最稳的表达顺序是什么?
通常先说问题类型,再说证据,再说动作和结果。例如先说明是扫描太多、排序太重还是 N+1,再补 Explain、慢日志或链路证据,最后再讲索引、改写 SQL、改交互或做缓存之后的效果。
2. 什么时候优先加索引,什么时候优先改 SQL 写法?
如果核心问题是访问路径缺失、过滤和排序没有被合适索引承接,就回到索引;如果索引明明有,但被函数、隐式转换、select *、深分页或不合理排序写法绕开,再去改 SQL 写法。
3. SQL 变慢,一定要在数据库层把它解决掉吗?
不一定。有些问题交给缓存、汇总表、离线计算、异步任务或产品交互收口会更省,没必要继续逼数据库实时硬算。
4. 这篇笔记适合拿来当线上慢 SQL 的起点吗?
不太建议。它更偏复核和总览;如果你已经在处理真实现场,还是回到 MySQL 慢查询怎么定位:从执行计划到真实瓶颈 这类按现场顺序收窄范围的文章。
十、最后给自己留一个排查清单
以后遇到慢 SQL,可以按下面顺序过一遍:
- 这条 SQL 的核心问题是扫描、排序、聚合,还是查询次数过多?
explain里有没有全表扫描、临时表、额外排序?- where 条件和 order by 能不能被同一个联合索引承接?
- 有没有
select *,有没有回表过多的问题? - 有没有在索引列上做函数、计算或隐式转换?
- 是不是深分页、模糊查询、复杂 join 这类典型场景?
- 问题应该在 SQL 层解决,还是该交给缓存、汇总表、异步任务?
SQL 优化真正难的地方,不是记住很多规则,而是知道哪条规则适用于哪个场景。
如果把每次慢查询都当成一次“数据库做了哪些无效工作”的排查过程,很多问题就会慢慢变得清楚起来。
所属专题
- 数据库与 MySQL 性能问题
如果你还想把 SQL 这一段继续拆细
- MySQL 慢查询怎么定位:从执行计划到真实瓶颈
- MySQL EXPLAIN 详解:type、rows、extra 到底怎么看
- MySQL 索引失效的 8 个常见原因
- MySQL 里 order by 为什么会慢?一套分析思路讲清楚
- MyBatis 分页为什么慢?深分页问题怎么优化
如果数据库慢已经开始传到别的环节
按你现在卡住的地方往下看
- 如果你先想把 SQL 优化这张总图补齐,就从这篇开始,把 索引、SQL 写法、排序、分页、N+1、执行计划 放回同一套判断里
- 如果你卡在执行计划读不明白,接着看 MySQL EXPLAIN 详解:type、rows、extra 到底怎么看 和 MySQL 索引失效的 8 个常见原因,把命中路径看扎实
- 如果慢点主要落在排序和深分页,直接去看 MySQL 里 order by 为什么会慢?一套分析思路讲清楚 和 MyBatis 分页为什么慢?深分页问题怎么优化
- 如果你已经在处理线上慢 SQL 现场,就回到 MySQL 慢查询怎么定位:从执行计划到真实瓶颈,按现场顺序收窄
- 如果 SQL 问题已经传导成事务变长、连接等待或接口变慢,再连着看 事务执行时间过长,真正拖慢系统的往往不只是数据库、数据库连接池打满时,根因通常不是连接数太小 和 接口响应慢怎么排查?后端性能问题定位步骤