慢SQL、索引失效、深分页卡顿,这几个词只要是搞过后端的人多少都撞见过几回。MySQL性能优化里,SQL写法往往是最先被盯上的一环——硬件配置可以加,架构可以改,但一条烂SQL写进业务里,再好的机器也扛不住。这篇文章不聊虚的,直接围绕SQL写法里的高频雷区、底层原理、排查方法和优化手段展开,把常见的坑一个个拆开讲清楚,适合写业务SQL的开发者、维护老系统被慢查询折磨的运维,以及准备面试被问到“SQL优化”就头皮发麻的同学。
1. 先搞懂MySQL到底是怎么查数据的
SQL优化的前提是理解MySQL执行查询的路径。你可以把InnoDB存储引擎想象成一个大型仓库,数据按照主键顺序存放在B+树的叶子节点上,每张表的主键索引就是这棵树的“目录”。当你执行一条SELECT语句时,MySQL先经过解析器、优化器生成执行计划,然后由执行器调用存储引擎接口去扫描索引、读取数据行。
1.1 索引的B+树结构决定了SQL写法的边界
很多SQL写法问题,根源都在B+树的结构特性上。B+树的叶子节点存储了完整的行数据(聚簇索引)或索引列的值+主键(二级索引),非叶子节点只存索引键值用于路由。这带来两个直接影响:
第一,索引数据是有序排列的。所以范围查询、ORDER BY、GROUP BY这三类操作,只要条件匹配了索引顺序,就能利用索引的有序性避免额外排序。反之,如果你在索引列上做了函数运算或隐式转换,破坏了有序性,优化器只能放弃索引。
第二,二级索引不包含整行数据。查询时如果SELECT的列不在索引里,就需要根据主键回表查聚簇索引。回表次数一多,性能自然下降。这就是为什么“覆盖索引”很重要——让查询所需的列全部包含在二级索引中,连回表都省了。
1.2 回表与覆盖索引:每次SELECT背后的代价
举个例子,表里有联合索引 (user_id, status),查询条件只用到user_id。如果执行 SELECT * FROM order WHERE user_id = 123,MySQL会先通过二级索引找到所有匹配的主键id,再一个个回表去取整行数据。数据量小没问题,一旦匹配几千几万行,回表开销就很明显。
如果改成 SELECT user_id, status FROM order WHERE user_id = 123,所需的两列都在联合索引里,MySQL直接从索引页返回结果,不需要回表。这就是覆盖索引优化。实际业务中*不要无脑SELECT ,先看看查询需要的列能不能被索引覆盖,这一条往往就能省下大量随机I/O。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SELECT写法:最常见的性能杀手
SELECT语句是业务里出现频率最高、也最容易写歪的部分。很多看来“没啥问题”的写法,在执行计划里已经埋了炸弹。
2.1 SELECT *:看着省事,实际上最贵
先说最普遍的 SELECT *。它的问题不只是多返回几个没用的字段占用网络带宽,更麻烦的是它基本断绝了覆盖索引的可能性。同时,如果表结构后续加了TEXT或者超长VARCHAR字段,SELECT * 还会让查询不得不去读额外的数据页,拖慢速度。
我曾经接手过一个报表接口,核心查询就是 SELECT * FROM record WHERE create_time BETWEEN ... AND ...,结果十几万行数据查了3秒多。改成只查需要的5个字段后,硬生生降到0.4秒——因为其中两个字段原来根本没用,而创建时间列本身有索引,只查必需列之后执行计划直接命中了覆盖索引。
2.2 隐式类型转换:索引失效的高频原因
这是新手最容易踩、老手偶尔也会翻车的坑。最常见的是字符串列与数字比较。比如 user_phone 是 VARCHAR 类型,你写 WHERE user_phone = 13800138000,MySQL会把字符串列隐式转换为数字再比较。一旦对索引列做了类型转换,索引就失效了,全表扫描没得跑。
判断方法很简单:执行 EXPLAIN,看 type 是不是从 ref 变成了 ALL,或者看 key 字段是否变成NULL。解决办法是写SQL时严格保持数据类型一致,字符串就加引号,别偷懒。
2.3 别再对索引列玩函数和计算
WHERE DATE(create_time) = '2025-01-01' 这种写法极其经典,但它会让索引失效。因为索引里存的是原始值,你拿函数处理后跟常量比较,优化器无法直接走索引树查找,只能全索引扫描然后逐行计算。
正确写法是改成范围条件:WHERE create_time >= '2025-01-01 00:00:00' AND create_time < '2025-01-02 00:00:00'。同理,WHERE salary * 12 > 100000 应该改成 WHERE salary > 100000/12,把计算从列上挪到常量这一侧。
注意:索引列上做任何操作,包括函数、计算、隐式转换、正则匹配,基本都会导致索引失效。优化器不是不能处理,是处理成本往往高于直接扫表,所以在大多数情况下它会放弃索引。
3. JOIN与子查询:关联查询的优化套路
多表关联是SQL里性能问题的重灾区,慢查询日志里十有六七都是JOIN和IN子查询。关联查询的关键在于理解驱动表、被驱动表以及连接字段的索引情况。
3.1 小表驱动大表:驱动表选择的底层逻辑
MySQL执行JOIN时,会先选一张表作为驱动表,然后用驱动表的每一行去匹配被驱动表。如果被驱动表的连接字段有索引,匹配走索引就很快;如果没有索引,每行都要全表扫一遍,那代价就是乘积级的。
优化器在有统计信息的情况下会自动选择小表驱动大表,但统计信息有时候会失真。实践中我更倾向于用STRAIGHT_JOIN强制指定驱动表顺序(仅限自己确认过数据分布的场景),或者通过改写SQL让语义更清晰。
3.2 EXISTS与IN怎么选,别背结论要看执行计划
网上流传“小表驱动大表时用IN,大表驱动小表时用EXISTS”,这个说法不完全对。现代MySQL优化器对半连接(semi-join)已经有很好的优化,很多IN子查询会被改写成JOIN或EXISTS的形式。真正的决定因素是子查询表是否走了索引、是否有重复值、结果集大小。
举个例子,查询“所有有过订单的用户”:
sql复制-- 写法A
SELECT * FROM user WHERE id IN (SELECT user_id FROM order WHERE status = 1);
-- 写法B
SELECT * FROM user WHERE EXISTS (SELECT 1 FROM order WHERE order.user_id = user.id AND status = 1);
如果order表数据量极大而user表很小,优化器通常会把IN改写成半连接JOIN,走order表上的索引 (user_id, status),效率很高。但如果你在子查询里又嵌套了其他复杂查询,优化器改写不动,就会退化成逐行执行子查询。所以我的建议是:关联查询直接用JOIN,表达更清晰,优化器更容易处理。
3.3 一个把子查询改成JOIN快几十倍的案例
之前处理过一个跑批任务,里面有一段逻辑:
sql复制SELECT * FROM t_apply WHERE user_id IN (
SELECT user_id FROM t_user_extra WHERE level = 3 AND register_time > '2024-01-01'
)
ORDER BY create_time DESC LIMIT 100;
t_apply 有600多万行,t_user_extra 过滤后有20多万行。这条SQL执行了 27 秒。用 EXPLAIN 一看,t_apply 走的是全表扫描,因为优化器把这个半连接改成了一种不怎么理想的执行策略。
我改成JOIN写法后:
sql复制SELECT t_apply.* FROM t_apply
INNER JOIN t_user_extra ON t_apply.user_id = t_user_extra.user_id
WHERE t_user_extra.level = 3 AND t_user_extra.register_time > '2024-01-01'
ORDER BY t_apply.create_time DESC LIMIT 100;
同时给 t_user_extra 加了联合索引 (level, register_time),给 t_apply 的 (user_id, create_time) 加了联合索引。改完执行时间从 27 秒降到 0.8 秒。关键点在于:连接字段和排序字段都有索引,驱动表(小表)走索引过滤,被驱动表通过主键或二级索引精确匹配。
4. ORDER BY、GROUP BY、分页:排序相关的坑
排序和分组也是SQL性能优化的重头戏。MySQL排序有两种方式:利用索引有序性直接返回,或者生成结果集后用文件排序(filesort)。文件排序又分内存排序和磁盘辅助排序,数据量大时非常慢。
4.1 两种避免文件排序的思路
第一种思路是让ORDER BY和WHERE条件能共用同一个索引。比如 WHERE create_time > '2024-01-01' ORDER BY id LIMIT 10,如果表主键是id自增、create_time有索引,MySQL可能会选择用主键排序而不是create_time索引,因为主键天然有序。但如果WHERE和ORDER BY用的是不同字段,就很容易出现文件排序。
第二种思路是减少参与排序的数据量。比如先在子查询里用覆盖索引取到主键ID,排序后再回表取完整数据,这就是下一节要说的延迟关联。
4.2 深分页的经典解法:延迟关联
LIMIT 1000000, 20 这种深分页是MySQL性能杀手。原因在于OFFSET越深,MySQL扫描并丢弃的无用行越多。它会先读前面100万行的主键,再回表取20行数据,白白浪费大量I/O。
延迟关联的思路是分两步走:
sql复制-- 优化前
SELECT * FROM t_order ORDER BY create_time DESC LIMIT 1000000, 20;
-- 优化后
SELECT t.* FROM t_order t
INNER JOIN (
SELECT id FROM t_order ORDER BY create_time DESC LIMIT 1000000, 20
) tmp ON t.id = tmp.id;
子查询里只查主键id和排序字段,如果排序字段有索引,这个子查询依然要扫描100万行索引,但至少避免了回表。整体性能提升非常明显,尤其当表行宽很大、包含多个大字段时。
4.3 GROUP BY其实可以走索引
GROUP BY 的本质是先排序后分组,所以如果GROUP BY的字段顺序和索引前缀一致,就能避免文件排序和临时表。比如联合索引 (category_id, status),执行 SELECT category_id, status, COUNT(*) FROM t GROUP BY category_id, status 就能完全走索引。
比较典型的问题是 GROUP BY 后面跟了非索引字段,或者分组字段顺序和索引不一致。这时候MySQL会建立临时表来分组聚合,如果数据量再上来,临时表可能被放到磁盘上,性能直接崩。优化手段要么调整索引顺序,要么把聚合结果提前算好放汇总表,要么接受改造成本。
5. 数据修改操作的性能陷阱
SELECT优化聊得多,但UPDATE和DELETE踩坑更致命,因为牵扯锁、事务、脏数据这些问题。
5.1 UPDATE没写全WHERE,代价比想象中大
UPDATE t SET status = 1 如果漏了WHERE,全表数据都会变。这个东西数据库不会报错,只有业务逻辑发现数据不对时才惊醒。即便你不是全表更新,只更新了大范围数据,也会带来大量的行锁和undo日志,主从复制延迟甚至会把从库拖垮。
一个真实场景:运营后台一键审核功能,原本以为是只把某个批次的数据状态改了,结果WHERE条件写少了,把三个月的数据全部更新了一遍。幸好是凌晨执行,加上binlog恢复了,不然就是事故。写UPDATE和DELETE前,先跑一条相同WHERE的SELECT COUNT(*)确认范围,这应该成为肌肉记忆。
5.2 大批量DELETE与死锁
一次性DELETE几百万行,会持有大量行锁,还容易触发死锁,同时产生巨大的undo日志,提交时也会长时间持有元数据锁。更稳妥的做法是分批删除:
sql复制DELETE FROM t WHERE create_time < '2023-01-01' LIMIT 5000;
循环执行直到影响行数为0。每次删除之间加一个短暂sleep,错开高峰期执行。另外,DELETE的WHERE条件尽量走索引,否则每批全表扫描,实际反而更慢。
5.3 只更新需要变化的字段
UPDATE t SET update_time = NOW(), ... 这种写法如果放在高频请求里,会导致大量无效的行版本更新,增加锁竞争和binlog体积。有些ORM框架默认更新所有字段,如果你知道只需要改一个字段,手写SQL反而更高效。做性能优化时,不光看读,也要看写路径上有没有不必要的更新。
6. 慢查询排查:让SQL问题自己现形
写了再多理论,最后还是得靠工具定位问题。慢查询日志 + EXPLAIN + profiling,这三板斧用好了,SQL问题基本藏不住。
6.1 慢查询日志的配置与使用
在MySQL中开启慢查询日志很简单,配置文件my.cnf里加上:
ini复制slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = ON
long_query_time 设置成1秒比较合理,生产环境可以先从3秒开始,逐步收紧。log_queries_not_using_indexes 会记录所有没走索引的查询,非常有用,但要小心——如果系统里本来就有很多低效SQL,日志量会暴增。
刷完日志后用 mysqldumpslow -s t -t 10 /var/log/mysql/slow.log 按执行时间排序,找出Top 10慢SQL,然后重点分析。
6.2 EXPLAIN执行计划里的几个关键信号
EXPLAIN的输出里,我重点关注这几个字段:
- type:从好到坏依次是
system > const > eq_ref > ref > range > index > ALL。看到ALL基本就是全表扫描,要警惕。 - key:实际使用的索引。为NULL说明没走索引。
- rows:预估扫描行数。和实际返回行数差距巨大,说明统计信息失真或者优化器选错方案。
- Extra:出现
Using filesort或Using temporary时,就要考虑是不是排序或分组没走索引了。
注意:
Using index是好事,表示覆盖索引,不需要回表;而Using index condition是索引下推,也有优化效果,但和覆盖索引不是一回事,注意区分。
6.3 一次真实排查案例实录
前阵子线上有个“用户订单列表”接口,高峰期P99延迟飙到4.5秒。慢日志里抓到这样一条:
sql复制SELECT * FROM t_order
WHERE user_id = 10086 AND status IN (1,2,3)
ORDER BY id DESC LIMIT 10;
EXPLAIN显示type=ref,key用了 idx_user_id,rows=18230,Extra里有 Using filesort。问题很清晰:user_id索引过滤出18000多行,再在内存里排序取10条。订单表数据量大,每行包含JSON扩展字段,回表取整行再排序的代价非常大。
我给这张表加了联合索引 (user_id, id),让排序直接走索引。改写后EXPLAIN变成type=ref、key=idx_user_id_id、Extra消失,P99降到120ms。这个案例也说明:一条SQL慢,很多时候不是条件没走索引,而是辅助操作(排序、回表、临时表)在拖后腿。
7. 常见坑速查表与优化习惯
最后把前面提到的高频坑整理成一张速查表,方便你排查问题时对照。
| 现象 | 常见原因 | 优化方向 |
|---|---|---|
| type=ALL 全表扫描 | 索引失效或没建索引 | 检查隐式转换、函数操作,补充合适索引 |
| Extra=Using filesort | ORDER BY字段未走索引 | 调整索引顺序,或使用延迟关联 |
| Extra=Using temporary | GROUP BY字段顺序与索引不符 | 让GROUP BY匹配索引最左前缀 |
| 深分页卡顿 | OFFSET过大扫描太多行 | 延迟关联、游标分页、禁止过深翻页 |
| 关联查询慢 | 被驱动表连接字段无索引 | 给连接字段加索引,控制驱动表大小 |
| 磁盘I/O高 | 频繁回表 | 按查询列建覆盖索引 |
| 大批量更新慢 | 锁竞争、undo膨胀 | 分批提交,错峰执行 |
优化SQL是个长期工程,不是改一条两条就能一劳永逸。我自己的习惯是每写一条查询SQL,下意识先想三件事:能不能走索引?会不会全表扫描?排序和分组能不能走索引避免临时表? 上线前再跑一下EXPLAIN排查一遍。坚持几周之后,慢查询数量肉眼可见地往下降。
再分享一个小工具经验:Percona Toolkit里的 pt-query-digest 能自动分析慢日志并按总耗时排序,省去手动翻日志的麻烦。我基本每周跑一次,把Top 20慢SQL整理出来逐条优化,效果比临时抱佛脚好得多。
