☰
SQL销售分析三题:聚合排序、集合差集与时间区间过滤实战
2026/10/17 22:18:28 网站建设 项目流程

日常和数据打交道,销售分析是绕不开的命题。更麻烦的是,销售分析并不只是 SUM 一下就完事:要找出并列第一的销售冠军怎么办?要找出“买过 A 产品却从来没买过 B 产品”的客户怎么办?要判断某个商品只在特定时间段有销量,其他时候都没卖过,又怎么写?我在实际报表里踩过太多类似的坑了。这篇文章要聊的,正是一套编号 1082 的“销售分析 1-3”系列题:三道题共用同一张销售流水表和产品表,却分别考察了聚合排序、集合判断、时间区间过滤三个完全不同的知识面。如果你在准备 SQL 面试,或者日常要写销售看板、客户购买行为分析、商品生命周期报表,这套题值得逐题拆开吃透。

1. 这套销售分析题共用同一张表,考的却不是同一种SQL能力

1.1 表结构:Sales 是流水,Product 是档案

先看数据模型,这套题的核心表就两张。

Sales 表记录每一笔销售流水,字段包括销售员、产品、买家、销售日期、数量和单价。Product 表是产品档案,保存产品 ID、产品名和标价。流水表最大的特点是:同一个销售员会出现在多行,同一个买家也会出现在多行,同一个产品更会反复出现。这意味着几乎所有查询的第一步都是“聚合”,但聚合之后要干什么,三题给出了三种完全不同的方向。

如果用业务语言打个比方:Sales 表像一堆订单明细,Product 表像商品目录,两者通过 product_id 关联。你拿到这两张表,可以做销售排行、客户选品分析、季节性商品识别。这三件事是真正常见的业务需求,不是刷题专用。

1.2 三题分别对应销售排行、交叉购买、商品生命周期

  • 销售分析 I:算每个销售员的总销售额,找出销售额最高的那个人。如果有并列,全部输出。本质是按销售员维度做聚合,再在聚合结果上做“取最大值”。对应业务就是月度销售冠军榜、销售提成计算。
  • 销售分析 II:找出买过 A 产品但没买过 B 产品的买家。本质是两个买家集合做差集。对应业务是交叉销售分析、竞品客户挖掘。
  • 销售分析 III:找出只在 2019 年春季(2019-01-01 到 2019-03-31)卖过、且其他时间都没卖过的产品。本质是时间区间判断。对应业务是季节性商品识别、商品生命周期判断。

这三道题放在一起看,恰好是 SQL 从“分组聚合”到“集合逻辑”再到“时间窗口”的能力递进。很多人在第一题写得很顺手,到第二题开始犹豫是 NOT IN 还是 NOT EXISTS,到第三题又搞不清楚怎么写才能排除区间之外的记录。下面逐题展开。

2. 销售分析 I:聚合后找最高销售额,LIMIT只能用来考试

2.1 第一直觉的 LIMIT 写法为什么有坑

第一次看到“找出总销售额最高的销售员”,大多数人会写下:

SELECT seller_id FROM Sales GROUP BY seller_id ORDER BY SUM(quantity * price) DESC LIMIT 1;

这段 SQL 在 MySQL 里能跑,不算错,但有两个问题。

第一个问题是并列丢失。如果两个销售员的总销售额一样高,LIMIT 1 只返回一条。到底是返回哪一条,取决于数据库对同值排序的物理实现,通常是不确定的。你这次跑出 A,下次跑出 B,都很正常。面试里一旦被问到“如果有并列怎么办”,这个写法直接暴露了对数据语义的理解不足。

第二个问题是数据库兼容性。LIMIT 是 MySQL 和 PostgreSQL 的写法,SQL Server 要用 TOP,Oracle 更麻烦。实际上“取第一名”这个需求,在真实业务里远没有聚合后比较最大值来得稳。

提示:不是所有“取前 N”都能用 LIMIT 解决。遇到并列需求,一定要用能感知“排名”的写法。

2.2 用 RANK() 处理并列才符合“销售并列第一”的业务语义

处理并列,正确姿势是窗口函数 RANK()。思路分两步:先按销售员分组求出总销售额,再对总额排序并赋予排名,最后取排名第 1 的人。

WITH sales_summary AS ( SELECT seller_id, SUM(quantity * price) AS total_sales FROM Sales GROUP BY seller_id ) SELECT seller_id FROM ( SELECT seller_id, RANK() OVER (ORDER BY total_sales DESC) AS rk FROM sales_summary ) ranked WHERE rk = 1;

RANK() 的特点是:值相等的人获得相同排名,并且后续排名会跳过。比如 900、900、800 三档,排名就是 1、1、3。用它来筛 rk = 1,就能自然覆盖所有并列第一的销售员。

注意顺序:这里先 GROUP BY 再算窗口函数。求和是分组聚合的结果,窗口函数是在聚合结果之上做排序,逻辑非常清晰。不要把 SUM(quantity * price) 直接写在 WHERE 里,聚合结果不能这么用。

2.3 不用窗口函数也能写:HAVING + 最大值子查询

如果你的 MySQL 版本较老,或者面试官明确说不能用窗口函数,也可以用 HAVING 加子查询。先把所有人的总销售额都算出来,然后找最大那个值,再倒回去筛人。

SELECT seller_id FROM Sales GROUP BY seller_id HAVING SUM(quantity * price) = ( SELECT MAX(total_sales) FROM ( SELECT SUM(quantity * price) AS total_sales FROM Sales GROUP BY seller_id ) t );

这段的优点是兼容老版本数据库,语义也很直白:总额等于最大总额的人,就是销售冠军。缺点是要写两层子查询,表一大性能会比窗口函数差一些,但作为解题思路完全合格。

还有一种不常见的 ALL 写法:

SELECT seller_id FROM Sales GROUP BY seller_id HAVING SUM(quantity * price) >= ALL ( SELECT SUM(quantity * price) FROM Sales GROUP BY seller_id );

意思是“比所有人的销售额都高或相等”。这个写法能处理并列,但可读性不如前面两个,我一般只用来和别人讨论解法时提一嘴。

2.4 本地建表与测试数据(含并列场景)

为了把问题看透,建议自己建表跑一遍。这里给一套 MySQL 8.0 的建表和造数脚本:

CREATE TABLE Sales ( seller_id INT, product_id INT, buyer_id INT, sale_date DATE, quantity INT, price INT ); INSERT INTO Sales VALUES (1, 1, 1, '2018-01-01', 5, 100), (1, 2, 2, '2018-02-02', 2, 200), (2, 2, 3, '2018-03-03', 3, 300);

算一下总额:销售员 1 是 5×100 + 2×200 = 900,销售员 2 是 3×300 = 900。两人并列第一。如果用 LIMIT 1,可能只输出一个;用 RANK() 或 HAVING 最大值的写法,两个都会输出。

-- 预期结果 seller_id 1 2

这就是实质差别:业务上的“销售冠军”如果并列,你应该把并列的人都报出来,不能因为排序方式把第二个人丢了。

3. 销售分析 II:买过 A 没买过 B,本质是集合做差而不是过滤

3.1 把需求翻译成集合运算:A 集合减 B 集合

第二题的题目描述通常是:找出购买了 A 产品但没有购买 B 产品的买家。

很多人第一反应是加条件过滤:

SELECT DISTINCT buyer_id FROM Sales WHERE product_id IN (SELECT product_id FROM Product WHERE product_name = 'A');

这只是找到了买过 A 的人,完全没有排除买过 B 的人。正确思路是做集合差:先得到集合 X = 所有买过 A 的买家,再得到集合 Y = 所有买过 B 的买家,答案就是 X - Y。

明确这一点后,SQL 写法就很清晰了,只是选择哪种排除方式有讲究。

3.2 NOT IN 的 NULL 陷阱与 NOT EXISTS 的正确姿势

最直觉的排除写法是 NOT IN:

SELECT DISTINCT s.buyer_id FROM Sales s JOIN Product p ON s.product_id = p.product_id WHERE p.product_name = 'A' AND s.buyer_id NOT IN ( SELECT s2.buyer_id FROM Sales s2 JOIN Product p2 ON s2.product_id = p2.product_id WHERE p2.product_name = 'B' );

这个写法在大多数情况下没问题,但有一个很经典的坑:如果子查询返回的结果里有 NULL,NOT IN 的语义会变得非常奇怪。举个例子,buyer_id NOT IN (1, NULL)会被解析成buyer_id != 1 AND buyer_id != NULL,而buyer_id != NULL的结果不是 TRUE 也不是 FALSE,而是 UNKNOWN,最终整行被过滤掉,查询结果就是空表。

也许你会说,buyer_id 是主键不可能为 NULL。但在真实数据库里,外键和业务主键都可能因为数据质量问题出现空值,尤其是从多个系统同步过来的销售表。所以写 NOT IN 时,最稳妥的办法是把条件改成 NOT EXISTS:

SELECT DISTINCT s.buyer_id FROM Sales s JOIN Product p ON s.product_id = p.product_id WHERE p.product_name = 'A' AND NOT EXISTS ( SELECT 1 FROM Sales s2 JOIN Product p2 ON s2.product_id = p2.product_id WHERE p2.product_name = 'B' AND s2.buyer_id = s.buyer_id );

NOT EXISTS 是逐行检查,遇到 NULL 就当成“不存在”处理,不会像 NOT IN 那样整个查询失效。从稳健性角度讲,我强烈建议生产环境的 SQL 优先用 NOT EXISTS。

3.3 一次 JOIN 一次 GROUP BY:用 HAVING 做条件计数

还有一种更高效、代码也更紧凑的写法:只用一次 JOIN,按买家分组,再用 HAVING 做条件计数。

SELECT s.buyer_id FROM Sales s JOIN Product p ON s.product_id = p.product_id WHERE p.product_name IN ('A', 'B') GROUP BY s.buyer_id HAVING SUM(CASE WHEN p.product_name = 'A' THEN 1 ELSE 0 END) > 0 AND SUM(CASE WHEN p.product_name = 'B' THEN 1 ELSE 0 END) = 0;

逻辑是:分组后,如果这个买家所有 A/B 产品购买记录中,出现 A 的次数大于 0,同时出现 B 的次数等于 0,那就满足条件。

这个写法的好处是扫描次数少。买过 A 的人可能有一堆,买过 B 的人也可能有一堆,NOT EXISTS 对每个“买过 A”的买家和产品组合都会去检查子查询;而 GROUP BY + HAVING 把所有相关记录一次性聚合,通常在大数据量下表现更好。

3.4 容易忽略的 DISTINCT 与重复数据问题

Sales 是流水表,同一个买家可能在一行买一台 A 产品,又在另一行买一台 A 产品。如果你用 SELECT s.buyer_id 不加 DISTINCT,同一个买家会输出多条记录。上面这个 GROUP BY 写法因为按 buyer_id 分组,天然去重;但 NOT EXISTS 和 NOT IN 版本一定要记得加 DISTINCT。

实际业务里我见过有人查交叉销售客户清单,返回结果里同一个客户出现几十次,就是因为漏了 DISTINCT。这种错误不会报语法错误,也不会报运行时错误,就是数据对不上,而且往往要到最后做数量核对才发现。

再补一个 LEFT JOIN + IS NULL 的等价写法,有些人更喜欢:

SELECT DISTINCT s.buyer_id FROM Sales s JOIN Product p ON s.product_id = p.product_id LEFT JOIN ( SELECT DISTINCT buyer_id FROM Sales s3 JOIN Product p3 ON s3.product_id = p3.product_id WHERE p3.product_name = 'B' ) b ON s.buyer_id = b.buyer_id WHERE p.product_name = 'A' AND b.buyer_id IS NULL;

这个方式的直观意思是“左边是买过 A 的人,把它和右边买过 B 的人做 LEFT JOIN,右边为空的人就是没买过 B 的”。可读性不错,但书写冗长,性能上也未必优于 NOT EXISTS。三种写法选哪一种,主要看团队习惯和表的数据量级。

4. 销售分析 III:只在春季卖过?用最大最小日期直接翻译

4.1 “所有销售都在区间内”等价于最小值和最大值都在区间内

第三题要求找出只在 2019 年春季卖过、而且确实在春季有销售的产品。翻译成业务语言:这个产品的所有销售日期,既不早于 2019-01-01,也不晚于 2019-03-31。

一个非常实用的数学等价关系:如果一组日期的最小值和最大值都落在某个区间内,那么这组日期的每一个值都落在该区间内。放在 SQL 里就是:

SELECT p.product_id, p.product_name FROM Product p JOIN Sales s ON p.product_id = s.product_id GROUP BY p.product_id, p.product_name HAVING MIN(s.sale_date) >= '2019-01-01' AND MAX(s.sale_date) <= '2019-03-31';

这里 JOIN 本身已经过滤掉了没有销售记录的产品。因为只要有销售记录,MIN 和 MAX 就不会是 NULL。有销售记录是前提,MIN 和 MAX 都落在区间内,说明所有销售记录都在区间内。

这个写法简洁、优雅,而且索引友好。只要在 sale_date 上建了索引,计算 MIN 和 MAX 比扫描全部记录快得多。

4.2 用 NOT EXISTS 表达存在性:存在春季,不存在非春季

如果觉得 MIN/MAX 不够直白,也可以用 EXISTS 和 NOT EXISTS 组合。这种写法更贴近题目语句本身,也更好向不懂 SQL 的业务同事解释。

SELECT p.product_id, p.product_name FROM Product p WHERE EXISTS ( SELECT 1 FROM Sales s WHERE s.product_id = p.product_id AND s.sale_date BETWEEN '2019-01-01' AND '2019-03-31' ) AND NOT EXISTS ( SELECT 1 FROM Sales s WHERE s.product_id = p.product_id AND (s.sale_date < '2019-01-01' OR s.sale_date > '2019-03-31') );

第一段 EXISTS 保证这个产品在春季有销售,第二段 NOT EXISTS 保证它没有任何一次销售发生在春季之外。两条同时成立,就是“只在春季卖过”。

这种写法不依赖 GROUP BY,逻辑上分两步,适合面试时展示自己能把自然语言翻译成 SQL。但要注意,如果 Sales 表很大,相关子查询会执行很多次,性能有可能不如聚合写法。产品数量少、销售流水大时要特别留意。

4.3 边界日期与 datetime 类型:BETWEEN 不是万能

很多人写日期区间习惯用 BETWEEN,但 BETWEEN 的边界行为值得细看。

如果 sale_date 是 DATE 类型,BETWEEN '2019-01-01' AND '2019-03-31' 没问题,它包含两个端点。但如果 sale_date 是 DATETIME 类型,这一句只包含 2019-03-31 00:00:00 之前的时间,3 月 31 日上午 10 点的销售会被漏掉。

更稳妥的写法是把结束日期写成开区间:

AND s.sale_date >= '2019-01-01' AND s.sale_date < '2019-04-01'

这样无论字段是 DATE 还是 DATETIME,都能覆盖整个 3 月 31 日。这个细节在生产环境尤其重要,因为很多业务表的时间字段是 DATETIME,订单时间带时分秒,直接用 BETWEEN 会出莫名其妙的边界漏数。

提示:判断区间时,优先用>= start AND < end_next_day的开区间写法,避免 date 与 datetime 混用导致的数据漏统计。

5. 三种查询的执行顺序、NULL陷阱与索引姿势

5.1 记住 SQL 的执行顺序,才知道 WHERE 为什么不能写聚合

SQL 的执行顺序不是 SELECT 优先,而是 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。

这个顺序能解释很多报错:WHERE 在 GROUP BY 之前执行,所以 WHERE 里不能直接用聚合函数或者聚合结果。聚合条件只能在 HAVING 里写。很多人刚开始会把“销售额大于10000”这种条件误写进 WHERE,然后发现字段不存在,其实就是执行顺序没搞清楚。

再比如销售分析 I 里的窗口函数,为什么能对聚合后的 SUM 排序?因为窗口函数在 SELECT 阶段执行,此时 GROUP BY 已经完成。如果你在窗口函数中直接引用聚合结果别名,有些数据库支持,有些不支持,稳妥做法是先包一层子查询或 CTE,像我前面给出的 RANK() 写法一样。

5.2 三种解法的性能取舍:扫描次数、相关子查询、索引利用

三道题各给出过多种解法,性能差异主要来自三方面:全表扫描次数、相关子查询执行次数、索引利用率。

场景推荐写法性能要点
找最高销售额销售员RANK() + CTE只需一次分组聚合,窗口函数在结果上排序
找买过 A 没买过 B 的买家GROUP BY + HAVING一次 JOIN 一次分组,避免多次相关子查询
找只在春季销售的产品MIN + MAX 聚合聚合能走 sale_date 索引,避免逐行相关子查询

如果是销售分析 II,NOT EXISTS 在买家数量少、B 产品买家少的时候也快;但买家量大时,每检查一个买家的 A 购买记录都要执行一次相关子查询,压力不小。相比之下,一次 JOIN + GROUP BY 的方案更可控。

如果是销售分析 III,NOT EXISTS 对索引依赖很强。如果 Sales(product_id, sale_date) 有复合索引,相关子查询会快;如果没有索引,几十万行的销售流水会让相关子查询慢到怀疑人生。而 MIN/MAX 聚合同样需要索引支撑,但整体扫描逻辑更集中。

5.3 几类常见隐藏问题的自查清单

把三道题放在一起,可以看到一些通用隐患:

  • NULL 导致 NOT IN 失效。最稳妥的方案是改用 NOT EXISTS。
  • 聚合分组后忘记去重。Sales 是流水表,按买家或产品查询时必须考虑重复。
  • 日期边界没考虑时分秒。生产环境用>= start AND < end_next_day。
  • 并列问题用 LIMIT 解决。要理解 RANK() 和 ROW_NUMBER() 的区别:前者并列同号,后者即使并列也强行编不同号。
  • 测试数据太干净。刷题平台给的数据通常规规矩矩,但真实表可能有脏数据,有重复,有空值。拿真实数据验证时,先多跑几条分步 SQL 看看中间结果。

6. 把这三题改造成真实业务查询时,我保留了这三个习惯

6.1 先把业务问题翻译成集合或条件,再落成 SQL

做销售分析题最受益的地方不是背住某个函数,而是养成“先建模再翻译”的习惯。销售分析 II,先写清楚集合 X 和集合 Y 以及要做差集,再决定用 NOT IN 还是 NOT EXISTS。销售分析 III,先理解“所有日期都在区间内”等价于 MIN、MAX 都在区间内,再动手写 SQL。

真实业务里也是这样。需求方说“我要看买了 A 但没买 B 的客户”,如果你直接上来写条件,很容易漏掉“没买 B”这个部分。先跟产品确认集合定义,再落 SQL,至少能少返工一半。

6.2 建一个带脏数据的小表,比刷十遍题更能加深理解

我建议把这套题在本地数据库里完整跑一遍,并且故意往表里塞几类脏数据:

  • 让两个销售员销售额相同,验证 RANK() 是否同时输出。
  • 在 Sales 表插入 buyer_id 为 NULL 的记录,看 NOT IN 瞬间“变成”空结果。
  • 把某个产品的 sale_date 改成带时分秒的 datetime,并落在 3 月 31 日下午,测试 BETWEEN 是否漏数。

只有自己亲眼看到这些反直觉的结果,才真正理解为什么有那么多规避写法。这也是我反复强调动手的原因:SQL 不是背出来的,是查出来的。

6.3 这三个查询还能演化成哪些更复杂的分析

销售分析 I 的升级版是区域销售排名、部门内 Top N、按月份排名。把 PARTITION BY 加进窗口函数,按区域分组再排序,一套逻辑就能复用。

销售分析 II 的升级版是“买过 A 且买过 B 但没买过 C”,或者“买了 A 之后 30 天内没买过 B 的客户”。后者会涉及时间差和去重,比简单的集合差复杂很多,但核心仍然是集合与关联。

销售分析 III 的升级版是识别“只在最近 30 天有销售的老品”“连续三个月销量为零后又突然出货的商品”,这些都可以用 MIN/MAX 或 EXISTS/NOT EXISTS 组合实现。理解了第三题的等价转换,这类需求也能快速拆解。

这三道题讲完了,但留下的思路是通用的:聚合、集合、时间区间,是销售分析里最常用的三大武器。我个人强烈建议你把这套题当成一个 mini 项目来练,建表、造数、写三种解法、跑结果、故意制造错误数据,折腾一遍后,你对 SQL 的掌握程度会明显不一样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询