说真的,一提到SQL优化和查询技巧,市面上文章十个有八个在讲多表JOIN、子查询、窗口函数,单表查询SQL反倒被当成"入门就该会的东西"一笔带过。我见过太多写了三五年业务的开发者,单表查询翻车却翻得很惨:一个看似普通的WHERE条件让索引失效,一个COUNT统计出错误数据,一个深分页把线上接口拖到超时。单表查询SQL看着简单,实际上藏着执行顺序、索引命中、聚合边界、NULL陷阱这一堆细节,任何一个没搞明白,线上就会给你颜色看。
这篇文章不做理论堆砌,我会从一条SQL的执行顺序讲起,把WHERE筛选、分组去重、聚合计算、分页排序这些单表查询的核心场景逐个拆开,结合真实业务里踩过的坑和排查过程讲透。适合刚学SQL的新手,也适合写了几年SQL但对某些"反直觉行为"说不清楚的老手。每一节都会给出可复现的示例和可以直接抄的改进写法。
1. 执行顺序是单表查询的"底层坐标系"
1.1 数据库不是按你写代码的顺序跑的
大多数人读SQL的习惯是从SELECT开始往下读,但数据库引擎的逻辑执行顺序跟书写顺序完全不同。以MySQL为例,一条标准单表查询的逻辑顺序是:
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
先确定从哪张表取数,再逐行过滤,然后分组,接着过滤分组,之后才轮到SELECT投影和计算表达式,最后排序、取限量。这个顺序不是随便定的,它决定了你写的每条SQL里哪些东西能用、哪些东西不能用。
举个最经典的例子:WHERE子句里不能使用SELECT子句定义的别名。我见过无数新人写过类似下面的SQL:
SELECT employee_name, salary * 1.2 AS new_salary FROM employee WHERE new_salary > 10000;这玩意儿在绝大多数关系型数据库里直接报错。原因很简单:WHERE在SELECT之前执行,当数据库开始处理WHERE时,new_salary这个别名根本还没生成。正确的写法是把计算条件重复一遍,或者套一层子查询:
SELECT employee_name, salary * 1.2 AS new_salary FROM employee WHERE salary * 1.2 > 10000;1.2 顺序决定了WHERE和HAVING的分工
同理,HAVING是在GROUP BY之后执行的,所以HAVING子句里可以使用聚合函数(COUNT、SUM这些),而WHERE子句不行。这不是语法限制,而是执行阶段决定的:WHERE面对的是原始行,一行一行地判断;HAVING面对的是已经分好组的组记录,可以基于组内聚合结果做判断。
我在某电商后台见过一个需求:"统计每个类目下大于100元的商品数量"。对应SQL很容易写:
SELECT category, COUNT(*) FROM product WHERE price > 100 GROUP BY category;但如果把需求改成"找出平均价格大于200元的类目",那就必须用HAVING,因为"平均价格"是分组之后才能算出来的东西。理解了这个顺序,你写WHERE和HAVING就不再靠死记硬背"哪里能用聚合函数"这种口诀了。
1.3 用一条查询把执行顺序串起来
看一个综合例子:
SELECT department_id, COUNT(*) AS emp_count FROM employee WHERE hire_date >= '2023-01-01' GROUP BY department_id HAVING COUNT(*) > 10 ORDER BY emp_count DESC LIMIT 5;它的执行路径是:先从employee表读取所有行,用WHERE过滤掉2023年之前入职的,再按department_id分组,每组的行数记进COUNT(*),HAVING把人数不超过10人的部门丢掉,然后SELECT才输出department_id和emp_count,ORDER BY按emp_count降序排列,最后LIMIT只保留前5条。每一步的执行结果都是下一步的输入,把这条链路捋顺了,单表查询就算入了门。
提示:这里说的是"逻辑执行顺序",实际优化器会根据索引、统计信息做物理执行层面的重排,比如把某些过滤提前。但逻辑顺序是理解SQL语义的基准,写错不算,读不懂才算。
2. WHERE条件里那些让索引"罢工"的写法
2.1 索引列上套函数,优化器直接放弃
这是单表查询里最常见、也最隐蔽的索引失效原因。很多人习惯为了"方便"在条件里写函数,比如按天统计订单:
-- 低效写法:DATE()函数包住了索引列 SELECT * FROM payment_order WHERE DATE(create_time) = '2023-06-01';create_time如果有索引,这个写法会让索引失效。因为索引里存的是原始时间值,不是DATE()函数处理后的结果,优化器无法对索引列做区间定位,只能把整张表的create_time全部取出、逐个套函数、再比较,等于全表扫描。正确写法是把函数从索引列上拿掉,改成区间条件:
-- 高效写法:等价于6月1日全天 SELECT * FROM payment_order WHERE create_time >= '2023-06-01 00:00:00' AND create_time < '2023-06-02 00:00:00';这个改写不仅让索引能用,语义也更严谨——它天然覆盖了23:59:59.999这类边界值。类似的还有YEAR(create_time)、MONTH(hire_date)、SUBSTR(phone, 1, 3)这些,但凡索引列被表达式包了一层,优化器基本就没办法走索引了。
2.2 隐式类型转换:类型不匹配时索引为何失效
另一个高频坑是隐式类型转换。比如手机号字段是VARCHAR类型,但查询时传了一个数字:
SELECT * FROM user WHERE phone = 13800138000;MySQL在比较时会尝试把字符串列转换成数字再比较,一旦发生这种转换,索引同样会失效。这条SQL在数据量小的时候毫无感觉,等到用户表上千万行,就会变成一条拖垮库的慢查询。
我自己的经验是:写WHERE条件前,先看一眼表结构的字段类型,参数类型和字段类型严格对齐。宁可多写几个引号,也不要让数据库帮你"智能转换"。
2.3 NULL的三种经典陷阱
NULL在SQL里是个特殊存在,跟它相关的坑我几乎每次培训都要讲一遍。最常见的三种:
第一种,用等号比较NULL。
SELECT * FROM user WHERE name = NULL;这条SQL永远返回空结果。NULL不是一个值,它表示"未知",未知不等于未知,所以不能用=比较,必须写成name IS NULL。
第二种,NOT IN子查询里混入NULL。
SELECT * FROM product WHERE id NOT IN (SELECT product_id FROM order_detail WHERE product_id IS NOT NULL);如果子查询结果集里出现NULL,整条NOT IN的结果会变成空集。逻辑上你是在说"排除这些ID",但NULL代表"不知道哪个ID",数据库无法确定一行是否匹配"不等于未知",干脆什么都不返回。稳妥做法是子查询里显式过滤掉NULL,或者改用NOT EXISTS。
第三种,COUNT(列名)对NULL的"视而不见",这个放到后面聚合函数章节细讲。这里先记住一个原则:涉及NULL的判断,永远用IS NULL、IS NOT NULL、ISNULL()这类专门语法,不要用=、!=。
2.4 LIKE模糊查询的三类命中情况
模糊查询能不能用索引,取决于通配符的位置。下面这个表是我在实际项目中总结出来的规律:
| 写法 | 能否走索引 | 说明 |
|---|---|---|
name = '张三' | 能 | 等值匹配,最完美 |
name LIKE '张三%' | 能 | 前缀确定,可做范围扫描 |
name LIKE '%张三' | 不能 | 不知道从头哪里开始,只能全扫 |
name LIKE '%张三%' | 不能 | 中间匹配,同样无法定位起点 |
如果业务真的需要%关键词%这种模糊搜索,数据量大了以后,正确解法不是硬调SQL,而是引入全文检索或搜索引擎那类专门的索引方案。靠改SQL硬撑,撑不过百万行。
另外还想提醒一个容易被忽略的:OR条件也会打乱索引计划。WHERE a = 1 OR b = 2,如果a和b都有独立索引,优化器可能用index merge,但如果只有其中一个有索引,另一个没索引,整条查询大概率退化。能改写成UNION的两个分支就改写,改写不了就考虑UNION ALL。
3. 分组去重:GROUP BY、HAVING与DISTINCT的边界
3.1 SELECT列为什么会被"限制"
MySQL在sql_mode包含ONLY_FULL_GROUP_BY时(5.7以后默认开启),会强制一个规则:SELECT后面的普通列,要么出现在GROUP BY中,要么被聚合函数包裹。比如下面这条:
SELECT department_id, employee_name, COUNT(*) FROM employee GROUP BY department_id;直接报错。因为按部门分组后,同一个组里可能有多个employee_name,数据库不知道该选哪一个。这个限制看着烦人,实际上是在保护你,防止返回"看似有值、实则随机"的数据。
如果确实想取组内某个值,可以用MAX(employee_name)这类聚合函数,或者用ANY_VALUE(employee_name)明确告诉数据库:我不在乎取哪个。但ANY_VALUE的正确使用场景很有限,别当万能钥匙。
3.2 WHERE和HAVING的分工,用一个例子讲透
业务上经常有人把WHERE该干的活放到HAVING里,或者反过来。这两者的语义差别,用一个对比例子就能看明白。
需求A:"统计每个价格大于100元的商品的类目分布":
SELECT category, COUNT(*) FROM product WHERE price > 100 GROUP BY category;需求B:"找出那些有商品单价超过100元的类目,并统计每个类目的全部商品数":
SELECT category, COUNT(*) FROM product GROUP BY category HAVING MAX(price) > 100;看起来都是过滤,但需求A是先去掉低价商品再分组,计数结果是每个类目下高价商品的数量;需求B是先全量分组,再用"组里是否存在高价商品"筛掉整个类目,计数结果是该类目所有商品的数量。一个条件放WHERE,一个条件放HAVING,查出来的数据完全不是一回事。写SQL前先想清楚"过滤的是行,还是组",这个想明白了,WHERE和HAVING就不会用错。
3.3 DISTINCT和GROUP BY的"分工错觉"
很多人觉得SELECT DISTINCT category FROM product和SELECT category FROM product GROUP BY category结果一样,于是随便用。功能上确实有重叠,但语义上DISTINCT是做"去重投影",GROUP BY是做"分组聚合"。当你只需要去重时,用DISTINCT更直白;当你还要配合COUNT、SUM这类聚合时,只能用GROUP BY。
DISTINCT真正容易踩的是多列去重加排序。下面这条在部分数据库会报错,或者行为诡异:
SELECT DISTINCT category FROM product ORDER BY create_time DESC;因为DISTINCT去重后,结果集里根本没有create_time这一列,ORDER BY却想按它排序,逻辑上说不通。如果确实要"每个类目最新的商品",那就不是单表简单去重能解决的,得用分组取每组最大值那条路。
3.4 GROUP BY对NULL的分组行为
还有个容易忽略的细节:GROUP BY会把所有NULL值归到同一个组里。比如按refund_reason分组统计退款原因,那些没有填写原因的记录(NULL)会单独形成一个组。这个行为本身没问题,但统计报表时看到多出来一个"空组",先别急着怀疑数据,可能是NULL被归组了。
4. 聚合函数的反直觉行为:COUNT、SUM、AVG的边界
4.1 COUNT(*)和COUNT(列名)的差异为什么重要
这是面试高频题,也是实际业务里最容易数错数据的点。我直接给结论:
| 写法 | 统计内容 | 对NULL的处理 |
|---|---|---|
COUNT(*) | 统计行数 | 包含所有行,不管哪列是NULL |
COUNT(1) | 统计行数 | 包含所有行,等价于COUNT(*) |
COUNT(column) | 统计该列的非NULL值的个数 | 忽略NULL |
COUNT(DISTINCT column) | 统计该列的去重非NULL值个数 | 忽略NULL,只计数 |
假设一张表存客户反馈,5条记录里有2条的feedback_content是空的,那么COUNT(*)返回5,COUNT(feedback_content)返回3——因为后者的含义是"有多少条反馈是填了内容的"。很多统计报表出数字对不上,查到最后就是COUNT用错了。
4.2 SUM返回NULL而不是0的场景
SUM的坑比COUNT更隐蔽。看看这条:
SELECT SUM(amount) FROM payment_order WHERE status = 'refunded';如果表里压根没有退款状态的订单,SUM的结果不是0,而是NULL。为什么?因为SUM对"一群不存在的行"求和的数学结果是"没有任何值",数据库用NULL表达"无可奉告"。问题在于,如果你在代码里直接拿这个结果做加法或展示,NULL会把整个计算结果"传染"成NULL,页面上就可能显示空白。
正确写法是给SUM兜底:
SELECT COALESCE(SUM(amount), 0) FROM payment_order WHERE status = 'refunded';类似的还有AVG、MAX、MIN,空结果集都会返回NULL。写统计类接口时,COALESCE兜底应该成为习惯。
4.3 AVG忽略NULL导致的"平均分幻觉"
AVG对NULL的处理是"直接跳过,不参与分子也不参与分母"。听起来合理,但业务上经常因此算出失真的平均数。
举个教育场景的例子:一个班级5个学生,其中一个缺考,比如成绩分别是80、90、95、70、缺考。AVG(score)会计算(80+90+95+70)/4 = 83.75,而不是除以5。如果你做学情报告,页面显示"全班平均分83.75",家长会以为那个缺考的孩子也考了,实际上这个平均分只代表"参加考试的人的平均水平"。
要不要把缺考算成0分,取决于业务语义。但你必须知道AVG的默认行为是忽略NULL,否则平均数算出来"偏乐观"你都不知道为什么。
4.4 单表也能玩的分组Top N
很多人一说到分组Top N就想到子查询加关联,其实在单表场景下,窗口函数就能解决。比如要取每个类目下价格最高的前三件商品:
SELECT category, product_name, price FROM ( SELECT category, product_name, price, ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn FROM product ) t WHERE rn <= 3;虽然标题是"单表查询",但窗口函数作用在同一张表上,完全不涉及多表关联。这个写法比"三层子查询然后自连接"清晰得多,性能也往往更好。
5. 分页排序的代价:LIMIT越翻越慢是怎么发生的
5.1 LIMIT offset背后的"白干活"逻辑
LIMIT offset, size看起来简单,但很多人没意识到它的执行代价。比如:
SELECT * FROM payment_order ORDER BY create_time DESC LIMIT 100000, 20;数据库得先按create_time排序,然后从头数到第100020条,把前100000条全部扔掉,只把最后20条返回给客户端。前100000条数据可不是白读的,它们都要经过排序、经过传输前的准备,只是最终被丢弃了。所以页码越深,LIMIT越慢,"翻到最后一页接口超时"是所有后台列表页的通病。
5.2 ORDER BY不匹配索引时,filesort在悄悄拖后腿
当排序字段上没有合适的索引,MySQL会在内存或磁盘上做一次额外的排序操作,这就是EXPLAIN里常见的Using filesort。小表无所谓,大表数据排不下内存时,会溢写到临时文件,那性能就是断崖式下跌。
避免filesort的核心思路是:让排序字段能用上索引。索引本身就是一种有序数据结构,MySQL顺着索引读出来的数据天然就是排好序的。但要特别注意的是,复合索引的字段顺序必须和ORDER BY的方向匹配。INDEX(status, create_time)能支撑ORDER BY status, create_time,也能支撑WHERE status = 'x' ORDER BY create_time这种组合——这正好是第6节那个真实案例的解法。
5.3 深分页的三种优化思路
如果业务确实需要很深的分页,有三种比较成熟的优化手段。
方法一:游标翻页(Keyset Pagination)。不传页码,传上一页最后一条记录的排序字段值:
SELECT * FROM payment_order WHERE create_time < '2023-12-15 10:30:00' ORDER BY create_time DESC LIMIT 20;每次只取"比上一页最后一条更早的20条",数据库只需要沿着索引往后扫,不用跳过大量行。缺点是无法直接跳到任意页码,但绝大多数业务根本不需要用户翻到第5000页。
方法二:覆盖索引二次查询。先用覆盖索引查出符合条件的ID,再回表取完整行:
SELECT * FROM payment_order JOIN ( SELECT id FROM payment_order ORDER BY create_time DESC LIMIT 100000, 20 ) t ON payment_order.id = t.id;内层子查询只扫二级索引,不用回表,IO量大幅降低。等MySQL 8.0的SKIP SCAN和优化器更聪明之后,有些场景能自动做类似变换,但显式写出来更可控。
方法三:基于ID的区间分页。如果你的主键是自增的,且排序逻辑就是ID排序,可以:
SELECT * FROM payment_order WHERE id > 100000 ORDER BY id LIMIT 20;本质和方法一相同,只是排序字段换成了主键。这条要特别注意:它只适用于排序字段单调递增的场景,不能直接套在ORDER BY create_time上,因为create_time可能存在相同值,游标会漏数据。
5.4 给分页查询加"护栏"
我个人的实操习惯是在分页接口里做两层保护:第一,限制单页大小,最多100条,参数越界直接报错;第二,限制最大翻页深度,超过某层就提示用户使用查询条件缩小范围。这个不是SQL层面的优化,但能从业务侧拦住90%的深分页慢查询,比任何索引优化都立竿见影。
6. 一次单表慢查询的真实排查链路
6.1 现象:同样的SQL,开发环境秒回,生产环境两秒八
某管理后台的"退款中订单列表"接口最近经常超时,开发环境只有几百条数据,怎么跑都是几十毫秒;生产环境的订单表已经积累了800多万行,按状态和日期过滤的查询稳定在2.8秒左右。用户只是翻个列表,2.8秒的体验基本等于"卡死"。
6.2 原始SQL和当时的索引情况
接口对应的核心SQL大致是:
SELECT id, order_no, merchant_id, amount, status, create_time FROM payment_order WHERE status = 'refunding' AND create_time BETWEEN '2023-01-01' AND '2023-12-31' ORDER BY create_time DESC LIMIT 20;当时的表上,status和create_time各有一个独立索引,是我接手前某同事加的。看起来"该有的索引都有了",为什么会慢?
6.3 EXPLAIN把问题摊开了
在查询前面加EXPLAIN,输出很快暴露了真相:
| 字段 | 值 | 解读 |
|---|---|---|
| type | ref | 用了索引,但不是最优 |
| key | idx_status | 优化器选了status的单列索引 |
| rows | 210000 | 估算要读21万行 |
| Extra | Using where; Using filesort | 先按索引查到21万行,再回表过滤日期,最后还要排序 |
问题就在这里:idx_status只能帮我们快速定位"status='refunding'"这个等值条件,但退款中的订单在生产环境有几十万行。这些行在索引里不是按create_time排列的,所以数据库把21万行全部取出来,先回表读完整行,再按日期过滤,最后做一次filesort。21万行的filesort,性能自然崩。
单列索引"各管各的",管不了"等值过滤+范围条件+排序"这种组合需求。
6.4 修复:一个复合索引解决战斗
排查到这一步,解决方案就很明确了——建一个复合索引,让过滤条件和排序条件共用同一棵索引树:
ALTER TABLE payment_order ADD INDEX idx_status_create_time (status, create_time);原理不复杂:复合索引的叶子节点先按status排序,status相同的情况下再按create_time排序。查询条件status = 'refunding'定位到索引的某个区域后,这个区域内的create_time本来就是有序的,ORDER BY create_time DESC可以直接倒着读这个区域,filesort彻底消失;同时create_time的范围过滤也能在索引内完成,回表只发生在最终要返回的那20行上。
优化后的EXPLAIN:
| 字段 | 优化前 | 优化后 |
|---|---|---|
| key | idx_status | idx_status_create_time |
| rows | 210000 | 300 |
| Extra | Using where; Using filesort | Using where; Using index condition |
| 实际耗时 | 2.8s | 0.04s |
从2.8秒降到0.04秒,只加了一个索引,查询SQL一行没改。这就是理解索引如何服务于WHERE与ORDER BY组合的价值。
6.5 这个案例教会我的三件事
第一,单列索引是"单点能力",组合需求要组合索引。只要发现SQL里同时存在等值条件、范围条件、排序,就要下意识想:能不能用复合索引把它们串起来。
第二,EXPLAIN的Extra列比type列更值得盯。Using filesort、Using temporary都是性能杀手,出现在慢查询里基本就要动手。
第三,开发环境验证不了性能问题。几百行数据走全表扫描也是毫秒级,必须用有代表性的数据量、或者直接在生产环境做只读分析,才能复现真实的性能瓶颈。我遇到慢查询,第一步永远是问:数据量到了什么级别?没有千万行以上的体量,很多索引问题是测试不出来的。
我还想多说一句关于索引选择性的体会。如果status='refunding'这个值占了全表90%的行,那即便有复合索引,优化器也可能觉得"扫全表算了,走索引还要回表,不划算"。这种时候光建索引没用,得从业务上减少结果集,比如把历史数据归档走,或者把"最近三个月"这种条件加进查询。索引不是万能的,过滤完还剩大半张表的数据,任何索引都救不了。
单表查询SQL写得好不好,从来不在于语法多花哨,而在于你是否知道一条查询在数据库内部到底经历了什么。执行顺序、索引命中、NULL语义、聚合边界、分页代价,这五件事吃透了,你写的每条单表查询都会稳很多。我自己的习惯是每写一条稍微复杂点的查询,都习惯性跑一遍EXPLAIN看一眼,这个动作坚持下来,能帮你避免绝大多数线上慢查询事故。