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,这个条件只能基于分组聚合后的结果判断,必须放在HAVING。
一个常见的性能误区是:把所有条件都丢进HAVING。比如:
sql复制SELECT user_id, SUM(amount)
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 1000
AND create_time >= '2025-01-01';
如果create_time这个条件在HAVING里,MySQL得先把所有满足分组条件的行都找出来,完成分组聚合,才能判断create_time的条件,这导致大量无效行参与了分组。而把create_time挪到WHERE里,可以先过滤掉大量行,再对剩余行分组,性能差距在数据量大时非常明显。
所以在写条件时,先问一个问题:这个条件能不能在分组之前确定?能用WHERE就别用HAVING,这是筛选条件放置的一个基本原则。
3.3 多条件筛选的组合写法与NULL陷阱
实际业务里,筛选条件常常是“前端传什么就拼什么”。但有一个极其经典、几乎每个月都能在技术群里看到一遍的坑:IN和NOT IN遇上NULL。
sql复制SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM blacklist);
如果blacklist.user_id列里有NULL,这条SQL会返回空结果。原因是SQL的三值逻辑:NULL既不是真也不是假,而是“未知”。NOT IN在对每一行判断时,如果子查询结果里存在NULL,那么任何xxx NOT IN (1, 2, NULL)的结果都是NULL,WHERE只接受TRUE,于是所有行都被过滤掉了。
处理办法通常是把NOT IN改成NOT EXISTS:
sql复制SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM blacklist b WHERE b.user_id = u.id
);
这个坑再次说明:写筛选条件时不能只盯着语法正确,还得想清楚NULL参与比较时会怎样。这也是为什么我建议团队新人把“SQL三值逻辑”作为必修课——它解释了IN、NOT IN、IS NULL、IS NOT NULL以及外连接时各种诡异结果的根源。
4. 分组实操:GROUP BY的高级用法与聚合陷阱
4.1 分组到底分的是什么
GROUP BY的核心语义可以这样理解:把满足WHERE条件的行,按照一个或多个列的值进行归类,值相同的行归入同一个组。分组之后,每个组输出一行结果。这就是为什么SELECT后面只能出现分组列和聚合函数包装的列。
再深入一点,GROUP BY背后的执行机制会涉及临时表或文件排序。MySQL通常需要对分组列排序或使用哈希聚合(在8.0里哈希聚合的优化更成熟),然后把相同键值的行聚到一起。这意味着如果分组列有合适的索引,分组操作可以直接利用索引的顺序来避免额外的排序开销。反之,如果没有索引支撑,MySQL就可能在内存临时表中做分组,临时表放不下时还会落到磁盘,性能断崖式下跌。
我在实际调优时经常做的一件事是:用EXPLAIN看Extra列是否出现Using temporary或Using filesort。这两个标志往往意味着分组或排序没有吃到索引红利。出现它们不一定是灾难,但要心里有数——一旦临时表大到磁盘,查询就会变得很慢。
4.2 聚合函数的正确打开方式
聚合函数是分组操作的好搭档。COUNT、SUM、AVG、MAX、MIN这五个是日常用得最多的。但有几个使用细节经常被忽视。
说一个COUNT的经典误用:
sql复制-- 统计订单总数的正确姿势
SELECT COUNT(*) FROM orders;
-- 统计有user_id的订单数(会忽略user_id为NULL的行)
SELECT COUNT(user_id) FROM orders;
COUNT()和COUNT(1)在现代MySQL里没有性能差异,优化器会把COUNT()当作COUNT(0)处理。真正重要的是COUNT(列名)会忽略NULL值,这在统计“有值数量”和“全表行数”时会有语义差异。
SUM函数也有类似坑:如果分组内所有行都是NULL,SUM返回NULL而不是0。很多报表在应用层直接拿SUM结果做除法时,会因为NULL引发问题。稳妥做法是:
sql复制SELECT user_id, IFNULL(SUM(amount), 0) AS total_amount
FROM orders
GROUP BY user_id;
AVG也有隐藏的陷阱,尤其当列中包含NULL时,AVG是SUM除以COUNT(非NULL行数),而不是除以总行数。如果业务上希望把NULL当作0来处理,就必须自己算:
sql复制SELECT user_id, SUM(amount) / COUNT(*) AS avg_amount_include_null
FROM orders
GROUP BY user_id;
4.3 分组前的去重与分组后的统计
GROUP BY天然具有去重能力,SELECT DISTINCT user_id FROM orders和SELECT user_id FROM orders GROUP BY user_id在大多数场景下结果一样。但GROUP BY的语义更“重”,它暗示了后面可能要配合聚合函数,而DISTINCT就是单纯去重。连表去重时这两者的执行计划也可能不同,实践中可以先试一下EXPLAIN看哪个更优。
分组之后常常还要做的一件事是统计每组行数,这在业务上很常见。比如统计每个城市用户数:
sql复制SELECT city, COUNT(*) AS user_cnt
FROM users
WHERE status = 1
GROUP BY city
ORDER BY user_cnt DESC;
这个SQL里COUNT(*)把分组和聚合结合起来,再配合ORDER BY实现“按人数从多到少排”。可以看到排序的对象是GROUP BY的产物——组级别的一条记录。
另外有一个比较进阶的用法:GROUP BY配合WITH ROLLUP可以生成分组汇总行。比如:
sql复制SELECT city, COUNT(*) AS user_cnt
FROM users
WHERE status = 1
GROUP BY city WITH ROLLUP;
结果里会多出一行city为NULL、user_cnt为总数的记录。这适合做分组报表时顺带输出总计。但要注意,这行汇总记录的city是NULL,应用层如果不处理,很容易误判为“城市为空的用户数”。
5. 排序的细节:ORDER BY里藏着索引的边界
5.1 用好索引,让排序基本不花钱
排序在逻辑上就是把结果集按指定列的顺序输出。实现上主要有两种路径:一种是利用索引天然有序的特性直接顺序读取,另一种是把结果集装入排序缓冲区做filesort。
在InnoDB里,B+树索引本身就是按索引列排好序的。如果ORDER BY的列恰好是索引列(或者索引的前缀列组合),MySQL就可以省去实际排序步骤,直接从索引里按顺序取行。这在EXPLAIN的Extra列里不会出现Using filesort,查询性能会非常优秀。
有一种常见场景能直观感受索引排序的优势:在(user_id, create_time)这个联合索引上执行
sql复制SELECT * FROM orders
WHERE user_id = 123
ORDER BY create_time DESC;
因为联合索引中user_id已经定位到某个用户,而create_time在同一用户下是递增排列的,ORDER BY create_time DESC就可以通过倒序扫描索引直接搞定,不需要额外排序。
但如果写法变成
sql复制SELECT * FROM orders
WHERE user_id = 123
ORDER BY amount DESC;
而amount不在索引里,MySQL就不得不把所有满足user_id=123的行读出来,再在内存中排序。数据量大时只能走filesort,临时文件排序和内存排序的取舍由参数sort_buffer_size决定。我见过不少线上事故就是因为sort_buffer_size设置过小,导致大量排序落到磁盘,查询耗时从毫秒级变成秒级。
关键结论是:ORDER BY的优化不能只看排序字段本身,要结合WHERE的等值条件一起构造联合索引。最经典的匹配原则是“等值条件在前,排序字段在后”。比如业务上高频查询是“查某用户在某时间范围内的订单,按金额倒序”,联合索引设计为(user_id, create_time)只解决范围筛选,但排序仍要filesort;如果高频查询的排序字段是金额,可能需要评估把索引设计成(user_id, amount)带来的收益。
5.2 多字段排序与排序方向
ORDER BY可以接多个字段,每个字段可以单独指定ASC或DESC。排序时先按第一个字段排,第一个字段相同再按第二个字段排。举个例子:
sql复制SELECT province, city, user_cnt
FROM user_stats
ORDER BY province ASC, user_cnt DESC;
这个SQL先按省份升序排,再在同一个省份内按用户数降序排。MySQL 8.0对索引的排序方向支持更好了,但在5.7及更早版本,如果索引是升序而你要倒序读取,虽然可以走“索引倒序扫描”,但效率上通常不如正序扫描。设计索引时如果明确知道排序方向,最好把索引建成同样的方向。
多字段排序还有一个容易被忽略的地方:NULL值的排序位置。MySQL默认NULL值在升序时排最前,降序时排最后。如果你需要控制NULL的显示位置,可以用ISNULL表达式或COALESCE把NULL转成一个极端值来变相控制。比如:
sql复制SELECT * FROM users
ORDER BY ISNULL(phone), phone;
这个技巧先把phone为NULL的行排到最后,剩下的再按phone排序。
5.3 自定义排序规则的CASE实战
业务上经常有“按指定状态顺序输出”的需求。比如订单状态有0待支付、1已支付、2已发货、3已完成、4已取消,产品经理希望列表按照“待支付→已发货→已完成→已支付→已取消”这样的自定义顺序展示。直接对status字段升序排列得到的是0、1、2、3、4,不符合需求。
这时候用FIELD函数最方便:
sql复制SELECT order_id, status
FROM orders
WHERE create_time >= '2025-01-01'
ORDER BY FIELD(status, 0, 2, 3, 1, 4);
如果排序逻辑更复杂,比如某些状态还要按时间倒序,其他状态按时间正序,就可以用CASE表达式给每条记录算一个“排序列”:
sql复制SELECT order_id, status, create_time
FROM orders
ORDER BY
CASE
WHEN status = 0 THEN 1
WHEN status = 2 THEN 2
WHEN status = 3 THEN 3
WHEN status = 1 THEN 4
ELSE 5
END ASC,
CASE
WHEN status IN (0, 2) THEN create_time
END DESC;
这种写法能实现非常灵活的排序规则,代价是CASE表达式没法走索引排序。如果数据量小,几百、几千行无所谓;如果是百万级表上的全量排序,就要考虑能不能把“自定义顺序”建模成一个排序列,写进一张状态字典表,用JOIN去替换CASE。
6. 排序后的限制:LIMIT的正确姿势与深分页优化
6.1 LIMIT做的是什么,不做的是什么
LIMIT的书写顺序在SQL的最后,逻辑上也在排序之后执行。它做的事情很纯粹:从结果集中取偏移量OFFSET之后的若干行。标准语法是LIMIT offset, row_count,等价写法是LIMIT row_count OFFSET offset。
常见用法是配合ORDER BY取前N条:
sql复制SELECT user_id, SUM(amount) AS total_amount
FROM orders
WHERE create_time >= '2025-01-01'
GROUP BY user_id
ORDER BY total_amount DESC
LIMIT 10;
这里的LIMIT 10是在ORDER BY完成之后才截取前十行。MySQL的执行顺序保证了LIMIT能看到的是“最终排序完毕的结果集”,所以结果符合预期。
但很多人没意识到:MySQL必须先把ORDER BY排序后的全部结果生成出来(在内存或临时文件中),然后才能丢弃前OFFSET行。LIMIT能减少最终返回给客户端的行数,却不能减少排序本身的工作量。当OFFSET特别大时,比如LIMIT 900000, 20,MySQL依然要把前90万行都排好序再丢掉,代价极高。
6.2 深分页的三个经典优化方案
深分页(翻到很靠后的页)是MySQL开发里很常见的问题,解决方案大约有三种。
第一种,延迟关联(deferred join)。先用覆盖索引快速定位主键,再回表取完整行:
sql复制SELECT o.*
FROM orders o
INNER JOIN (
SELECT id
FROM orders
WHERE create_time >= '2025-01-01'
ORDER BY create_time
LIMIT 900000, 20
) tmp ON o.id = tmp.id;
子查询里只需要查id列,如果id是主键且create_time有索引,整个过程可以避免回表读取大量无用行。外层再按id关联取完整数据,性能比直接LIMIT好很多。
第二种,游标分页或键集分页(keyset pagination)。不依赖OFFSET,而是记住上一页最后一条记录的排序键值,下一页查询时用WHERE条件把“已经看过的数据”直接挡掉:
sql复制-- 第一页:取前20条
SELECT id, create_time FROM orders
ORDER BY create_time DESC, id DESC
LIMIT 20;
-- 第二页:假设上一页最后一条是 create_time = '2025-01-10 12:00:00', id = 9999
SELECT id, create_time FROM orders
WHERE (create_time < '2025-01-10 12:00:00')
OR (create_time = '2025-01-10 12:00:00' AND id < 9999)
ORDER BY create_time DESC, id DESC
LIMIT 20;
键集分页的优点是无论翻到多深,MySQL都能通过索引的范围扫描直接定位起点,不需要排序后丢弃大量行。缺点是分页的URL或接口参数没法用简单的pageNo来表达,必须传递上一页的游标值。对大部分内部管理系统来说,改造游标方式可能需要改接口协议,成本略高。
第三种,利用子查询或JOIN提前缩小范围。适用于业务上确实需要用户跳转到超深页码的场景(比如管理后台的“跳转第9000页”)。可以考虑禁止跳转超过阈值,或用搜索引擎/OLAP来做深翻页,而把MySQL查询限定在前几百页内。
6.3 分页统计中的COUNT技巧
带分页的列表接口往往需要返回总条数。最直接的做法是SELECT COUNT(*) FROM 表 WHERE 条件。但数据量大时,COUNT本身也可能很慢,尤其当WHERE条件很宽泛或者有多个表关联时。
优化COUNT的优先级大概是:先看能否走覆盖索引;再看是否可以用近似值替代(比如信息页显示“约XX条”);最后考虑缓存计数结果。MySQL的COUNT在InnoDB里是实时遍历的,没有像MyISAM那样直接存总行数。所以“SELECT COUNT(*) FROM big_table”对超大表来说并不轻松。
我在实践中常做的一个优化是:把统计SQL和列表SQL分开执行。列表SQL走最优索引,统计SQL单独优化。有时候统计SQL甚至可以用一个更小的汇总表替代——业务上维护一张按天/按小时刷新的计数表,比每次实时数几百万行划算得多。
7. 综合实例:把筛选、排序、分组、限制串起来跑一遍
7.1 业务场景定义
假设现在有这样一个运营需求,页面展示“2025年1月至今,按品类汇总销售金额,筛选出累计销售额大于50万元的品类,按销售额从高到低排列,分页展示第一页的10条数据”。
底层有一张订单明细表,结构大概是:
sql复制CREATE TABLE order_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
category_id INT NOT NULL,
product_name VARCHAR(128) NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
order_time DATETIME NOT NULL,
KEY idx_order_time (order_time),
KEY idx_category_time (category_id, order_time)
);
这里amount字段没有单独存,销售额用quantity * unit_price计算。先列出目标SQL,再逐段解释:
sql复制SELECT
category_id,
SUM(quantity * unit_price) AS sales_amount,
COUNT(*) AS order_cnt
FROM order_items
WHERE order_time >= '2025-01-01'
AND order_time < '2025-02-01'
GROUP BY category_id
HAVING sales_amount > 500000
ORDER BY sales_amount DESC
LIMIT 10;
7.2 拆解每条子句的执行逻辑
这条SQL从FROM开始,MySQL读取order_items表。WHERE判断order_time落在2025年1月区间内,把不符合的日期直接过滤掉。这里idx_order_time能帮上忙,通过索引范围扫描,快速定位到1月份的数据块,避免全表扫。
然后GROUP BY category_id把1月份的订单按品类归类。因为idx_category_time这个联合索引设计的是(category_id, order_time),理论上对idx_category_time做范围扫描会比单独用idx_order_time更高效,因为索引里已经包含category_id,分组时可以直接利用category_id的有序性,减少临时表的排序压力。但真正的优化器选择要看MySQL的统计数据,实际生产中应该用EXPLAIN确认。
分完组之后,每个品类算SUM(quantity * unit_price)和COUNT(*)。此时HAVING sales_amount > 500000上场,这个条件是组级别的,把累计销售额不足50万的品类直接过滤掉,最终只剩少数几个满足条件的品类。
接下来ORDER BY sales_amount DESC对剩余的几组做排序。注意,这里排序的数据量已经是“满足HAVING条件的品类数”,可能就几十个,排序开销非常小。LIMIT 10直接取前10条,返回结果。
细心的读者可能会问:HAVING sales_amount > 500000里的sales_amount是别名吗?MySQL允许HAVING引用SELECT的别名,前面解释过了,逻辑上它会在分组聚合后计算sales_amount的值,然后参与比较。
7.3 这个简单例子背后的优化对照
如果数据量不大(几十万行以内),上面这条SQL已经够用。但假设order_items有3000万行,1月份订单有500万行,按品类分组后可能产生上千个分组。虽然最终排序的组数不多,但分组聚合本身要扫描这500万行,这部分的成本绕不开。
优化方向有三个。第一,确保WHERE能高效定位到1月份的数据。对3000万行来说,如果order_time上没有索引,那就是全表扫,直接想都不用想。第二,如果业务经常做“按月统计每个品类的销售额”这类查询,光靠明细表实时聚合迟早会扛不住。更通用的方案是建一张销售日汇总表,每天跑批把当天的明细聚合好,查询只扫汇总表。这也是一种典型的“预聚合”思路。
再看一个变体。如果需求变成“只统计已发货订单(status=2)”,那WHERE条件加AND status = 2。如果status过滤性很好,单靠order_time索引还不够,可以建联合索引(order_time, status)或者(status, order_time),具体哪个更好要看哪个条件更先过滤。这个案例想说明的道理是:筛选、排序、分组、限制四个环节,真正决定性能的不是某一个单独动作,而是它们在执行计划里的配合方式。
8. 常见问题与排查技巧实录
8.1 高频问题速查表
下面把我在实际运维和开发中遇到频率最高的一些问题整理成表格,每一条都是真实业务中发生过的。
| 症状 | 可能原因 | 快速解法 |
|---|---|---|
| GROUP BY后查询多列报错 | ONLY_FULL_GROUP_BY模式下出现非分组列 | 用ANY_VALUE()包住列,或改写查询逻辑 |
| WHERE里用不了SELECT别名 | 执行顺序里WHERE先于SELECT | 把别名计算逻辑复制到WHERE,或包一层子查询 |
| NOT IN子查询返回空集 | 子查询结果含NULL,触发三值逻辑 | 换成NOT EXISTS |
| ORDER BY很慢且Extra有Using filesort | 排序字段没走索引或sort_buffer过小 | 检查索引设计,适当调大sort_buffer_size |
| 分页越翻越慢 | OFFSET过大导致MySQL丢弃大量排序行 | 键集分页或延迟关联 |
| COUNT(*)在几百万行表上很慢 | InnoDB没有直接缓存总行数 | 增加汇总表或用EXPLAIN估算 |
| GROUP BY和ORDER BY字段不一致导致临时表 | 分组列和排序列没有相同索引前缀 | 统一设计联合索引 |
| SUM返回NULL导致报表异常 | 分组内无符合条件的行 | IFNULL(SUM(x), 0) |
| 字符串字段筛选时隐式转换 | 查询条件类型和列类型不一致 | 参数类型对齐,避免列上类型转换 |
| HAVING里混入大量可提前过滤的行级条件 | 无效行参与了分组聚合 | 把条件下沉到WHERE |
这张表是我自己总结的,不是文档里的标准定义,但每一条背后都有真实的“惨案”。比如第一个ONLY_FULL_GROUP_BY问题,我经常看到5.7升级后生产环境SQL突然报错,原因是5.6时代写惯了不严谨的分组查询,到5.7后默认模式变了。解法除了改SQL,也可以临时改sql_mode,但正规做法永远是修SQL。
8.2 用EXPLAIN验证筛选、排序、分组是否吃到索引
排查SQL性能问题时,第一步永远是EXPLAIN:
sql复制EXPLAIN SELECT
category_id,
SUM(quantity * unit_price) AS sales_amount
FROM order_items
WHERE order_time >= '2025-01-01'
AND order_time < '2025-02-01'
GROUP BY category_id
ORDER BY sales_amount DESC
LIMIT 10;
EXPLAIN输出里重点看几列:
- type列:至少要达到range或者ref,如果看到ALL(全表扫描),在数据量大时基本可以判定有问题。
- key列:实际使用的索引名。
- rows列:预估扫描行数,这个值是优化器根据统计信息估算的,不一定精确,但能反映量级。
- Extra列:重点看是否出现Using temporary和Using filesort。如果分组和排序同时出现这两个标志,就要考虑联合索引是否有优化空间。
我在实际项目中要求团队所有SQL上线前必须跑EXPLAIN,重点看rows有没有预期之外的数量级,以及Extra有没有临时表或文件排序。这不是教条,而是因为在真实业务数据量下,SQL性能的好坏往往在执行计划阶段就有定论。
8.3 排查临时表和慢SQL的一个真实案例
有次排查一个“导出订单报表特别慢”的问题,SQL长这样:
sql复制SELECT user_id, COUNT(*), SUM(amount)
FROM orders
WHERE status = 1
GROUP BY user_id
ORDER BY COUNT(*) DESC;
orders表有索引(status),但没有(status, user_id)联合索引。实际执行时,WHERE status = 1范围扫描出200万行,然后GROUP BY user_id需要把所有200万行按user_id分组,由于user_id没有索引支撑顺序,MySQL只能建内部临时表,用哈希结构或排序来完成分组。EXPLAIN的Extra列很刺眼地显示着Using temporary; Using filesort。
当时的处理方式是:先确认业务真的需要一次性统计全表的数据吗?如果需求是导出,而不是实时展示,完全可以挪到离线跑批。业务方接受了这个方案,压力直接消失。如果非要在线跑,就得建(status, user_id)索引,让索引同时覆盖“筛选”和“分组”两件大事。但即使建了索引,在200万行上做实时聚合对OLTP库也是一种压力,所以有时候业务方案调整比技术优化更关键。
这里想传达的经验是:排查SQL慢,不要只盯着怎么写SQL,也要反问“这个SQL操作的数据范围有没有可能先缩小”。筛选条件的业务含义往往比技术上的索引优化更能决定生死。
8.4 分组排序限制常见写法误区总结
把散落各处的误区汇总一下,方便以后写SQL时对照:
- 误区一:WHERE和HAVING里重复书写同一个过滤条件。如果这个条件对行和组都成立,写一遍即可,冗余条件不仅搞笑,还可能干扰优化器。
- 误区二:GROUP BY后SELECT列过多,依赖MySQL的ANY_VALUE宽松模式掩盖bug。项目里应始终开启ONLY_FULL_GROUP_BY,让SQL在开发期就暴露出问题。
- 误区三:在ORDER BY里对聚合结果做函数运算,比如ORDER BY ABS(SUM(x)),这几乎百分百走不了索引排序。
- 误区四:LIMIT 1000000, 10和LIMIT 10没啥区别?实际上前者可能要扫描并排序大量行,后者的开销可以忽略不计。
- 误区五:OFFSET分页越深越慢,于是干脆去掉LIMIT一次性返回全部。这会把压力转嫁给网络传输和应用层内存,通常会造成更大的事故。
- 误区六:COUNT(*)和COUNT(某个可为NULL的列)混用,导致统计口径不一致。
9. 先排序还是先分组:一组等价改写带来的思考
多表关联时,筛选、排序、分组、限制的顺序问题会更复杂。举一个我印象比较深的案例:业务上要按“客户维度的销售额”排序,但客户表和订单表是分开的,于是写出:
sql复制SELECT c.customer_name, SUM(o.amount) AS total_amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_time >= '2025-01-01'
GROUP BY c.customer_name
ORDER BY total_amount DESC
LIMIT 50;
如果这里改成INNER JOIN,WHERE中关于o表的条件就会把没有订单的客户过滤掉,语义和LEFT JOIN完全不同。这在业务上可能影响“没有消费客户是否展示”的规则。LEFT JOIN + WHERE o.order_time非空条件,本质上等价于INNER JOIN,很多人踩过这个坑而不自知。
换一个角度,假设需求变成“找出所有有订单的客户,按最近一次订单时间倒序取前50名,并显示订单总金额”。这样的需求有几种写法,一种是用窗口函数:
sql复制SELECT customer_name, latest_order_time, total_amount
FROM (
SELECT
c.customer_name,
MAX(o.order_time) AS latest_order_time,
SUM(o.amount) AS total_amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id
) t
ORDER BY latest_order_time DESC
LIMIT 50;
这种写法把“按客户分组聚合”放在子查询里,外层负责排序和限制,逻辑层次分明。很多人写复杂SQL容易把GROUP BY、ORDER BY、LIMIT全堆在一起,可读性差且调优困难。拆成子查询或公共表表达式(CTE)之后,每一层的职责清晰了,EXPLAIN分析起来也方便。
用CTE改写:
sql复制WITH customer_stats AS (
SELECT
c.customer_id,
c.customer_name,
MAX(o.order_time) AS latest_order_time,
SUM(o.amount) AS total_amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name
)
SELECT customer_id, customer_name, latest_order_time, total_amount
FROM customer_stats
ORDER BY latest_order_time DESC
LIMIT 50;
CTE让业务步骤一目了然:第一步定义统计结果,第二步排序限制。MySQL 8.0支持CTE和窗口函数之后,这种写法已经可以在生产环境放心使用。
10. 一条SQL的“生命周期”:写完它该如何自检
如果问我个人经验里最重要的一条,那就是写完一条包含筛选、分组、排序、限制的SQL后,一定要在脑子里按执行顺序走一遍。
第一步,确认FROM后要读哪些表,JOIN类型对不对。第二步,确认WHERE能把不需要的行挡在外面,并且能利用索引做范围扫描或者等值匹配。第三步,确认GROUP BY的分组键是否正确,SELECT列是否满足分组规则或聚合函数约束。第四步,确认HAVING里的条件是组级条件,不是行级条件被误放进来。第五步,确认ORDER BY的排序规则符合业务要求,NULL值的处理是否符合预期。第六步,确认LIMIT的偏移量和行数在可接受范围,是否存在深分页问题。
按步骤在脑子里执行一遍,能救回很多低级错误。我有一次在评审代码时发现同事写的SQL,WHERE和HAVING条件完全一样,也就是说他在分组前后用一模一样的条件过滤了两遍。问下来发现他自己也没意识到这个条件是行级还是组级的。其实只要走一遍执行顺序,就会明白那个条件在WHERE阶段已经过滤掉所有不合格行,到HAVING阶段再重复一遍毫无意义。通过这种方式,既能提高正确性,也能提升SQL性能。
11. 写在最后的几条实操建议
在做SQL评审和优化时,我经常会遇到一个问题:讲执行顺序、讲临时表、讲索引,很多人觉得“道理我都懂,但写的时候还是把握不好”。我觉得本质原因是缺少一个稳固的分析框架。这里分享几个实战里觉得最有用的建议。
第一,养成看EXPLAIN的习惯,但不要只看是否用了索引,还要看Extra列里的Using temporary和Using filesort。这两个标志是判断分组排序是否省力最直观的抓手。我见到过不少慢查询,索引其实用上了,但分组和排序还是要建临时表,说明索引虽然帮忙完成了筛选,却没能帮上分组或排序的忙。这种时候就要考虑联合索引是否能覆盖“WHERE+GROUP BY+ORDER BY”的完整链条。
第二,不要过度依赖“把所有筛选都写在WHERE里”这种一刀切建议。前文强调过HAVING做的是组级筛选,如果一条SQL里根本不需要分组,那HAVING就不该出现。不要把分组前的行级过滤放进HAVING,也不要把分组后的组级条件硬塞进WHERE然后额外包一层子查询。每种写法的取舍都取决于查询本身的目标。
第三,MySQL 8.0相比5.7在很多细节上做了改进。窗口函数、CTE、哈希连接、更好用的优化器让它处理“分组取每组前N条”这类问题轻松不少。如果项目还在5.7且无法立刻升级,那就要熟悉5.7的替代写法,比如用关联子查询或用户变量。
第四,数据量上去后,任何实时查询都可能会被打爆,这是物理规律,不是SQL技巧能完全对抗的。当发现单条SQL需要扫描的行数到了千万级别,而且业务访问频繁时,优先考虑预聚合、汇总表、缓存或者离线计算。“先让数据变小,再让SQL变快”永远比在一条大SQL里抠细节更有效。
最后一个小技巧:开发环境造数据时,别只造几千行,至少要造到百万级别并加上模拟的真实分布,否则你的SQL在开发环境跑得飞快,上线后面对真实数据就可能崩盘。用存储过程批量造数据、往索引列里注入一些重复值,是模拟生产环境最实际的手段。我在团队里做SQL评审时,第一句话通常是:这条SQL在100万行数据下EXPLAIN的结果是什么?没有这个前提,讨论SQL性能其实没有太多意义。
