优化一条SQL,你可能只需要二十分钟;但定位它为什么慢,可能花掉你两天。这话一点也不夸张。我做后端开发和数据库运维这些年,见过太多团队把精力花在“怎么改SQL”上,却很少有人先把“为什么慢”想透。其实MySQL的SQL优化没那么玄乎,它有一套固定的分析路径:先看执行计划,再定位瓶颈,然后针对索引和SQL写法做调整。这套路径走熟了,绝大多数慢SQL都能在几分钟内搞定。这篇文章我打算结合自己处理过的真实案例,把MySQL SQL优化的核心思路、索引背后的原理、执行计划的读法,还有排序、分页、JOIN、UPDATE这几个高频场景的坑,一次性讲透。不管你是刚接触MySQL的新手,还是被慢查询折磨过很多次的后端开发,应该都能从里面找到能直接落地的办法。
1. 一次真实慢查询:问题从哪来,优化往哪走
先聊一个我印象很深的案例。有一年我接手了一个电商后台的订单列表接口,产品反馈说后台打开订单页要等好几秒,操作体验非常差。我看了一下代码,发现查询语句本身并不复杂——就是常规的条件筛选加排序:
sql复制SELECT *
FROM orders
WHERE status = 0
AND created_at BETWEEN '2025-01-01' AND '2025-01-31'
ORDER BY updated_at DESC
LIMIT 20;
当时这张表的数据量大概是三百多万行,放在MySQL里不算大。按说这个量级,只要索引建对了,查个20条数据应该是毫秒级的事。可实际上这条SQL的耗时稳定在2.8秒左右,非常离谱。我第一反应是检查表结构,看看索引情况。结果发现orders表上只有一个主键索引id,其他字段全都没有索引。这就是典型的“用全表扫描扛业务”:MySQL每次执行这条SQL,都要把三百多万行数据从磁盘读出来,逐行判断status和created_at的条件是否满足,然后把满足条件的行扔进临时表做排序,最后再取20条返回。
这个案例看起来简单,但它暴露了一个普遍问题:大部分慢SQL的根因不是SQL写得不够“花哨”,而是索引缺失导致扫描的行数过多。 在说具体优化方案之前,我想先建立一个衡量标准:一条SQL到底“慢不慢”,看的不是执行时间这一个孤立指标,而是它扫描了多少行、回表了多少次、有没有用到临时表和文件排序。这些信息在EXPLAIN里全都有,后面我会详细讲。
顺着这个案例往下说。我当时加的索引是组合索引(status, created_at, updated_at),为什么这样设计?因为WHERE条件用了status和created_at,排序用了updated_at。索引把这三个字段都覆盖进去之后,MySQL可以直接走索引定位到满足条件的最小范围,并且天然按updated_at排好序,省去了ORDER BY的额外排序动作。优化之后,这条SQL从2.8秒降到了大约30毫秒,算是质的飞跃。
这个例子想说明两件事:第一,索引是MySQL性能的命脉,没有索引的SQL写什么都是白搭;第二,索引不是随便建的,它要根据查询的特征——过滤条件、排序字段、返回列——来组合设计。接下来几章,我会沿着这条路径逐层展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引原理与失效现场:快慢的分水岭在这里
2.1 B+树索引结构:为什么它能让你“少翻书”
很多新手理解不了索引为什么能加快查询,我习惯用一个查字典的类比来解释。
想象你在一本没有目录的字典里找一个字,唯一的办法是从第一页翻到最后一页。这叫全表扫描,MySQL里叫ALL。如果这本字典带了一个按拼音排列的目录,你就能先定位到拼音所在的大致页码,然后在新华字典里跳到那一页,再在那一页附近精确匹配。这就是索引的基本工作方式。
MySQL默认使用InnoDB存储引擎,它用的是B+树索引。B+树是一种多路平衡搜索树,它的特点是:数据只保存在叶子节点上,非叶子节点只存索引键值;同一层的节点之间通过指针串联,形成一个有序链表。这意味着两件事:
- 查找某个值时,你沿着根节点一路往下走,每一层都能排除掉大量不满足条件的节点,所以单次搜索的时间复杂度是
O(log N)。数据量越大,这个优势越明显。 - 想要范围查询或者按顺序遍历时,你只需要找到第一个满足条件的叶子节点,然后沿着叶子节点的链表往后走就行,不需要回溯上层。
还有一个关键点:InnoDB的聚簇索引(clustered index)决定了数据行本身就是按主键排列的,主键索引的叶子节点直接存了整行数据。而二级索引(secondary index)的叶子节点只存索引列加主键值。所以当你用一个非主键索引去查询时,MySQL先通过二级索引找到主键值,再用主键去聚簇索引里回表取整行数据。这个“回表”动作如果次数太多,性能就会明显下降。后面讲的覆盖索引,核心目的就是减少回表。
2.2 最左前缀原则与索引失效的常见场景
组合索引有个绕不开的规则——最左前缀原则。MySQL文档里的原话是“最左前缀,因为索引是从索引的最左侧开始匹配的”。我解释得通俗一点:如果你建了一个(a, b, c)的组合索引,那MySQL会按照a、a+b、a+b+c这三种前缀去匹配查询条件。以下几种情况都用得上这个索引:
sql复制WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
但如果你直接查WHERE b = 2或者WHERE c = 3,少了最左边的a,这个索引就没法用。这不是索引有缺陷,而是B+树的底层结构决定的:索引按从左到右的顺序排序,先按a排,a相同再按b排,b相同再按c排。没有a作为前提,b和c在索引里并不是全局有序的,自然没法用来快速定位。
除了最左前缀,索引失效的重灾区还包括这几种:
-
对索引列使用函数。比如
WHERE DATE(created_at) = '2025-01-01'。这条表达式让created_at字段被DATE()函数包了一层,索引对它的作用就完全失效了。因为索引里存的是字段原始值,你切了函数之后,MySQL没法直接利用索引的有序性去做匹配,只能把索引列的值一个个取出来做函数运算,再比较结果,等于把索引退化成了全索引扫描。正确的写法是WHERE created_at >= '2025-01-01' AND created_at < '2025-01-02',用范围条件替代函数转换。 -
隐式类型转换。比如
status字段是VARCHAR类型,但你写了WHERE status = 1。MySQL会把字段值转成数字再比较,因为类型转换发生在索引列上,所以索引同样会失效。更隐蔽的情况是字符集不一致导致的隐式转换——两表JOIN时,如果关联字段的字符集不同,MySQL也会做类型转换,导致关联字段上的索引失效。这个问题排查起来很隐蔽,我后面在JOIN那一节还会提到。 -
LIKE前缀模糊。
LIKE '%keyword%'这种写法,因为通配符在开头,MySQL不知道要从索引的哪个位置开始找,所以只能全索引扫描。但如果通配符只在结尾,比如LIKE 'keyword%',索引还是能用的,因为索引本身按字典序排列,keyword开头的字符串是连续的一块,可以快速定位。 -
OR连接条件。
WHERE status = 0 OR created_at >= '2025-01-01'这种写法有个隐患:只要OR连接的多个条件里有一个字段没索引,MySQL就可能放弃索引,转成全表扫描。即便两个字段都有索引,优化器也可能选择用union方式把两个索引的结果集合并,效果往往不如你直接拆成两条SQL再union。在实际优化中,我遇到OR的第一反应是看看能不能改成IN或者拆成UNION ALL。
2.3 覆盖索引:一次查询不碰数据行
前面提到二级索引的叶子节点存的是索引列加主键值。如果一个查询需要返回的列全部落在索引里,那MySQL压根不需要回表去读整行数据,直接从索引的叶子节点就能拿到所有结果。这种优化就叫覆盖索引(Covering Index)。
举个例子,订单表上有(status, created_at)这个组合索引,然后我执行:
sql复制SELECT id, status, created_at
FROM orders
WHERE status = 0
AND created_at >= '2025-01-01';
id是主键,所有二级索引的叶子节点里都带主键值,而status和created_at又是索引列。所以这个查询要的字段全都能从索引里取到,不需要回表。这就比SELECT *快很多,因为SELECT *在命中索引之后还得根据主键去聚簇索引里把整行捞出来。
这里有个衍生建议:在建组合索引的时候,可以把SELECT里高频出现的字段往后拼,让它们成为索引的一部分,这样能在不增加额外索引的条件下,把一部分查询变成覆盖索引查询。当然,索引字段太多会增加写入开销,这个取舍得根据业务实际来定,不是越多越好。
3. 读懂EXPLAIN执行计划:EXPLAIN里的每一列都在说话
3.1 先把EXPLAIN跑起来看什么
说再多理论,不如直接跑一条真实SQL看一眼。在SQL前面加上EXPLAIN关键字,MySQL就会返回这个语句的详细执行计划,不真正执行。
sql复制EXPLAIN SELECT *
FROM orders
WHERE status = 0
AND created_at BETWEEN '2025-01-01' AND '2025-01-31'
ORDER BY updated_at DESC
LIMIT 20;
结果表里最关键的几个字段是type、key、rows、Extra。type表示访问类型,从好到差的顺序大致是system、const、eq_ref、ref、range、index、ALL。其中:
const和eq_ref是最理想的状态,一般出现在主键或唯一索引等值查询时,最多只匹配一行。ref表示使用了非唯一索引进行等值匹配,这是很常见的良好状态。range表示使用了索引做范围扫描,比如BETWEEN、>=、LIKE 'abc%'这类。范围扫描虽然比等值匹配要扫更多行,但仍然走索引,性能可以接受。index表示全索引扫描,也就是遍历了整个索引树。这个比ALL好一点,因为索引文件通常比数据文件小,但本质上也属于扫描。ALL就是全表扫描,这是最需要警惕的。
key字段显示实际用到的索引名。如果这里显示NULL,说明这条SQL没有使用任何索引,大概率伴随rows字段值很大和type为ALL。
rows是MySQL预估的扫描行数,这个数字是优化器估算的,不一定准,但能反映大概的量级。优化一条SQL的核心目标,本质上就是把rows降下来,让扫描行数从百万级降到千级甚至几十级。
Extra字段里会显示一些额外信息,常见的几种值得记住:
Using index:表示使用了覆盖索引,不需要回表,这是很棒的情况。Using where:表示在存储引擎层拿到结果后,还需要在Server层再过滤一次。这个不一定是坏事,但配合type=ALL时就得注意了。Using index condition:表示走了索引下推(Index Condition Pushdown,ICP),MySQL会把一些过滤条件下推到索引层先做判断,减少回表次数。Using filesort:表示无法利用索引完成排序,需要额外排序。这个往往是大数据量下性能恶化的元凶。Using temporary:表示需要用临时表来辅助查询,常见于GROUP BY、DISTINCT、某些ORDER BY场景。
3.2 一个慢SQL的EXPLAIN逐列解读
继续看前面那个订单查询。在没有加索引之前,它的执行计划大概是:
| 字段 | 值 |
|---|---|
| type | ALL |
| key | NULL |
| rows | 3,400,000 |
| Extra | Using where; Using filesort |
翻译成人话就是:全表扫了340万行,逐行过滤条件,然后把满足条件的结果放到排序缓冲区里做了文件排序,最后才取20条返回。每一步都在“硬碰硬”,性能自然上不去。
加上(status, created_at, updated_at)组合索引之后,执行计划变成:
| 字段 | 值 |
|---|---|
| type | ref |
| key | idx_status_create_upd |
| rows | 3,560 |
| Extra | Using index condition |
type从ALL变成了ref,说明MySQL沿着索引定位到了status=0的所有记录,大约3560行;key显示实际用到了我们建的索引;Using index condition说明用了索引下推,created_at的范围判断在索引层就过滤掉了一部分,回表的次数进一步减少。Using filesort消失了,因为索引已经按updated_at排好序,MySQL可以直接按索引顺序读取,省掉了排序步骤。
这个前后变化特别直观:同样是写这条SQL,索引从无到有,执行计划完全变了。所以我一直觉得,做SQL优化一定要养成先EXPLAIN的习惯,别凭空猜。 你看到的慢查询日志只是表象,EXPLAIN列出来的访问路径才是真正的病根。
3.3 执行计划常见误读
在执行计划上,有几点容易被误解的,我额外提醒一下。
第一,rows是预估而非实际值。优化器基于统计信息估算,如果表的统计信息过期了,结果可能偏差很大。遇到执行计划里type=ALL但rows显示很小的情况,先更新一下统计信息再重新看。InnoDB里可以用ANALYZE TABLE 表名来更新。
第二,key列显示用了索引,不代表这条SQL就没有问题。比如查询需要返回的列不在索引里,MySQL在索引扫描后还会大量回表,这时Extra会显示Using index condition,回表次数取决于rows的值。如果rows有几万,回表照样会拖慢速度。这时候就要考虑覆盖索引或者改写SQL。
第三,Using filesort不一定会把文件写到磁盘。MySQL 5.7以上的版本里,如果排序的数据量小于sort_buffer_size,排序在内存里就完成了,不会产生磁盘临时文件。但不管是在内存还是磁盘,文件排序都要额外消耗CPU和内存,能避免就尽量避免。
4. 排序、分页、JOIN和UPDATE:四大高频坑位逐一拆解
4.1 ORDER BY排序:让索引替你排好
很多开发者在设计表结构时不考虑排序字段,以为ORDER BY只是查询时的一个小尾巴,写上去就行了。但一旦数据量上来,排序就是性能杀手。为什么?因为ORDER BY要么利用索引的有序性直接输出,要么把所有满足条件的数据行收集起来,在排序缓冲区里重新排序。前者是Using index,几乎没有额外成本;后者是Using filesort,随着数据量增长,排序开销会越来越明显。
想让索引直接帮我们排序,需要满足一个条件:排序字段和查询中其他用到索引的字段能让ORDER BY走最左前缀匹配的延续。 我现在专门带你推演一遍。
假设有组合索引(category_id, created_at),以下SQL可以利用索引避免排序:
sql复制SELECT *
FROM articles
WHERE category_id = 10
ORDER BY created_at DESC;
因为WHERE里已经用category_id定位到了一个固定值,ORDER BY的created_at正好是索引的第二列,索引在这条路径里已经天然保证created_at有序。
再看这几种情况:
sql复制-- 不满足最左前缀,无法利用索引排序
SELECT *
FROM articles
ORDER BY created_at DESC;
-- 排序方向和索引顺序不一致,无法利用
SELECT *
FROM articles
WHERE category_id = 10
ORDER BY created_at ASC, id DESC;
-- 中间少了category_id,也没法利用
SELECT *
FROM articles
WHERE created_at >= '2025-01-01'
ORDER BY id;
第一种情况,ORDER BY的字段不是索引最左列,索引从头到尾就不是按created_at排序的,所以只能文件排序。第二种情况更微妙:索引按category_id, created_at的顺序存储,但ORDER BY created_at ASC, id DESC要求先按created_at排,再按id从大到小排,而索引在同一category_id下只按created_at排,没有按id排,所以文件排序无法避免。第三种情况,WHERE里的created_at范围条件让id字段在索引中失去了原有的连续性,所以也不能用来排序。
这里有个值得记住的操作习惯:写完一条带ORDER BY的SQL,先EXPLAIN一下,看看Extra里有没有Using filesort。 如果有,优先调整索引顺序;实在调整不了的,再考虑在SQL层面用更小的结果集去排序。
4.2 分页深翻页:LIMIT 100000, 20为什么那么慢
分页是Web系统里最常见的功能,很多人的写法是:
sql复制SELECT *
FROM orders
WHERE status = 0
ORDER BY updated_at DESC
LIMIT 100000, 20;
这条SQL的慢,很多人没有细想过。它并不是只查20条数据,而是要把前100000条满足条件的数据全部找出来,跳过,再取第100001到100020条。无索引时,这个“找出前100000条”的动作就是全表扫加全量排序,代价极其高昂。
常见的优化手段有两种。第一种,利用主键或者唯一键做书签定位:
sql复制SELECT *
FROM orders
WHERE status = 0
AND id > 100000
ORDER BY id
LIMIT 20;
这种方式要求排序字段和定位字段一致,而且业务上要能接受“按主键顺序分页”的语义变化。如果必须按updated_at排序,可以记住上一页最后一条记录的updated_at和id,然后这样写:
sql复制SELECT *
FROM orders
WHERE status = 0
AND (updated_at < '2025-01-20 12:00:00'
OR (updated_at = '2025-01-20 12:00:00' AND id < 100345))
ORDER BY updated_at DESC
LIMIT 20;
这种“游标分页”的好处是:它不依赖偏移量,MySQL可以直接利用索引定位到上一页最后一条数据的位置,然后顺着索引往下读20条,扫描行数永远可控。
第二种,用延迟关联来优化大偏移量。先把分页需要的id从索引里取出来,再用JOIN回原表取完整行:
sql复制SELECT t.*
FROM orders t
INNER JOIN (
SELECT id
FROM orders
WHERE status = 0
ORDER BY updated_at DESC
LIMIT 100000, 20
) tmp ON t.id = tmp.id;
因为子查询只需要取id,而id在二级索引的叶子节点里就有,可以直接走覆盖索引扫描,避免了回表一整段数据行的开销。等确定20个目标id之后,再一次性回表取数据。如果子查询段的索引设计得当,这个方案能把深翻页的耗时压一个数量级。
4.3 JOIN优化:驱动表顺序与字段陷阱
多表JOIN慢,本质上是因为驱动表(外层表)的每一行都要去匹配被驱动表(内层表)的数据。如果把单次连接的成本摊开看:驱动表扫描N行,每行都要去被驱动表里做一次索引查询,总成本就是N * 单次查询成本。所以JOIN优化的核心思路是——让驱动表尽量小,让被驱动表的连接字段尽量有索引。
MySQL优化器一般会自己选择小表作为驱动表,但前提是它拿到了准确的统计信息。你不需要手动指定驱动表,大多数情况下让优化器决定就好。真正需要操心的是被驱动表的连接字段有没有索引。比如:
sql复制SELECT *
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.status = 0;
只要users.id是主键,orders.user_id上建有索引,这条JOIN的性能一般不会太差。如果orders.user_id上没有索引,MySQL就得对每个users.id去全表扫描orders,那速度就惨不忍睹了。
这里隐藏着一个大坑:连接字段的类型和字符集必须一致。 我踩过一次很隐蔽的坑,两张表的user_id字段,一张是BIGINT,一张是VARCHAR(20),因为历史遗留原因没对齐。平时单独查都没问题,一JOIN起来性能立刻崩。原因是MySQL需要把VARCHAR转成数字再做比较,这个转换让被驱动表的索引失效,被迫全表扫描。后面我把两边的字段类型统一成BIGINT,JOIN秒回。这个经验后来成了我每次做表设计评审的必查项:同名字段的类型、长度、字符集必须保持一致。
4.4 UPDATE优化:别让锁的范围越过你的预期
UPDATE语句的优化,很多人不重视,觉得写对逻辑就行。但在高并发场景下,一次糟糕的UPDATE可能把整张表锁住,拖垮整个业务。
先看一个常见的坑:
sql复制UPDATE orders
SET status = 1
WHERE status = 0;
这条SQL的逻辑是对的:把所有未处理订单改成已处理。但问题是,它一次要更新几十万甚至几百万行。InnoDB默认的行锁机制在“一次更新大量行”时,会申请大量的锁,锁的维护成本、死锁概率、binlog压力都会飙升。更严重的是,如果status上没有索引,InnoDB没法确定具体的行,就只能锁全表。
改善思路有几种:
-
务必保证WHERE条件走索引。 哪怕是低选择性的普通索引,也会让InnoDB能精确定位到需要加锁的行,而不是整表锁定。这是处理UPDATE查询的第一原则。
-
分批更新。 把一次更新一百万行,拆成一百次,每次一万行:
sql复制UPDATE orders
SET status = 1
WHERE status = 0
AND id BETWEEN 1 AND 10000;
每一批执行完,可以提交事务,释放锁,给其他事务让路。这样单次锁的数量可控,对在线业务的影响最小。在数据量特别大的场景下,这个方案几乎是必须的。
- 注意UPDATE和SELECT的锁语义差异。
UPDATE在读到满足条件的行后,会立即加上排他锁,并且保持到事务结束。如果事务迟迟不提交,其他线程对这些行的读操作会被阻塞(取决于隔离级别),写操作则会被锁等待。这也是为什么我见到很多“UPDATE卡住”的问题,根源往往是事务里还有其他耗时的SQL或者根本没有及时提交。排查这类问题,可以查看performance_schema.data_lock_waits或者INFORMATION_SCHEMA.INNODB_TRX,找到持有锁的未提交事务,然后从业务代码里找到问题所在。
4.5 其他几个容易忽视的高频坑
除了上面四个大方向,还有几个细节点,我在日常开发里经常看到有人踩,也一起说了。
关于WHERE条件里对字段做数学运算和函数处理。 比如WHERE price * 2 > 100、WHERE YEAR(created_at) = 2025,这类写法会让索引失效。解决办法是把计算移到等号另一边,让字段裸奔:WHERE price > 100 / 2,WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01'。
关于隐式排序。 没有ORDER BY的时候,MySQL不保证结果集的顺序。有些应用在开发环境看到结果是有序的,就以为默认有序,结果到生产数据一多就乱。如果你需要有序,必须显式写ORDER BY;如果你不需要,千万别乱加排序字段,否则白白增加一次文件排序。
关于COUNT(*)。 在InnoDB里,COUNT(*)和COUNT(1)性能基本一样,但COUNT(某个字段)会额外判断这个字段是否为NULL,如果你的字段是NOT NULL那还好,如果是可空字段,性能会有损耗。想要统计行数,直接用COUNT(*)最稳妥。
关于IN和EXISTS的选择。 这个争议很大,关键看表的数据分布。一般经验是:外层表数据量小时,用IN;外层表数据量大、内层表数据量小时,用EXISTS。但现代MySQL优化器已经能在很多场景自动做子查询转换,所以与其纠结哪种写法快,不如直接用EXPLAIN看执行计划,对比一下rows和type。
5. 完整优化复盘:一个订单列表从2秒到20毫秒
前面讲的都是知识点,这一章我用一个贴近真实业务的例子,完整走一遍优化流程。假设有一个后台订单查询列表,支持按订单状态筛选、按下单时间范围筛选,还要按更新时间倒序排列,分页20条。
5.1 原始状态:表结构与SQL
表结构大概是这样的:
sql复制CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_user_id (user_id),
KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
现在业务方给的查询需求是:
sql复制SELECT *
FROM orders
WHERE status = 0
AND created_at BETWEEN '2025-01-01' AND '2025-01-31'
ORDER BY updated_at DESC
LIMIT 20;
我先把这条SQL放到测试环境压了一下。测试环境数据量约200万行,执行耗时大概1.6秒。EXPLAIN的结果是:type=ALL,key=NULL,rows=2,000,000,Extra=Using where; Using filesort。这和我们前面分析的第一个案例基本一样:全表扫、无索引、文件排序,三座大山全占齐了。
5.2 第一步优化:先解决全表扫描
我先把(status, created_at)组合索引加上:
sql复制ALTER TABLE orders ADD INDEX idx_status_created_at (status, created_at);
重新EXPLAIN,结果变成:type=ref,key=idx_status_created_at,rows=8,340,Extra=Using index condition; Using filesort。
rows从200万降到了8340,说明MySQL已经能利用索引快速定位到满足状态的订单了。但Extra里仍然有Using filesort,说明ORDER BY updated_at DESC还是要额外排序。这张表现有的二号索引idx_created_at对排序没有帮助,因为ORDER BY和WHERE的组合不是同一个索引能覆盖的。
这时候如果你直接看线上性能,耗时大概在200毫秒左右,比之前好多了,但离“秒开”还有距离。
5.3 第二步优化:把排序也交给索引
要消除Using filesort,就得让ORDER BY updated_at能和WHERE status、created_at在一个组合索引上走同一个有序路径。注意这里有个排序方向的问题:ORDER BY updated_at DESC要求在索引里updated_at是降序的,或者MySQL能倒序扫索引。MySQL 8.0开始支持降序索引,但5.7及之前的版本里,索引默认都是升序存储的。倒序扫描从物理结构上是可行的,所以就算索引里是升序,MySQL也可以从索引末尾往前读。关键是:排序字段必须在组合索引里紧跟在范围条件之后。 但这里有个限制,created_at用的是BETWEEN范围条件,范围条件后面的字段没法再用于排序。换句话说,如果索引设计成(status, created_at, updated_at),那在created_at范围扫描结束后,updated_at已经不再严格有序。
所以这一波有个取舍:如果业务对updated_at排序是硬需求,就需要单独设计一个以排序为核心的索引方案。我试过两种可行路径:
方案A:把组合索引改为(status, updated_at),让ORDER BY updated_at DESC能直接走索引,然后再用created_at >= '2025-01-01' AND created_at < '2025-01-31'做过滤。缺点是created_at变成了普通过滤条件,MySQL在索引扫描时得逐个判断,但好在扫描基数已经被status和updated_at大幅缩小了。
方案B:保留(status, created_at)用于过滤,然后在应用层接收结果后用filesort。200毫秒的响应在很多后台场景里也能接受,如果业务上要求不高,这个方案反而简单。
我实际选的是方案A。改完索引结构为(status, updated_at, created_at)之后,EXPLAIN结果变成:type=ref,key=idx_status_updated_created,rows=8,340,Extra=Using index condition。Using filesort消失了。这背后是:索引在status等值时,updated_at本来就是有序的,MySQL按索引逆序读,天然就是updated_at DESC,然后在这个基础上判断created_at是否在范围内,取够20条就停。
最终线上实测:SQL从1.6秒降到了大约20毫秒。这个案例的启发在于——优化SQL时,不仅要看WHERE条件,还要盯着ORDER BY和LIMIT,把排序、分页、过滤放到同一个索引上下文里去统一设计。 很多时候,方案A和方案B都能跑,但性能差一个数量级。EXPLAIN里Using filesort有没有消失,就是一个很直观的判定标准。
5.4 优化复盘:这些教训可以提前规避
复盘整个过程,有几条经验是可以在项目早期就规避的。
第一,表结构设计阶段就要预判查询模式。 先想清楚业务的查询条件会用什么字段、排序用什么字段,再设计索引,而不是等功能上线后出现慢查询再回头补。
第二,别把所有字段都往索引里塞。 组合索引字段越多,写入时维护索引的成本越高,存储空间也越大。很多业务的写入量远大于查询量,一个“完美覆盖所有查询”的索引可能适得其反。设计索引要抓主要矛盾:高频查询优先,低频查询可以忍受慢一点。
第三,给SQL做性能测试时,要基于真实数据量。 我的测试环境曾经只有几万行数据,一条慢SQL根本测不出来。后来我在开发库灌了接近生产量级的数据,才发现一堆性能问题。建议有条件的话,直接用生产数据的脱敏副本。
第四,建立慢查询监控习惯。 开启MySQL的慢查询日志,设一个合理的阈值(比如1秒),定期分析。慢查询日志里的每条记录,都值得你用EXPLAIN过一遍。很多问题在数据量小时根本不是问题,到数据量大了才突然爆发。提前发现,提前处理,比线上出事故再救火要舒服得多。
最后再说一个我的个人习惯:优化完SQL之后,我会把修改前后的EXPLAIN结果和耗时记录放在一起,写进代码评审的注释里。这样后续接手的人能一眼看懂当时为什么这么改,也方便下次做类似的优化。这个习惯帮我省过不少重复沟通的时间。
