1. 从一条慢查询说起:筛选、排序、分组为什么总是“连体婴”
先讲个我上周在处理线上工单时遇到的场景。某个业务模块每隔几分钟就报一条慢查询,开发同事把SQL发给我,大意是从一张几百万行的订单表里,按用户维度统计最近30天每个用户的订单金额,只保留金额超过5000的用户,最后按金额倒序取前100名。这条SQL拆开看每一步都很常规:先按时间窗口筛选,再按用户分组聚合,再用HAVING过滤分组结果,接着ORDER BY排序,最后LIMIT限制输出行数。但就是这样一个“例行公事”的查询,在业务量上来之后,执行时间从最初的几十毫秒一路飙到了好几秒。
这个场景几乎是MySQL日常开发里最典型的缩影——筛选、排序、分组、限制,这几个操作单独拿出来都不难,难的是它们组合在一起时,你对执行顺序、索引利用、临时表策略、分页边界这些底层细节的理解,会直接决定这条SQL是跑得飞快还是拖垮数据库。
这篇文章就是围绕这几个相互纠缠的核心操作展开的。我会先讲清楚SELECT语句背后的执行顺序,再分别拆解WHERE和HAVING的筛选差异、GROUP BY分组聚合的常见坑、ORDER BY排序的索引优化细节、LIMIT限制的分页问题,最后用一个完整的综合案例把这几件事串起来。适合刚把SQL语法学完、想真正理解查询逻辑的初学者,也适合写了好几年业务代码、但没系统梳理过这些细节的开发同学。
先说一句很多人容易忽略的话:在MySQL里,SQL写出来的顺序和执行顺序是两回事。很多看起来“玄学”的查询结果,其实都是没搞懂执行顺序导致的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞懂SELECT的执行顺序,后面才不会翻车
2.1 一条SQL的完整旅程
以一条最典型的查询为例:
sql复制SELECT user_id, SUM(amount) AS total_amount
FROM orders
WHERE create_time >= '2025-01-01'
GROUP BY user_id
HAVING total_amount > 1000
ORDER BY total_amount DESC
LIMIT 10;
这段SQL的书写顺序是SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT,但MySQL服务端真正执行时,逻辑顺序不是这样的。实际的执行顺序大致是:
- FROM 与 JOIN:确定要读取哪张表,以及表之间如何关联。
- WHERE:对FROM阶段产生的行做逐行筛选,过滤掉不满足条件的记录。这一步是在分组之前做的。
- GROUP BY:把满足WHERE条件的行按指定列分组。
- HAVING:对分组后的结果做筛选,注意这里是“组”级别的过滤,不是“行”级别的。
- SELECT:确定最终要输出的列,计算表达式、别名等。
- ORDER BY:对结果集排序。
- LIMIT:截取指定范围的行。
这也解释了为什么某些写法会报错。比如想在WHERE里引用SELECT中定义的别名:
sql复制-- 下面这条SQL会报错:Unknown column 'total_amount' in 'where clause'
SELECT user_id, SUM(amount) AS total_amount
FROM orders
WHERE total_amount > 1000;
原因很简单:WHERE执行时,SELECT还没轮到执行,别名自然不存在。而HAVING之所以能用别名,是因为HAVING的执行顺序在SELECT之前还是之后?这里要特别说明一下。MySQL在实现上做了一点“特殊处理”:它允许HAVING引用SELECT中的别名。但从逻辑语义上来理解,HAVING本来不应该知道别名,因为标准SQL的执行顺序里HAVING在SELECT之前。MySQL放宽了这个限制,所以HAVING能用别名,但WHERE不行。这一点在实际开发时要特别注意。
2.2 用执行顺序看透“分组后再排序”这类老问题
有个经典问题:我想取每个用户最近一条订单记录,为什么下面这条SQL结果不对?
sql复制SELECT user_id, order_id, create_time
FROM orders
GROUP BY user_id
ORDER BY create_time DESC;
很多初学者以为这样会先按用户分组,然后每组里按时间排序取最新的那条。实际上,这条SQL在分组时,order_id和create_time到底取哪一行的值,MySQL的语义是“不确定的”。在默认配置下它可能会取到任意一条,这取决于索引、物理存储顺序等因素。而ORDER BY虽然写了create_time DESC,但排序的对象只是分组后每个用户对应的那一条“幸运行”,根本不是你想象中每组最新的一条。
这里其实隐含了一个更深层的问题:ONLY_FULL_GROUP_BY模式。在MySQL 5.7及以上版本,如果开启了ONLY_FULL_GROUP_BY(默认开启),上面这条SQL会直接报错,因为在GROUP BY user_id之后,查询列里出现了order_id和create_time,这两个列没有被GROUP BY包住,也没有被聚合函数包裹,违背了“分组后只能查询分组列或聚合结果”的规则。
如果确实需要实现“取每组最新一条”的需求,正确的做法通常是窗口函数:
sql复制SELECT user_id, order_id, create_time
FROM (
SELECT user_id, order_id, create_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) AS rn
FROM orders
) t
WHERE rn = 1;
MySQL 8.0及以上版本才支持窗口函数。如果你还停留在5.7,那就要用关联子查询或者自连接来绕。
这里我想强调一个思路:写SQL不要按“我要什么”直接从SELECT开始想,而是按执行顺序从FROM和WHERE开始想。把每一步的数据集变化在脑子里过一遍,很多问题根本不会发生。
3. 筛选的艺术:WHERE和HAVING的分工与配合
3.1 WHERE行级过滤,永远最先执行
WHERE是整个查询链条的入口过滤器,它的特点是针对FROM阶段产生的每一行做条件判断,符合条件的留下,不符合的丢弃。WHERE没走索引的话,MySQL就得做全表扫描,这时无论后面排序分组写得多漂亮,性能都已经输了。
常见的WHERE条件写法里有几个值得注意的细节。最容易被忽视的是对索引列做函数运算:
sql复制-- 如果create_time上有索引,下面这样写会导致索引失效
SELECT * FROM orders WHERE DATE(create_time) = '2025-01-15';
原因是MySQL需要对每一行的create_time先执行DATE()函数,然后才能和常量比较,原有的B+树索引按原始值排序的信息在这里帮不上忙。改成范围条件就能走索引:
sql复制SELECT * FROM orders
WHERE create_time >= '2025-01-15 00:00:00'
AND create_time < '2025-01-16 00:00:00';
另一个细节是隐式类型转换。如果user_id列是字符类型但存的是数字,而你写WHERE user_id = 123456,MySQL会把列值转换成数字再比较,这同样会使索引失效。规则很简单:写条件时,类型能对上就尽量对上。
WHERE里多个条件的组合逻辑也很值得讲。AND条件的筛选顺序并不是严格从左到右,而是由优化器根据统计信息决定。这意味着你写在WHERE后面的条件顺序,对最终结果没有影响,但对执行计划有影响的是——哪些条件能命中索引,以及索引选择的区分度。不要把“条件顺序影响性能”这种八股文当真,真正影响性能的是索引设计和统计信息。
3.2 HAVING分组后的二次筛选,不是WHERE的补充
HAVING和WHERE的分工很明确:WHERE在分组之前过滤行,HAVING在分组之后过滤组。
用实际场景来说。假如我要统计用户订单总额,只保留消费超过1000元的用户。这里面有两层筛选逻辑:
- 第一层,如果只统计2025年的订单,那2024年及以前的脏数据、测试数据应该在分组前就干掉,这用WHERE。
- 第二层,用户2025年的订单总额是否大于1000,这个条件只能基于分组聚合后的结果判断,必须放在HAV
