做数据查询这件事,很多人第一次真正被卡住,不是在语法上,而是在"我知道要什么,但不知道怎么写"。某在线编程实训平台的这道电影、演员与票房主题的 SELECT 关卡就是个典型样本:表不多,字段也不复杂,但一旦要求"找出参演过三部以上影片、且平均票房排前几的演员",不少人就开始 SELECT 里套 SELECT、JOIN 写完发现条数翻倍。这篇就把这套电影票房数据集从建表到复杂查询完整拆一遍,重点讲清楚每个写法的判断依据,而不是罗列语法。刚接触 SQL 的朋友可以照着代码跑,已经能写单表查询、但在多表关联上总踩坑的朋友,可以重点看第 3 和第 4 部分。涉及的影片名、演员名均为虚构示例数据,只用于说明查询逻辑。
1. 先想清楚数据长什么样——电影票房库的模型拆解
写查询之前先看表结构,这一步省不得。很多人拿到题目直接写 SELECT,写一半发现字段不够用,又回头改关联,来回折腾。真正高效的顺序是:先看清楚有几张表、每张表一行代表什么、表之间靠哪个字段连接,然后再动键盘。
1.1 四张表怎么划分职责
这套数据集通常拆成四张表:影片表、演员表、参演关系表、票房表。划分的依据是"一个事实只存一份"。影片的基本信息(片名、上映年份、时长、类型)属于影片本身,放影片表;演员的姓名、性别、出生年份属于演员本身,放演员表;"谁演了哪部片、演什么角色"既不属于影片也不属于演员,它描述的是两者之间的一次关联,所以单独成立一张参演关系表;票房是一笔按影片、按日期统计的资金数据,独立成表。
这个拆法和现实业务是一致的。一部影片可能有十几个演员,一个演员也可能参演几十部影片,这是典型的"多对多"关系。如果在影片表里塞一个"主演"字段,遇到联合主演就没法存;如果塞多个字段"主演1、主演2",那查询"某演员参演的全部影片"就得写一堆 OR,扩展性极差。中间表的做法虽然多一次 JOIN,但换来的是任意维度的查询自由。
需要注意的是,票房表在有的版本里是"一片一行"的汇总,有的版本是"一片一天一行"的明细。这两种结构的查询写法差别很大。前者直接 SUM 就行,后者必须先按影片聚合再算总和,否则会把同一天的记录重复累加。拿到表先SELECT * ... LIMIT 10看一眼数据形态,比看字段名猜要靠谱得多。
1.2 字段类型与主外键的取舍
字段类型选错是后面一堆怪问题的源头。金额字段我一般用DECIMAL(12,2),不用FLOAT或DOUBLE。票房动辄上亿,浮点数在这种量级上做加减会出现末位误差,做 SUM 之后误差还会累积,最后对不上账。DECIMAL 是定点存储,精度可控,代价是稍微占空间,做财务类统计这个代价值得付。
年份用SMALLINT而不是INT,够用且省空间;时长用INT存分钟数比存HH:MM字符串好,后者排序和比较都要转格式。上映日期如果只需要年份,就别存完整的 DATETIME,多出来的时分秒只会让 GROUP BY 分组的粒度变细,产生一堆本不该出现的分组。
主外键方面,影片表用movie_id做主键,自增;演员表用actor_id做主键;参演关系表用(movie_id, actor_id)做联合主键,这个设计能天然防止同一个演员在同一部影片里被重复插入。外键约束在练习环境里加不加都能跑,但我建议加上,因为它能在插入脏数据的时候立刻报错,而不是等到查询结果对不上才回头找原因。
如果你在实训环境里遇到建表语句执行失败,先确认外键依赖的两张表的创建顺序,被引用表必须先建。
1.3 把一句业务需求翻译成查询条件
这是整个 SELECT 训练里最核心的能力转换。题目给的是中文需求,SQL 要的是"从哪张表取、按什么条件筛、按什么分组、按什么排序、取几条"。我用一个例子演示这个拆解过程:需求是"统计 2019 年之后上映、时长超过 120 分钟的国产影片,按上映年份倒序、时长降序排列"。
拆解如下:数据来源是影片表,因为所有筛选条件都在影片自身的属性上;筛选条件是上映年份大于等于 2019、国家为国产、时长大于 120;排序字段是上映年份和时长;不需要分组,因为是一行一影片;不需要限制条数,因为题目没要求。翻译成 SQL 就是:
SELECT movie_id, title, release_year, country, duration_min FROM movie WHERE release_year >= 2019 AND country = '中国' AND duration_min > 120 ORDER BY release_year DESC, duration_min DESC;这个"取哪张表、筛什么、分不分组、排什么序、取几条"的五问法,几乎能套用到所有单表查询上。习惯之后,看需求就能直接出结构,剩下的只是填字段名。我的经验是,写复杂查询前先在纸上(或者注释里)用中文把这五问写一遍,写完再敲 SQL,返工率能低一大截。
2. SELECT 基础查询:先把"取哪些行、哪些列"说准
单表查询看着简单,但真正写得准不容易。列选多了结果对不上,条件写松了行数翻倍,排序没写导致每次运行结果顺序都不同——这些在人工看数据时都不算问题,在自动评测里就是直接判错。
2.1 列选择、别名与 DISTINCT 的真实用途
SELECT *在探索数据阶段很好用,能快速看清表里有什么。但在答题和正式业务里,我基本不用它。原因有三个:一是评测环境的列顺序和你想象的未必一致;二是多余的大字段会拖慢查询;三是代码审阅时看不出你到底需不需要这些列。
显式列出字段,配合别名,是更稳的写法:
SELECT title AS 影片名, release_year AS 上映年份, duration_min AS 时长分钟 FROM movie;别名在这里不只是好看。多表 JOIN 之后,不同表可能有同名字段(比如都叫name),不加别名结果集里会出现两列同名,取数据时容易错位。另外,如果后面要用ORDER BY或HAVING引用聚合结果,别名能让语句短不少。
DISTINCT的误用很普遍。它是对整行去重,不是对某一列去重。写SELECT DISTINCT country, release_year FROM movie得到的是"国家+年份"的唯一组合,不是"唯一国家"。想要唯一国家列表,只能选一列。还有一种情况是用 DISTINCT 掩盖了关联错误——本来因为 JOIN 写错产生了重复行,加个 DISTINCT 看起来正常了,但如果你后面还要 SUM 票房,重复行已经被算进去了,这时候 DISTINCT 救不了。所以看到自己想去重的时候,先问一句:重复行是从哪来的。
2.2 WHERE 条件里的运算符与 NULL 陷阱
WHERE 是过滤行的地方,运算符就那么几个,但坑集中在 NULL 和类型转换上。
NULL 表示"未知",它不等于任何值,也不等于自己。WHERE birth_year != 1990这个条件会把birth_year为 NULL 的行直接排除掉,因为"未知不等于 1990"这个判断结果是 NULL,而 WHERE 只保留判断为 TRUE 的行。想包含 NULL,得写成:
SELECT actor_id, actor_name, birth_year FROM actor WHERE birth_year IS NULL OR birth_year != 1990;NOT IN的坑更隐蔽。WHERE actor_id NOT IN (SELECT actor_id FROM movie_actor WHERE movie_id = 3),如果子查询的结果里包含任何一个 NULL,整个 NOT IN 会返回空结果。这不是数据库的 bug,是 SQL 三值逻辑的必然结果。规避方式是子查询里加WHERE actor_id IS NOT NULL,或者改用NOT EXISTS。
还有字符串比较。country = '中国'这种写法依赖表里的数据和你写的字符完全一致,空格、全半角差异都会导致匹配不上。练习数据一般是干净的,但真做项目时会遇到带前后空格的脏数据,用TRIM(country) = '中国'更稳,代价是索引可能失效,这个取舍在第 4 部分展开。
BETWEEN是闭区间,BETWEEN 100 AND 200包含两端。日期范围用它的时候要小心:BETWEEN '2020-01-01' AND '2020-12-31'如果字段是 DATETIME 类型,12 月 31 日当天的记录会因为时分秒大于 00:00:00 而被漏掉。这种情况用>= 起始 AND < 次日更保险。
2.3 ORDER BY 与 LIMIT:排序字段和分页边界
排序决定结果集的行顺序。评测环境如果对顺序敏感,不写 ORDER BY 就等于把结果交给数据库的运气。MySQL 不保证没有 ORDER BY 时的返回顺序,数据量小的时候看起来是按主键,数据一多或者执行计划一变就乱了。
多字段排序的优先级要写清楚:
SELECT title, release_year, duration_min FROM movie ORDER BY release_year DESC, duration_min DESC, movie_id ASC;这里第三个字段movie_id是兜底用的。当两个影片年份和时长都一样时,加上主键排序能保证结果稳定可复现。写分页查询时这个习惯尤其重要,否则翻页可能看到重复或遗漏的记录。
LIMIT在 MySQL 里支持LIMIT offset, count和LIMIT count OFFSET offset两种写法,后者可读性更好。要注意 offset 很大时性能会下降,因为数据库需要先扫描并丢弃前面的行。练习环境数据量小感觉不到,真实场景几百万行的时候,深分页是很明显的性能问题,正规做法是用"上一页最后一条的排序值"做游标。
-- 取票房前三:先关联再排序再截断 SELECT m.title, b.total_amount FROM movie m JOIN box_office b ON m.movie_id = b.movie_id ORDER BY b.total_amount DESC LIMIT 3;顺带说一个执行顺序的事:SQL 的逻辑执行顺序是 FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。所以 WHERE 里不能用 SELECT 中定义的列别名(MySQL 对 GROUP BY 和 HAVING 放宽了,允许用别名,但 WHERE 不行)。知道这个顺序,很多"为什么这里不能引用"的疑问就自动消解了。
2.4 实训评测环境下的输出对齐技巧
在线评测判题通常是拿你的结果集和标准答案逐行逐列比对,所以几个细节值得注意。一是列的顺序要按题目要求的顺序写,别依赖SELECT *碰运气;二是列名如果有要求就用别名对齐,有些题目会校验列名;三是行顺序如果有业务含义(比如"排名前三"),必须写 ORDER BY 且和答案的排序规则一致,多写一个排序字段都会导致顺序不同。
我的习惯是先在本地把数据插进去跑一遍,用SELECT *看看数据长什么样,确认没问题再改成正式答案提交。一次没通过的时候,别急着改逻辑,先把结果条数打出来对比——条数不对说明过滤或关联有问题,条数对但内容不对说明列选错或分组维度错了。这个判断顺序能省掉大量盲目试错。
3. 多表关联与聚合:业务问题的主战场
单表查询占实际工作的比例其实不高,绝大部分需求都要跨表。电影票房这个场景尤其典型,任何稍微像样的需求都会同时涉及影片、演员和票房。
3.1 INNER JOIN 与 LEFT JOIN 的选择判断
这两个关联方式的区别只有一句话:INNER JOIN 只保留两边都匹配上的行,LEFT JOIN 保留左表全部行,右表没匹配上的用 NULL 填充。选哪个不取决于习惯,取决于业务语义。
"查询每部影片的票房"——如果有些影片还没上映、票房表里没有记录,而你希望这些影片也出现在结果里(票房显示为空),就必须用 LEFT JOIN。如果用 INNER JOIN,这些影片会被静默丢掉,结果看起来正常,但少了几行,这种错误最难发现。
反过来,"查询有票房记录的影片及其票房",用 INNER JOIN 是有意为之,它天然过滤掉了没票房的影片。但更清晰的做法还是写 LEFT JOIN 加WHERE b.movie_id IS NOT NULL,把自己的意图明确表达出来。代码是给人看的,语义清晰比少敲几个字重要。
-- 找出没有任何票房记录的影片 SELECT m.movie_id, m.title FROM movie m LEFT JOIN box_office b ON m.movie_id = b.movie_id WHERE b.movie_id IS NULL;这个"左连接 + 判断右表主键为空"的模式,是找"孤儿数据"的标准写法,比用 NOT IN 子查询更快也更安全。
另一个常被忽略的点是 ON 和 WHERE 的区别。在 LEFT JOIN 里,把右表的过滤条件写在 ON 和写在 WHERE,结果完全不同。写在 ON 里是"关联时只匹配满足条件的右表行,左表行照样保留";写在 WHERE 里是"先关联出全部结果,再过滤掉不满足条件的行",那些右表为 NULL 的行会因为条件不成立被一起干掉,LEFT JOIN 就退化成了 INNER JOIN。
-- 写法 A:所有影片都保留,只有 2021 年的票房被关联上 SELECT m.title, b.total_amount FROM movie m LEFT JOIN box_office b ON m.movie_id = b.movie_id AND YEAR(b.stat_date) = 2021; -- 写法 B:只保留 2021 年有票房记录的影片 SELECT m.title, b.total_amount FROM movie m LEFT JOIN box_office b ON m.movie_id = b.movie_id WHERE YEAR(b.stat_date) = 2021;这两个写法我都见过有人混用,结果差别巨大。记住一句话:左连接里想过滤右表,先想清楚是要"限制匹配"还是"筛选结果"。
3.2 GROUP BY 与 HAVING 的分工边界
分组是聚合的前提。GROUP BY后面跟的字段决定了"每一行代表什么"。SELECT actor_id, COUNT(*) FROM movie_actor GROUP BY actor_id,含义是"每个演员一行,统计他参演的影片数"。如果把GROUP BY去掉,聚合函数就会作用于全表,返回一行总数。
分组字段和 SELECT 字段的关系是老生常谈但必须说清楚:SELECT 里出现的非聚合列,原则上都应该出现在 GROUP BY 里。MySQL 在ONLY_FULL_GROUP_BY模式下会强制检查这一点。早期版本默认关闭这个模式,随便写也能跑,返回的是分组内任意一行的值,这种"能跑但结果不可靠"的行为在生产环境里非常危险。我建议在任何环境都把这个模式打开,逼自己写规范。
-- 查看当前模式 SELECT @@sql_mode;HAVING是分组之后的过滤,WHERE是分组之前的过滤。这个先后顺序决定了性能:能在 WHERE 里过滤掉的,绝对不要留到 HAVING。比如"统计参演三部以上影片的演员的平均票房,只算 2015 年之后的影片"——年份过滤应该放在 WHERE 里,先把无关行剔掉再分组;如果放到 HAVING 里,虽然语法上也能写,但等于让数据库对全量数据做了分组,白白浪费。
SELECT a.actor_id, a.actor_name, COUNT(DISTINCT ma.movie_id) AS 参演数, ROUND(AVG(b.total_amount), 2) AS 平均票房 FROM actor a JOIN movie_actor ma ON a.actor_id = ma.actor_id JOIN movie m ON ma.movie_id = m.movie_id JOIN box_office b ON m.movie_id = b.movie_id WHERE m.release_year >= 2015 GROUP BY a.actor_id, a.actor_name HAVING COUNT(DISTINCT ma.movie_id) >= 3 ORDER BY 平均票房 DESC LIMIT 10;这段里有几个细节值得单独讲。COUNT(DISTINCT ma.movie_id)里的 DISTINCT 不是多余的:如果有演员在同一部影片里演了多个角色(中间表可能有多行),不去重会把一部片算成两部。AVG用的是票房表的总额字段,如果票房表是按日粒度的,这里应该先按影片汇总再求平均,否则平均的是"每次统计记录"而不是"每部影片"。GROUP BY里带上actor_name是为了让 SELECT 合法,虽然actor_id已经能唯一确定姓名,但严格模式下必须写全。
3.3 子查询、EXISTS 与派生表的用法
子查询分三种位置:WHERE 里的标量子查询、WHERE 里的 IN/EXISTS 子查询、FROM 里的派生表。用途不一样,性能特点也不一样。
标量子查询返回单个值,适合和聚合结果比较。比如"找出票房高于全部影片平均票房的影片":
SELECT m.title, b.total_amount FROM movie m JOIN box_office b ON m.movie_id = b.movie_id WHERE b.total_amount > (SELECT AVG(total_amount) FROM box_office) ORDER BY b.total_amount DESC;这个写法要先算一次全表平均,再拿每行去比。数据量小的时候完全没问题,数据量大时用窗口函数AVG(total_amount) OVER ()可以只扫一遍表。
EXISTS适合判断"是否存在满足条件的关联记录",语义是布尔值,找到第一条就停。判断"参演过影片的演员":
SELECT a.actor_id, a.actor_name FROM actor a WHERE EXISTS ( SELECT 1 FROM movie_actor ma WHERE ma.actor_id = a.actor_id );和IN相比,EXISTS在子查询结果集大、外表小的时候通常更快,而且不受 NULL 影响。NOT EXISTS同理,是替代NOT IN的稳妥选择。
派生表(FROM 子句里的子查询)适合分步计算。比如"先算每部影片的参演人数,再找参演人数超过 5 人的影片":
SELECT m.title, t.cnt AS 参演人数 FROM movie m JOIN ( SELECT movie_id, COUNT(*) AS cnt FROM movie_actor GROUP BY movie_id ) t ON m.movie_id = t.movie_id WHERE t.cnt > 5 ORDER BY t.cnt DESC;MySQL 8.0 之前派生表会被物化成临时表,数据量大时有额外开销;8.0 之后优化器会把条件下推,性能好很多。写的时候养成给派生表起别名的习惯,这是语法要求,缺了直接报错。
3.4 窗口函数做分组排名与占比
如果你用的是 MySQL 8.0 及以上,窗口函数能解决一大类以前必须靠自连接或者变量才能做的需求。它和 GROUP BY 最大的区别是:GROUP BY 会把多行压缩成一行,窗口函数不减少行数,只是在每行旁边多算一列。
按年份给影片票房排名:
SELECT m.title, m.release_year, b.total_amount, RANK() OVER (PARTITION BY m.release_year ORDER BY b.total_amount DESC) AS 年度排名 FROM movie m JOIN box_office b ON m.movie_id = b.movie_id;PARTITION BY相当于分组,ORDER BY决定排名依据。RANK、DENSE_RANK、ROW_NUMBER三者的区别在并列时体现:RANK并列后跳号(1,1,3),DENSE_RANK并列不跳号(1,1,2),ROW_NUMBER强制不并列(1,2,3)。选哪个取决于业务定义,"并列第一之后下一个是第三名还是第二名"这个问题得先问清楚。
算占比也很方便:
SELECT m.title, b.total_amount, ROUND(b.total_amount / SUM(b.total_amount) OVER () * 100, 2) AS 票房占比 FROM movie m JOIN box_office b ON m.movie_id = b.movie_id ORDER BY 票房占比 DESC;SUM(...) OVER ()不带 PARTITION 就是全表求和,每行都能拿到这个总数,直接做除法就得到占比。这种写法比以前"先在子查询里求总和再关联"简洁太多。窗口函数不能写在 WHERE 里,因为它是在 WHERE 之后才计算的,要过滤排名结果必须再包一层子查询或者用 HAVING 配合 CTE。这个限制第一次遇到会懵,记住执行顺序就不难理解。
4. 常见问题与排查实录
前面讲的是怎么写对,这一部分讲写错了怎么找。SQL 的报错信息通常很简短,只说"语法错误"或者"列不在 GROUP BY 中",真正的根因得自己顺藤摸瓜。
4.1 结果条数对不上:先查关联条件再查过滤条件
条数翻倍是最常见的问题,九成以上的原因是关联条件不完整,产生了笛卡尔积的部分匹配。排查方法是把 JOIN 一层层拆开,每加一个 JOIN 就看一次条数。
SELECT COUNT(*) FROM movie; -- 假设 200 SELECT COUNT(*) FROM movie_actor; -- 假设 800 SELECT COUNT(*) FROM movie m JOIN movie_actor ma ON m.movie_id = ma.movie_id; -- 应该等于 800如果第三句的结果远大于 800,说明关联字段不唯一,或者漏写了关联条件(变成交叉连接,200 × 800 = 16 万行)。漏写 ON 条件时 MySQL 不一定报错,会老老实实给你全组合,这种"能跑但结果荒谬"的情况最坑。
另一种条数不对是过滤条件写宽了。比如WHERE release_year = 2019 OR duration_min > 120,因为没有括号,AND的优先级高于OR,实际执行的逻辑可能和你想的完全不同。我的建议是只要 WHERE 里同时出现 AND 和 OR,一律加括号,别指望读者(包括三个月后的自己)记得优先级。
还有一种隐蔽情况是多表关联后做聚合,COUNT(*)数的是关联后的行数而不是主表行数。想数影片数就必须写COUNT(DISTINCT m.movie_id),否则一部影片关联了 5 个演员,会被数成 5。
4.2 聚合报错与数值精度问题
ONLY_FULL_GROUP_BY报错的提示是 "Expression #N of SELECT list is not in GROUP BY clause and contains nonaggregated column"。解决方法不是关掉这个模式,而是补齐 GROUP BY 字段,或者对那个列用聚合函数包起来。前者适用于"这个列本来就该是分组维度",后者适用于"这个列的值在组内唯一,随便取一个就行",比如按actor_id分组时取actor_name,此时应该写成MAX(actor_name)或者干脆把actor_name也放进 GROUP BY。
金额精度问题我踩过一次。用 FLOAT 存票房,做SUM之后结果末尾出现.999999,导出报表对不上账。换成 DECIMAL 之后问题消失。涉及金额、比例、评分这类需要精确计算的数据,一律 DECIMAL;如果只是做粗略统计或者科学计算,FLOAT 和 DOUBLE 都可以。
除法也要注意。MySQL 里整数相除会自动转成小数,但如果两个整数列相除你想保留小数位,最好显式用ROUND(x / y, 2),避免一长串小数位影响阅读。另外除数为 0 时 MySQL 返回 NULL 而不是报错,做占比统计时可以用NULLIF(分母, 0)主动规避。
4.3 从能跑到跑得快:几个不起眼的书写习惯
练习数据量小,性能差异基本感觉不到,但养成好习惯的收益在真实项目里会放大很多倍。
第一个习惯是 WHERE 条件里避免对字段做函数运算。WHERE YEAR(release_date) = 2019会让release_date上的索引失效,改成WHERE release_date >= '2019-01-01' AND release_date < '2020-01-01'就能走索引。前后空格同理,TRIM(name) = 'xxx'用不了索引,规范数据入库比查询时补救更划算。
第二个习惯是 JOIN 的驱动表要选小的。MySQL 优化器大部分时候能自己选对,但在统计信息陈旧的时候会选错。可以用EXPLAIN看执行计划,关注type列(ALL是全表扫描,ref和eq_ref是走索引)和rows列(预估扫描行数)。这个工具我用得最多的场景不是优化,而是验证自己的理解——写了一句自认为很聪明的 SQL,EXPLAIN一看扫描了几十万行,就知道判断错了。
第三个习惯是别在 SELECT 里写用不到的列。特别是带 TEXT 或者大 VARCHAR 的表,多取一列会多传不少数据。看起来是小钱,累积起来是实实在在的开销。
第四个习惯是分页不要用大 offset。LIMIT 100000, 20需要扫描 100020 行再丢弃前 100000 行。如果业务允许,用"上一页最后一条记录的 id 做游标"的方式翻页,性能稳定。
4.4 高频问题速查表
| 现象 | 最常见原因 | 排查动作 | 解决写法 |
|---|---|---|---|
| 结果条数比预期多 | 关联条件缺失或不唯一 | 逐层 JOIN 看 COUNT | 补全 ON 条件,聚合时用 COUNT(DISTINCT) |
| 结果条数比预期少 | 用了 INNER JOIN 把空关联行丢了 | 改成 LEFT JOIN 对比条数 | 按业务语义选 LEFT JOIN 加 IS NULL 判断 |
| LEFT JOIN 结果退化成内连接 | 右表过滤条件写在了 WHERE | 把 WHERE 条件移到 ON | 明确"限制匹配"还是"筛选结果" |
| 分组查询报错 | ONLY_FULL_GROUP_BY 模式 | 看报错中的列名 | 补齐 GROUP BY 或对列用聚合函数 |
| 排序结果每次不同 | 排序字段有重复值且未兜底 | 检查 ORDER BY 字段唯一性 | 追加主键作为最后排序字段 |
| NOT IN 返回空结果 | 子查询结果含 NULL | 单独执行子查询看有无 NULL | 改 NOT EXISTS 或加 IS NOT NULL |
| 金额汇总有尾差 | 用了 FLOAT/DOUBLE | 看字段类型 | 改为 DECIMAL |
| 日期范围查询漏数据 | BETWEEN 用在 DATETIME 字段上 | 查边界当天的记录 | 用 >= 起始 AND < 次日 |
这张表是我自己在反复练习中总结的,实际遇到的问题九成都能往里套。真正卡住的时候,我的排查顺序固定是:先看条数,再看列名,再看排序,最后才怀疑聚合逻辑。这个顺序是按"出问题概率从高到低"排的,能省不少时间。
最后分享一个我在练习中形成的习惯:每写完一段查询,先不加聚合和排序,把原始结果拉出来看十行。看什么?看关联后的字段是不是都对得上、有没有明显重复、NULL 出现在哪里。这十行数据能暴露的问题,比盯着代码看十分钟还多。SQL 这门语言的特点是"写错了也能跑",所以验证环节比写代码本身更值得花时间。