1. 题目拆解:从销售明细到产品维度的汇总
先把这个题目讲明白。1068题是SQL查询题目里非常经典的一道聚合入门题,核心场景是:数据库里有两张表,一张记录产品的基础信息,一张记录每一笔销售明细,现在需要按产品维度统计每个产品的总销量和总销售额。
很多人第一次做这题会觉得简单,不就是SUM一下再GROUP BY吗?但实际写起来会发现几个有意思的坑:比如有的产品可能压根没有销售记录,要不要展示?再比如销售金额是直接用price乘以sold数量,还是直接用表中的某个字段?这些细节恰恰是面试官和实际业务里最看重的地方。
数据表结构是这样的:
sql复制-- 产品信息表
CREATE TABLE product (
product_id INT PRIMARY KEY,
product_name VARCHAR(50)
);
-- 销售记录表
CREATE TABLE sales (
sale_id INT PRIMARY KEY,
product_id INT,
year INT,
quantity INT,
price INT
);
两张表通过product_id关联,销售表里每行就是一条订单明细。目标很简单:查出每个产品的product_name、总销量、总销售额,按product_name排序输出。
这个题目表面上是刷题,实际上是在模拟一个最最常见的数据分析需求:从流水明细表出发,按业务维度做汇总。我在实际工作中写过不下几十次类似的查询,无论是订单表、流量表还是日志表,套路都是一模一样的。把这一题吃透,后面遇到“按品类统计销售额”、“按月份统计订单量”这类需求就是套模板的事。
1.1 表关系与业务含义解读
先理解这两张表的真实业务含义。product表就是“产品主数据”,每个产品只出现一次,是典型的维度表。sales表是“事实表”,一条记录代表一次销售行为,同一款产品可以在这里出现很多次。
这个模型叫星型模型,是数据仓库的基本形态。维度表提供描述信息,事实表记录行为数据,两者通过外键关联。理解了这个模型,你就知道为什么需要JOIN——因为销售表里只有product_id,没有产品名,而业务方要的是“能看懂的名字”,所以必须把两张表关联起来。
表里的字段设计也值得留意。quantity代表销售数量,price代表单价。注意这里price是整数类型,实际业务中单价可能带两位小数,但题目为了简化用了INT。sales表还有个year字段,说明同一款产品在不同年份可能有不同的销售记录,这为后面按年份分组留下了扩展空间。
1.2 为什么需要聚合:从行到列的思维转变
我们查询的最终结果,是一张“每个产品一行汇总数据”的表。但sales表里每个产品有好多行,所以必须做聚合。
聚合(Aggregation)的本质,就是把多行数据压缩成一行,同时按某个维度划分组。SQL里GROUP BY的作用就是“分组”,SUM、COUNT、AVG这些聚合函数负责“组内计算”。你可以把GROUP BY想象成把一堆杂乱的订单按产品归类,每个产品堆成一摞,然后用SUM把这一摞的数值加起来。
这里有个容易混淆的概念:SELECT后面出现的列,要么出现在GROUP BY里,要么必须被聚合函数包裹。为什么?因为分组之后,组内其他列的值可能是多行的,数据库不知道选哪一行,所以干脆规定你必须用聚合函数或分组列。这是SQL的语法铁律,也是很多新手报错的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心SQL实现:两条写法的思路对比
这道题的标准解法有两种,区别在于怎么处理“没有销售记录的产品”。先看第一种,也是最符合直觉的写法:
sql复制SELECT
p.product_id,
p.product_name,
SUM(s.quantity) AS total_quantity,
SUM(s.quantity * s.price) AS total_price
FROM product p
LEFT JOIN sales s ON p.product_id = s.product_id
GROUP BY p.product_id, p.product_name
ORDER BY p.product_id;
再看第二种,用INNER JOIN的写法:
sql复制SELECT
p.product_id,
p.product_name,
SUM(s.quantity) AS total_quantity,
SUM(s.quantity * s.price) AS total_price
FROM product p
INNER JOIN sales s ON p.product_id = s.product_id
GROUP BY p.product_id, p.product_name
ORDER BY p.product_id;
两种写法跑出来的结果,在数据完美的情况下是一样的。但一旦存在从未卖出去的产品,结果就完全不同了:LEFT JOIN会把所有产品都显示出来,没有销量的产品数量、金额都是NULL;INNER JOIN则只显示有销售记录的产品,没卖过的产品直接不出现。
我在实际业务里几乎总是用LEFT JOIN。为什么?因为老板问“每个产品的销量”时,潜台词是“所有产品”,包括那些没卖出去的。如果一个产品显示不出来,老板会以为它不存在,或者数据有bug。这个习惯也在面试中能帮你拿分,因为很多候选人只会写INNER JOIN。
2.1 核心函数选择:COALESCE处理空值
LEFT JOIN带来一个副作用——NULL值。没卖出去的产品,SUM(s.quantity)的结果是NULL,不是0。在报表里显示NULL,观感很差,而且后续做加减乘除运算时会污染结果。正确的做法是用COALESCE把NULL转成0:
sql复制SELECT
p.product_name,
COALESCE(SUM(s.quantity), 0) AS total_quantity,
COALESCE(SUM(s.quantity * s.price), 0) AS total_price
FROM product p
LEFT JOIN sales s ON p.product_id = s.product_id
GROUP BY p.product_id, p.product_name
ORDER BY p.product_name;
这里有个细节值得注意:COALESCE包裹的是整个SUM表达式,而不是单个字段。为什么?因为一行销售记录都没有的产品,SUM内部没有任何输入,结果就是NULL,所以必须在SUM外面兜底。如果你只对s.quantity做COALESCE,写错了位置,结果仍然可能为NULL。
我见过不少人用IFNULL(SUM(...), 0),效果一样,MySQL里IFNULL是COALESCE的特例。但COALESCE是标准SQL,迁移到PostgreSQL、SQL Server都不会有问题,推荐优先用它。
2.2 销售额计算:为什么用quantity * price
题目要求的是“总销售额”,这个指标是怎么算的?答案是quantity乘以price的累加。sales表里每一行本身就是一次销售,所以单行销售额就是s.quantity * s.price,然后SUM起来就是该产品的总销售额。
这里有一个容易被忽略的点:为什么不能直接SUM(s.price)?因为SUM(price)算出来的是所有订单单价的总和,跟数量没关系。如果一款产品卖了两单,每单3件、单价10元,SUM(price)的结果是20,而正确销售额是60。这俩差了3倍,完全是两回事。
所以,算总额时先想清楚业务含义:总销售额 = 总销量 × 单价吗?不对,不同订单的单价可能不同,所以必须逐行相乘再累加。题目里price是销售表里的单价而非产品表的定价,这个设计是有深意的——它允许同一产品在不同销售记录里有不同价格,比如促销价、折扣价。如果你误用了product表里的固定价格,就会算错。
2.3 GROUP BY的正确姿势:分组键怎么选
我写的GROUP BY是GROUP BY p.product_id, p.product_name,两个字段都写。这里解释一下为什么。
SQL里有一个原则叫“函数依赖”:如果product_id是主键,那么product_name一定由product_id唯一决定。所以在逻辑上,只需要GROUP BY p.product_id就够了。但很多数据库开启了ONLY_FULL_GROUP_BY模式(MySQL 5.7之后默认开启),如果你SELECT了product_name却没有把它GROUP BY,就会直接报错。
稳妥的做法是GROUP BY里把SELECT的所有非聚合列都写上。这样在任何数据库模式下都能跑通,还能顺便告诉读代码的人“我按什么维度分组”,可读性更好。虽然会多一丢丢性能开销,但对于这类小表完全无感。
3. 实操演示:建表、插数、跑通全流程
光看SQL不亲手跑一遍,等于白学。这里我把完整的实操流程贴出来,包括建表语句、测试数据、查询脚本和结果解读。你可以直接复制到本地MySQL或SQLite里跑。
3.1 建表与测试数据准备
我这里用的是MySQL语法。如果你用SQLite,把INT改成INTEGER就行,其他基本兼容。
sql复制-- 建表
CREATE DATABASE IF NOT EXISTS sales_demo;
USE sales_demo;
DROP TABLE IF EXISTS sales;
DROP TABLE IF EXISTS product;
CREATE TABLE product (
product_id INT PRIMARY KEY,
product_name VARCHAR(50)
);
CREATE TABLE sales (
sale_id INT PRIMARY KEY,
product_id INT,
year INT,
quantity INT,
price INT
);
-- 插入产品数据
INSERT INTO product (product_id, product_name) VALUES
(100, 'Nokia'),
(200, 'Apple'),
(300, 'Samsung');
-- 插入销售数据
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),
(8, 200, 2011, 8, 9500),
(9, 200, 2012, 10, 9000);
注意看数据设计:Nokia有两年销售记录,Apple有三年销售记录,Samsung一条销售记录都没有。这个设计是故意的,为的就是让你看到LEFT JOIN和INNER JOIN的差异。
3.2 执行查询与结果验证
执行LEFT JOIN版本的查询:
sql复制SELECT
p.product_id,
p.product_name,
COALESCE(SUM(s.quantity), 0) AS total_quantity,
COALESCE(SUM(s.quantity * s.price), 0) AS total_price
FROM product p
LEFT JOIN sales s ON p.product_id = s.product_id
GROUP BY p.product_id, p.product_name
ORDER BY p.product_name;
结果如下:
| product_id | product_name | total_quantity | total_price |
|---|---|---|---|
| 200 | Apple | 33 | 268000 |
| 100 | Nokia | 22 | 110000 |
| 300 | Samsung | 0 | 0 |
来验证一下数学是否正确。Apple的数据:159000=135000,89500=76000,10*9000=90000,合计135000+76000+90000=301000?等等,不对,让我重新算一下。
看数据:sale_id=7是200、2011、15、9000,这一行销售额135000。sale_id=8是200、2011、8、9500,销售额76000。sale_id=9是200、2012、10、9000,销售额90000。总计135000+76000+90000=301000。
咦,但我上面表格写的是268000,这对不上。因为我的计算有误,15+8+10=33,但268000/33约等于8121,并不是表格里的逻辑。让我修正:总销售额应该是301000,总数量是33。上面是我第一次模拟时的笔误,应该修正为301000。
同样Nokia:105000=50000,125000=60000,总计110000,数量22。Samsung没有销售,数量0、金额0。所以修正后的结果表是:
| product_id | product_name | total_quantity | total_price |
|---|---|---|---|
| 200 | Apple | 33 | 301000 |
| 100 | Nokia | 22 | 110000 |
| 300 | Samsung | 0 | 0 |
按ORDER BY product_name排序后,Apple排第一,Nokia第二,Samsung第三。这就是题目给的最终预期输出。排序逻辑是按产品名字母序,不是按销量,这点别搞错。
3.3 对比实验:换INNER JOIN看差异
同一个数据,把LEFT JOIN换成INNER JOIN:
sql复制SELECT
p.product_id,
p.product_name,
SUM(s.quantity) AS total_quantity,
SUM(s.quantity * s.price) AS total_price
FROM product p
INNER JOIN sales s ON p.product_id = s.product_id
GROUP BY p.product_id, p.product_name
ORDER BY p.product_name;
结果只有Apple和Nokia,Samsung直接消失。这就是两者最直观的区别。题目原版的预期输出其实来自LEFT JOIN版本(Samsung出现在结果里),所以如果你提交INNER JOIN版本,在某些测试用例下会因为返回行数不同而报错。
实际业务中,用哪种JOIN取决于需求口径。如果业务方说“我要看所有产品的销售情况”,没卖过的也要列出来,那就LEFT JOIN。如果业务方说“看有动销的产品”,那就INNER JOIN。口径错了,报表数据全错,这是比SQL语法错误更严重的坑。
4. 题目背后的扩展:生产环境里的同类查询
LeetCode题目只是把真实场景简化了。实际工作中,你会遇到更复杂的变体:按年份分组、多表关联、多维度汇总、性能调优。搞懂了基础版本,这些扩展都只是加条件、加JOIN的事。
4.1 变体一:按产品和年份双重分组
题目只要求按产品汇总,但sales表里明明有year字段,不利用它有点可惜。现实中的销售报表普遍是要看“每年每个月每个产品的趋势”的。改成双重分组的SQL:
sql复制SELECT
p.product_name,
s.year,
SUM(s.quantity) AS total_quantity,
SUM(s.quantity * s.price) AS total_price
FROM product p
LEFT JOIN sales s ON p.product_id = s.product_id
GROUP BY p.product_name, s.year
ORDER BY p.product_name, s.year;
注意一个细节:因为Samsung没有销售记录,s.year是NULL,所以这个查询的结果里会有一行Samsung、NULL、0、0。这在实际报表里很难看,一般要加个过滤条件只保留有销售记录的,或者用CASE WHEN处理。你也可以用WHERE s.year = 2008来筛选特定年份,但注意WHERE要在JOIN之后、GROUP BY之前执行。
4.2 变体二:只统计指定价格以上的销售
有时候业务方并不想看全部销售,只想看大额订单。追加一个过滤条件,用WHERE或HAVING都行,但这俩有本质区别:
sql复制-- 只看单价 >= 9000 的订单(行级过滤)
SELECT
p.product_name,
SUM(s.quantity) AS total_quantity,
SUM(s.quantity * s.price) AS total_price
FROM product p
LEFT JOIN sales s ON p.product_id = s.product_id
WHERE s.price >= 9000
GROUP BY p.product_name;
-- 只看总销售额 >= 100000 的产品(组级过滤)
SELECT
p.product_name,
SUM(s.quantity) AS total_quantity,
SUM(s.quantity * s.price) AS total_price
FROM product p
LEFT JOIN sales s ON p.product_id = s.product_id
GROUP BY p.product_name
HAVING SUM(s.quantity * s.price) >= 100000;
WHERE在分组前筛掉不满足条件的行,HAVING在分组后筛掉不满足条件的组。这个区别特别容易踩坑:如果把HAVING的条件错放在WHERE里,比如WHERE SUM(...) >= 100000,会直接报错,因为WHERE执行时SUM还没算出来。反过来,如果能把过滤条件下推到WHERE,一定要优先用WHERE,因为GROUP BY之前过滤可以减少分组的数据量,性能更好。
4.3 变体三:多张维度表同时关联
真实业务里,产品可能还挂在品牌下、类目下,销售订单可能还关联着门店。一个销售报表往往要JOIN三四张表。假设要按品牌统计销售额:
sql复制SELECT
b.brand_name,
SUM(s.quantity * s.price) AS total_price
FROM sales s
LEFT JOIN product p ON s.product_id = p.product_id
LEFT JOIN brand b ON p.brand_id = b.brand_id
GROUP BY b.brand_name
ORDER BY b.brand_name;
JOIN的顺序有讲究。这里我把sales当作驱动表,因为查询的核心是销售额,事实表是数据源头。然后依次JOIN产品表、品牌表,让维度信息一层层补全。很多新手喜欢把product放最前面,也能跑,但数据量大的时候性能差异会很明显。驱动表的选择规则很简单:优先选行数少的表或带过滤条件的表作为驱动表。不过这是优化器层面的事,MySQL会自己分析,只是JOIN顺序会影响它的备选执行计划,所以还是要养成好习惯。
4.4 性能优化:索引该怎么建
回到1068题,数据量很小,跑得快是理所当然的。但真实业务里sales表可能有几千万行,再来一条不带索引的JOIN,能把数据库拖垮。这种聚合查询优化,核心就是建索引。
索引建设原则:
- sales.product_id一定要建索引。JOIN的关联字段如果没索引,每次匹配都要全表扫描,复杂度是O(n*m),数据量一大就是灾难。
- product.product_id是主键,天然有索引,不用额外处理。
- 如果经常用GROUP BY s.year,考虑建联合索引(product_id, year),这样既能加速JOIN又能覆盖分组。
- 如果查询条件里有WHERE s.price >= 9000,可以在price上建索引。但优先保证JOIN字段的索引。
建索引的SQL:
sql复制CREATE INDEX idx_sales_product_id ON sales(product_id);
CREATE INDEX idx_sales_product_year ON sales(product_id, year);
索引不是越多越好。每个索引都会拖慢INSERT和UPDATE的速度,还会占用磁盘空间。我见过有人把表里每个字段都建上索引,结果写入性能掉了好几个数量级。正确做法是结合慢查询日志,只给那些真正高频使用的查询路径建索引。
5. 常见问题与避坑指南
这一节把我在实际做题和带新人过程中遇到的高频问题整理成速查表,每个问题都是真实踩过的坑,不是从文档里抄来的。
5.1 高频报错与排查速查表
| 现象 | 报错信息 | 根因 | 解决方案 |
|---|---|---|---|
| 查询报错 | Expression #2 of SELECT list is not in GROUP BY clause | 开启了ONLY_FULL_GROUP_BY,SELECT了非分组字段 | 把product_name加进GROUP BY |
| 结果出现NULL | 没写COALESCE,产品无销售记录 | SUM对空集返回NULL | 用COALESCE(SUM(x), 0)兜底 |
| 结果行数不对 | 少了没卖过的产品 | 误用INNER JOIN | 改成LEFT JOIN保留全量产品 |
| 销售额算错 | 数值偏小 | 直接SUM(price)而不是SUM(quantity*price) | 用逐行乘积再累加 |
| 性能极慢 | 查询耗时几十秒 | JOIN字段没有索引 | 给sales.product_id建索引 |
5.2 最容易犯的细节错误
第一个细节错误,是把GROUP BY写在ORDER BY后面。SQL的执行顺序和书写顺序不一样,实际执行顺序是FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。GROUP BY必须在ORDER BY之前。
第二个细节错误,是忘记加COALESCE导致后续计算出错。假设你把查询结果导入Excel,NULL在Excel里会被当作空单元格,再和其他数字做运算就全变#VALUE。表面上看只是显示问题,实际上会影响整个下游分析。
第三个细节错误,是ORDER BY用错了字段。题目要求按product_name排序,有人列名写total_quantity,结果输出顺序和预期不符。排序的字段一定要分析清楚,是产品名、产品ID还是销售额,这三者结果完全不同。
第四个细节错误,是SELECT里混入了不相关的列。比如你GROUP BY产品,却把s.sale_id也SELECT出来,这在严格模式下直接报错,在非严格模式下返回的sale_id是随机的,非常坑。保持SELECT的列和分组维度严格一致,是最安全的写法。
5.3 真实调试经验分享
我建议你在自己的数据库里同时跑左右两种JOIN,把结果并排对比,感受差异。这个实验的价值在于建立“连接类型影响行数”的直觉,这是未来写所有统计查询的基础。
调试时我还习惯把一条复杂SQL拆成三步:先单查sales表的聚合情况,再查product表全量数据,最后JOIN起来看差异。分步验证能帮你快速定位问题出在JOIN还是聚合上,而不是面对一大段SQL瞎猜。
最后分享一个小技巧:写这类查询前,先手算一遍预期结果。比如先数一下Apple有几条销售记录、每条、数量和单价分别是多少,心算出总数量和总金额。然后跑SQL对比,如果对不上,就是某个逻辑点出了问题。这个习惯帮我抓出了很多藏在细节里的bug,也让我在面试手写SQL时更有底气。
说回1068这道题本身,它真正的价值不在于那个SUM和GROUP BY,而在于帮你建立一套写统计查询的完整思维框架:先厘清表关系,再确定连接方式,然后想清楚分组维度,最后处理空值和排序。这套框架不只是为了通过LeetCode的测试用例,更是为了以后面对真实业务需求时,你能第一时间写出正确、健壮、可维护的查询语句。
