简介:面向国家开放大学 MySQL 数据库应用课程学习者的实验训练 2 配套文档,聚焦数据查询操作,适合正在完成课程实验、准备数据库基础练习或需要梳理 SQL 查询思路的初学者。内容围绕字段查询、多条件筛选、DISTINCT 去重、ORDER BY 排序、GROUP BY 分组及 COUNT、SUM、AVG、MAX、MIN 等聚合函数展开,并延伸到内连接、外连接、复合条件连接、IN/NOT IN、比较运算符、EXISTS 等嵌套查询场景,每类实验均配有题目分析与实现思路,便于对照理解查询条件和语句结构。资源包共 1 个 PDF 文件,大小约 1.75MB,单文件便于在电脑或平板上查阅,适合作为实验课配套讲义和课后复盘材料。目前已有 3339 人学习,说明其内容对课程实验与 SQL 入门练习具有较好的参考价值,可帮助读者系统梳理从单表查询到多表连接、子查询的知识链路。
1. 查询的四层骨架:从单表筛选到子查询嵌套
MySQL 数据查询看着零散,把整套实验训练拆开看,其实是一条四层的线:先用 WHERE 把行筛出来,再用 DISTINCT 和 ORDER BY 整理输出形态,接着用 GROUP BY 加聚合函数把行压成组,然后用 JOIN 把分散在多张表里的字段拼回一行,最后用子查询和 UNION 把前几步当成零件嵌套起来。
前面的层没吃透,后面的嵌套就会变成玄学。这套东西适合三类人:正在做数据库课程实验、要交出能跑的查询语句的;准备数据库相关考试、需要把语法点归位的;以及写业务 SQL 时总在分组和连接上翻车的。反直觉的地方在于,多数人以为难点是多表连接,实际丢分最多的是 DISTINCT 去重的边界、外连接左右表的位置,还有 WHERE 与 HAVING 的分工。
2. 单表查询四件套:字段、条件、去重与排序的复习重点
单表查询是整条链的地基,判断标准很朴素:查询要的列、过滤要的条件,是不是都在同一张表里。只要有一个列不在,就得上连接或子查询;如果全在,就没必要把语句写复杂。
2.1 字段查询与 WHERE 条件:先确认「一张表够不够」
整套实验里最简单的两个例子恰好说明了这个判断方法。查商品名称为「挡风玻璃」的商品信息,商品名称和商品信息都在商品表,所以是单表;查 ID 为 1 的订单,订单号和订单内容都在订单表,用户 ID 也在订单表里,依然是单表。很多人一看到「订单」就下意识去连用户表,其实这一题根本不需要用户名。
-- 列和条件都落在商品表,单表就能完成 SELECT id, name, price, stock FROM goods WHERE name = '挡风玻璃'; -- 订单表里已经有 user_id,不需要为了这条查询去连用户表 SELECT id, user_id, total, order_date FROM orders WHERE id = 1; -- 多条件查询:既是促销商品,价格又要低于 1000 SELECT id, name, price FROM goods WHERE is_promotion = 1 AND price < 1000;SELECT后面列出的字段决定结果集的宽度,WHERE决定结果集的行数,两者互不影响,但都影响性能。写SELECT *在实验里不算错,可一旦表里有大文本字段,把整行拉回来既浪费传输也拿不到覆盖索引的好处,复习阶段就养成写明确列名的习惯,考试时反而不容易漏列。
关于执行顺序,逻辑上是FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY。这条顺序解释了两件事:WHERE里不能用SELECT中起的别名,因为那时别名还没产生;而ORDER BY和HAVING可以用别名,因为它们在SELECT之后。这一点在做复杂查询时特别关键,别名报错十有八九是位置放错了。
多条件里最容易踩的是AND和OR的优先级。AND的优先级高于OR,所以「促销且价格低于 1000,或者库存为 0」这种语义,如果写成is_promotion = 1 AND price < 1000 OR stock = 0,实际含义会变成「(促销且低价) 或 (库存为 0)」。只要出现OR,把每组条件用括号包起来,是最省后悔药的做法。
2.2 DISTINCT:去重去的是「整行组合」
DISTINCT的语义经常被误解成「对某一列去重」,准确说法是:对SELECT列表里所有列组成的整行组合去重。只有一列时两者看起来一样,一旦写成两列,差别立刻显现。
-- 一个用户可以对同一件商品评论多次,这里要的是「哪些用户评论过」 SELECT DISTINCT user_id FROM comments WHERE goods_id = 1; -- 多列去重:去的是「用户 + 商品」这个组合,不是单独的 user_id SELECT DISTINCT user_id, goods_id FROM comments; -- 统计评论过该商品的用户人数,DISTINCT 要写进聚合函数里 SELECT COUNT(DISTINCT user_id) AS user_count FROM comments WHERE goods_id = 1; -- 会员创建时间段,一年为一段:先取年份再去重 SELECT DISTINCT YEAR(create_date) AS create_year FROM users ORDER BY create_year;最后那条例子值得多想一步。如果直接SELECT create_date FROM users,每个会员一条记录,同一年会出现很多重复;套上YEAR()再DISTINCT,才得到「年份段」这个粒度。这里隐含一个思路:去重之前先想清楚输出的粒度是什么,粒度是「用户」还是「用户+商品」,决定了DISTINCT后面跟几列。
DISTINCT和GROUP BY在只输出分组键时结果等价,SELECT DISTINCT user_id FROM comments与SELECT user_id FROM comments GROUP BY user_id返回一样。区别在于GROUP BY还能顺手带聚合函数,而DISTINCT不行。执行层面,DISTINCT通常要走排序或哈希去重,数据量大时不是零成本操作,能靠更精确的WHERE少返回行,就别指望DISTINCT兜底。
2.3 ORDER BY:降序只作用于它前面那一列
排序本身不难,难在细节。ORDER BY默认是ASC升序,DESC只对紧挨着它的那一列生效,多列排序时每一列都要单独声明方向。这个规则在实验里不明显,一旦题目要求「先按类别升序,同类别内按价格降序」,写错方向就是整题扣分。
-- 类别 1 的所有商品,按商品 ID 降序 SELECT id, name, price FROM goods WHERE category_id = 1 ORDER BY id DESC; -- 今年新增会员,按用户名排序 SELECT id, username, create_date FROM users WHERE YEAR(create_date) = YEAR(CURDATE()) ORDER BY username ASC; -- 多列排序:类别升序,同类别内价格降序 SELECT id, category_id, price FROM goods ORDER BY category_id ASC, price DESC;还有一个 MySQL 特有的行为要记住:升序排列时NULL排在最前,降序时NULL排在最后。如果题目要求「价格从低到高,没有价格的排最后」,光写ORDER BY price ASC是不达标的,得补一个排序键,比如ORDER BY price IS NULL, price ASC,利用布尔值 0/1 把空值压到后面。
排序字段不要求出现在SELECT列表里,SELECT id, name FROM goods ORDER BY price DESC是合法的。但DISTINCT和ORDER BY同时出现时,排序键必须在SELECT列表内,否则语义上说不清是按哪一行排。这个限制在不同版本上表现略有差异,写语句时按最严格的标准要求自己更稳妥。
3. 分组与聚合:GROUP BY 和五个聚合函数怎么配合
分组聚合是很多人第一处真正卡住的地方,因为它要求思维从「一行一行看」切换成「一桶一桶看」。切不过去,后面连接和子查询都会连带出问题。
3.1 五个聚合函数的语义边界
聚合函数的作用是把多行压成一个值。五个函数里,COUNT的花样最多,也最容易答错。
| 函数 | 处理 NULL 的方式 | 空表或全 NULL 时的返回 | 典型用途 |
|---|---|---|---|
COUNT(*) | 统计行数,不看列值 | 0 | 订单笔数、记录条数 |
COUNT(col) | 跳过该列为 NULL 的行 | 0 | 统计某字段有值的行数 |
COUNT(DISTINCT col) | 先去重再计数,跳过 NULL | 0 | 去重后的人数、种类数 |
SUM(col) | 跳过 NULL | NULL | 消费总额、销售额 |
AVG(col) | 跳过 NULL,分母是非 NULL 行数 | NULL | 平均客单价 |
MAX与MIN | 跳过 NULL | NULL | 最高价、最低价、文本字母序 |
-- 类别数量:COUNT(*) 和 COUNT(id) 在 id 非空时结果一致 SELECT COUNT(*) AS category_count FROM category; -- 每天的接单数:聚合函数配合 GROUP BY SELECT order_date, COUNT(*) AS order_count FROM orders GROUP BY order_date; -- 每天的销售额 SELECT order_date, SUM(total) AS daily_amount FROM orders GROUP BY order_date; -- 所有订单的平均销售金额 SELECT AVG(total) AS avg_amount FROM orders; -- 商品数量最大者 SELECT MAX(stock) AS max_stock FROM goods; -- 价格最低者 SELECT MIN(price) AS min_price FROM goods; -- 文本列也能比大小,结果取决于字符集排序规则 SELECT MAX(username) AS last_name FROM users;MAX和MIN用在文本列上这件事,实验里专门提了一句,容易被当成冷知识跳过。它的实际含义是「按当前排序规则比较出的最大或最小字符串」,中文环境下受排序规则影响,未必等于拼音顺序,考试如果问「名字最靠前的人」,答「用 MIN 或 MAX 都可以」比答一个确定结果更安全。
AVG有一个隐蔽的分母问题。当列里有NULL时,AVG(total)的分母是非NULL的行数,不是总行数。想要「把 NULL 当 0 参与平均」,得写SUM(total) / COUNT(*),或者AVG(IFNULL(total, 0))。这两种写法的结果不一样,题目说「所有订单的平均金额」时,按业务语义通常应该是分母为总订单数,这一点在复盘时要专门标出来。
3.2 GROUP BY:分组键决定结果的行数
GROUP BY的本质是把行按分组键切成若干个桶,每个桶输出一行。所以有一句可以直接背下来的判断:结果集有多少行,取决于分组键有多少种不同组合。
-- 每个用户的消费总金额,一个用户一行 SELECT user_id, SUM(total) AS total_amount FROM orders GROUP BY user_id; -- 多列分组:类别和价格都相同才算同一个桶 SELECT category_id, price, COUNT(*) AS goods_count, SUM(stock) AS stock_sum FROM goods GROUP BY category_id, price; -- 分组之后再排序,按总额从高到低 SELECT user_id, SUM(total) AS total_amount FROM orders GROUP BY user_id ORDER BY total_amount DESC;第二条是实验里点名的「多列分组」,比单列分组难一档。它和SELECT DISTINCT category_id, price的结果行数完全一样,只是多了聚合值。写这类语句时有一个自检办法:把GROUP BY后面的列和SELECT后面的非聚合列对一遍,两边必须严格一致,多一个少一个都是错的。
较新的 MySQL 默认开启ONLY_FULL_GROUP_BY,SELECT里出现既不在分组键、又没被聚合函数包住的列会直接报错。这个报错不是刁难,它在提醒你语句的语义不自洽。绕过它有两个方向:把列加进GROUP BY,或者给它套一个聚合函数(比如MAX(name))。前者更符合直觉,后者属于「取桶里任意一行」的妥协写法,业务上要谨慎。
3.3 WHERE 与 HAVING:一个筛行,一个筛组
这两个子句混用是高频错误。判断标准只有一句:过滤的对象是行还是组。行级条件放WHERE,组级条件(尤其是带聚合函数的)放HAVING。
-- 先筛行(只算今年的订单),再分组,最后筛组(总额超过 1000 的用户) SELECT user_id, SUM(total) AS total_amount FROM orders WHERE order_date >= '2024-01-01' GROUP BY user_id HAVING SUM(total) > 1000 ORDER BY total_amount DESC;这条语句的执行顺序能解释为什么WHERE里不能写SUM(total) > 1000:WHERE执行时还没有分组,聚合值根本不存在。
性能上还有一个取舍:能在WHERE里过滤掉的行,就别留到HAVING。上面例子里把日期条件放WHERE,参与分组的行数会少很多;如果写成HAVING order_date >= '2024-01-01',得先把全部订单分组再筛,白白多做一遍功。同理,HAVING最好只放聚合函数条件,其他条件一律下沉到WHERE。
4. 连接与嵌套:内连接、外连接、子查询与 UNION 的位置感
多表查询的难点不在语法,在于「位置感」:谁和谁通过什么字段对齐、哪张表的行要全部保留、子查询应该嵌在哪一层。位置搞反,语法再对结果也是错的。
4.1 内连接:两张表靠什么字段对齐
需要连接的信号很明确:查询要的列分布在两张表上。实验里「查询所有订单的发出者名字」,订单信息在订单表,用户名在用户表,订单表里只有user_id这个外键,于是两张表通过user_id = id对齐。
-- 订单表连用户表:连接条件是外键 = 主键 SELECT o.id AS order_id, o.total, u.username FROM orders o INNER JOIN users u ON o.user_id = u.id; -- 购物车连商品表:拿到商品名称 SELECT c.user_id, g.name AS goods_name, c.quantity FROM cart c INNER JOIN goods g ON c.goods_id = g.id;INNER JOIN的语义是「只保留两边都匹配上的行」。所以内连接有个特性:换表的先后顺序,结果集完全一样。上面第一条把users写在前面,结果不变。
ON后面写连接条件,WHERE后面写过滤条件。在内连接里这两个位置在结果上等价,FROM a JOIN b ON a.id = b.a_id WHERE b.price > 10和把b.price > 10挪进ON的结果一样。但这种等价只在内连接成立,外连接里挪位置会改变语义,下一节会看到。
多表连接是链式的,三张表就用两个JOIN,每个JOIN只负责把新表接上去:
-- 三表串联:订单明细 → 订单 → 用户 SELECT od.order_id, o.user_id, u.username, od.goods_id FROM order_detail od INNER JOIN orders o ON od.order_id = o.id INNER JOIN users u ON o.user_id = u.id;链式连接的顺序可以调换,只要每一环的连接条件成立。但为了可读性,习惯上从「最核心的事实表」出发,比如订单明细,再一路往外扩。
4.2 外连接:谁全保留,谁就写在左边
外连接考的是「保留哪一边的全部行」。LEFT JOIN保留左表全部行,右表匹配不上的位置补NULL;RIGHT JOIN反过来保留右表全部行。实验里那句「查询列出所有用户 ID,以及他们的评论,如果有的话」——关键词是「所有用户」,所以用户表必须全保留。
-- 写法一:用户表在左,LEFT JOIN 保留用户表全部行 SELECT u.id, c.content FROM users u LEFT JOIN comments c ON u.id = c.user_id; -- 写法二:等价写法,用户表在右,RIGHT JOIN 保留用户表全部行 SELECT u.id, c.content FROM comments c RIGHT JOIN users u ON c.user_id = u.id;两条语句结果一致,但第一条更好读,因为它把「全保留的那张表」放在左边,LEFT这个单词的含义和直觉对齐。复习时建议统一采用「全保留表放左边 + LEFT JOIN」的写法,能省掉大量位置判断。
外连接最容易出错的地方在WHERE。看下面这条:
-- 错误示例:WHERE 里筛右表列,外连接退化成内连接 SELECT u.id, c.content FROM users u LEFT JOIN comments c ON u.id = c.user_id WHERE c.content IS NOT NULL;c.content IS NOT NULL会把右表补出来的NULL行全部过滤掉,结果和INNER JOIN一模一样。如果业务本意就是「只要评论过的用户」,那这么写没错,但如果题目要「所有用户,有评论就显示」,这里就直接翻车。想把条件作用在连接阶段而不是结果阶段,条件要写进ON:
-- 条件写进 ON:保留所有用户,只连接符合条件的评论 SELECT u.id, c.content FROM users u LEFT JOIN comments c ON u.id = c.user_id AND c.content IS NOT NULL;4.3 复合条件连接:连接完再筛,还是筛完再连
复合条件连接就是「连接 + 额外过滤」。实验里「查询用户 ID 为 1 的客户的订单信息和客户名」,先按user_id = id连好,再加一条u.id = 1。
-- 连接 + 等值过滤 SELECT o.id AS order_id, o.total, u.username FROM orders o INNER JOIN users u ON o.user_id = u.id WHERE u.id = 1; -- 连接 + 排序:购物车中的商品价格,按价格排列 SELECT c.user_id, g.name, g.price FROM cart c INNER JOIN goods g ON c.goods_id = g.id ORDER BY g.price ASC;这两条对应两种组合模式:连接负责「把列凑齐」,WHERE负责「把行收窄」,ORDER BY负责「把顺序定下来」。三者是流水线上的不同工序,写的时候按这个顺序逐段加,比一上来就写完整句更容易排错。
过滤条件写在ON还是WHERE,内连接下随意,外连接下必须想清楚。这个判断在前面提过一次,这里再强调是因为它同时是最常考、最容易扣分的点:想保留哪张表的全部行,就把作用在另一张表上的过滤条件写进ON。
4.4 子查询四种形态:IN、比较符、EXISTS、ANY/ALL
子查询的本质是「把一个SELECT的返回值当成另一个SELECT的输入」。按返回值的形状分四类,写法跟着变。
第一类返回一列多行,用IN和NOT IN。实验里「查询订购商品 ID 为 1 的订单 ID,再根据订单 ID 查用户 ID」,就是先由订单明细查订单号,再拿这批订单号去查订单表。
-- 由内到外:先找出买了商品 1 的订单,再查这些订单是谁下的 SELECT user_id FROM orders WHERE id IN ( SELECT order_id FROM order_detail WHERE goods_id = 1 ); -- 反面:没买过商品 1 的用户 SELECT user_id FROM orders WHERE id NOT IN ( SELECT order_id FROM order_detail WHERE goods_id = 1 );NOT IN有一个隐蔽的陷阱:如果子查询结果里含NULL,整个NOT IN会返回空集甚至UNKNOWN,因为「不等于任何值」碰到NULL无法判定为真。补法是子查询里加WHERE order_id IS NOT NULL,或者改用NOT EXISTS。这个坑在做「排除类」题目时几乎必踩,值得单独记一笔。
第二类返回单值,用比较运算符。实验里「今年新增会员的订单,且订单总价小于 100」属于这一类。
-- 子查询返回一批用户 ID,外层用 IN 承接;总价条件写在 WHERE SELECT id, total FROM orders WHERE user_id IN ( SELECT id FROM users WHERE YEAR(create_date) = YEAR(CURDATE()) ) AND total < 100;第三类是EXISTS,它只判断子查询有没有返回行,返回真假,不关心具体值。所以子查询里写SELECT 1就够,写SELECT *也不会更慢。
-- 用户 100 存在时,列出他的信息 SELECT * FROM users WHERE id = 100 AND EXISTS (SELECT 1 FROM users WHERE id = 100); -- EXISTS 配合额外条件:类别 100 存在时,列出该类别下价格小于 5 的商品 SELECT id, price FROM goods WHERE category_id = 100 AND price < 5 AND EXISTS (SELECT 1 FROM category WHERE id = 100);EXISTS与IN的取舍在于语义:IN是「值在集合里」,EXISTS是「存在这样的行」。当子查询只用来做存在性判断、不需要拿值出来比,EXISTS更贴切,而且遇到NULL时不会像NOT IN那样翻车。
第四类是ANY和ALL,必须跟比较运算符连用。> ANY的含义是「比子查询结果中的某一个值大」,等价于「比最小值大」;> ALL是「比所有值都大」,等价于「比最大值大」。
-- 比类别 1 中任一商品价格高的商品:等价于大于该类别最低价 SELECT id, price FROM goods WHERE price > ANY (SELECT price FROM goods WHERE category_id = 1); -- 比类别 1 中所有商品价格都高的商品:等价于大于该类别最高价 SELECT id, price FROM goods WHERE price > ALL (SELECT price FROM goods WHERE category_id = 1);把ANY记成最小值、ALL记成最大值,是这类题目最快的判断方式。反过来> ALL若子查询为空集,条件恒为真,这也是个容易忽略的边界。
4.5 集合查询:UNION 与 UNION ALL 的取舍
集合查询是把两条独立SELECT的结果上下拼起来。使用前提有三条:列数相同、对应列类型兼容、列顺序一致。
-- UNION:合并结果并去掉完全重复的行 SELECT id, name FROM goods WHERE price < 5 UNION SELECT id, name FROM goods WHERE category_id IN (1, 2); -- UNION ALL:保留所有行,包括两条语句重复返回的 SELECT id, name FROM goods WHERE price < 5 UNION ALL SELECT id, name FROM goods WHERE category_id IN (1, 2);实验里专门对比了这两个关键字,区别就在去重。UNION内部通常要做一次去重(排序或哈希),开销更大;UNION ALL直接拼接,快得多。如果业务上已经确定两条语句不会产生重复,或者重复行本来就该保留,用UNION ALL更划算。
另一个细节:ORDER BY只能写在最后一条SELECT之后,作用于合并后的整体结果;写在中间会被当成那条子查询自己的排序,甚至直接报错。想让整体结果有序,把ORDER BY放到最外层。
UNION和OR有时能互相替代。WHERE price < 5 OR category_id IN (1,2)和上面的UNION结果接近,但前者是单表一次扫描,通常更快;UNION更适合两条语句来自不同表、或条件复杂到用OR写会失控的场景。
5. 易错点排查:五个最容易翻车的查询写法
前面四层都是正向知识,这一章反过来,只列那些「语法没错、结果错」的地方。这类错误最难自查,因为语句能跑,报错信息一条都没有。
5.1 五个高频翻车点逐条拆
易漏点一:LEFT JOIN 之后在 WHERE 里过滤右表列
为什么漏:写语句时习惯把「所有条件」都堆到WHERE,而WHERE在语法树上排在JOIN后面,看起来天经地义。可外连接补出来的NULL行也会被WHERE检查,一筛就没了,语句悄悄退化成内连接。
怎么补:写完外连接先问一句「结果里允许出现 NULL 吗」。如果允许,作用在右表上的所有过滤条件都写进ON;只有确实想排除未匹配行的条件才留在WHERE。更省事的办法是先用LEFT JOIN把语句跑一遍看总行数,再加WHERE对比行数有没有掉,掉了就说明位置错了。
易漏点二:GROUP BY 与 SELECT 非聚合列不一致
为什么漏:分组键和输出列分两处写,中间隔着好几个字段,眼睛容易滑过去。在宽松模式下还能跑出结果,取的是桶里任意一行的值,看起来像对的。
怎么补:形成固定动作——SELECT里除了聚合函数包住的列,其余每一列都要能在GROUP BY里找到。多列分组时尤其要逐列核对。把ONLY_FULL_GROUP_BY打开来写实验,让数据库替你报错,比事后自查可靠。
易漏点三:NOT IN 撞上 NULL 返回空结果
为什么漏:IN写对了,NOT IN就照抄,只改一个关键字,看起来是安全的对称操作。但三值逻辑里x <> NULL的结果是UNKNOWN而不是TRUE,只要子查询结果含一个NULL,整个条件就废了。
怎么补:写NOT IN之前先确认子查询那列有没有NULL,有就补IS NOT NULL条件,或者整体换成NOT EXISTS。做「排除某类记录」的题目时,这条几乎必查。
易漏点四:COUNT 三种写法混着用
为什么漏:三个写法长得像,COUNT(*)、COUNT(col)、COUNT(DISTINCT col)在数据没有NULL、没有重复时结果完全一样,实验数据干净,看不出差别。
怎么补:记语义而不是记结果。COUNT(*)数行,COUNT(col)数该列非空的行,COUNT(DISTINCT col)数去重后的非空值。题目问「有多少人评论过」用第三种,问「有多少条评论」用第一种,问「有多少条评论填了内容」用第二种。这三个问法在题干里往往只差几个字。
易漏点五:多列排序时 DESC 只写了一次
为什么漏:ORDER BY a DESC, b读起来像「a 和 b 都降序」,实际b是默认升序。单列排序练多了,多列时容易漏掉第二个方向词。
怎么补:多列排序时逐列写方向,哪怕全是升序也写上ASC,让每一列的方向都显式可见。写完以后拿一条真实数据验证:找一组分组键相同、排序键不同的记录,看输出顺序对不对。
5.2 拿到题先问四个问题
把上面这些点收成一套可以照着走的流程,比零散记规则更管用。每道查询题落笔前先回答四问。
第一问:需要的列分别在哪几张表。全在一张,就是单表;跨两张,就要连接或子查询。这一问决定语句的骨架,答错后面全错。
第二问:结果要行还是组。题干出现「每个」「每天」「各」这类词,基本就是分组;出现「所有」「列出」,通常是不分组但可能要外连接。
第三问:有没有「全部保留」的要求。出现「所有用户,如果有评论就显示」这种句式,答案就是外连接,而且要立刻决定哪张表写在左边。
第四问:过滤条件作用在行、组,还是连接阶段。行级进WHERE,组级进HAVING,外连接里作用在非保留表上的进ON。
-- 四问走完的成品:所有用户 + 其评论条数 + 只统计 2024 年之后的评论 -- 第一问:用户表、评论表 → 需要连接 -- 第二问:每个用户一行 → 需要分组 -- 第三问:所有用户 → 外连接,用户表放左边 -- 第四问:年份条件作用在评论表 → 写进 ON,不能写进 WHERE SELECT u.id, u.username, COUNT(c.id) AS comment_count FROM users u LEFT JOIN comments c ON u.id = c.user_id AND c.create_date >= '2024-01-01' GROUP BY u.id, u.username ORDER BY comment_count DESC;这条语句把四个易错点全踩了一遍又全避开了:外连接保留左表、过滤条件在ON、COUNT(c.id)而不是COUNT(*)(后者会把没评论的用户也数成 1)、分组键与SELECT非聚合列一致。写完这样一条,比刷十条简单查询更有用。
6. 复习清单:把查询题拆成可自查的四步
复习到最后一轮,不建议再从头看语法,改成按「题型 → 检查项」的方式过一遍。下面这张表可以当作自查底稿,每勾掉一行,就等于确认了一类题不会翻车。
| 题型 | 关键检查项 | 最容易错的地方 |
|---|---|---|
| 单表条件查询 | 列和条件是否同表;AND与OR是否加括号 | 明明一张表够用却去连表 |
| 去重查询 | 输出粒度是单列还是组合;要不要配COUNT(DISTINCT) | 把DISTINCT当成单列去重 |
| 排序查询 | 每列方向是否都写明;NULL排在哪 | DESC只作用于最近一列 |
| 分组聚合 | SELECT非聚合列是否都在GROUP BY里 | 混用COUNT的三种写法 |
| 分组后过滤 | 条件对象是行还是组 | 聚合条件写进WHERE |
| 内连接 | 连接字段是否是外键对主键 | 漏写ON变成笛卡尔积 |
| 外连接 | 全保留的表是否在左;过滤条件在ON还是WHERE | 条件放WHERE退化成内连接 |
| 子查询 | 返回值是单值、一列多行还是真假 | NOT IN遇NULL |
| 集合查询 | 列数、类型、顺序是否一致;ORDER BY是否在最外层 | UNION与UNION ALL混用 |
具体怎么过,我的做法是三遍。第一遍只做「判断题型」:拿题干遮住答案,十秒内说出这题属于表里哪一行,说不出来就说明分类没建立。第二遍只写骨架不写细节,比如外连接题就写FROM users u LEFT JOIN comments c ON ...,把位置定死,细节留空。第三遍才补完整语句并对数据,重点看行数对不对,行数不对几乎一定是连接位置或过滤位置的问题。
第三遍有个技巧:把语句拆成两半分别跑。先跑FROM ... JOIN ...部分,看连接出来多少行;再加上WHERE看剩多少行;最后加GROUP BY看压成多少行。三次行数变化能直接指出哪一段出了问题,比盯着整条语句找错快得多。这个习惯我是踩过坑之后才养成的——有次做「所有用户及其评论」的题,语句跑出来只有几十行,比用户总数还少,查了半天才发现是WHERE里多写了一个右表条件,把外连接筛成了内连接。
从那以后,我每次写带LEFT JOIN的语句,都强制先跑一遍不带WHERE的版本记下行数,再逐段往上加条件,行数一掉就停下来看是哪个条件把保留行筛掉了。这个动作花不了半分钟,却能挡掉最常见的一类错误。希望帮到你。
本文还有配套的精品资源,点击获取