上两篇我们聊了怎么连接数据库、怎么查看表结构、怎么让 SELECT 把指定列的数据取出来,也把 WHERE 里的基础比较运算符过了一遍。这篇继续往查询这条线上走,重点解决三类连真实业务最常碰到的问题:结果怎么按想要的顺序出现、明细数据怎么变成汇总统计、几万行记录里怎么只拿自己需要的那一页。也就是说,把 MySQL 基本查询中的排序、去重、分组聚合、LIMIT 分页一次性说清楚。
如果你正在写 JavaWeb、管理后台报表、做数据分析前的数据探索,或者单纯面试前补 SQL 基础,这篇都适合。默认你用 MySQL 8.0。先提醒一句,刚入门时最容易踩的坑不是 SQL 写不写得出来,而是明明语法看着对,结果和自己心里预期完全不一样。之所以会这样,往往是对数据库执行查询的顺序没有概念。
1. 先把查询逻辑搞顺:数据库并不是从左往右读的
1.1 SELECT 只是“告诉数据库我想要什么”的一层外衣
很多人第一次看 SELECT 语句,会觉得这玩意儿就是从表里挑数据,没什么技术含量。从使用效果上说确实没毛病,但一旦你开始写多表关联、分组统计、嵌套子查询,就会发现一个根本矛盾:用户通常用中文描述业务需求,而 SQL 是一门对执行顺序有严格规定的语言。
比如“查每个班级的平均分,只要平均分不低于 60 分的,按平均分从高到低排,取前 5 个班级”。你脑子里首先想到的可能是“我要展示平均分”“要按平均分排序”,然后你会很自然地把 AVG(score)、ORDER BY 写在前面。但数据库拿到 SQL 以后,是先去找数据源,再过滤行,再分组,再筛选分组结果,最后才决定投影哪些列和怎么排序。
这就是为什么网上所有的 SQL 执行顺序图都把 FROM/JOIN 放在第一位。掌握这个顺序,不只是为了背面试题,更关键的是它能直接解释两个极其常见的问题:
- 为什么 WHERE 里不能写
COUNT(*) > 5这种条件?因为 WHERE 执行时还没分组,COUNT 根本还没算出来。 - 为什么 SELECT 里起的别名,在 WHERE 里用不了,但 ORDER BY 里往往能用?因为 WHERE 比 SELECT 先执行,而 ORDER BY 比 SELECT 晚执行。
一条多表查询的执行顺序大致可以这么理解:先确定数据来自哪些表,再通过 WHERE 把行过滤到最少,接着按分组字段把数据分堆,然后对每一堆执行聚合函数,再用 HAVING 过滤掉不符合条件的分组,最后才轮到 SELECT 计算表达式、去重、排序和分页。
1.2 用一个例子把执行顺序串起来看
看下面这条 SQL:
sql复制SELECT
class_no AS c,
COUNT(*) AS num
FROM student_score
WHERE course_name = '数学'
GROUP BY class_no
HAVING COUNT(*) >= 10
ORDER BY num DESC;
如果不了解执行顺序,很容易疑惑:num 这个别名在 SELECT 里刚定义,为什么 ORDER BY 能直接用?其实 MySQL 处理这条语句时,会先把 student_score 表找出来,用 course_name = '数学' 过滤掉不是数学成绩的行,然后按 class_no 分组,对每组统计个数,再用 HAVING COUNT(*) >= 10 把人数不到 10 的班级组扔出去。等这些事情做完以后,SELECT 才开始把 class_no 和统计结果拿出来并且补一个叫 num 的临时名字,排序阶段自然就能引用这个别名了。
所以我在带团队时要求新人先写 FROM 再写 WHERE 再写 GROUP BY,最后写 SELECT 和 ORDER BY。就算最后提交的代码把 SELECT 放在最前面,大脑里也要按照这个顺序去推导结果。养成这个习惯之后,很多所谓的“SQL 玄学问题”其实都是纸老虎。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. WHERE 过滤的隐藏玩法:别让逻辑运算符坑了你
2.1 AND 和 OR 混用时,括号永远不要省
上一篇文章里讲过简单的 WHERE 条件,比如 WHERE score >= 60。但真实系统中的过滤条件经常是并列的:查某个班级,同时还要看成绩段;查两个班级,还要排除某些课程。这时候一旦把 AND、OR 混在一起,优先级问题就会立刻冒出来。
MySQL 里的优先级规则是:NOT 最高,AND 次之,OR 最低。也就是 A OR B AND C 会被解析成 A OR (B AND C)。
实战现场模拟一下:
sql复制SELECT *
FROM student_score
WHERE class_no = '202501'
OR class_no = '202502'
AND score >= 60;
很多人的本意是:“我要查 202501 或 202502 这两个班,而且这两个班都要 60 分以上。”但上面这条 SQL 在数据库眼里是“查 202501 这个班的所有人,再加上 202502 班里 60 分以上的人”。这个差别,轻则报表多出几十条数据,重则线下排查半天。
正确的写法是用括号把 OR 两边先合并成一个整体:
sql复制SELECT *
FROM student_score
WHERE (class_no = '202501' OR class_no = '202502')
AND score >= 60;
写任何多条件 WHERE 时,我的建议是:不管优先级是否真的会有歧义,只要出现了 OR 和其他条件混合,就一律加括号。括号不会让性能变差,但能让读代码的人不用猜你的意图。
2.2 IN、BETWEEN、LIKE 是时候单独拎出来说了
这三个运算符在基础查询里出现频率极高,但踩坑点各不相同。
先看 IN。WHERE class_no IN ('202501', '202502') 看起来比一串 OR 干净得多。它等价于 class_no 等于列表里某一个值。需要注意的是,如果 IN 后面接的是一个子查询,而子查询结果里包含 NULL,那结果往往不符合直觉。尤其是 WHERE id NOT IN (SELECT student_id FROM blacklist) 这种写法,如果 blacklist 表里存在任意一个 NULL,那么 NOT IN 的结果不会是“所有不在黑名单里的人”,而是整条查询返回空。原因很简单:NULL 参与比较时结果是“未知”,MySQL 不会把未知判定为真。所以遇到 NOT IN 子查询时,要么先确保子查询结果没有 NULL,要么改用 NOT EXISTS。
再看 BETWEEN。BETWEEN 60 AND 80 在 MySQL 里是闭区间,也就是大于等于 60 且小于等于 80。最经典的坑发生在日期查询上。比如查 1 月份的数据,有人会写:
sql复制WHERE create_time BETWEEN '2025-01-01' AND '2025-01-31'
如果 create_time 是 datetime 类型,那么等于 >= '2025-01-01 00:00:00' AND <= '2025-01-31 00:00:00'。1 月 31 日当天 0 点之后的数据,一条都查不出来。正确习惯是写成 create_time >= '2025-01-01' AND create_time < '2025-02-01'。这也是我特别想提醒的一点:日期范围查询宁可写成左闭右开,也不要依赖 BETWEEN 去猜边界。
最后看 LIKE。它主要用于模糊匹配,% 代表任意多个字符,_ 代表任意一个字符。比如查课程名以“数”开头的课,可以写 course_name LIKE '数%'。如果数据里真带百分号,比如课程名是“平时表现占100%”,搜索时就得转义:
sql复制WHERE course_name LIKE '%100!%%' ESCAPE '!'
注意这里用了 ESCAPE '!' 把感叹号定义为转义字符,第二个百分号才是真正的通配符。另外还要养成一个意识:LIKE 前面带 % 的写法几乎无法利用普通索引,数据量一大查询就会慢,能避免尽量避免。
3. ORDER BY 和 DISTINCT:给结果理清楚顺序和边界
3.1 多字段排序要写出优先级
ORDER BY 的语法很简单,难的是排序字段的选择。一个最常见的错误是只按一个字段排序,结果遇到相同值时顺序不稳定。
举个例子,一个班里有多个学生考了 650 分。如果只写 ORDER BY score DESC,这些 650 分的人到底谁在前,MySQL 会按照它认为方便的顺序返回,但这个顺序在数据量变化、索引变化后可能不一样。对于报表输出,这会让分页数据出现重复或跳行。
稳妥做法是给相同值再补一个排序维度:
sql复制SELECT student_no, student_name, score
FROM student_score
WHERE exam_id = 202501
ORDER BY score DESC, student_no ASC;
这里表达的意思很明确:先按分数从高到低,如果分数一样,再按学号从低到高。这样连续翻十页都不会因为边界抖动看到重复记录。
另外还要记住 NULL 的排序位置。MySQL 默认升序时 NULL 排在前面,降序时 NULL 排在后面。这经常让新人惊讶,因为他们本想把缺考的人放最后。如果你想让 NULL 排最后,可以用一个布尔判断把 NULL 单独拎出来:
sql复制ORDER BY score IS NULL, score ASC;
score IS NULL 这个表达式为真时值是 1,为假时值是 0。先按它排序,NULL 记录就会因为 1 大于 0 沉到底部。
3.2 DISTINCT 去重没你想象的那么省事
DISTINCT 的本意是把查询结果里完全相同的行合并成一行。它最基础的用法是查一张表里有哪些班级:
sql复制SELECT DISTINCT class_no
FROM student_score;
但很多初学者会误以为 DISTINCT class_no, gender 是对 class_no 和 gender 分别去重。其实不是。它去重的是 (class_no, gender) 这个组合,相当于“包含这两个字段的每条完整行之间不允许完全重复”。所以想查“一共有多少个班级”“一共有多少个性别”,应该分别写两条查询,而不是塞进同一个 DISTINCT 里。
还要注意,DISTINCT 通常意味着数据库要做额外的去重计算。数据量只有几百条时你完全不用在乎,但如果一张表有上千万行,且只需要统计 COUNT(DISTINCT column) 这样的结果,这个计算代价就不低,可能要走临时表或文件排序。业务上如果对实时性要求很高,这种统计往往需要靠预先落一张汇总表来支撑,而不是每来一次请求就全表去重一次。
4. GROUP BY 聚合查询:把明细变成报表的关键一步
4.1 聚合函数在统计时必须清楚的语义差异
MySQL 里最常用的聚合函数无非五个:COUNT、SUM、AVG、MAX、MIN。单独看都不难,难的是了解它们对 NULL 的处理。
先看 COUNT 的三兄弟:
sql复制SELECT
COUNT(*) AS total_rows,
COUNT(score) AS total_score_not_null,
COUNT(1) AS total_rows_again
FROM student_score;
COUNT(*) 统计的是结果集总行数。COUNT(1) 和 COUNT(*) 在 MySQL 8.0 里性能差别几乎可以忽略,也是总行数。但 COUNT(score) 统计的是 score 这一列中非 NULL 的个数。如果 score 列里有缺考导致的 NULL,三个值就可能不一样。
这个差别在业务统计时非常致命。比如统计“有多少学生填了手机号”,如果某一行手机号是空字符串但不是 NULL,COUNT 也照样会算进去;反过来,如果一行里有 NULL,就会被忽略。所以写统计 SQL 前,先确认你统计的字段到底存不存在 NULL,存储的“空值”到底是 NULL 还是空字符串。
SUM 和 AVG 也会忽略 NULL。假设一门考试缺考的人成绩是 NULL,你写 AVG(score) 算出来的平均分,是不包含缺考学生的平均分;而如果缺考的成绩存的是 0,平均分就会把 0 算进去。两者在报表上会差很多,这是业务语义问题,SQL 本身没有对错。
4.2 分组后到底能 SELECT 什么列
GROUP BY 最基础的用法是:把相同字段值的行合成一组,然后对每一组做聚合。
sql复制SELECT class_no, AVG(score) AS avg_score
FROM student_score
GROUP BY class_no;
但下面这条 SQL 在 MySQL 8.0 里直接会报错:
sql复制SELECT student_name, class_no, AVG(score)
FROM student_score
GROUP BY class_no;
原因是 student_name 不在 GROUP BY 里,也不是聚合函数的参数。当分组是以 class_no 为单位时,一组里可能有多名学生,数据库不知道该把哪个 student_name 展示出来。MySQL 8.0 默认开启了 ONLY_FULL_GROUP_BY,所以会直接拒绝执行,不允许这种含糊其辞的 SQL 存在。
有些老教程里会说 MySQL 比标准 SQL 宽松,可以随便 SELECT。这建立在你把 ONLY_FULL_GROUP_BY 关掉的前提下。我强烈建议不要关。你在自建环境里觉得方便,一旦换成线上默认配置,SQL 直接跑不起来,而且这种不规范的写法很容易在分组后取到随机行,最后统计结果连你自己都没法解释。
如果需要的是“每个班级总分最高的学生姓名”这类需求,不能靠 SELECT 不分组列来解决,而应该用窗口函数或者先查出每班最高分,再通过关联把学生信息找出来。这属于进阶查询,下一篇我们再展开,但要先建立意识:GROUP BY 之后,每条 SELECT 的非聚合列必须和分组字段有确定的对应关系。
4.3 HAVING:分组之后的第二道闸门
WHERE 负责在分组前过滤行,HAVING 负责在分组后过滤分组。两者最大的区别是 HAVING 可以使用聚合函数,WHERE 不能。
举一个很容易踩的场景:想找出平均分低于 60 分的班级。
sql复制-- 错误写法
SELECT class_no, AVG(score) AS avg_score
FROM student_score
WHERE AVG(score) < 60
GROUP BY class_no;
这条 SQL 会报 Invalid use of group function,也就是错误码 1111。逻辑上完全说不过去:WHERE 执行时分组还没发生,AVG 根本算不出来。正确写法是:
sql复制SELECT class_no, AVG(score) AS avg_score
FROM student_score
GROUP BY class_no
HAVING AVG(score) < 60;
HAVING 还可以和 WHERE 配合得很紧密。比如先只统计“期末考试”的成绩再分组:
sql复制SELECT class_no, AVG(score) AS avg_score
FROM student_score
WHERE exam_type = '期末考试'
GROUP BY class_no
HAVING AVG(score) < 60;
这个执行过程是:先把不是期末考试的行全部过滤掉,再按班级分组,最后只保留平均分低于 60 的组。用一句话概括就是:能在 WHERE 里过滤掉的,就别留到 HAVING;HAVING 只做聚合后的条件判断。
5. LIMIT 分页:看着简单,深坑不少
5.1 OFFSET 和 ROW_COUNT 的两种写法
几乎每个后台管理系统都离不开分页。MySQL 的 LIMIT 有两种常见形式。
第一种是只限制返回多少行:
sql复制SELECT *
FROM student_score
ORDER BY score DESC
LIMIT 10;
这个很好理解,取排序后的前 10 条。第二种是跳过一段再取:
sql复制SELECT *
FROM student_score
ORDER BY score DESC
LIMIT 10, 5;
这里的第一个数字 10 不是“最多返回 10 条”,而是“跳过前面 10 条”,第二个数字 5 才是“返回 5 条”。所以上面这条 SQL 的结果是取出第 11 到第 15 条记录。很多人刚接触时会把顺序记反,写成 LIMIT count, offset,直接导致数据错乱。
为了避免歧义,我更喜欢用 OFFSET 这种显式写法:
sql复制SELECT *
FROM student_score
ORDER BY score DESC
LIMIT 5 OFFSET 10;
语义非常明确:偏移 10 行,再取 5 行。如果你用 Java 或者 Python 封装分页,通常页面从 1 开始,每页显示 size 条,那么第 page 页的 offset 是 (page - 1) * size。比如第 3 页,每页 20 条,就是 LIMIT 20 OFFSET 40。
5.2 深分页会让数据库扫到崩溃
LIMIT 的坑不在于写错语法,而在于分页过深。假设有一张表有 100 万条成绩记录,你要查第 50000 页,每页 20 条。你可能觉得数据库只查最后 20 条应该很快,但真实情况是,MySQL 会先按排序条件把前 100 万条都找出来,再丢掉前 999980 条,最后留下 20 条。这个开销极其夸张。
如果只是个人开发环境几千条数据,你完全感觉不到问题。但在生产环境的报表查询里,LIMIT 1000000, 20 这种写法很可能会把一个本来支持并发的数据库拖慢到接口超时。
常见的优化方案是使用“上一页最后一条记录的排序键”来做条件过滤。也就是说,翻页时不传页码,而是把当前页最后一条记录的 id 传给后端,后端执行:
sql复制SELECT id, student_no, score
FROM student_score
WHERE score < 650
ORDER BY score DESC
LIMIT 20;
每次拿到的都是比上次游标更小的一批数据,数据库只需要沿着索引往下扫 20 条,而不是丢掉几十万条无用记录。这种方案更适合 App 端无限下拉列表,后台管理系统如果坚持要做页码跳转,也不要怕麻烦,业务上通常会把最大翻页深度限制住,比如只允许翻到前 100 页。
6. 实战:用成绩表的几个查询把今天的内容串起来
6.1 先造一个简单的成绩表
理论说了再多,不如直接在终端里跑一遍。建一张学生成绩表:
sql复制CREATE TABLE student_score (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
student_no VARCHAR(20) NOT NULL COMMENT '学号',
student_name VARCHAR(50) NOT NULL COMMENT '姓名',
class_no VARCHAR(20) NOT NULL COMMENT '班级',
course_name VARCHAR(50) NOT NULL COMMENT '课程',
score DECIMAL(5,2) COMMENT '成绩,缺考则 NULL',
exam_type VARCHAR(20) NOT NULL DEFAULT '期末考试',
PRIMARY KEY (id),
KEY idx_class_course (class_no, course_name),
KEY idx_score (score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生成绩表';
我特意把 score 字段设置为允许 NULL,用来模拟缺考。实际做报表时,数据处理起来会比想象中繁琐,这也是为什么造数阶段就要刻意留一个 NULL 场景。
插入几行模拟数据后,开始跑查询。
6.2 四个典型场景可以直接套用
场景一:统计每门课程的平均分、最高分、最低分和参考人数。
sql复制SELECT
course_name,
COUNT(*) AS exam_cnt,
COUNT(score) AS actual_cnt,
AVG(score) AS avg_score,
MAX(score) AS max_score,
MIN(score) AS min_score
FROM student_score
WHERE exam_type = '期末考试'
GROUP BY course_name
ORDER BY avg_score DESC;
这里 COUNT(*) 和 COUNT(score) 同时查出来,就是为了让你直观看到缺考人数:两者不一致,就说明这门课有成绩为 NULL 的记录。AVG 只统计非 NULL,所以缺考不会拉低平均分。
场景二:找出每个班级总分排名前 10 的学生。没法用单纯分组解决(因为需要保留学生姓名),这是基础查询里最容易困惑的问题。此时可以退一步先得到每个班级的总分排序明细,再交给应用层处理。如果只要前几,可以在应用层限制,或者后面学窗口函数。我们这里先用一个“每个班级各科成绩在 60 分以下科目数”的案例来演示 WHERE、GROUP BY、HAVING 的组合:
sql复制SELECT
class_no,
COUNT(*) AS fail_subject_cnt
FROM student_score
WHERE score < 60
OR score IS NULL
GROUP BY class_no
HAVING COUNT(*) >= 3
ORDER BY fail_subject_cnt DESC;
这里把低于 60 分和 NULL 都视为“未通过科目”,按班级统计挂科数,最后只保留挂科 3 门以上的班级。WHERE 先把非挂科记录过滤掉,GROUP BY 再按班级统计,HAVING 再砍掉挂科少的组,执行链路非常清晰。
场景三:查询每门课程中“班级最高分”出现在哪个班。可以直接用多个聚合条件:
sql复制SELECT
course_name,
MAX(score) AS max_score
FROM student_score
GROUP BY course_name
ORDER BY course_name;
如果还需要最高分对应的人,不建议在一条 SQL 里硬塞,可以先查出课程和最高分,再写一条关联查询,保持思路简单可靠。
场景四:对分数做分页排序查看。
sql复制SELECT student_no, student_name, course_name, score
FROM student_score
ORDER BY score DESC, student_no ASC
LIMIT 5 OFFSET 0;
把 OFFSET 改为 5、10、15,就能逐页往后看。前提是排序字段稳定,所以我把 student_no 加上了,避免同分时出现顺序漂移。
7. 基础查询常见问题与排查技巧实录
7.1 一张速查表解决高频报错
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
Unknown column 'avg_score' in 'where' |
WHERE 中使用了 SELECT 别名 | WHERE 阶段别名还没生成,改用 HAVING 或重复写聚合表达式 |
Invalid use of group function |
在 WHERE 里使用 COUNT、SUM 等聚合函数 | 聚合条件放到 HAVING 里 |
Expression #1 of SELECT list is not in GROUP BY clause |
只开了严格模式,SELECT 了不在 GROUP BY 中的普通列 | 要么把该列加进 GROUP BY,要么用聚合函数包裹 |
Every derived table must have its own alias |
子查询没有取别名 | 给子查询加别名,例如 FROM (...) AS t |
| 统计数据看起来“少了” | 没注意 COUNT(col) 会忽略 NULL | 按业务确认需要 COUNT(*) 还是 COUNT(非空列) |
ORDER BY 排序结果不稳定 |
排序字段存在重复值 | 增加一个唯一字段作为第二排序条件 |
这里单独提一下子查询别名。MySQL 要求每个派生表必须有别名。很多人第一次写 FROM 后面的子查询时都会漏:
sql复制SELECT *
FROM (
SELECT class_no, AVG(score) AS avg_score
FROM student_score
GROUP BY class_no
) AS class_avg
WHERE avg_score > 80;
不写 AS class_avg 会直接报错。这不是业务逻辑问题,纯粹是语法规定,但报错信息足以让新手一头雾水。以后看到 Every derived table must have its own alias,第一反应就是给派生表补别名。
7.2 两个特别容易忽略的习惯
第一个习惯,是在开发环境写完 SQL 后,先加上 EXPLAIN 看一眼执行计划。执行计划里最直观的参考是 type 列和 rows 列。如果 type 是 ALL,说明在做全表扫描;rows 显示 100 万而你最终只要 20 条,那这条 SQL 上生产前就要好好优化。
比如刚才的深分页问题,在 EXPLAIN 里看到 rows 还是几十万时,就能猜到真实线上会慢。用主键游标改写后,rows 会大幅度减少。这个动作花不了十秒钟,却能在上线前拦截掉大部分SQL性能问题。
第二个习惯,是尽量不使用 SELECT *。一是从基础查询阶段就只选需要的列,能减少网络传输的字段;二是当你写 SELECT * 配合 GROUP BY 时,严格模式会报错,而且这种写法没法表达你到底想要哪些字段。真正上手项目后你会发现,SQL 代码的可读性比省几次键盘敲击重要得多。把需要的字段一个一个列出来,别人 review 代码时才能看清这条查询到底在做什么。
这套查询内容写到这儿,我的体会是:SQL 语法本身没什么高深的东西,但每个关键字背后都藏着一套执行顺序和 NULL 处理规则。你越是把“业务需求翻译成执行顺序”当成思考习惯,越少遇到那些让人原地转圈的诡异结果。下一篇如果继续往查询方向走,我会把子查询和 JOIN 怎么衔接也拉出来聊透。
