如果你刷过 LeetCode 的 SQL 题库,应该对“产品销售分析”这个系列不陌生。这个系列一共好几道题,1068. 产品销售分析 I 是第一题,也是整个系列里最基础、最友好的一道“开胃菜”。它看起来简单,就是一个两表关联查询,但这个简单背后,其实覆盖了 SQL 入门阶段最核心的几个知识点:表关系理解、JOIN 类型选择、列投影、以及结果集的完备性判断。
我在帮团队带新人或者自己复习 SQL 基础的时候,经常拿这道题当“试金石”——如果你能把这题讲清楚,尤其是能把“为什么用 LEFT JOIN 而不是 INNER JOIN”这个问题回答明白,那说明你的 SQL 基础基本过关了。这篇文章我会把这道题掰开揉碎地讲一遍,从题目理解、建表数据、解题思路、SQL 写法、扩展到常见变体和坑点,一步一步来。不管你是准备面试,还是刚入门数据分析,这篇都应该能帮到你。
1. 题目理解与需求拆解
1.1 原始表结构与关系梳理
这道题给了两张表:Sales(销售表)和 Product(产品表)。
先看 Sales 表的结构:
sale_id:销售记录的唯一标识product_id:产品编号,这个字段是外键,关联到 Product 表year:销售发生的年份quantity:销售数量price:单价的销售价格
再看 Product 表的结构:
product_id:产品编号,主键product_name:产品名称
从业务逻辑上看,这两张表的关系是:一个产品可以有多条销售记录(一对多),一条销售记录只能对应一个产品。Sales.product_id 作为外键,引用了 Product.product_id。这是数据建模里非常经典的外键关联场景——把“维度信息”(产品名称)单独拆成一张表,而“事实信息”(销售数量、价格)放在另一张表,减少数据冗余。
1.2 查询目标与输出要求解析
题目的需求是:查询出每一条销售记录的 product_name、year 和 price。
也就是说,最终输出结果需要包含三列:
- 产品名称(来自 Product 表)
- 销售年份(来自 Sales 表)
- 销售价格(来自 Sales 表)
注意,这里的输出列需要把两张表的信息合并在一起展示。由于产品名称只存在于 Product 表,而销售年份和价格在 Sales 表,所以我们必须把两张表通过 product_id 关联起来,才能拿到完整的结果集。
这个需求本身很简单,但它非常典型——它代表了一大类“查明细数据时需要补全维度名称”的场景。在实际业务里,你可能会遇到查订单时要把用户 ID 换成用户名、查交易时要把商品 ID 换成商品名称、查日志时要把端口号换成服务名,本质上都是同样的问题。
1.3 示例数据推演
为了把后面的 SQL 讲清楚,我们先在本地模拟一份数据。
Sales 表:
| sale_id | product_id | year | quantity | price |
|---|---|---|---|---|
| 1 | 100 | 2008 | 10 | 5000 |
| 2 | 100 | 2009 | 12 | 5000 |
| 7 | 200 | 2011 | 15 | 9000 |
Product 表:
| product_id | product_name |
|---|---|
| 100 | Nokia |
| 200 | Apple |
| 300 | Samsung |
我们用这份数据来跑一下预期结果。因为要查出每条销售记录对应的产品名、年份、价格,所以结果应该是:
| product_name | year | price |
|---|---|---|
| Nokia | 2008 | 5000 |
| Nokia | 2009 | 5000 |
| Apple | 2011 | 9000 |
注意,Product 表里的 Samsung(product_id = 300)没有任何销售记录,所以它不会出现在结果集里。这一点很关键,我们后面会专门展开讨论。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心 SQL 解法与原理拆解
2.1 基础版本:使用 INNER JOIN
最直接的写法是使用内连接:
sql复制SELECT
p.product_name,
s.year,
s.price
FROM Sales s
INNER JOIN Product p ON s.product_id = p.product_id;
我们一步步看这个查询的执行逻辑:
- 从
Sales表逐行取出数据 - 针对每一行销售记录,拿着它的
product_id去Product表里找匹配的记录 - 如果能找到,就把两张表的对应列拼成一行输出;如果找不到,这行销售记录就不会出现在结果里
对于示例数据,三条销售记录的 product_id 分别是 100、100、200,在 Product 表里都能找到匹配项,所以结果集就是上面推演的三行。
这个写法的特点是:只返回能够匹配上的记录。那如果有一条销售记录的 product_id 在 Product 表里不存在(比如数据清洗不干净导致的外键失效),这条销售记录就会被静默丢弃。这在某些场景下可能是有问题的。
2.2 进阶思考:为什么用 LEFT JOIN 更稳妥
在实际业务查询中,我更推荐使用 LEFT JOIN:
sql复制SELECT
p.product_name,
s.year,
s.price
FROM Sales s
LEFT JOIN Product p ON s.product_id = p.product_id;
为什么?因为这个查询的“主表”是 Sales。业务需求是“查询每一条销售记录对应的产品信息”,那销售记录一条都不能少。如果用 INNER JOIN,万一某条销售的 product_id 在产品表里匹配不上(比如产品被删了、或者录入时有脏数据),这条销售记录就会莫名其妙地消失,最终统计出来的结果和真实业务数据对不上。
而 LEFT JOIN 会保证 Sales 表中的行全部保留,匹配不上的话,Product 表的字段以 NULL 填充。这样输出的行数一定和 Sales 表的行数一致,数据完整性得到了保障。
题目的示例数据里没有这种“孤儿记录”,所以两种写法结果一样。但放在真实业务环境里,LEFT JOIN 显然更保险。这也是我面试候选人时喜欢追问的一个细节——能主动说出这个区别的人,说明他真的理解 JOIN 的语义,而不是停留在“会写”的层面。
2.3 执行顺序与性能直觉
很多人写 SQL 只关心结果对不对,不关心数据库是怎么跑的。但对这道题,我们至少要有基本的执行直觉。
简化来说,这个查询的执行流程大致是:
- 确定驱动表:优化器会根据表的大小、索引情况、统计信息等来决定先访问哪张表。在
Sales LEFT JOIN Product的语义下,Sales作为左表通常是驱动表。 - 访问 Product 表:对
Sales中的每一行,根据连接条件s.product_id = p.product_id去Product表中查找匹配记录。如果Product.product_id有主键索引,这一步是索引查找,速度非常快。 - 投影输出列:把
p.product_name、s.year、s.price三个字段的值组成结果行返回。
实际执行计划里,优化器可能会选择 Hash Join、Nested Loop Join 等不同的连接算法,这在数据量大的时候会有性能差异。但对于这道题的数据量级,完全不需要担心性能问题。我们真正需要记住的是:连接字段上有索引,JOIN 才会快。Product.product_id 是主键,天然有索引;Sales.product_id 如果有外键约束,一般也会有索引。所以这个查询在正常建模的数据库里,性能是有保障的。
3. 实操建议与多解法对比
3.1 使用子查询是否可行
除了 JOIN,有的同学可能会想到子查询。比如:
sql复制SELECT
(SELECT product_name FROM Product p WHERE p.product_id = s.product_id) AS product_name,
s.year,
s.price
FROM Sales s;
这个写法在功能上也能得到同样的结果。它是一种“标量子查询”的用法:对 Sales 表中的每一行,执行一次子查询去取产品名。
这种写法的优点是不需要显式 JOIN,语义上也比较直观。但缺点也很明显:
- 如果
Sales表有 N 行,子查询可能被执行 N 次,性能不如 JOIN(在大多数数据库实现中,优化器有时会把标量子查询改写为 JOIN,但不确定因素更多) - 当连接条件复杂或者涉及多张表时,子查询的可读性和维护性会明显下降
- 如果子查询返回多行,会直接报错(“Subquery returns more than 1 row”),虽然在这个场景不会出现
所以我的建议是:能用 JOIN 就用 JOIN。这是 SQL 里最标准、最通用的表关联方式,不管是可读性、性能还是可维护性,都是更好的选择。
3.2 多了一道“产品没有销售记录”条件的情况
这道题作为系列第 I 题,只要求输出有销售记录的产品。而系列第 II 题则变成了“选出在 2019 年春季没有销售记录、但在 2019 年有销售记录的产品”,那边的解法就会用到 LEFT JOIN 加 IS NULL 过滤,或者 NOT EXISTS 子查询。
虽然本题不需要,但如果把思路延伸一下:如果我们想找出“没有卖出去过的产品”,怎么办?
两种常见写法:
sql复制-- 写法一:LEFT JOIN + IS NULL
SELECT p.product_id, p.product_name
FROM Product p
LEFT JOIN Sales s ON p.product_id = s.product_id
WHERE s.sale_id IS NULL;
-- 写法二:NOT EXISTS
SELECT p.product_id, p.product_name
FROM Product p
WHERE NOT EXISTS (
SELECT 1 FROM Sales s WHERE s.product_id = p.product_id
);
对于示例数据,结果会返回 Samsung(product_id = 300)。这两种写法性能差别不大,但在语义上,NOT EXISTS 更容易理解,而且在大数据量下往往有更好的执行计划。这也回答了我们前面“为什么本题用 INNER JOIN 结果正确”的疑问——因为需求要的就是“有销售记录的”,内连接天然就把无销售记录的产品过滤掉了。
3.3 窗口函数能不能用,会不会更高级
我经常看到有人问:这道题能不能用窗口函数(比如 ROW_NUMBER())来解?
坦率地说,这个场景完全不需要窗口函数。窗口函数解决的是“在结果集内分组排序、计算累积值、获取前后行”等分析需求,而这道题只是一次普通的表关联和列投影。为了一道连过滤条件都没有的题引入窗口函数,除了炫技,没有任何实际收益。
学习 SQL 最重要的原则就是:能简单就简单,不要为了复杂而复杂。等你真的遇到“每个产品最近一年的销售记录”“累计销售额达到某个阈值”这类问题时,再上窗口函数也不迟。
4. 实际应用:从 LeetCode 到真实业务场景
4.1 这类查询在报表系统里的典型用法
这道题的查询模式,在实际工作中几乎是天天见。我给你举几个真实案例。
第一个是电商订单明细报表。业务方想看“每天的订单明细,包含商品名称、购买数量、成交价”,但订单表里只有 sku_id,商品名称在另一张商品表里。这时候就需要 订单表 LEFT JOIN 商品表 把名称补全。如果商品被下架删除了,LEFT JOIN 还能让订单记录保留下来,只是商品名称显示为 NULL,方便后续排查。
第二个是财务对账场景。财务系统里有一张流水表,一张账号表。对账时要输出“每笔流水的账号名称、金额、时间”。如果用 INNER JOIN,万一流水表里有账号 ID 在账号表里不存在(比如旧系统迁移数据缺失),这笔流水就对不上了,金额就平不了。用 LEFT JOIN 之后,至少能看到哪几笔流水的账号名称是 NULL,定位问题也会更快。
第三个是用户行为分析。埋点日志表里有 user_id,用户注册信息表里有用户昵称和注册渠道。分析“每个用户的最近 100 条行为记录,输出昵称、行为类型、行为时间”时,也是同样的 JOIN 套路。
这些场景和 1068 题的核心逻辑完全一致:一张事实表 + 一张维度表,通过外键关联,把维度名称或属性补全到事实明细上。
4.2 从一道题延伸出的 SQL 学习路线建议
如果你是在准备面试,或者刚入行数据分析,我建议不要只满足于“把这道题 AC 了”。你可以顺着这道题做一系列延伸练习,把 SQL 基础打牢:
- 第 1 层:今天这道题,两表单字段 JOIN,输出指定列——掌握 JOIN 基本语法
- 第 2 层:试着自己给
Sales表加一条product_id不在Product表里的记录,然后分别用 INNER JOIN 和 LEFT JOIN 跑一遍,观察结果差异——真正理解 JOIN 的保留语义 - 第 3 层:给
Sales表加一个sale_date字段,然后写查询“按产品统计每年销售总额”——掌握 GROUP BY 和聚合函数 - 第 4 层:筛选出“2019 年有销售、2020 年没有销售”的产品——掌握多表关联 + 条件过滤的进阶用法
- 第 5 层:用窗口函数计算“每个产品按年份累计的销售总额”——理解窗口函数的执行时机和 ORDER BY 的窗口默认范围
如果你能把这条路线走完,SQL 的基础能力应付大部分日常工作是没问题的。关键是每一步都要亲手写一遍、跑一遍,光看别人的答案和自己实际上手写的差距非常大。
4.3 面试中关于这道题的追问与踩坑点
LeetCode 上这道题难度是“简单”,但面试官可能会在简单题后面埋几个坑。这里整理一些我遇到过的、或者我自己面别人时喜欢问的追问:
- “INNER JOIN 和 LEFT JOIN 在这个查询里结果一样吗?” 如果数据没有孤儿记录,结果一样;如果 Sales 表存在无法匹配的 product_id,INNER JOIN 会丢行,LEFT JOIN 会保留行并用 NULL 填充。
- “如果 Product 表里有重复的 product_id,会发生什么?” 这会造成结果集膨胀(行数变多),因为每条销售记录会匹配到多行产品记录。实际工作中应该先排查 Product 表是否有重复主键。
- “如果字段名在两张表里相同怎么办?” 可以在 SELECT 里用表别名限定,比如
s.year、p.product_name。养成给表起别名的习惯,既防止歧义,也让 SQL 更简洁。 - “查询出来的 price 是不是需要考虑折扣?” 题目状态是销售单价,实际业务中可能还有优惠券、满减、税费等逻辑,这些都是字段口径问题,不在 SQL 题本身的范围内。但面试时被追问的话,你要能给出“价格字段的口径需要跟业务方确认”这样成熟的回答。
写 SQL 题,很多时候不是看你会不会写这一条查询,而是看你能不能把边界情况想清楚、把执行逻辑讲明白。这也是我为什么建议大家,做完每道题,都要追问自己几个“如果……怎么办”,把一道题吃透,比刷十道题有用得多。
5. 常见问题与踩坑实录
5.1 在本地环境复现这道题的完整步骤
如果你不满足于在 LeetCode 网页上做题,想在自己电脑的数据库里完整体验一下这道题,可以参考下面的步骤。
以 MySQL 为例,完整的建表、插入数据和查询步骤如下:
sql复制-- 建表
CREATE TABLE Sales (
sale_id INT,
product_id INT,
year INT,
quantity INT,
price INT
);
CREATE TABLE Product (
product_id INT,
product_name VARCHAR(20)
);
-- 插入数据
INSERT INTO Sales (sale_id, product_id, year, quantity, price)
VALUES
(1, 100, 2008, 10, 5000),
(2, 100, 2009, 12, 5000),
(7, 200, 2011, 15, 9000);
INSERT INTO Product (product_id, product_name)
VALUES
(100, 'Nokia'),
(200, 'Apple'),
(300, 'Samsung');
-- 查询
SELECT
p.product_name,
s.year,
s.price
FROM Sales s
INNER JOIN Product p ON s.product_id = p.product_id;
在 LeetCode 的评测环境里,题目会自动建表和插入数据,你只需要写查询部分。但本地自己动手跑一遍,对理解表结构和 JOIN 的执行结果非常有帮助。特别是你可以自己改数据、加几条特殊的记录,观察结果怎么变化,这种“破坏性实验”是学习 SQL 最快的方式之一。
5.2 高频报错与逻辑陷阱
这道题虽然简单,但我见过不少人在这里栽跟头,主要集中在这几类问题上:
问题一:忘记在 SELECT 中使用表别名限定列名
如果你写的查询是:
sql复制SELECT product_name, year, price
FROM Sales s
INNER JOIN Product p ON s.product_id = p.product_id;
在两张表没有同名字段时,数据库通常不会报错,因为优化器能推断出 product_name 只存在于 Product 表、year 和 price 只存在于 Sales 表。但一旦两张表出现同名字段,比如都有 product_id,而你 SELECT 里没有限定别名,数据库就会报“Column 'product_id' in field list is ambiguous”错误。
问题二:把关联字段写错
有人会写成 ON s.product_id = s.product_id 或者 ON p.product_id = p.product_id。前者等于没用加入连接条件,会形成笛卡尔积;后者连接条件恒为真,也是笛卡尔积。这个错误比较低级,但新手很容易在“复制粘贴”时犯。跑出来的结果行数会远大于预期,一眼就能看出来不对。
问题三:结果列顺序和题目要求不一致
这道题要求的输出列顺序是 product_name、year、price,顺序不能乱。虽然有些数据库的评测系统不区分列顺序,但 LeetCode 是严格按列顺序比对结果的。如果你写成 year, price, product_name,就算数据内容一样,也会被判错。
问题四:不需要的运算
这道题要求输出的是每条销售记录的单价 price,而不是总价。我看到有些人会习惯性写成 quantity * price,这在其他“销售总额”类题目里是对的,但在这个题干里就是画蛇添足。审题一定要仔细,别凭经验套模板。
5.3 判断查询结果是否正确的方法
在 LeetCode 上,系统会直接比对预期结果集,正确与否一目了然。但如果你在本地练习,或者在实际工作中写类似的查询,怎么判断自己的查询结果是不是对的?我分享几个实用的检查方法。
第一,先确认行数。如果业务上知道 Sales 表有 1000 条记录,那查询结果也应该是 1000 行。如果少了,多半是有匹配不上的记录被 INNER JOIN 过滤掉了;如果多了,多半是关联字段在另一张表里有重复值,产生了行数膨胀。
第二,抽查几个关键记录。随机挑几条销售记录,手工到另一张表里去查对应的产品名称,核对输出结果是否正确。这个手工核对的过程很笨,但非常可靠。
第三,使用 COUNT 对比。可以分别查一下 SELECT COUNT(*) FROM Sales 和 SELECT COUNT(*) FROM (上面的查询) t,如果两个值一致,基本可以断定没有丢行。
第四,检查 NULL 值。如果你用了 LEFT JOIN,看看结果里是否有 product_name 为 NULL 的行。有的话,说明存在孤儿数据,需要跟进处理。
这些检查方法不仅适用于这道题,也适用于工作中任何表关联查询的验证。别嫌麻烦,数据准确性就是靠这样的细节一点点保障的。
6. 写在最后的实操体会
这道 1068 题,刷起来可能五分钟都不用,但它背后涉及的思维方式,我每次带新人的时候都会反复强调。SQL 写得对不对,很多时候不取决于你背了多少语法,而在于你是不是真正理解数据的“形状”和表与表之间的“关系”。
我个人在实际操作中的一个习惯是:拿到任何查询需求,先不急着写代码,而是先在纸上把表结构和关系画出来。哪张表是主表、哪张表是维度表、通过哪个字段关联、关联之后结果集的行数会不会变化,想清楚这四个问题再动手,SQL 基本不会出错。这道题就是最好的练习素材——两张表、一个关联字段、三行输出,但它涵盖的判断逻辑,足够帮你建立一套稳妥的查询思路。
另外,真的建议所有人不要只在 LeetCode 的编辑器里做这道题,花十分钟在本地建个库,自己插入数据、改数据、跑查询。你会发现“哦,原来 LEFT JOIN 里匹配不上的记录长这样”“原来加了重复数据结果集会变多”,这些经验是刷题网站给不了你的。等你把这些基础打扎实了,后面遇到多表关联、复杂子查询、窗口函数,才不会慌。
