SQL销售分析三题:聚合排序、集合差集与时间区间过滤实战
日常和数据打交道销售分析是绕不开的命题。更麻烦的是销售分析并不只是 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 要用 TOPOracle 更麻烦。实际上“取第一名”这个需求在真实业务里远没有聚合后比较最大值来得稳。提示不是所有“取前 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 INSELECT 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 );这个写法在大多数情况下没问题但有一个很经典的坑如果子查询返回的结果里有 NULLNOT 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 EXISTSSELECT 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 索引避免逐行相关子查询如果是销售分析 IINOT EXISTS 在买家数量少、B 产品买家少的时候也快但买家量大时每检查一个买家的 A 购买记录都要执行一次相关子查询压力不小。相比之下一次 JOIN GROUP BY 的方案更可控。如果是销售分析 IIINOT 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 的掌握程度会明显不一样。