我前几天排查一个后台报表慢查询,一条SQL里既有WHERE又有ORDER BY、GROUP BY、LIMIT,看起来每个关键词都认识,但执行计划就是不走索引,几百万行的表硬生生扫了十几秒。问题不在数据量,而在筛选、排序、分组、限制这几个动作之间的配合方式没想清楚。
这篇分享想把MySQL里最常用的四组查询动作拆开揉碎讲一遍:用WHERE筛选行,用ORDER BY排序,用GROUP BY分组,用LIMIT限制返回行数。它们单独用都不难,难的是组合在一起时怎么猜透MySQL的执行路径。文章会从执行顺序讲起,再到每类动作的常见坑、索引优化思路,最后附上实际业务里能直接套用的排查方法。刚学SQL的可以当语法进阶,写过一段时间但经常被慢查询困扰的,也能在里面找到几张能直接用的“药方”。
1. 先搞清SQL执行顺序,筛选才不容易写错
1.1 书写的SQL不是MySQL真正执行的顺序
大多数人写SQL的习惯是照模板:SELECT要哪些列,FROM哪张表,WHERE怎么过滤,GROUP BY怎么分组,ORDER BY怎么排,最后LIMIT分页。这个书写顺序没问题,但MySQL实际执行并不是从左往右按书写顺序跑的。
一条典型查询的真实执行顺序大致是这样的:先确定FROM后面那张表,把表数据取出来;接着执行WHERE,对每一行做条件判断,把不满足条件的行直接丢掉,此时大部分数据量已经被砍掉;然后把剩下数据按GROUP BY字段分组,分组后如果还有HAVING条件,再对组做一次过滤;之后才轮到SELECT去计算最终要展示的列和聚合值;最后再执行ORDER BY排序,排序完用LIMIT截取指定行数返回。
这个顺序解释了工作中常见的现象:在SELECT里给某个计算列起别名,却想在WHERE里直接用这个别名,结果MySQL报错“Unknown column”。原因很简单,WHERE执行时SELECT还没运行,别名根本不存在。反观ORDER BY就能用SELECT里的别名,因为排序发生在SELECT计算完成之后。实际编码中见过太多人踩这种错,把WHERE条件反复改了几遍才发现是顺序问题。
理解执行顺序还有一个价值:确认过滤时机。同样是筛选,发生在WHERE里的行级过滤越早越好,因为每早一步丢掉一批行,后面的分组、排序、分页成本都会成倍降低。判断自己的SQL过滤是不是足够早,最直接的办法是看EXPLAIN执行计划里的rows字段,如果预估扫描行数和表总行数差不多,说明过滤条件没有发挥应有作用。
1.2 拿着EXPLAIN看SQL,比凭空猜靠谱得多
碰到一条慢SQL,先别急着优化,先在SQL前面加一个EXPLAIN关键字跑一遍,MySQL会给出这条语句的执行计划,包括用了哪张表、预估扫描多少行、使用哪个索引、是否用到临时表和文件排序。EXPLAIN本身不执行真实查询,所以哪怕线上库也能放心查看。
执行计划重点看几个字段:
- type:访问类型,从好到差依次有system、const、eq_ref、ref、range、index、ALL。见到ALL就说明是全表扫描,这是最危险的信号。
- key:实际用到的索引名,如果是NULL,说明没走索引。
- rows:MySQL预估需要扫描的行数,这个数字越接近最终返回行数越好。
- Extra:如果出现Using filesort、Using temporary,就说明排序和分组没能利用索引,MySQL额外做了工作。
我在业务中大致的判断套路是:先看type是不是range以上,再看rows有没有被限定在一个可接受范围,最后看Extra有没有文件排序和临时表。这三个地方只要有一处不理想,就值得顺着执行顺序往下排查。工具给出来的信息比凭感觉优化要准确得多,SQL调优如果能坚持“先看执行计划再动手改”,基本能避开大部分无效努力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 筛选过滤:WHERE的常见误区和高效写法
2.1 组合条件时的优先级陷阱
多条AND和OR混在一起写,是筛选阶段最容易出问题的地方。SQL里AND的优先级高于OR,也就是说,如果写的是:
sql复制WHERE status = 1 OR status = 2 AND amount > 100
MySQL会理解成status = 1,或者status = 2且amount > 100,而不是你脑子里以为的“(status = 1或status = 2)且amount > 100”。这个差异在业务上一旦理解错,查出来的数据就可能多出一大截,而且很难一眼发现。
解决办法就是加括号,把逻辑表示清楚:
sql复制WHERE (status = 1 OR status = 2) AND amount > 100
我见过一些老系统里的慢查询,排查半天发现不是索引问题,而是OR条件包没包括号导致结果集变大,间接让后续排序和分页也变慢了。条件逻辑正确是性能优化的前提,结果集本身就是错的,性能再快也没有意义。
另一个常见误区是把or条件换成IN。IN在MySQL优化器里的处理通常比连续OR要舒服得多,5.7以上版本对IN列表还能做范围优化。能用IN表达就不要手写一长串OR。
2.2 别在索引列上做函数运算和隐式转换
WHERE条件能不能走索引,很大程度取决于索引列是否被“动了手脚”。最常见的问题是给索引列套函数。比如订单表在created_at上有索引,但为了查某一天的数据,写了:
sql复制WHERE DATE(created_at) = '2025-06-01'
DATE函数把每行的created_at算了一遍,MySQL在绝大多数情况下无法直接利用普通索引,只能全表扫描。改成范围写法就能用上索引:
sql复制WHERE created_at >= '2025-06-01 00:00:00'
AND created_at < '2025-06-02 00:00:00'
这种写法的好处是把“对列做运算”变成“对条件做范围界定”,列本身保持纯净,索引才有用武之地。
隐含的类型转换同样坑人。如果user_id是varchar类型,索引也建了,但查询里传的是数字:WHERE user_id = 1001,MySQL会把列的值隐式转成数字再比较,等于又对列做了一次运算,索引照样失效。排查的时候看到某个等值查询明明有索引却走了全表,可以先检查字段类型和传入参数类型是否一致。
2.3 NULL、模糊匹配等边界条件的处理
SQL里NULL是一个特殊存在,它的比较结果既不是真也不是假,而是unknown。用字段 = NULL永远查不出数据,必须写成IS NULL。之所以单独提这个,是因为有些开发会习惯性把“没有手机号的用户”写成WHERE phone = NULL或WHERE phone <> NULL,前者查不到数据,后者会把有手机号和无手机号的情况一起丢光。
模糊搜索也是索引话题里的重点。LIKE 'abc%'这种前缀匹配还有机会走索引,LIKE '%abc%'因为通配符在最前面,索引的顺序结构无法帮忙,只能扫描全部可能的记录。如果业务实在需要中间包含搜索,又不能接受全表扫,那就得另外考虑全文索引或者搜索引擎方案,而不是继续在LIKE上硬凑。
关于NULL还有一个统计上的坑:COUNT(字段)不会统计值为NULL的行,COUNT()才会把所有行数算上。统计“有多少个用户传过手机号”用COUNT(phone),统计“总共多少用户”用COUNT(),两者含义完全不同。
3. ORDER BY排序:索引排序与文件排序的取舍
3.1 什么情况下ORDER BY能完全走索引
MySQL里InnoDB的索引结构本身是有序的,B+树的叶子节点按索引字段的顺序排列。如果查询的排序字段和某个索引的字段顺序一致,MySQL可以直接按索引顺序扫描数据,排好序的结果天然就在那里,不会再额外做排序动作。反之,如果排序字段和索引对不上,MySQL只能把查询结果先装进内存或临时文件,再执行文件排序,也就是EXPLAIN里的Using filesort。
文件排序并不是说一定很慢,小结果集在sort_buffer里排序很快,但如果结果集很大,或字段中有大文本,内存装不下就会落盘,这时候性能就很难看了。
一个典型场景:订单表建了索引idx_user_created(user_id, created_at),查询user_id = 88这个用户的订单,并按created_at倒序排:
sql复制SELECT id, amount, created_at
FROM t_order
WHERE user_id = 88
ORDER BY created_at DESC
这时候MySQL先通过索引定位user_id = 88的所有记录,而索引叶子节点本身已经按user_id相同、created_at有序的方式排列,倒序扫描完全不需要额外排序。如果把排序字段改成amount:
sql复制WHERE user_id = 88
ORDER BY amount DESC
情况就变了,amount不在idx_user_created里,MySQL需要先拿到所有符合条件的行,再对amount做排序。结果集不大还好,如果这位用户的订单有几万条,排序就会成为瓶颈。
3.2 排序方向不一致也会让索引失效
很多人以为索引里字段的顺序对了就行,忽略了排序方向。MySQL 5.7之前,索引本身只按升序存储,默认也只支持升序扫描,你要是ORDER BY a DESC,从索引反方向扫描也能勉强利用,但如果查询是ORDER BY a ASC, b DESC这种混合方向,情况就非常尴尬。
举个例子,索引是(a, b),查询要求a升序、b降序。从索引排好的顺序看,a相同的那些行里b是升序的,想得到b降序只能把分组内的顺序反过来,这时候无法完全依赖索引顺序,MySQL很可能选择文件排序。MySQL 8.0开始支持降序索引,可以在建索引时指定字段方向,从而兼容这类排序。
考虑到生产环境可能还在用5.7,更稳妥的做法是评估业务到底需不需要“一升一降”的复杂排序,或者调整排序字段方向让SQL顺应现有索引,而不是一味增加索引。
3.3 中文排序和分页排序不稳定的问题
字符集排序规则会影响字符串排序结果,这点在中文环境里尤其容易踩坑。MySQL默认的utf8mb4排序规则基于Unicode编码比较,中文按编码排序并不是按拼音排,所以ORDER BY name查出来既不是拼音序也不是偏旁序,看起来有些乱。
如果产品需求明确要按中文拼音排序,又不想每次查询都付出额外代价,一个常用技巧是把字段转成GBK再排序:
sql复制ORDER BY CONVERT(name USING gbk)
GBK编码按拼音排中文基本是符合直觉的。注意这种写法对排序字段做了函数处理,没法走索引,数据量大的时候要考虑能不能把拼音列单独存一份用于排序。
分页排序不稳定的问题也很隐蔽。ORDER BY created_at单独排序时,如果同一时间created_at完全相同的记录非常多,MySQL两次查询返回的相对顺序可能不一致,翻页时会出现同一行数据出现在不同页或漏数据的现象。解决方式很粗暴也有效:排序字段追加一个具有唯一性保障的字段,比如ORDER BY created_at DESC, id DESC,让每行数据的排序位置唯一,分页结果才能真正稳定下来。
4. GROUP BY分组:分出来的不是结果,是统计视角
4.1 分组后SELECT列的合法性变化
MySQL在5.7版本之后默认开启了ONLY_FULL_GROUP_BY模式,这个模式下,SELECT后面的普通列必须出现在GROUP BY子句里,或者被聚合函数包起来。原因很好理解:一旦分完组,每组可能对应多行,MySQL不知道你要用哪一行的普通列值。SQL标准要求这种查询必须用聚合函数或跟在分组列后面,否则结果就是不确定的。
举个例子,把订单按user_id分组后,想同时查出amount,但在同一组内存在多条不同amount的订单,这个amount到底取哪条?如果直接SELECT amount,MySQL在旧版本会随意取一条,这完全属于不确定性结果。正确的做法是想清楚你到底要什么:要组内金额最大值就用MAX(amount),要平均值就AVG(amount),要明细汇总或拼接就用GROUP_CONCAT。
实际业务里很多人用GROUP BY只是想看“哪些user_id存在重复数据”,这时候通常只SELECT分组字段和聚合计数值就够了。如果确实需要获取某组内指定条件下的某一行,比如每个用户最新的订单,GROUP BY不适合硬解,后面会用窗口函数给出更好的方案。
4.2 WHERE、GROUP BY、HAVING三者的过滤分工
WHERE、GROUP BY、HAVING的执行顺序是WHERE先过滤行,再分组,最后HAVING过滤组。三者的分工决定了条件放在哪里。
要统计“在职员工中,每个部门的人数”,先得把离职员工剔除,再分组统计,离职条件属于行级过滤,放在WHERE里:
sql复制SELECT department_id, COUNT(*) AS total
FROM employee
WHERE status = '在职'
GROUP BY department_id;
要反过来统计“哪些部门在职人数超过10人”,部门人数是一个聚合结果,只有分组之后才能判断大小,所以过滤条件放在HAVING:
sql复制SELECT department_id, COUNT(*) AS total
FROM employee
WHERE status = '在职'
GROUP BY department_id
HAVING COUNT(*) > 10;
实践中最忌讳的是把所有过滤条件都塞进HAVING。比如“只要2025年之后的订单”这种行级条件也写进HAVING,MySQL就得先把所有年份的数据都分组聚合完,再逐组过滤,白白浪费大量计算。HAVING里尽量不要放那些能在WHERE解决的问题,只有聚合结果的条件才值得交给HAVING。
4.3 聚合函数和行转列的特殊玩法
GROUP BY经常和聚合函数搭配使用,常见的有COUNT、SUM、AVG、MAX、MIN。需要注意聚合函数对待NULL的方式不同,前面说过COUNT(字段)跳过NULL,SUM也忽略NULL,如果整列都是NULL,SUM会返回NULL而不是0,业务上往往需要配合IFNULL处理。
分组还有个行转列的实用场景。一个订单表里有多个订单状态,想按user_id统计他每种状态各有多少单,传统做法是用SUM配合IF或CASE WHEN:
sql复制SELECT
user_id,
SUM(IF(order_type = 1, 1, 0)) AS type1_cnt,
SUM(IF(order_type = 2, 1, 0)) AS type2_cnt
FROM t_order
GROUP BY user_id;
这种用条件聚合实现的竖表转横表,理解起来不难,也不会有额外临时表开销,但在业务字段很多时SQL会变得长而难维护。也可以用GROUP_CONCAT把组内多个值拼成一个字符串,例如查看某个用户所有订单号,但要注意GROUP_CONCAT的输出受group_concat_max_len限制,默认1024字节,拼长文本时会截断。看到输出字符数少了时先别怀疑数据问题,大概率是这个参数没调。
5. 限制返回行数:LIMIT背后的分页暗礁
5.1 LIMIT的分页性能为什么越翻越慢
LIMIT在MySQL里最常见的用法是分页:LIMIT offset, row_count意思是先跳过offset行,再返回row_count行。前端页码越大,offset越大,MySQL的查询就越慢。
慢的根因是MySQL没有“从第100000行开始读”的指令,LIMIT 100000, 10的底层实现仍然是从第一行开始读,把前100000行一条条扫过去丢掉,最后才拿到第100001到第100010行。offset越大,数据库做的无用功越多,翻到几百页以后查询慢是必然的。
一条经常被忽视的经验是:LIMIT对排序结果过滤时,如果还伴随ORDER BY,MySQL必须有足够多的排序结果才能截取。假设现在order by一个没有索引的字段,limit 10,理论上排序前10个就够了,但MySQL无法预知最终前10来自哪里,只能把所有符合条件的记录先排好,再取前10。
5.2 三个常用的深分页优化方案
业务分页很难彻底抛弃LIMIT,但深分页场景可以用三种方式绕开深offset。
第一种是延迟关联。先用子查询查出当前页需要的主键ID,再让原表和这批主键做连接。这样排序和limit操作只作用于主键和必要的连接字段,数据量小时排序开销会显著下降:
sql复制SELECT a.*
FROM t_order a
INNER JOIN (
SELECT id
FROM t_order
ORDER BY created_at DESC, id DESC
LIMIT 100000, 10
) b ON a.id = b.id;
第二种是记录上一页最后一条数据的排序值,用范围条件直接定位下一页,不再使用offset。这里要求业务上按顺序翻页,不支持跳页。通常做法是记住上一页最后一个id和created_at:
sql复制WHERE (created_at < '2025-06-01 10:00:00'
OR (created_at = '2025-06-01 10:00:00' AND id < 123456))
ORDER BY created_at DESC, id DESC
LIMIT 10;
第三种是在有必要时给业务加一个明确的排序字段,比如自增id或更新时间,让“下一页从哪开始”这件事可以用一个简单的主键条件表达。该方法尤其适合“下拉加载更多”这类App端分页。
5.3 组内取前N条:老版本最烦的问题
比深分页更复杂的场景是“每个分组取前几条”。比如统计每个用户最近的三笔订单,这种SQL很多人第一感觉是先GROUP BY user_id,但普通分组只能拿到组内聚合值,拿不到每个组内的前几条明细。
MySQL 8.0直接用窗口函数处理非常清爽:
sql复制SELECT user_id, amount, created_at
FROM (
SELECT
user_id,
amount,
created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM t_order
) t
WHERE rn <= 3;
ROW_NUMBER按user_id分组,再在组内按created_at倒序编号,每组编号小于等于3的就是最近三条。MySQL 5.7没有窗口函数,只能通过用户变量模拟,不仅SQL复杂,执行顺序一旦没控制好还容易出错。生产环境如果还在用5.7而且频繁遇到这类需求,升级8.0带来的收益远比想象中要大。
5.4 LIMIT在DELETE和UPDATE中的作用
LIMIT不只属于SELECT。大批量的DELETE或UPDATE如果一次性执行,会锁住大量行,产生巨大undo日志,还可能拉长主从延迟。分批处理时LIMIT可以限定每次影响的行数,让一批影响千条左右,执行完停顿一下再继续:
sql复制DELETE FROM t_order
WHERE status = 3
ORDER BY id
LIMIT 1000;
这里有几个操作陷阱。不带ORDER BY的DELETE配合LIMIT并不是一定按主键顺序删除,需要明确排序时就把ORDER BY写上。另外MySQL不支持在DELETE子查询里直接引用同一张表,如果通过子查询筛选要删除的数据,得先套一层派生表。这类批量操作放到业务低峰期,配合sleep间隔执行,会比一条超大SQL理智得多。
6. 常见问题速查与慢查询排查清单
6.1 一张表记下高频问题的症状和解法
下面这张速查表是我日常排查MySQL查询问题时最容易翻到的几类情况,基本覆盖了筛选、排序、分组、限制四个方向的主要坑点。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| WHERE条件明明有索引却不生效 | 索引列上套了函数,或类型隐式转换 | 改成范围条件,或保证参数和列类型一致 |
| 分页越翻越慢 | offset过大导致MySQL扫描大量丢弃行 | 改延迟关联、上一页游标,或“加载更多”模式 |
| ORDER BY触发Using filesort | 排序字段和索引顺序不一致 | 调整索引顺序,或弱化排序需求 |
| 翻页时前后数据不一致 | 排序字段存在重复值,顺序不稳定 | 追加主键或唯一字段排序 |
| SELECT报错:列不在GROUP BY中 | 开启了ONLY_FULL_GROUP_BY | 把普通列放进GROUP BY或改为聚合函数 |
| GROUP CONCAT结果被截断 | group_concat_max_len太小 | 按需调大参数或改用其他拼接方案 |
| HAVING里放了行级过滤条件 | 把能在WHERE里筛的条件放到了分组后 | 行级条件移到WHERE中提前减负 |
| 删除大量数据拖垮主从 | DELETE一次影响行数过多 | 分批按LIMIT删除,中间加停顿 |
排查问题时不要零散地试,先确认问题发生在哪个阶段,再进入对应的方向。
6.2 完整排查一条慢SQL的三个步骤
第一步,把慢SQL前面加上EXPLAIN跑一遍,看type、key、rows和Extra。如果看到ALL全表扫描,优先检查WHERE条件能否走索引;看到Using temporary和Using filesort同时出现,优先检查GROUP BY和ORDER BY是否和同一个复合索引的字段顺序匹配。
第二步,做减法定位瓶颈。把ORDER BY去掉再跑一次,查看耗时回落多少;把GROUP BY去掉再跑一次,看临时表是否消失。分段对比能快速判断性能损耗是来自排序、分组还是初筛。这里注意别在线上真跑一次大查询压垮数据库,可以在从库或夜间低峰执行。
第三步,选择代价最小的改动方案。排序和分组成为瓶颈时可考虑建复合索引;筛选条件慢时优先优化WHERE写法或增加索引;分页慢采用游标方案;SQL写法和索引都无法兼顾时,才考虑改业务层逻辑,比如降低单页返回数据量、改成异步导出。
6.3 一个多层组合SQL的真实调优过程
有次线上有个报表接口,伪代码大致是查出一张交易明细表最近七天的数据,先按商户分组,筛出交易额比较大的商家,再按金额倒序返回前20条。卡在十几秒才出来。
看执行计划发现查询没有走分区键,导致最初的数据范围没有收缩。最外层看起来只是20条数据,但MySQL需要把所有满足条件的记录分组排序后才能知道前20是哪几家,几百万行全部参与计算。优化分两步完成:第一步把时间过滤条件拿到最内层子查询里,并把商户表和交易表关联时的驱动顺序固定;第二步给商户统计表加了(交易日期, 商户状态, 金额)的复合索引,让最耗费成本的分组扫描直接从索引读取。调整后接口返回进入秒级以内。
这个案例启发是,多关键字组合出现时,我们往往只盯着最后一行LIMIT,觉得“只需要20条怎么会慢”,实际数据库必须把中间结果全部算完才能截断。追查时一层一层剥开子查询,看每一层扫描多少行、有没有文件排序,问题便会自己浮现。
我个人在实际操作里的体会是,筛选、排序、分组、限制这四件事在MySQL里单拿出来都不算复杂,难的是组合出现时对执行顺序的理解。每改一条SQL,我都会顺着数据流问自己三个问题:能提前减掉多少行?排序能不能直接利用索引?有没有可能让数据库少做一次临时表或文件排序?想清楚这三点,大部分查询问题都能在没有高级知识的情况下解决。后续你要是在这个方向上继续深入,窗口函数、复合索引设计、执行计划里Extra字段的详细含义,都是很值得延展的下一站。
