要说SQL刷题里最容易被低估的题,1068.产品销售分析绝对排得上号。它在高频SQL 50题里属于第一档的简单题,不少同学看一眼表结构就觉得没难度,实际写起来却会在JOIN类型、聚合、排序这些细节上翻车。这篇文章我会从题目本身出发,把建表、解题SQL、执行验证、常见报错和业务扩展整个链路都过一遍,适合正在准备数据分析面试的人,也适合刚学完SQL基础想用实战题巩固的新手。
1. 先把题目场景还原清楚
1.1 两张表的结构和业务含义
题目给的是两张表:Product和Sales。Product是产品维度表,字段是product_id和product_name,主键是product_id。Sales是销售事实表,字段有sale_id、product_id、year、quantity、price,主键是(sale_id, year)。
这里要先搞明白一个关键点:为什么Sales表的主键是(sale_id, year)而不是sale_id单独做主键?这说明同一个销售单号下可以存在不同年份的记录,但同一年内不会出现相同的sale_id。反过来,同一个产品在同一年是可以有多个销售记录的,比如同一款手机在2009年1月和3月各卖出一批,这两条记录在Sales表里就是独立的行。
这个结构在真实业务里很像“商品主数据 + 销售流水”。Product表相当于商品目录,每行是某个产品的档案,product_id一旦确定,产品名称就不会变;Sales表相当于收银小票,每卖出一件商品就多一行记录。如果还是觉得抽象,可以想象成书店的场景:Product表是书架上的书目卡片,Sales表是每天打印出来的销售清单。销售清单上的每一本书,都应该能在书目卡片里找到对应的书名,否则就是脏数据。
1.2 题目要求到底在问什么
原题要求写一个SQL查询,返回每个销售记录对应的product_name、year和price。注意这里的关键词是“每个销售记录”,不是“每个产品”,也不是“每年”。
也就是说,结果集的行数应该和Sales表的行数保持一致,因为题目没有要求做任何聚合,也没有过滤条件。一个常见的错误是把这道题等价成“查询每个产品每年的销售情况”,然后顺手写成GROUP BY,导致多条销售记录被合并成一条,输出行数少于Sales表行数,语义完全变了。
另一层含义是,你需要把Sales表通过product_id连接到Product表,从而把product_name“补充”到销售明细里。这个操作很像Excel里的VLOOKUP,但SQL里更推荐直接使用JOIN完成。把需求拆开来看,一共三件事:连接键是product_id,输出列是product_name、year、price,排序方式是product_id和year。三件事中,连接是核心,输出列决定结果长什么样,排序只影响展示顺序,不影响返回行数。
1.3 查询前先画一条“行映射关系”
我习惯在写SQL之前,先在草稿纸上画输出行和输入行之间的关系。这道题里,Sales表每一行都会产生一行结果,Product表只是用来补充产品名称。
画成箭头大概是这样的:Sales.sale_id -> Product.product_name,方向是“销售记录去找产品名”。因为Product表中的product_id是主键,所以一条销售记录最多匹配到一条产品信息,不会因为连接而变多。因此输出行数就是Sales表的行数。
这个小步骤看起来多余,但在复杂查询里特别有用。很多人在做多表关联时,没有先想清楚粒度,直接写JOIN,结果行数一会儿多一会儿少,最后不知道问题出在哪里。1068题正好可以帮你建立这个意识:拿到需求先问自己,结果对应的粒度是明细行,还是汇总行?如果是明细行,通常不需要GROUP BY;如果是汇总行,才需要考虑聚合函数或者窗口函数。
2. 标准解法:INNER JOIN + 明确排序
2.1 最小可用的SQL写法
先给出一版可以直接跑通的答案,以MySQL为例:
SELECT p.product_name, s.year, s.price FROM Sales AS s INNER JOIN Product AS p ON s.product_id = p.product_id ORDER BY s.product_id, s.year;这段代码的核心逻辑是:Sales表作为驱动表,每一行通过product_id去Product表里找对应的产品名称。因为外键关系保证了每个s.product_id都能在Product表里匹配到记录,所以用INNER JOIN不会丢行。
这里有三个细节值得展开。
第一,SELECT里带上了表别名s和p。这样写有两个好处:一是避免两个表出现同名字段时数据库无法判断,比如Sales和Product里如果都有product_id,直接写product_id会报错;二是让读代码的人一眼就知道每一列来自哪张表,代码可读性更高。
第二,排序用的是s.product_id,而不是p.product_id。虽然当前数据里两者值一样,但规范上应该用Sales表自己的product_id排序,因为需求是围绕销售记录展开的。如果只写ORDER BY product_id,在多个表连接后可能会产生歧义。
第三,如果题目要求按product_id和year排序,就不要只写ORDER BY product_id。否则同一个产品不同年份的行,输出顺序在数据库里可能不稳定,尤其当year列上有索引时,执行计划可能会选择索引扫描,顺序不一定是年份递增的。
2.2 为什么选择INNER JOIN而不是LEFT JOIN
这是初学者经常纠结的问题。从结果上看,只要Sales表里的product_id都能在Product表里匹配到,INNER JOIN和LEFT JOIN返回的行数完全一样。既然结果相同,那是不是选哪个都可以?
并不是。
如果Sales表里存在某些product_id在Product表里找不到对应记录,LEFT JOIN会把销售记录保留下来,但product_name会变成NULL;而INNER JOIN会直接把这行过滤掉。题目并没有明确告诉你数据一定是干净的,所以选择哪种连接,取决于你是否想发现脏数据。
从面试题的标准答案出发,用INNER JOIN更贴合题目暗示:每个销售记录都能在Product表里找到对应产品。但如果我在真实业务中处理销售数据,我会更倾向于先用LEFT JOIN加IS NULL检查一下数据质量,比如这样:
SELECT s.sale_id, s.product_id FROM Sales AS s LEFT JOIN Product AS p ON s.product_id = p.product_id WHERE p.product_id IS NULL;这个查询的结果是“所有在Product表里找不到对应记录的销售单”,也就是孤儿数据。这种检查在数据管道里非常常用,可以提前暴露上游数据问题。换句话说,INNER JOIN是“我只要匹配上的数据”,LEFT JOIN是“我保留左侧全量数据,再判断右侧有没有”。在这道题里,标准解法选INNER JOIN更干净,但理解两者差异比背答案重要得多。
2.3 排序的细节和可复现性
题目最后通常会写一句“返回结果按product_id和year排序”。为什么要强调排序?因为SQL表里的行本身是没有绝对顺序的,只有ORDER BY才能保证多次执行得到同样的顺序。如果你不写排序,数据库可能因为存储引擎、内存排序策略等原因返回不同顺序,面试官看到这种结果会怀疑你对SQL的理解不够扎实。
排序还有个容易忽略的细节:ORDER BY p.product_id, s.year和ORDER BY s.product_id, p.year在数据正常时结果等价,但一旦两张表中存在NULL值,排序位置就会不同。另外,如果排序字段上已经建立了索引,数据库可以避免额外的排序操作,直接利用索引有序性返回数据。这个点对于慢SQL优化很重要,后面我会再展开。
2.4 price字段的取值来源
题目要求输出的price来自Sales表,不是Product表。Product表里只有product_id和product_name,并没有价格字段。为什么这么设计?因为在真实的数据模型里,价格往往随时间、促销活动、客户类型变化,属于事实数据,放在销售事实表里更合理。商品主数据表里可能有“建议零售价”,但实际成交价还是要看销售记录。
这一点在写SQL时容易被忽略。有些同学一看到“销售分析”,就理所当然地认为价格应该从产品信息里取,结果在Product表里找不到price字段,开始怀疑题目是不是有问题。其实只要回到两张表的结构里看一下字段,就不会犯这个错误。
也正因为price在Sales表里,所以查出来的价格是每笔销售记录对应的成交价,而不是产品档案里的固定价格。如果同一产品在不同年份卖出的价格不同,结果里会出现两行相同产品名、不同价格的数据,这正好符合真实业务。
2.5 ON与WHERE条件不能混着用
在JOIN语句中,ON用来指定两个表的连接规则,WHERE在连接完成之后对结果行做过滤。两者的执行时机完全不同。
你可能见过下面这种写法:
SELECT p.product_name, s.year, s.price FROM Sales AS s LEFT JOIN Product AS p ON s.product_id = p.product_id WHERE p.product_id IS NOT NULL;这个查询的结果接近INNER JOIN,但它有一个问题:过滤条件写在了WHERE里,意味着先做LEFT JOIN,把未匹配的NULL行保留下来,然后再将NULL行过滤掉。虽然在结果上可能和内连接一样,但逻辑上绕了一大圈,可读性也差。更糟糕的是,如果使用LEFT JOIN并且过滤条件是关于右表的字段,放在WHERE和放在ON里结果可能不同。
之所以要单独提这一点,是因为很多人在刷题时会顺手把过滤条件写在ON后面,觉得反正结果对就行。一旦遇到LEFT JOIN,这个习惯就会导致数据丢失或者出现预期外的NULL行。规范的做法是:ON里只写连接条件,行筛选放WHERE,这样任何时候执行结果都符合直觉。
3. 解题之外:这道题背后的SQL核心考点
3.1 连接的本质和笛卡尔积风险
JOIN的底层逻辑可以理解成两个表做行与行的组合。以本题为例,如果忘了写连接条件,写成:
SELECT p.product_name, s.year, s.price FROM Sales AS s, Product AS p;Sales表有3行,Product表有3行,结果会变成9行,这个现象就是笛卡尔积。笛卡尔积在真实业务中会放大得非常恐怖,如果一张表有10万行,另一张表有1万行,没有连接条件的结果就是10亿行,数据库基本直接卡死。
排查笛卡尔积的方法是:先分别统计两张表的行数,再对比JOIN后结果的行数。如果结果行数远大于左表行数,第一时间检查ON条件是否完整。本题中Sales的product_id在Product表中是唯一的,正常连接后行数不会变化,所以一旦出现行数翻倍,几乎可以肯定是连接条件写错了或者漏写了。
3.2 列的选择与别名
许多新手喜欢用SELECT *,觉得省事,但在这里我不建议这么做。原因很简单:第一,SELECT *会把两个表里的product_id都带出来,造成列名重复,下游代码里可能不知道该取哪一个;第二,SQL题和真实工程一样,只取需要的列能让结果更干净,也能减少网络传输和IO开销;第三,显式列名配合别名,能提升代码的可读性。
给表起别名也是同样的道理。Sales AS s,Product AS p,这两个短别名不是为了耍酷,而是让长SQL不至于到处都是完整的表名。更重要的是,在连接查询中,如果两个表有同名列,必须用别名区分。养成这个习惯后,从简单的1068题到复杂的多表连接,都会少踩很多坑。
3.3 聚合是陷阱:什么时候才需要GROUP BY
这题最容易翻车的写法是:
SELECT p.product_name, s.year, s.price, SUM(s.quantity) FROM Sales AS s JOIN Product AS p ON s.product_id = p.product_id GROUP BY p.product_name, s.year;这种写法在MySQL的ONLY_FULL_GROUP_BY模式下会直接报错,因为s.price没有被聚合,也没有出现在GROUP BY里。即使在一些版本里侥幸跑通,返回的price也可能是表中任意一行,结果不可控。更致命的是,同一产品同一年如果有多条销售记录,GROUP BY会把它们合并成一行,结果行数小于Sales表行数,不满足“每个销售记录”的要求。
GROUP BY什么时候才需要?当你希望输出的粒度从“每笔销售”变成“每个产品每年”的时候。比如要查每个产品每年的总销量或总销售额,这时候才需要按产品加年份分组,并对quantity和price做聚合。面试时如果题目只写了“报表输出”,没有明确说明粒度,一定要先问清楚,或者从题目要求里推断。我的经验是:输出行数如果不等于事实表行数,大概率是要做某种汇总,这时才考虑GROUP BY或窗口函数。
3.4 面对NULL值怎么办
题目给的数据比较干净,没有NULL,但真实环境里NULL无处不在。比如Product表里某个product_name是NULL,或者Sales表里price是NULL。一旦涉及NULL,之前理所当然的JOIN和排序行为都会发生变化。
两个NULL永远不会相等。所以如果Sales表里某些行的product_id是NULL,INNER JOIN会直接把这一行丢弃。如果确实需要保留这些无产品的销售记录,可以在SELECT里用COALESCE填充默认值,比如:
SELECT COALESCE(p.product_name, '未知产品') AS product_name, s.year, s.price FROM Sales AS s LEFT JOIN Product AS p ON s.product_id = p.product_id;在处理排序时也要注意,NULL在MySQL默认排在最前面,在Oracle里则排在最后面。面试中如果问你和NULL相关的坑,你就要能说出这些差异。1068题虽然没考NULL,但完全可以把这道题改造成“找出没有产品信息的销售记录”,用来练习数据质量检查。这也是为什么简单题往往有更多扩展空间。
4. 实操过程实录:建表、插入、查询与验证
4.1 环境准备和建表语句
我一般在本地用MySQL 8.0练习,下面是一套可以直接运行的建表语句。字段类型和题目保持一致,主键和外键都加上,方便观察约束对数据的影响。
CREATE TABLE Product ( product_id INT PRIMARY KEY, product_name VARCHAR(100) ); CREATE TABLE Sales ( sale_id INT, product_id INT, year INT, quantity INT, price INT, PRIMARY KEY (sale_id, year), FOREIGN KEY (product_id) REFERENCES Product(product_id) );插入题目给出的示例数据:
INSERT INTO Product VALUES (100, 'Nokia'), (200, 'Apple'); INSERT INTO Sales VALUES (1, 100, 2008, 10, 5000), (2, 100, 2009, 12, 5000), (7, 200, 2011, 15, 9000);这里必须提醒一句:插入数据的顺序不能乱。先插入Product,再插入Sales,否则Sales表里的外键在Product表里找不到对应记录,数据库会报违反外键约束的错误。在实际业务环境中,如果使用外键约束,导入数据时通常也需要先导入维度表,再导入事实表。
4.2 执行查询并核对结果
执行标准解法:
SELECT p.product_name, s.year, s.price FROM Sales AS s INNER JOIN Product AS p ON s.product_id = p.product_id ORDER BY s.product_id, s.year;输出结果:
| product_name | year | price |
|---|---|---|
| Nokia | 2008 | 5000 |
| Nokia | 2009 | 5000 |
| Apple | 2011 | 9000 |
核对三个要点:第一,行数是3,和Sales表行数一致;第二,每一行都已经补上了product_name;第三,排序是先按product_id,再按year,所以Nokia的两条记录在前面,2008在前,2009在后。这三点都满足,答案基本就是正确的。
4.3 慢SQL和索引的初步影响
虽然1068题本身数据量极小,不需要优化,但把它扩展到百万级销售流水,索引就变得非常重要。Sales表上的product_id字段作为外键,默认情况下并不会自动创建索引,而JOIN的ON条件在大型表上需要快速查找对应值。如果没有索引,每处理一条销售记录,数据库都可能要做一次全表扫描来匹配Product表,性能会急剧下降。
建议在Sales.product_id上建立索引:
CREATE INDEX idx_sales_product_id ON Sales(product_id);在MySQL里,可以用EXPLAIN观察执行计划:
EXPLAIN SELECT p.product_name, s.year, s.price FROM Sales AS s INNER JOIN Product AS p ON s.product_id = p.product_id ORDER BY s.product_id, s.year;如果possible_keys里出现idx_sales_product_id,说明连接时能走索引。如果Extra列出现Using filesort,说明排序没有利用到索引,数据量大时可能拖慢查询。更精细的做法是建立一个复合索引(product_id, year),这样既能支撑连接,也能覆盖排序字段,属于慢SQL优化里常见的思路。
4.4 用窗口函数做一次交叉验证
为了验证“输出行数等于Sales行数”这一点,可以故意插入一条同产品同年但不同价格的记录:
INSERT INTO Sales VALUES (8, 100, 2009, 5, 4500);此时Nokia在2009年有两条销售记录,一条price是5000,一条是4500。再次执行标准JOIN查询,会发现结果变成了4行,其中Nokia 2009出现了两次,price分别是5000和4500。
如果不放心,还可以用窗口函数给每行编号:
SELECT p.product_name, s.year, s.price, ROW_NUMBER() OVER ( PARTITION BY s.product_id, s.year ORDER BY s.sale_id ) AS rn FROM Sales AS s JOIN Product AS p ON s.product_id = p.product_id ORDER BY s.product_id, s.year;执行后能看到同一产品同一年出现两条记录,rn分别是1和2。这个操作很直观地告诉你:Sales表里本来就有两条独立的销售明细,如果你用GROUP BY去合并,相当于人为丢失了数据。窗口函数在这里既是交叉验证工具,也是后续做销售明细排名的起点。
5. 常见问题与避坑指南
5.1 结果重复或漏行的排查思路
遇到结果行数不对,不要急着改SQL,先从数据本身下手。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 结果行数比Sales多 | 连接条件错误,或Product表存在重复product_id | 检查ON条件,并统计Product.product_id是否唯一 |
| 结果行数比Sales少 | 使用了INNER JOIN且存在孤儿销售记录 | 用LEFT JOIN + IS NULL找出未匹配的行 |
| 结果看似正确但顺序不对 | 缺少ORDER BY或排序字段写错 | 检查ORDER BY字段是否来自正确的表 |
我记得第一次做这道题时,因为没看主键定义,以为Sales表里product_id是唯一的,结果把题目理解成了“每个产品每年只有一条销售记录”。后来多插了一条同产品同年不同price的数据,才意识到这里其实是一对多的销售明细。所以一定要先看主键和外键,字段粒度决定SQL写法。
5.2 字段不存在的常见原因
练习中遇到最多的报错是Unknown column。原因通常是三个:第一,字段名拼写不一致,比如把product_name写成productname,或者把year写成年;第二,多表查询里出现同名字段,但没有用别名限定,数据库不知道该取哪一个;第三,字段确实不存在,比如想在Product表里找price,但它根本不在那张表里。
解决方法是显式加表别名,并养成写列名前都带别名的习惯。比如写s.year而不是只写year,写p.product_name而不是只写product_name。这样能避免多数字段相关的报错。如果还是报错,可以用DESC Sales或DESC Product查看表结构,确认字段名大小写是否完全一致。
5.3 同一产品同一年有多个价格怎么办
这是从1068题延伸出来的业务问题。如果需求是“查询销售明细”,那就不应该合并,直接输出每一行的price就好。如果需求是“查询每个产品每年的平均成交价”,就需要用GROUP BY和AVG:
SELECT p.product_name, s.year, AVG(s.price) AS avg_price FROM Sales AS s JOIN Product AS p ON s.product_id = p.product_id GROUP BY p.product_name, s.year;这个查询返回的结果粒度是“产品+年份”,不再是销售明细。理解这一点很重要:SQL里的DISTINCT、GROUP BY、窗口函数,本质上都是在调整结果粒度。很多新手混淆“去重”和“聚合”,就是因为没有先明确粒度。1068题不要求去重,也不要求聚合,所以最朴素的JOIN才是正确解法。
5.4 从1068题到慢SQL优化的落地技巧
虽然这是一道入门题,但可以把优化的意识提前建立起来。刷题时不要只追求跑通,每次跑完顺手看一眼执行计划,看有没有全表扫描、有没有Filesort、有没有用上索引。这些习惯对后期处理真实的大表非常有帮助。
我在实际执行这道题时,也遇到过执行计划显示Product表被全表扫描的情况。因为Product表很小,全表扫描也无所谓,但换个场景,如果Product表是几十万行的商品库,Sales表是上千万行的流水,这种连接方式就会成为慢SQL的头号嫌疑。常见的优化手段包括:给连接键建索引、避免在ON条件里写函数、避免SELECT *、确认连接字段的数据类型一致。这些点放在一起,就是一次完整的慢SQL优化闭环。
最后再分享一点个人体会。这道题虽然简单,但值得多花十分钟做几组变体测试:造一条同产品同年不同价格的数据,看看不加聚合和误加聚合的区别;删掉Product表里的一条记录,看看INNER JOIN和LEFT JOIN的输出差异。只有亲手踩过这些坑,再面对类似的销售分析题时,才会条件反射地先确认粒度,再动笔写SQL。刷题的目的不是背答案,而是把SQL查询的思维方式刻进脑子里。