☰
MySQL数据查询四层结构:从单表到多表与子查询
2026/10/27 19:24:11 网站建设 项目流程

简介:面向学习MySQL数据库应用、需要完成数据查询实验训练的学生与自学者,这份PDF文档以汽车用品网上商城为统一案例,系统梳理字段查询、多条件查询、DISTINCT去重、ORDER BY排序、GROUP BY分组及COUNT、SUM、AVG、MAX、MIN等聚合函数操作,并延伸至内连接、外连接、复合条件连接以及IN、比较运算符、EXISTS嵌套查询。文档按“实验目标—分析思路—SQL实现”组织,每个实验均说明涉及的表、字段与条件关系,分析部分强调去重原因、分组依据与连接键选择,便于对照练习和复盘查询逻辑,也适合整理实验报告时参考其分析表述。包内共1个PDF文件,大小约1.75MB,轻量便携,适合课前预习、课中跟练和课后查漏补缺。该资料已有3338人学习,读者可借此掌握从单表检索到多表关联的完整查询路径,并积累条件组合、结果去重、分组统计与子查询的排错经验。

1. 数据查询的地图:从单表到多表,一条 SELECT 的四层结构

拿到一句查询需求,最容易卡住的地方往往不是语法写不出来,而是不知道该先想什么:先想表、先想条件、还是先把结果长什么样画出来。MySQL 数据库应用里的数据查询这一块,看着就是 SELECT 后面不停加关键字,实际是四层结构叠起来的——单表筛、聚合分、多表连、子查询套。前一层没想明白,后一层写出来基本靠碰运气。这份笔记把实验训练里的十九个查询重新归到这四个层次上,标清每层的判断依据、常见写法和容易翻车的位置,适合正在系统学 SQL、或者临考要把数据查询快速过一遍的人按图索骥。

2. 单表查询:字段、条件、去重、排序四个动作

单表查询是整套数据查询的底座。实验里前八个需求看似各管一摊,其实共用同一个判断流程:先看需求里出现哪些字段,再看这些字段落在哪张表上,如果只落在一张表,就不用考虑连接;然后才是挑条件、挑去重方式、挑排序规则。这个流程走顺了,后面的聚合、连接、子查询都只是在这套流程上再加一层。把实验场景里的表先摆出来,后面所有例子都基于它:

表名存什么关键字段
goods 商品表商品信息goods_id、goods_name、cat_id、shop_price、is_promote、goods_number
category 类别表商品类别cat_id、cat_name
users 用户表商城会员user_id、user_name、create_time
orders 订单表订单主信息order_id、user_id、order_total、order_date
order_detail 订单明细表订单中的商品行order_id、goods_id、goods_num
comments 评论表商品评论comment_id、user_id、goods_id、content
cart 购物车表用户购物车user_id、goods_id、goods_num

2.1 字段查询:定位表比写 SELECT 更难

需求里说“查询商品名称为挡风玻璃的商品信息”,判断依据只有一条:商品名称这个字段在商品表里,输出要求也是商品信息,那么整条语句只需要一张表。同理,查询 ID 为 1 的订单,订单信息全在订单表,用户 ID 也在这张表里,不需要再去连用户表。

-- 条件列和输出列都在 goods 表,单表即可完成 SELECT goods_id, goods_name, cat_id, shop_price FROM goods WHERE goods_name = '挡风玻璃'; -- 订单表自带 user_id,查订单不需要连用户表 SELECT order_id, user_id, order_total, order_date FROM orders WHERE order_id = 1;

第一段代码演示的是“条件列决定 FROM”这个思路:WHERE 里用到 goods_name,输出又要商品明细,于是 FROM 只能是 goods。第二段说明另一个判断习惯——外键字段已经在表里,并不等于一定要连表,只有当需求要输出另一张表的字段时,连接才有必要。

SELECT *在实验里能用,但写作业之外的场景最好把列名列全:一来结果稳定,二来表结构变动时不会突然多出几列把前端搞崩。字符串条件一定用单引号包住,数字类型的列不要加引号,混着写会触发隐式类型转换,索引直接失效,这类问题排查起来相当玄学。

2.2 多条件查询:AND 的优先级与条件的写法

“查询所有促销的价格小于 1000 的商品”有两个条件:是否促销、价格。两个条件都在商品表内,属于典型的多重条件查询,用 AND 连接即可。

-- is_promote 用 1 表示促销,两个条件必须同时满足 SELECT goods_id, goods_name, shop_price FROM goods WHERE is_promote = 1 AND shop_price < 1000;

这段的关键在于把“同时满足”翻译成 AND,而不是把两个条件写成两个 SELECT。布尔型字段在表里通常存成 tinyint,写is_promote = 1比写is_promote = '是'稳得多,后者在部分字符集下会静默返回空结果。价格这类数值列用<、<=、BETWEEN都行,BETWEEN 0 AND 1000包含边界,和shop_price <= 1000等价,但和< 1000差一个端点值,考试和实际业务里都常在这一个等号上翻车。

AND 和 OR 混用时优先级是最容易忽略的点:AND 先于 OR 计算。写“促销且价格低于 1000,或者类别为 3”如果不加括号,语义会变成“促销且(价格低于 1000 或类别为 3)”。稳妥做法是任何混用都加括号,宁可多打两个字符,也别指望读代码的人替你脑补优先级。

2.3 DISTINCT:去重去的是整行不是某一列

“查询所有对商品 ID 为 1 发表过评论的用户 ID”,一个用户可以评论多次,直接查会得到重复的 user_id,所以要 DISTINCT。这里有个概念要钉死:DISTINCT 作用在 SELECT 的整个列表上,不是只作用在紧挨着它的那一列。

-- 一个用户评论多行,去重后每个用户只剩一行 SELECT DISTINCT user_id FROM comments WHERE goods_id = 1;

如果写成SELECT DISTINCT user_id, comment_id,因为 comment_id 本身唯一,整行永远不重复,DISTINCT 等于白写,结果和没加一样。这是很多人第一次用 DISTINCT 时的血泪经验:以为在给第一列去重,其实是在给整行去重。

第二个场景是查询会员创建时间段,1 年为一段。用户表里每年会有多个会员,直接列年份必然重复,用 DISTINCT 包住 YEAR() 函数就能得到去重后的年份集合。

-- 先截取年份,再对结果集去重,最后按年份排一下更好读 SELECT DISTINCT YEAR(create_time) AS create_year FROM users ORDER BY create_year;

写法上要注意函数的位置:DISTINCT YEAR(create_time)是对函数结果去重,YEAR(DISTINCT create_time)是对精确到秒的时间去重、几乎没意义,两者结果差得很远。另外 DISTINCT 天然带一次排序或哈希去重的开销,数据量大时比 GROUP BY 慢,能明确分组维度时优先用 GROUP BY。

2.4 ORDER BY:默认升序、多列、NULL 的位置

排序需求有两个要素:排哪一列、升序还是降序。不写方向时默认 ASC 升序,降序必须显式写 DESC,这一点实验里两个例子正好对照。

-- 类别为 1 的商品,按商品 ID 从大到小 SELECT goods_id, goods_name, shop_price FROM goods WHERE cat_id = 1 ORDER BY goods_id DESC; -- 今年新增会员,按用户名升序;YEAR 比较避免写死具体年份 SELECT user_id, user_name, create_time FROM users WHERE YEAR(create_time) = YEAR(CURDATE()) ORDER BY user_name ASC;

第一段说明 ORDER BY 永远写在 WHERE 之后、LIMIT 之前,执行顺序上它作用在筛选完的结果集上。第二段有两个细节:一是“今年”这种相对时间不要在 SQL 里写死年份,用YEAR(CURDATE())这类函数表达,跨年运行时结果才正确;二是文本列排序按字符集排序规则来,中文和英文混排时的顺序依赖 collation,不是简单的拼音序,遇到结果“看着不对”先查排序规则,别急着怀疑语句。

多列排序的写法是ORDER BY 列1 DESC, 列2 ASC,前序列相同才轮到后序列。NULL 在排序里的位置各数据库不一致,MySQL 中升序时 NULL 排在最前,需要固定顺序时用ORDER BY 列 IS NULL, 列这类写法显式控制。排序字段如果没有索引,大数据量下会走文件排序,这是慢查询里出现频率很高的一类原因。

3. 聚合与分组:GROUP BY 和五个聚合函数的配合

单表筛选解决的是“取哪些行”,聚合解决的是“把这些行压成几个数”。实验里 COUNT、SUM、AVG、MAX、MIN 五个函数加上 GROUP BY,构成了统计类需求的全部套路。这一块的难点不在函数名,而在分组粒度和 NULL 的处理:分组粒度错了结果会多出一堆行,NULL 处理错了数字会偏大或偏小,而且两条都不会报错,只能靠对结果。

3.1 五个聚合函数的分工与返回值

函数作用对 NULL 的处理实验里的典型场景
COUNT(*)数行数统计所有行,含全 NULL 行每天的接单数
COUNT(列)数该列非空值个数跳过 NULL类别数量
SUM(列)求和跳过 NULL,全 NULL 返回 NULL每天销售额、用户消费总额
AVG(列)求平均跳过 NULL,分母是非空行数所有订单平均销售金额
MAX(列)最大值跳过 NULL商品数量最大者、名字最靠前者
MIN(列)最小值跳过 NULL商品价格最低者

MAX 和 MIN 不只能用于数字,文本列按排序规则取字母序最靠前或最后的值,日期列取最早或最晚的时间,实验里“按字母排序中名字最靠前者”考的就是这个点。COUNT(*) 与 COUNT(列) 的差别要单独记:前者数行,后者数非空值,某列有 NULL 时两者结果不同,而这类差异在数据量小的时候很难被发现。

-- 类别数量:COUNT(*) 与 COUNT(cat_id) 在主键列上结果一致 SELECT COUNT(*) AS cat_cnt FROM category; -- 商品数量最大者和价格最低者,两个聚合可以写进同一条语句 SELECT MAX(goods_number) AS max_num, MIN(shop_price) AS min_price FROM goods;

两段代码说明:不含 GROUP BY 的聚合查询,整张表就是一组,结果永远只有一行,这一行里可以并排放多个聚合函数,因为它们的分组粒度相同。要留意 SUM 和 AVG 在空表或无匹配行时的返回:SUM 返回 NULL 而不是 0,前端如果直接做加法会变成 NaN,需要 COALESCE 兜底。

3.2 GROUP BY 的执行顺序:先分组再聚合

“查询每个用户的消费总金额”这句话里,“每个”就是分组信号。订单表里一行是一个订单,同一个用户有多行,先按 user_id 分组,再对组内的 order_total 求和。

-- 先按用户分组,再把组内订单总价相加 SELECT user_id, SUM(order_total) AS total_amount FROM orders GROUP BY user_id;

理解这段的关键是执行顺序:FROM 取表 → WHERE 过滤行 → GROUP BY 分组 → 聚合函数计算 → SELECT 输出列 → ORDER BY 排序。因为聚合发生在 SELECT 之前,所以 SELECT 列表里除了聚合函数,只能出现 GROUP BY 里列过的列。写了没分组的列,在严格模式下直接报错,在宽松模式下会从组内随机挑一行的值返回,结果看起来正常但语义是错的,这是最容易留下隐患的写法。

3.3 多列分组与 HAVING 的位置

“查询类别价格一样的各种商品数量总和”比上一个例子复杂:分组依据是类别和价格两列,两列都相同才算一组,这叫多列分组。

-- 类别 + 价格两列组合决定分组,统计每组商品数量 SELECT cat_id, shop_price, COUNT(*) AS goods_cnt FROM goods GROUP BY cat_id, shop_price;

如果把 GROUP BY 只写 cat_id,结果就变成“每个类别一个数”,与需求里的“类别价格一样”完全不匹配。多列分组的判断方法是:把需求里“每个 X 的 Y”读出来,X 有几个维度就分几列,缺一列粒度就粗了。

分组之后要过滤组,就得用 HAVING。WHERE 过滤的是行、执行在分组之前,HAVING 过滤的是组、执行在聚合之后,两者位置不能互换。

-- WHERE 先筛出有效订单,GROUP BY 再分组,HAVING 最后筛组 SELECT user_id, SUM(order_total) AS total_amount FROM orders WHERE order_date >= '2024-01-01' GROUP BY user_id HAVING SUM(order_total) > 500 ORDER BY total_amount DESC;

这段把四个子句的先后关系串起来了。判断某个条件该放 WHERE 还是 HAVING,看它依赖什么:只依赖单行的列,放 WHERE;依赖聚合结果,放 HAVING。把SUM(order_total) > 500写进 WHERE 会直接报错,因为分组还没发生,聚合值不存在。

3.4 按天统计:日期列必须先截断再分组

“每天的接单数”“每天的销售额”看着简单,实际是最容易出错的一类统计。订单表里的下单时间是 datetime,精确到秒,直接GROUP BY order_date会把每一秒都变成一组,结果和订单条数一样多。

-- 用 DATE() 把时间截断到天,再按天分组统计单量和金额 SELECT DATE(order_date) AS stat_date, COUNT(*) AS order_cnt, SUM(order_total) AS total_amount FROM orders GROUP BY DATE(order_date) ORDER BY stat_date;

代码里的 DATE() 是关键,它把 2024-03-01 09:12:33 和 2024-03-01 21:40:02 归成同一个 2024-03-01。如果需要按月统计,换成DATE_FORMAT(order_date, '%Y-%m');按年就是 YEAR()。SELECT 里的别名 stat_date 不能在 GROUP BY 里引用(标准 SQL 中 GROUP BY 先于 SELECT 执行),要么重复写函数,要么用子查询包一层。

平均单价这类指标也要注意分母:AVG(order_total)是所有订单金额的平均,SUM(order_total) / COUNT(*)结果相同但意图更清楚;如果分母想用“有金额的订单数”,就得写SUM(order_total) / COUNT(order_total),因为 COUNT(列) 会跳过 NULL。

4. 连接与嵌套:多表查询的三种组织方式

单表能解决的问题终究有限,一旦需求里同时出现两张表的字段,就必须进入多表查询。实验里从 2.11 到 2.19 覆盖了三条路线:JOIN 把表横向拼起来、子查询把一条语句的结果喂给另一条、集合运算把多个结果集纵向摞起来。三条路线经常能互相改写,选哪条主要看可读性和数据量。

4.1 内连接:ON 写的是两张表的关系

“查询所有订单的发出者名字”,订单号在订单表、用户名在用户表,两张表通过 user_id 关联,这就是内连接的标准场景。

-- 订单表别名 o,用户表别名 u,ON 描述两表的对应关系 SELECT o.order_id, o.order_total, u.user_name FROM orders o INNER JOIN users u ON o.user_id = u.user_id;

购物车商品名称同理,购物车表同时有 user_id 和 goods_id,通过 goods_id 连商品表就能拿到名称:

SELECT c.user_id, g.goods_name, c.goods_num FROM cart c INNER JOIN goods g ON c.goods_id = g.goods_id;

内连接的语义是“两边都匹配上才保留”。如果某条订单的 user_id 在用户表里找不到对应记录(用户被删除、脏数据),这条订单在内连接结果里会直接消失。做核对类需求时要意识到这一点:内连接的结果行数通常小于等于左表的行数,数量对不上先查关联键是否有孤儿值。给表起别名不只是少打字,两张表有同名列时(比如都叫 user_id),不加别名限定会报“列名歧义”。

4.2 外连接:谁是“保留全部”的那张表

“列出所有用户 ID,以及他们的评论,如果有的话”——“所有”和“如果有的话”是外连接信号:用户表是保留全部的一方,评论表是可能缺失的一方。

-- 用户表在左,LEFT JOIN 保留左表全部行,没评论的补 NULL SELECT u.user_id, c.comment_id, c.content FROM users u LEFT JOIN comments c ON u.user_id = c.user_id; -- 等价写法:把用户表放右边,用 RIGHT JOIN 保留右表全部行 SELECT u.user_id, c.comment_id, c.content FROM comments c RIGHT JOIN users u ON u.user_id = c.user_id;

两段代码结果集相同,区别只在书写习惯。判断用 LEFT 还是 RIGHT 的方法很简单:先在脑子里确定哪张表要“全部保留”,把它放在 LEFT JOIN 的左边,另一张放右边。写完再检查一遍 ON 后面的关联键方向,左右写反不会报错,只会让结果集从“全部用户”变成“全部评论”,条数差一个量级。

外连接里没匹配上的列会填 NULL,所以后续如果有WHERE c.comment_id = 5这种条件,会把补 NULL 的行全部过滤掉,外连接退化成内连接。要过滤右表又想保留左表全部行,条件得写在 ON 里而不是 WHERE 里。

注意:LEFT JOIN 之后对右表列加非空过滤,是外连接失效最常见的原因,写完先看结果行数对不对。

4.3 复合条件连接:连接条件放 ON,过滤条件放 WHERE

“查询用户 ID 为 1 的客户的订单信息和客户名”,先做内连接拿到订单和用户名的组合,再加一个精确条件缩小范围。

-- ON 负责两表怎么对上,WHERE 负责筛掉不要的行 SELECT o.order_id, o.order_total, u.user_name FROM orders o INNER JOIN users u ON o.user_id = u.user_id WHERE u.user_id = 1;

第二个例子是购物车商品价格并按价格排序,连接之后再排序:

-- 连接拿价格,ORDER BY 对最终结果集排序 SELECT c.user_id, g.goods_name, g.shop_price FROM cart c INNER JOIN goods g ON c.goods_id = g.goods_id ORDER BY g.shop_price ASC;

这两段体现的是分工:ON 描述表与表的关系,WHERE 描述业务过滤,ORDER BY 作用在连接后的结果集上。内连接时把过滤条件写进 ON 或 WHERE 结果相同,但可读性差很多;外连接时写错位置直接改变语义。养成“关系进 ON、过滤进 WHERE”的习惯,能省掉大量排查时间。

4.4 嵌套查询:IN 与 NOT IN 的空值陷阱

“查询订购商品 ID 为 1 的订单 ID,再根据订单 ID 查发出订单的用户 ID”,这句话本身就是两层:内层在订单明细表查订单号,外层在订单表查用户。

-- 内层返回多个订单号,外层用 IN 匹配 SELECT DISTINCT user_id FROM orders WHERE order_id IN ( SELECT order_id FROM order_detail WHERE goods_id = 1 );

反过来查“未发出此订单的用户”,把 IN 改成 NOT IN 即可。但 NOT IN 有个经典坑:如果子查询结果里含 NULL,整个 NOT IN 的比较结果会变成 UNKNOWN,外层一行都查不出来。补法是子查询里加WHERE 列 IS NOT NULL,或者改用 NOT EXISTS。

比较运算符做子查询时,要求内层只返回单值,例如“今年新增会员中订单总价小于 100 的订单”:

-- 子查询先取今年注册的用户,外层再按用户和金额筛选 SELECT order_id, user_id, order_total FROM orders WHERE user_id IN ( SELECT user_id FROM users WHERE YEAR(create_time) = YEAR(CURDATE()) ) AND order_total < 100;

这里的金额条件放在外层 WHERE,属于对最终结果的过滤;如果塞进子查询,就变成“用户表里有金额小于 100 的记录”,语义完全跑偏。

4.5 EXISTS、ANY、ALL:三个容易被念错的关键字

EXISTS 判断子查询有没有返回行,返回布尔值,不关心具体列是什么,所以子查询里写SELECT 1就够了。

-- 相关子查询:外层每一行都拿去内层验证一次 SELECT u.user_id, u.user_name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id );

这段和IN (SELECT user_id FROM orders)结果相近,但机制不同:EXISTS 逐行验证、命中即停,子查询里有 NULL 也不影响结果;IN 是先算出整个子查询结果集再做匹配。数据量大时 EXISTS 往往更稳。

ANY 和 ALL 必须跟比较运算符一起用,语义是“和子查询结果中的任意一个比”与“和所有值比”:

-- 比类别 2 中任意一个商品贵,等价于大于该类别的最低价 SELECT goods_id, shop_price FROM goods WHERE shop_price > ANY (SELECT shop_price FROM goods WHERE cat_id = 2); -- 比类别 2 中所有商品都贵,等价于大于该类别最高价 SELECT goods_id, shop_price FROM goods WHERE shop_price > ALL (SELECT shop_price FROM goods WHERE cat_id = 2);

记忆方法是把 ANY 换算成 MIN、ALL 换算成 MAX:> ANY等于> MIN,> ALL等于> MAX。子查询返回空集时,ANY 为假、ALL 为真,这个边界在实验数据里碰不到,但在真实数据里会突然冒出来。

4.6 集合查询:UNION 去重、UNION ALL 不去重

“查询价格小于 5 的商品,以及类别 ID 为 1 和 2 的所有商品”,两个条件没有包含关系,结果要合并成一个集合,用 UNION。

-- 两条 SELECT 纵向拼接,UNION 自动去掉完全重复的行 SELECT goods_id, goods_name FROM goods WHERE shop_price < 5 UNION SELECT goods_id, goods_name FROM goods WHERE cat_id IN (1, 2);

把 UNION 换成 UNION ALL,重复行会原样保留。两者的选择标准很直接:需要去重就用 UNION,确定两边不会有交集、或者业务上允许重复,就用 UNION ALL,省掉一次排序去重的开销。使用 UNION 有几个硬性前提:两条 SELECT 的列数必须相同、对应列的类型要兼容、结果集的列名取第一条 SELECT 的列名。列顺序对不上不会报错,但数据会错位,这种错误比语法错误难查得多。

5. 易错点:五个不报错但结果错的位置

数据查询最折磨人的地方在于:语法错会立刻报错,语义错却安安静静返回一个看起来合理的数字。下面五条是这套实验里最常出问题、也最容易被忽略的位置,每条都按“漏在哪、为什么漏、怎么补”拆开。

5.1 易漏点:在 WHERE 里过滤聚合结果

漏在哪:想筛出消费总额超过 500 的用户,写成WHERE SUM(order_total) > 500。

为什么漏:WHERE 的执行时机在分组和聚合之前,那时候 SUM 还根本不存在,语句直接报错。很多人第一次遇到这个报错时以为是函数写错了,反复改函数名。

怎么补:聚合结果的过滤一律交给 HAVING,GROUP BY user_id HAVING SUM(order_total) > 500。写之前先问一句“这个条件依赖的是单行还是一组”,依赖一组就是 HAVING,这是最快的判断方法。

5.2 易错点:GROUP BY 的 SELECT 列表里混进非分组列

漏在哪:SELECT user_id, order_id, SUM(order_total) FROM orders GROUP BY user_id,order_id 既没分组也没聚合。

为什么漏:在非严格模式下这条语句能跑,order_id 会从组内随机取一行的值返回,结果看起来像“这个用户的代表订单”,实际毫无业务含义;换台服务器开了严格模式就突然报错,同一份作业两种结果。

怎么补:SELECT 列表里只允许两类东西——GROUP BY 里出现过的列、包在聚合函数里的列。确实需要组内某一行的明细时,先用聚合算出每组的关键值,再和原表连接回去取明细。

5.3 易漏点:外连接的过滤条件写进了 WHERE

漏在哪:FROM users u LEFT JOIN comments c ON u.user_id = c.user_id WHERE c.goods_id = 1,本意是“所有用户,附上他们对商品 1 的评论”。

为什么漏:WHERE 在连接之后执行,没评论的用户在 c 相关列上全是 NULL,c.goods_id = 1对 NULL 判定为 UNKNOWN,这些行被过滤掉,左外连接退化成内连接,结果只剩评论过的用户。语句不报错,条数少了也没人察觉。

怎么补:把右表的过滤条件挪进 ON,LEFT JOIN comments c ON u.user_id = c.user_id AND c.goods_id = 1,这样连接时先按条件找匹配,匹配不上仍然保留左表整行。判断口诀是:外连接里想“限制匹配”的条件放 ON,想“限制最终结果”的条件放 WHERE。

提示:看到 LEFT JOIN 就顺手数一下左表总行数和结果行数,对不上大概率是 WHERE 出的问题。

5.4 易错点:NOT IN 遇到 NULL 返回空结果

漏在哪:WHERE order_id NOT IN (SELECT order_id FROM order_detail WHERE goods_id = 9),子查询里只要有一行 order_id 是 NULL,整条语句返回零行。

为什么漏:NOT IN 展开后是一串<> NULL的比较,任何值和 NULL 比较结果都是 UNKNOWN,AND 起来整条条件不成立。这个行为符合 SQL 三值逻辑,但和人的直觉相反,而且没有任何警告。

怎么补:子查询里显式加WHERE order_id IS NOT NULL,或者改用NOT EXISTS (SELECT 1 FROM ... WHERE ... AND order_id = 外层.order_id),后者对 NULL 免疫。凡是写 NOT IN,条件反射检查一下子查询列是否允许为空。

5.5 易漏点:COUNT(列) 与 COUNT(*) 混用、DISTINCT 位置写错

漏在哪:统计“有多少用户下过单”,写成COUNT(user_id)却在一个可能为空的列上统计;或者想统计“有多少个不同用户”,写成COUNT(user_id)而不是COUNT(DISTINCT user_id)。

为什么漏:COUNT(列) 跳过 NULL,如果关联键有缺失,结果会比真实行数少;不做 DISTINCT 则把同一用户的多条订单重复计数,两个错误方向相反,混在一起更难发现。

怎么补:数行用 COUNT(*),数非空值用 COUNT(列),数不同值用 COUNT(DISTINCT 列),三者在纸上写清楚再敲键盘,比事后对账省事。含 NULL 的列做统计前,先用SELECT COUNT(*) - COUNT(列)看看差值有多大,心里有个底。

6. 查漏补缺:一份可以照着走的复习清单

复习到这个阶段,最有效的做法不是重看一遍语法,而是拿一句需求反推该用哪些子句。下面这张表把实验里的需求类型和判断依据对上,做题时先归类再动笔,比凭感觉写快得多。

需求特征判断依据该用的子句
只出现一张表的字段字段全在一张表WHERE + 列清单
“并且”“同时满足”多个条件同时成立AND,混用 OR 时加括号
“不重复”“有哪些”可能有重复行DISTINCT 或 GROUP BY
“按……排序”“前几名”有明确顺序要求ORDER BY + ASC/DESC
“每个……的总额/数量”出现“每个”GROUP BY + 聚合函数
“总额大于……”条件依赖聚合结果HAVING
需求里同时出现两张表字段需要横向拼接INNER/LEFT JOIN + ON
“所有……即使没有”有一方要全部保留LEFT JOIN
需要先算一个结果再用语句有先后依赖子查询 + IN/EXISTS
两个结果集合并条件无包含关系UNION / UNION ALL

配套的自查动作有三步。第一步查行数:单表查询结果条数不该超过原表条数,内连接结果不该超过任一张表,外连接结果不该少于保留侧那张表,数字对不上就是连接或过滤出了问题。第二步查 NULL:凡是结果里出现 NULL 的列,回头确认它是不是外连接补出来的、是不是被聚合函数跳过了。第三步查粒度:所有带“每个”的语句,把 GROUP BY 的列和需求里的维度逐个对照,少一列粒度就粗一档。

-- 对账用的三个小查询:原表行数、分组数、含 NULL 的键数 SELECT COUNT(*) AS total_rows FROM orders; SELECT COUNT(DISTINCT user_id) AS user_cnt FROM orders; SELECT COUNT(*) - COUNT(user_id) AS null_key_rows FROM orders;

这三条语句在调试任何统计类需求时都能派上用场:第一条给分母,第二条给预期分组数,第三条提醒你关联键是不是干净。跑完再对照业务语句的结果,差异立刻现形。

我自己的习惯是每写完一条带 GROUP BY 或 JOIN 的语句,都强制走一遍“行数—NULL—粒度”这三步,不跳步。刚开始觉得啰嗦,被随机取值的 GROUP BY 和悄悄退化的 LEFT JOIN 坑过几次之后,这套动作已经变成肌肉记忆。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询