
刷LeetCode SQL题的朋友应该都认识这题1068. 产品销售分析属于高频SQL 50题里最靠前的一道。题目本身不复杂核心就一个表连接但恰恰是这种“看起来很简单”的题最能暴露基本功。我见过太多人一出考场就拍大腿不是不会写JOIN而是没搞明白JOIN到底连的是什么、连完之后结果集会变成什么样。这篇文章就把这道题从头到尾掰开揉碎讲一遍包括标准解法、常见错误、业务场景延伸、性能优化思路以及面试官后续会追问的变体希望能帮你把这道题吃透而不是只背一个答案。无论你是刚入门SQL的新手还是准备面试的求职者这篇都能给你一些实打实的参考。1. 题目到底在考什么1.1 先看原始题干别急着写代码LeetCode 1068题的原题描述是给定两张表一张是Sales销售记录表另一张是Product产品信息表要求编写一个SQL查询报告每个产品的销售年份和价格并按产品的product_id升序返回。两张表的结构大概是这样的Sales表列名类型sale_idintproduct_idintyearintquantityintpriceintProduct表列名类型product_idintproduct_namevarcharSales表中的(sale_id, year)是联合主键product_id是外键关联Product表的product_id。price字段实际代表的是“每个产品的单价”不是一笔订单的总价这个细节后面会用到。第一眼看到这道题很多人的反应是两表一JOINSELECT需要的字段完事。确实最基础的解法就这几行。但题目设计成“高频”本质上是想考察你对表关系模型的理解是否扎实能不能正确处理“一对多”关系带来的结果集变化以及能不能写出清晰、可维护、性能过关的连接查询。这些恰恰是实际业务里天天都要用的东西。1.2 拿到题之后先想清楚这四件事再动手我不建议一上来就写SELECT而是先在脑子里把几个问题问明白第一这两张表的关系是什么Product表是“一”的一方Sales表是“多”的一方一个产品可以有多条销售记录。连接之后每个产品的product_name会跟着它的每条销售记录一起出现。第二连接键是什么两边都有product_id这就是连接条件。多表连接的第一原则是必须有明确且正确的连接键否则你就是在做笛卡尔积结果集数量会爆炸。第三最终要输出哪些列题目要求输出product_id、product_name、year、price四个字段这四个字段分布在两张表里所以需要JOIN把它们拼到一张结果集上。第四排序怎么处理题目明确要求按product_id升序所以ORDER BY product_id不能少。很多人写完JOIN就交卷了漏掉排序这在判题系统里是百分百要报错的。把这四件事想清楚写代码只是时间问题。真正的分水岭不在于能不能写出JOIN而在于你知不知道为什么需要JOIN以及JOIN之后结果集的行数会怎么变化。2. 从最简写法到规范写法2.1 第一版能过的标准答案先给一个最直接、能通过判题系统的写法SELECT p.product_id, p.product_name, s.year, s.price FROM Sales s INNER JOIN Product p ON s.product_id p.product_id ORDER BY p.product_id;这里我用的是INNER JOIN。为什么因为Sales表里的product_id都应该能在Product表里找到对应记录不存在“孤儿销售记录”的假设前提下INNER JOIN是最干净、最贴题意的写法。它会把两张表中匹配成功的行组合在一起输出一个完整的结果集。执行过程你可以这样理解先把两张表做连接如果连接键相等就把Product表的行和Sales表的行拼成一行如果某个产品没有销售记录或者某条销售记录找不到产品信息INNER JOIN会直接把这些行丢掉。在这个题目场景下我们需要的是“报告每个产品的销售年份和价格”本质上是“基于销售记录回查产品名称”所以INNER JOIN完全够用。2.2 为什么JOIN条件写在ON而不是WHERE这个点特别容易被忽略。很多新手会把连接条件顺手写成SELECT ... FROM Sales s, Product p WHERE s.product_id p.product_id;这种老式写法在MySQL里也能跑但在现代SQL开发和维护中强烈不推荐。原因有两个一是语义不清晰。ON是专门的连接条件区WHERE是筛选条件区。把连接条件混进WHERE里等SQL复杂起来你根本分不清哪些是过滤数据的条件哪些是建立表关系的条件。别人看你代码的时候第一眼没法判断表之间到底怎么关联的。二是实际执行计划可能有差异。虽然优化器通常能把两者等价改写但在某些复杂查询里ON和WHERE的语义差异会影响优化器对连接顺序的选择进而影响性能。所以建议养成一个习惯连接关系全部写在ON子句里WHERE只负责行级过滤。这也是所有大厂SQL规范里默认的写法。2.3 规范化的进阶版本再看一个稍微讲究一点的写法SELECT p.product_id, p.product_name, s.year, s.price FROM Sales AS s JOIN Product AS p ON s.product_id p.product_id ORDER BY p.product_id ASC, s.year ASC;这里的几个细节值得说细一点。第一使用AS别名。Sales AS s、Product AS p让SQL更短更清晰也避免后续列名冲突。你可能会问为什么连接前就要给表起别名因为一旦涉及多表列名就可能重复。比如两张表都有product_id你直接写product_id数据库根本不知道你指的是哪张表的会直接报“ambiguous column”错误。第二ORDER BY p.product_id ASC排序列用表别名限定。题目只要求按product_id排序但实际业务里同一个产品可能有多年的销售记录所以在product_id之后再加一个year让同一个产品的记录按年份有序排列可读性会好很多。第三SELECT列表里显式写出每个字段的来源表。比如销售年份用s.year产品名称用p.product_name。这是多表查询的黄金法则所有列名都显式标明来源表防止将来表结构变动导致歧义。2.4 用子查询也能做但没必要我再给一种可行的写法作为对比SELECT p.product_id, p.product_name, sub.year, sub.price FROM Product p LEFT JOIN ( SELECT product_id, year, price FROM Sales ) sub ON p.product_id sub.product_id ORDER BY p.product_id;这个写法在功能上没有问题但有几个明显的缺点子查询通常意味着额外的一次数据处理可读性也更绕而且在某些数据库版本或优化器策略下子查询可能不能很好地利用索引。对于1068这个场景表连接是更直接、高效、符合直觉的方案。记住一个原则能用JOIN解决的就不要用子查询绕路。JOIN是SQL声明式思维的核心学会读表、连表、投影列比背一堆奇技淫巧重要得多。3. 这道题背后的业务价值3.1 从刷题到报表销售明细查询的真实场景如果你觉得1068只是LeetCode上一道小题那就太可惜了。“产品销售分析”这个场景在真实业务里每天都在发生。运营要拉一张“每个产品的销售年份、单价、销售数量”的明细表财务要核对“每个产品在某个时间段的销售流水”甚至后台的订单列表、商品列表、销售看板底层都是类似的JOIN查询。我举个具体的例子假设你在一家电商公司产品表里存的是商品基础信息销售表里存的是每一笔订单明细。运营同事要一份“2024年每一笔成交订单对应的商品名称、成交年份和成交单价”的Excel表格你写的SQL本质上就是1068的加宽版SELECT p.product_id, p.product_name, o.order_year, o.item_price, o.quantity, o.order_id FROM order_items o INNER JOIN products p ON o.product_id p.product_id WHERE o.order_year 2024 ORDER BY o.order_id;这就是1068题的业务化身。表名变了字段名变了但核心逻辑完全一样。所以这道题的意义不是在LeetCode上拿个Accepted而是让你形成一种“条件反射”遇到需要从多张表里取数据的需求第一反应就是找关联键然后决定JOIN类型最后明确输出列和排序规则。3.2 扩展一从明细到汇总引出聚合查询1068题本身只要求输出明细但真实场景里我们往往不需要看到每一笔记录而是要看汇总数据。比如“每个产品在每一年的总销售额”。这时候就要在JOIN的基础上加上聚合。SELECT p.product_id, p.product_name, s.year, SUM(s.quantity * s.price) AS total_sales FROM Sales s INNER JOIN Product p ON s.product_id p.product_id GROUP BY p.product_id, p.product_name, s.year ORDER BY p.product_id, s.year;注意这里GROUP BY的分组字段必须和SELECT里的非聚合字段保持一致否则会报错或者在部分数据库的宽松模式下返回一堆你不想看到的随机值。这是一个非常经典的考点面试官特别爱考因为实际业务里几乎天天要用GROUP BY。从这个版本出发你还可以继续加需求只看总销售额超过某一数值的产品、只统计某一年份的数据、按销售额降序排序取Top 10等等。每加一个需求就对应一个SQL知识点。1068就是这条技能树的起点。3.3 扩展二LEFT JOIN与“无销售记录的产品”如果题目换一个条件“报告所有产品的信息包括那些没有过销售记录的产品”那INNER JOIN就不够了必须改用LEFT JOIN。因为LEFT JOIN会保留左表中的所有行即使右表没有匹配也会用NULL填充右侧列。SELECT p.product_id, p.product_name, s.year, s.price FROM Product p LEFT JOIN Sales s ON p.product_id s.product_id ORDER BY p.product_id;在这个结果集里没有销售记录的产品year和price会显示为NULL。这个场景对应什么业务呢比如老板问你“这些产品上了半年怎么有些一点销量都没有”那你需要的就是全部产品列表再左连接销售数据看看哪些产品的销售字段是空的。这就是为什么我前面强调“INNER JOIN和LEFT JOIN的选取要看业务需求”。1068题用INNER JOIN是因为题目隐含了“所有记录都来自销售流水”的前提但真实世界里你经常会遇到“保留主表全部数据”的需求这时候LEFT JOIN就是唯一正确解。如果你把这两种JOIN搞混了轻则报表数据缺行重则业务决策被带偏。3.4 性能视角为什么JOIN会慢以及怎么优化聊完业务再聊点性能。很多人在本地小数据量上测不出JOIN的问题但一到生产环境几千万行的大表一JOIN直接把你查询拖到几十秒。这里面有几个常见的坑。第一连接列有没有索引。Sales表的product_id是外键通常有索引但如果你在真实业务里碰到的表没有给连接键加索引JOIN就会变成全表扫描套全表扫描性能会很差。检查方式很简单用EXPLAIN看执行计划重点看type字段如果是ALL或者index就需要注意了。第二别用SELECT *。1068题我们只取四个字段但很多人写习惯了大而全的SELECT *把两张表的所有字段全捞出来网络传输和内存消耗都会变大。实际开发中只查询需要的列是基本的职业素养。第三连接条件的字段类型要一致。如果一边是VARCHAR一边是INT数据库会做隐式转换导致索引失效。这种坑特别隐蔽排查起来也费劲最好在设计表结构时就规避。第四考虑连接方向。小表驱动大表是优化器的工作但有些场景下人工调整查询也可以发挥作用。不过在没有索引的情况下任何JOIN都可能成为性能瓶颈优先解决索引问题永远是最有效的。4. 刷这道题时最容易踩的坑4.1 漏掉排序白丢分这是最冤枉的丢分方式。结果集的内容全对但没加ORDER BY判题系统直接判错。很多新手写SQL时关注点在“查哪些字段”上忽略了题目明确的排序要求。记住读题的时候把“按xx升序/降序”圈出来ORDER BY不能漏。4.2 列名歧义数据库直接报错在两张表都有product_id的情况下SELECT里直接写product_id而不加表别名前缀会触发“Column product_id in field list is ambiguous”错误。解决办法就是前面强调的所有字段都用表别名限定。这个习惯在只有两张表的简单查询里体现不出优势一旦你JOIN了五张表每张表都有一堆字段限定前缀能救你一命。4.3 把INNER JOIN和LEFT JOIN搞混我见过不少人在1068的变体题里明明要求“报告所有产品的销售信息”结果用了LEFT JOIN导致结果集里出现NULL行跟预期完全不匹配或者反过来要求“只看有销售记录的产品”却用了LEFT JOIN把无销售记录的产品也带出来了。记住一个口诀要保留哪张表的全部行哪张表就放在JOIN的左边或作为LEFT JOIN的左表。这样即使右表不匹配左表的行也不会丢。4.4 忽略price是单价还是总价题目里的price是单价如果要算销售额需要quantity * price。很多刷题的人栽在这上面不是SQL语法问题而是业务语义理解问题。这道题只让你输出price所以不影响但要是面试官扩展问你“每个产品每年总销售额”你必须立刻反应过来要用SUM(quantity * price)。永远记住读题时先弄清楚字段的业务含义再动手写SQL。4.5 在JOIN之后用WHERE过滤结果跟预期不符有一种经典误区想把连接条件写在WHERE里再加别的过滤条件然后发现结果不对。比如“查2024年以后销售的Nokia手机”如果写成SELECT p.product_name, s.year, s.price FROM Sales s, Product p WHERE s.product_id p.product_id AND s.year 2024 AND p.product_name Nokia;这个写法在MySQL里能跑结果也对但前面说过语义不清晰维护性差。更严重的是如果后面再加LEFT JOIN连接条件如果写在WHERE里会被当成过滤条件左表的保留行为就会被破坏。这也是为什么现在所有SQL规范都强制要求连接条件放ON筛选条件放WHERE。5. 面试官会怎么追问这道题5.1 追问一每个产品的第一年销售记录怎么查这是1068题最常见的变体。需求变成“查出每个产品的首次销售年份和对应价格”。很多人的第一反应是先GROUP BY product_id取MIN(year)然后再JOIN原表。但在SQL中更优雅的解法是窗口函数SELECT product_id, product_name, year, price FROM ( SELECT s.product_id, p.product_name, s.year, s.price, ROW_NUMBER() OVER ( PARTITION BY s.product_id ORDER BY s.year ASC ) AS rn FROM Sales s INNER JOIN Product p ON s.product_id p.product_id ) t WHERE rn 1 ORDER BY product_id;这里用ROW_NUMBER()对每个产品按年份排序然后取排名为1的记录。窗口函数现在是SQL面试的高频考点1068正好可以作为引子从简单JOIN一路递进到窗口函数你会对这套知识体系有更立体的理解。5.2 追问二总销售额大于某个值的产品有哪些这个追问直接考察GROUP BY和HAVING的配合。注意HAVING和WHERE的区别是必背点WHERE在分组前过滤行HAVING在分组后过滤组。比如“产品总销售额大于20000的产品”SELECT p.product_id, p.product_name, SUM(s.quantity * s.price) AS total_sales FROM Sales s INNER JOIN Product p ON s.product_id p.product_id GROUP BY p.product_id, p.product_name HAVING SUM(s.quantity * s.price) 20000 ORDER BY total_sales DESC;这个查询看起来简单但里面藏着好几个考点为什么GROUP BY要带p.product_name为什么HAVING里不能直接用total_sales别名而是重复表达式这些细节都是面试官爱挖的点。5.3 追问三性能优化怎么聊当面试官问“这个查询在生产环境跑得很慢你会怎么排查”你需要给出一个结构化的回答路径先用EXPLAIN查看执行计划确认是否走索引检查两张表的连接列是否有索引字段类型是否一致看是否存在全表扫描再审视是否查询了不必要的列最后考虑数据量级是否需要对大表做分区或归档。重点是“先分析定位再动手改”而不是一上来就说“加索引”。这个思路比背几个命令值钱得多。5.4 追问四写出可读性更好的SQL你可能会觉得奇怪SQL也有可读性要求当然有。在大厂SQL是团队协作的产物一段别人看不懂的SQL就是一段失败的SQL。可读性好的SQL有几个特征统一的缩进和大小写规范、表别名清晰、字段全部限定来源、逻辑顺序和业务自然顺序一致。很多人觉得这些是小事但等到review代码的时候这些小事就是code review的讨论重点。建议从现在刷题开始就把规范刻进习惯里。6. 复盘这道题带给我的几点体会先说个人经验。我最初刷1068的时候觉得JOIN嘛谁不会。后来在一次真实的数据报表任务里我需要把订单表、产品表、门店表、员工表四张表连在一起才发现自己对连接的理解不够细。左表右表放颠倒了结果集行数差了几万行字段没限定前缀数据库直接报错WHERE过滤条件写错位置莫名的NULL行一直出现。那一天我才意识到LeetCode上每道简单题都是在替未来真实业务里的你排雷。建议你刷完1068之后不要急着往后刷而是做几件延伸的事情第一自己手动造一份数据把INNER JOIN和LEFT JOIN的结果集差异一行一行对比出来第二试着用窗口函数写一遍“每个产品第一次售卖记录”体验一下从聚合思维到窗口思维的转变第三想一想如果Sales表真的有几千万行你的查询应该怎么优化。这三件事做完1068这道题对你来说才算是真正通关了。最后再分享一个小习惯每次刷完一道SQL题在笔记里记录三个东西。一是这题考察的知识点是什么二是你第一次写的错误版本为什么错三是这道题能否映射到某个真实业务场景。这个习惯坚持几十道题后你再看SQL题的感觉会完全不一样。希望这篇对你有用也欢迎你在自己的刷题过程中多多体会JOIN背后那些容易忽略的小细节。