ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CPXS数据库实验:统计查询、分组过滤与嵌套子查询四层方案

CPXS数据库实验:统计查询、分组过滤与嵌套子查询四层方案

简介:《数据库实验5嵌套查询》是一份面向数据库课程学习者与实验课教师的实验报告文档,围绕 SELECT 统计查询、常用统计函数、连接查询与嵌套查询展开,帮助读者理解多表检索、子查询、派生表及相关谓词的使用场景。文档以 CPXS 数据库为操作对象,结合客户、产品、销售等表给出统计客户数量、求库存总量、按客户分组汇总、筛选上海客户订单、比较产品单价、查找同城客户、查询未被订购产品等 SQL 实例,并配有实验目的、内容、结论与思考,便于对照课堂进度完成实验和复盘语法。资源包共 1 个 doc 文件,大小约 642KB,结构完整、可直接查阅。目前已有 931 人学习,适合需要巩固数据库查询基础、练习嵌套查询写法与理解连接查询差异的读者参考。

1. 从一次结果对不上的统计说起:这套查询方案要解决什么

实验报告里 14 条查询跑完,第 12 题返回了 3 行,第 14 题返回 0 行,第 8 题的数字和手工算的对不上。这类统计查询与嵌套查询的实验,丢分点几乎从不在语法报错上——数据库会把错的结果安安静静地返回给你,一个字都不提醒。这套方案要解决的就是这件事:把 CPXS 库的客户、产品、订购三张表,按「单表聚合 → 分组过滤 → 多表连接 → 嵌套子查询」四层拆开,每条实验要求先定位到层,再落 SQL,最后用反例自证结果。适合正在交数据库实验报告、课程设计里要写查询模块,或者要给一组人统一 SQL 口径的读者。反直觉的地方在于:14 道题里真正需要嵌套的只有 4 道,剩下的大半用 GROUP BY 加 HAVING 就能收工,上来就套子查询反而容易把统计口径写歪。

2. 数据模型与查询分层设计:三张表、四类查询的职责划分

动手写 SQL 之前,先把表之间的关系画清楚,比急着敲 SELECT 划算得多。CPXS 这个库的结构不复杂,但三张表之间的连接路径有两条:客户到订购是一对多,产品到订购也是一对多,客户和产品之间没有直接外键,必须经过 SALE 这张中间表。搞不清这一点,后面写「上海客户订购了哪些产品」这种题就会条件写漏。

2.1 CPXS 库的三张表与字段约定

先把字段语义固定下来,后面所有查询的口径都以这张表为准。实验报告里最容易出问题的地方,往往不是语法,而是对字段含义的理解偏差,比如 STOCKS 到底是「库存件数」还是「库存批次」,直接决定 COUNT 里该写星号还是写列名。

表名字段含义约束
CUSTOMERCNO客户编号主键
CUSTOMERCNAME客户公司名称非空
CUSTOMERSITE所在城市用于同城查询
CUSTOMERTELE联系电话可为空
PRODUCTPCODE产品编号主键,字母开头
PRODUCTPNAME产品名称非空
PRODUCTPRICE单价数值型
PRODUCTSTOCKS库存量数值型
SALECNO客户编号外键指向 CUSTOMER
SALEPCODE产品编号外键指向 PRODUCT
SALEOQUANTITY订购数量数值型

三张表的基数关系是这套方案的骨架:一个客户可以有多条订购记录,一个产品也可以被多个客户订购,SALE 是典型的多对多桥接表。凡是题目里出现「每个客户」「每种产品」这类措辞,基本就落在分组查询上;出现「和……在同一城市」「比……单价高」这类措辞,基本就落在嵌套查询上。这个判断习惯一旦建立,选题型的时间能从几分钟压到几秒。

2.2 把 14 条实验要求拆成四层查询能力

实验指导书给的是 14 条零散要求,直接一条条写会写成流水账,报告里也看不出设计思路。我一般会先做一次归类,把它们映射到四层能力上,每一层对应一组语法点,这样报告的结构和代码的组织能对齐。

层级能力对应题目核心语法
第一层单表聚合客户数目、库存总量、最高单价、B 开头最低单价COUNT / SUM / MAX / MIN
第二层分组过滤订购两种以上的客户、每客户订购总量、上海客户数、库存超 500 的产品数GROUP BY + HAVING
第三层多表连接上海客户订购明细、高于 A01 单价的产品订购等值连接、三表连接
第四层嵌套子查询同城客户、未被订购的产品标量子查询、IN、NOT EXISTS

这份映射表是整套方案的核心设计产物,它同时解决三个问题:报告里「实验设计」一节有东西可写;写代码时知道该用哪组语法,不会拿连接查询硬凑子查询的题;验收时能按层逐个对答案,出错范围立刻缩小到某一层。我在带课程设计时通常要求先交这张表,再交 SQL,返工率能降一半。

有一点需要说明:层级不是难度的严格排序,而是依赖关系的排序。第一层的聚合函数会出现在第二层的 HAVING 里,第二层的结果又常被第四层拿来当条件。所以写的时候按层推进,别跳着写。

2.3 建表与样例数据:让每条查询都能复现

实验环境里库是现成的,但报告要能复现,最好把建表和最小样例数据一起写进去。数据量不用大,每张表五六行足够暴露问题——尤其是同城客户和未被订购产品这两类题,数据太少会得到「恰好也对」的假象。

-- CPXS 实验库:三张表 + 最小可复现数据集 CREATE TABLE CUSTOMER ( CNO CHAR(4) PRIMARY KEY, -- 客户编号,固定 4 位 CNAME VARCHAR(30) NOT NULL, -- 公司名称,同城判断靠 SITE SITE VARCHAR(20), -- 所在城市 TELE VARCHAR(20) -- 联系电话,允许为空 ); CREATE TABLE PRODUCT ( PCODE CHAR(4) PRIMARY KEY, -- 产品编号,B 开头用于 LIKE 测试 PNAME VARCHAR(30) NOT NULL, PRICE DECIMAL(8,2), -- 单价,嵌套比较的对象 STOCKS INT -- 库存量,统计题的分组/过滤字段 ); CREATE TABLE SALE ( CNO CHAR(4), -- 外键:客户 PCODE CHAR(4), -- 外键:产品 OQUANTITY INT, -- 订购数量 PRIMARY KEY (CNO, PCODE), -- 联合主键,同一客户同一产品只一条 FOREIGN KEY (CNO) REFERENCES CUSTOMER(CNO), FOREIGN KEY (PCODE) REFERENCES PRODUCT(PCODE) ); -- 样例数据:刻意构造同城、未订购、单价交叉三种情形 INSERT INTO CUSTOMER VALUES ('C01','美美公司','上海','021-1111'), ('C02','东方商行','上海','021-2222'), ('C03','北方物资','北京','010-3333'); INSERT INTO PRODUCT VALUES ('A01','打印机', 1200.00, 300), ('B01','硒鼓', 180.00, 620), ('B02','墨盒', 15.00, 480), ('C01','扫描仪', 900.00, 120); INSERT INTO SALE VALUES ('C01','A01', 150), ('C01','B01', 260), ('C02','A01', 220), ('C02','B02', 400), ('C03','B01', 130);

建表脚本里有两个设计取舍值得在报告里点一句。第一,SALE 用了 (CNO, PCODE) 联合主键,这意味着同一客户对同一产品只有一条汇总记录,所以后面统计「订购种类数」时 COUNT(PCODE) 就是种类数,不用加 DISTINCT;如果实际业务允许多批次下单,主键要加流水号,那时 COUNT 就得改成 COUNT(DISTINCT PCODE)。第二,样例数据里刻意让 C01 和 C02 都在上海,这样第 12 题的同城查询会返回多行,正好能暴露「用 = 接子查询」的隐患。

外键约束在这里不只是规范问题。少了外键,SALE 里可以插入不存在的客户编号,连接查询就会静默丢行,结果少了几条你根本发现不了。这类问题在小数据集上尤其隐蔽,因为你怎么看都觉得「数字挺合理的」。

3. 统计查询与分组:聚合函数、GROUP BY 与 HAVING 的落地写法

统计类题目看着最没技术含量,实际上丢分最多。原因很统一:聚合函数把多行压成一行,你没法像普通查询那样一眼看出哪行错了,只能靠口径推演。这一章把 14 条要求里属于第一层和第二层的部分拆开写,每条给出可执行 SQL 和一句口径说明。

3.1 五个聚合函数在题目里的分工

COUNT、SUM、MAX、MIN、AVG 这五个函数,实验里真正用到的场景可以归成三类:计数类、求和类、极值类。分工搞清楚了,题目读完就知道该拿哪个。

计数类用的是 COUNT。它的坑在参数上:COUNT() 统计行数,COUNT(列名) 统计该列非空的行数,两者在有 NULL 的列上结果不同。实验里「统计客户的数目」这种题,标准写法就是 COUNT(),语义是「有多少条客户记录」,不关心哪个字段是否为空。

求和类用的是 SUM,只对数值列有效,且自动忽略 NULL。求「库存量总和」「每客户订购总量」都属于这一类。极值类用 MAX 和 MIN,除了数值列,也能用在字符列上——按字符序比较,所以「B 开头产品的最低单价」是先 LIKE 过滤再取 MIN,不是反过来。

AVG 在这 14 条里没有直接出现,但答辩时经常被追问「平均订购量怎么算」,标准答案要补一句「AVG 会忽略 NULL,如果某条记录数量为空,分母会变小」,这是它和 SUM/COUNT 手动相除的差别。

3.2 GROUP BY 与 HAVING 的执行顺序:为什么 WHERE 里不能写 COUNT

这是整个实验里最值得讲清楚的一个知识点,也是报告「实验原理」一节最容易写空的地方。SQL 的逻辑执行顺序大致是:FROM → WHERE → GROUP BY → 聚合计算 → HAVING → SELECT → ORDER BY。放到分组查询里,含义很明确:

WHERE 在分组之前生效,过滤的是原始行,所以它不能引用聚合函数;HAVING 在分组之后生效,过滤的是分组结果,所以它可以引用 COUNT、SUM 这类聚合值。同理,SELECT 里出现的非聚合列,必须出现在 GROUP BY 里,否则这一列取的是组内哪一行的值,取决于具体实现。

-- 正确分层:先 WHERE 过滤原始行,再 GROUP BY 分组,最后 HAVING 过滤组 SELECT CNO, COUNT(PCODE) AS 产品种类数 FROM SALE WHERE OQUANTITY > 0 -- 第一道闸:过滤无效订购行 GROUP BY CNO -- 按客户分组 HAVING COUNT(PCODE) >= 2; -- 第二道闸:过滤组,聚合函数可出现在这里

这段 SQL 的价值不在语法,而在顺序感。很多人写分组查询时把条件一股脑塞进 HAVING,虽然结果常常也对,但等于让数据库先分组再丢弃整组,效率差且语义含混。判断标准很简单:条件针对「单行」就放 WHERE,针对「一组」就放 HAVING。OQUANTITY > 0是单行条件,COUNT(PCODE) >= 2是组条件,各归各位。

参数上要注意的是,GROUP BY 后面可以跟多列,形成多级分组;HAVING 里可以出现 SELECT 中没写出来的聚合函数;ORDER BY 里的别名在大多数数据库里可用,但标准只保证 SELECT 列表中的列别名在 ORDER BY 可见,跨库移植时建议直接重写表达式。

3.3 逐题实现:从客户数目到每客户订购总量

下面这组 SQL 对应第一层和第二层的前半部分,每条后面跟一句口径说明。写法上我倾向显式写 AS 别名,报告里结果集有列名,答辩时对着结果讲比对着代码讲快。

-- 1. 统计客户数目:COUNT(*) 统计行数,不受任何列 NULL 影响 SELECT COUNT(*) AS 客户总数 FROM CUSTOMER; -- 2. 求库存量总和:SUM 自动忽略 NULL,结果是一行一列 SELECT SUM(STOCKS) AS 库存总量 FROM PRODUCT; -- 3. 至少订购两种产品的客户编号与种类数 SELECT CNO, COUNT(PCODE) AS 种类数 FROM SALE GROUP BY CNO HAVING COUNT(PCODE) >= 2; -- 4. 每个客户订购产品数量的总数 SELECT CNO, SUM(OQUANTITY) AS 订购总量 FROM SALE GROUP BY CNO; -- 5. 所在城市为"上海"的客户公司数 SELECT SITE, COUNT(CNO) AS 客户数 FROM CUSTOMER GROUP BY SITE HAVING SITE = '上海';

第 3 条和第 4 条是一对,同样的 GROUP BY,一个数种类、一个数总量。这里用 COUNT(PCODE) 而不是 COUNT(*),是因为 SALE 的联合主键保证 PCODE 非空,两者结果相同,但写列名更能表达「数产品种类」的意图。第 5 条值得单独说:HAVING SITE = '上海'在功能上等价于WHERE SITE = '上海',因为 SITE 是分组列。更规范的写法是放 WHERE,因为它在分组前就把非上海的行排除了,分组规模更小。报告里可以把两种写法都列出来,说明结果一致但执行路径不同,这是加分项。

如果题目要的是「每个城市的客户数」而不限定上海,那就去掉 HAVING,直接 GROUP BY SITE,结果会返回北京和上海两行。这个改写练习建议做一遍,能立刻分清「过滤组」和「过滤行」的区别。

3.4 范围条件与模糊匹配:BETWEEN、LIKE 与聚合前过滤

剩下几条统计题的核心不是聚合,而是聚合之前的行过滤。单价在 10 到 20 元之间的产品个数、以 B 开头的产品最低单价、库存超过 500 的产品个数,都属于这一类。这类题的通用形态是「先缩小行集,再统计」,所以过滤条件应该落在 WHERE 上,不是 HAVING,也不是 GROUP BY。

-- 6. 单价在 10~20 元之间的产品个数(闭区间,含两端) SELECT COUNT(*) AS 产品个数 FROM PRODUCT WHERE PRICE BETWEEN 10 AND 20; -- 7. 以字母 B 开头的所有产品的最低单价 SELECT MIN(PRICE) AS 最低单价 FROM PRODUCT WHERE PCODE LIKE 'B%'; -- 8. 库存量超过 500 的产品个数 SELECT COUNT(*) AS 产品个数 FROM PRODUCT WHERE STOCKS > 500; -- 9. 所有产品的最高单价 SELECT MAX(PRICE) AS 最高单价 FROM PRODUCT;

第 6 条的 BETWEEN 是闭区间,等价于PRICE >= 10 AND PRICE <= 20,边界值会算进去。如果题目要求开区间,必须手写成>和<,别指望 BETWEEN 帮你排除端点。第 7 条的 LIKE 'B%' 里,百分号匹配任意长度字符,下划线匹配单个字符,这两个通配符的区别建议在报告里写一句,答辩常问。

第 8 条是最容易写歪的一条。我见过不少报告写成GROUP BY STOCKS HAVING STOCKS > 500再 COUNT,那样得到的是「每种库存值各有多少个产品」,是一堆行,不是总数。只要题目问的是「个数」而不是「每种多少个」,就用 WHERE 过滤加 COUNT(*),不需要 GROUP BY。判断依据还是那句:题目要一行结果,就别分组。

4. 连接查询与嵌套查询:从等值连接到 EXISTS 的选型

第三层和第四层是这份实验的重点,也是标题里「嵌套查询」四个字的落点。但嵌套不是越多越好,很多题写成连接反而更清楚。这一章先把连接和子查询的适用边界划出来,再逐题落地。

4.1 三表连接的写法与笛卡尔积风险

CPXS 里最典型的三表题是「查询由上海客户订购的、且订购数量大于 200 的所有客户编号、产品编号、联系人和订购数量」。这条要求横跨 CUSTOMER 和 SALE 两张表,条件分属两表:城市在 CUSTOMER,数量在 SALE。

-- 10. 上海客户订购且数量 > 200 的明细 SELECT S.CNO, S.PCODE, C.CNAME, S.OQUANTITY FROM SALE S JOIN CUSTOMER C ON C.CNO = S.CNO -- 连接条件:客户编号对齐 WHERE C.SITE = '上海' -- 过滤条件:城市 AND S.OQUANTITY > 200; -- 过滤条件:数量 -- 11. 高于 A01 单价的产品:连接 + 标量子查询组合 SELECT S.PCODE, S.CNO, S.OQUANTITY FROM SALE S JOIN PRODUCT P ON P.PCODE = S.PCODE WHERE P.PRICE > (SELECT PRICE FROM PRODUCT WHERE PCODE = 'A01');

第 10 条的关键是连接条件必须写全。漏掉C.CNO = S.CNO的后果不是报错,而是笛卡尔积——3 个客户乘 5 条订购,返回 15 行,其中大部分是客户和订购记录错配出来的假数据。更麻烦的是这种错误在小数据集上返回的行数看着也「不算离谱」,很容易被当成正确答案交上去。

第 11 条演示了连接和子查询的组合用法:外层用连接把订购明细和产品价格拉到一起,内层用标量子查询取出 A01 的单价当比较基准。这里子查询返回单值,所以能用>直接比。如果 A01 在产品表里有两条记录,这条 SQL 会直接报「子查询返回多于一行」,这也是为什么 PCODE 必须是主键。

写连接时我一般的习惯是:先写 FROM 和 JOIN,把连接条件补齐,跑一次看行数对不对;再加 WHERE 条件逐条筛。这样出错时能立刻定位是连接错了还是过滤错了。

4.2 标量子查询、IN、ANY/ALL、EXISTS 的适用边界

嵌套查询这一块的语法点密集,实验要求里提到的操作符和谓词基本都要覆盖到。按返回形态可以分成四类,选型逻辑很清楚:

子查询形态返回值配套运算符典型场景
标量子查询单行单列= > < >= <=和 A01 单价比较
多行单列一列多行IN / NOT IN同城客户、未被订购产品
多行单列 + 量词一列多行ANY / ALL / SOME「高于任意一个」这类模糊比较
相关子查询布尔EXISTS / NOT EXISTS存在性判断,逐行执行

标量子查询最容易出的问题是「以为返回一行,实际返回多行」。同城查询就是典型:WHERE SITE = (SELECT SITE FROM CUSTOMER WHERE CNAME='美美')在「美美」只有一条记录时能用,一旦同城有多家公司且名称重复,立刻报错。稳妥写法是把=换成 IN,语义从「等于那个值」变成「属于那个集合」。

ANY 和 ALL 用得少但必须会。> ANY (子查询)等价于「大于最小值」,> ALL (子查询)等价于「大于最大值」。这两个谓词在报告里写一句等价转换,能说明你真的理解了量词,而不只是背了语法。

EXISTS 和前三种的根本区别在于它是相关子查询:子查询里引用了外层的列,所以对外层的每一行都要执行一次。它的优势在于语义直白——「存在满足条件的记录」——而且不关心子查询选出什么列,SELECT 1和SELECT *效果一样。判断「没有被订购的产品」这类否定题,NOT EXISTS 是首选。

4.3 逐题实现:同城客户、高于 A01 单价、未被订购的产品

-- 12. 与"美美"同城的客户公司名称及电话(用 IN 防子查询多行) SELECT CNAME, TELE FROM CUSTOMER WHERE SITE IN (SELECT SITE FROM CUSTOMER WHERE CNAME = '美美') AND CNAME <> '美美'; -- 通常要排除自己 -- 13. 单价高于 A01 的产品:用 EXISTS 改写,避免连接重复 SELECT PCODE, PNAME, PRICE FROM PRODUCT P WHERE EXISTS ( SELECT 1 FROM PRODUCT X WHERE X.PCODE = 'A01' AND P.PRICE > X.PRICE ); -- 14. 没有被订购的产品编号和名称(NOT EXISTS 标准写法) SELECT P.PCODE, P.PNAME FROM PRODUCT P WHERE NOT EXISTS ( SELECT 1 FROM SALE S WHERE S.PCODE = P.PCODE );

第 12 条加了CNAME <> '美美',因为题目问的是「与美美同城的客户」,逻辑上不含它自己。这一句是我在批改时常见的遗漏点,不加也能跑,但语义多算了一家。

第 13 条用 EXISTS 改写标量子查询,好处是把「A01 的单价」这个基准也变成存在性判断,即使产品表里有多个编号相似的产品也不会报多行错误。代价是可读性下降,所以报告里建议两种写法并列,说明各自适用条件,这比只写一种更有说服力。

第 14 条是整套实验里错误率最高的一题。很多人写成WHERE PCODE != (SELECT PCODE FROM SALE),一旦 SALE 表里有两条以上记录,子查询返回多行,语句直接报错;即使只有一条记录,!=的语义也是「不等于那一个产品编号」,而不是「没被任何客户订购过」。正确做法只有两条路:NOT EXISTS,或者NOT IN (SELECT PCODE FROM SALE)。两者在 SALE.PCODE 允许 NULL 时行为不同——NOT IN 遇到 NULL 会整体返回空结果,NOT EXISTS 不受影响,所以更推荐前者。

4.4 相关子查询与不相关子查询:执行次数决定性能

这个区分在实验数据量下看不出差别,但答辩和后续课程设计一定会问。不相关子查询独立于外层,只算一次,把结果当常量用,比如第 11 条里的 A01 单价。相关子查询引用了外层列,外层每行都要跑一次,比如第 14 条的 NOT EXISTS,产品表有几行就执行几次。

-- 不相关子查询:先算出上海客户集合,再一次性比对 SELECT CNO, PCODE FROM SALE WHERE CNO IN (SELECT CNO FROM CUSTOMER WHERE SITE = '上海'); -- 相关子查询:外层每行都要回查一次 SALE SELECT CNO, PCODE FROM SALE S WHERE EXISTS (SELECT 1 FROM CUSTOMER C WHERE C.CNO = S.CNO AND C.SITE = '上海');

两条 SQL 结果一样,执行路径不同。前者子查询只跑一次,后者跑 N 次。数据量小的时候后者甚至可能更快(因为能走索引),但报告里应当写明这个差别,说明你知道自己在选什么。我一般的做法是:能用不相关子查询就用不相关,只有在语义天然是「逐行判断存在性」时才用相关子查询。

5. 避坑与排查:五个最容易翻车的点

这一章是从实际批改和自查里攒出来的。每一条都按「现象 → 原因 → 解决」写,对着自己的 SQL 逐条过一遍,能挡掉大部分返工。

5.1 坑一:SELECT 里的列没进 GROUP BY

现象:查询能跑通,但同一客户在不同数据库上返回的订购数量不一样,或者报告里的数字和同学的对不上。

原因:SELECT 列表里出现了既不是聚合函数、也不在 GROUP BY 中的列,比如SELECT CNO, OQUANTITY FROM SALE GROUP BY CNO。此时 OQUANTITY 取的是组内哪一行的值,标准没有规定,取决于具体实现,属于典型的未定义行为。

解决:要么把该列加进 GROUP BY,要么用聚合函数包起来。业务含义上,一个客户对应多条订购记录时,想拿数量就应该写SUM(OQUANTITY),而不是随便抓一行。写完分组查询后,扫一眼 SELECT 列表,逐个确认「这列是分组列还是聚合列」,不属于任何一类的直接删掉或改写。

5.2 坑二:把 HAVING 当 WHERE 用,或者反过来

现象:库存超 500 的产品个数查出来是一堆行,每行一个数字,加起来才对;或者按城市统计时用 HAVING 过滤城市,结果虽然对,但执行计划显示扫描了全表。

原因:过滤条件的作用对象搞混了。HAVING STOCKS > 500是在分组之后过滤组,如果 GROUP BY 的是 STOCKS,每个库存值自成一组,返回的自然是「每种库存值有多少产品」。而HAVING SITE = '上海'虽然结果正确,但它在分组后才丢弃非上海组,做了无用功。

解决:判断标准只有一条——条件针对单行就放 WHERE,针对整组就放 HAVING。凡是列名直接和常量比较的(城市、单价区间、编号前缀),一律 WHERE;凡是包了聚合函数的(COUNT、SUM),一律 HAVING。写完检查一遍两边的条件,看有没有放错位置的。

5.3 坑三:用 != 或 NOT IN 判断「没有被订购」

现象:第 14 题报错「子查询返回多于一行」,或者返回 0 行,明明产品表里有未被订购的记录。

原因:WHERE PCODE != (SELECT PCODE FROM SALE)有两个问题。一是子查询返回多行时,标量比较直接报错;二是即使只返回一行,语义也是「不等于这一个编号」,而不是「没被任何客户订购」。换成NOT IN后又踩第二个坑:如果 SALE.PCODE 里存在 NULL,整个 NOT IN 比较结果变成未知,查询返回空集。

解决:否定类存在性判断统一用 NOT EXISTS,它不受 NULL 影响,语义也最贴近题目原话。如果非要用 NOT IN,先确认子查询列上有没有 NULL,没有才用。

-- 推荐:NOT EXISTS,逐行判断是否存在订购记录 SELECT PCODE, PNAME FROM PRODUCT P WHERE NOT EXISTS (SELECT 1 FROM SALE S WHERE S.PCODE = P.PCODE);

5.4 坑四:多表连接漏写连接条件

现象:查询返回的行数明显偏多,明细里出现「上海客户订购了北京才有的产品」这种不可能的组合,但语句不报任何错。

原因:FROM 后面跟了两张表却只写了过滤条件,没写连接条件,数据库按笛卡尔积组合。3 行乘 5 行得 15 行,其中 12 行是假的。数据集小的时候,假数据看着也像真的,这是最危险的地方。

解决:养成「先连接、后过滤」的书写顺序,JOIN 后面立刻跟 ON 条件,跑一次看行数是否符合预期,再加 WHERE。另外可以在报告里写一句行数校验:预期行数等于满足条件的订购记录数,实际返回对不上就是连接写错了。用显式 JOIN ... ON 语法而不是逗号加 WHERE,也是降低这类错误的手段。

5.5 坑五:标量子查询在多行数据上直接崩

现象:同城查询在测试数据上跑得好好的,换一套数据就报「子查询返回多于一行」,报告里的 SQL 直接失效。

原因:用了= (子查询)这种标量比较,前提是子查询严格返回一行一列。只要同城有两家客户、或者产品表里有两个 B 开头的编号,前提就不成立。

解决:按语义选运算符。确定返回单值时(比如按主键查单价)才用=;可能返回多值时一律用 IN,或者改成 EXISTS。写完子查询后先单独跑一遍,看它到底返回几行,这个习惯能省掉大量排查时间。

6. 验收与答辩:用对照表和反例自证结果

这套实验的验收,核心不是「SQL 能跑」,而是「结果能自证」。我在带课程设计时通常要求交三样东西:一份逐题结果对照表、一组故意构造的反例、一段口述的执行顺序说明。三样齐了,答辩基本不会被动。

对照表的做法是把每条查询的预期结果先手算出来,再和 SQL 实际返回比。手算不用算全表,抽两三行样本就行。比如第 10 题,先确认上海有 C01、C02 两家,再数它们在 SALE 里数量大于 200 的记录有几条,预期值写死,跑完对不上就说明条件写漏了。这张表长这样:

题号检查点预期值(样例数据)判定标准
3订购两种以上的客户C01、C02行数与客户数一致
10上海客户大额订购2 行不含北京客户记录
12与美美同城1 行(C02)不含美美自身
14未被订购产品C01 扫描仪不含已订购产品

反例的设计思路是「故意让错写法暴露」。比如验证第 14 题,可以临时往 SALE 插一条 PCODE 为空或者重复的记录,看 NOT EXISTS 是否仍然稳定返回;验证第 12 题,可以再插一家同城的公司,看=写法是否报错、IN 写法是否正常。这一组反例写进报告,比任何文字说明都有说服力。

答辩时被问得最多的是执行顺序和子查询类型,准备两句话就够:一句讲 WHERE 在分组前、HAVING 在分组后,所以聚合函数不能进 WHERE;一句讲相关子查询逐行执行、不相关子查询只执行一次,所以能用前者改写的地方优先改写。被问到「为什么这题不用嵌套」时,答「分组能解决就不引入子查询,可读性和执行计划都更好」,这是标准答案。

从那以后我每次交这类实验报告,都强制走一遍「手算小样本 → 跑 SQL → 差异定位」的流程,哪怕只查三行数据,也不省这一步。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进