要说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 sProduct 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是NULLINNER 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_nameyearpriceNokia20085000Nokia20095000Apple20119000核对三个要点第一行数是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和AVGSELECT 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查询的思维方式刻进脑子里。
阅读完成 · 觉得有帮助?