找出每个产品销量排名前 3 的城市。
Find the top 3 cities by sales for each product.
考察要点
这道题是典型的 "分组 Top-N" 问题,面试官想考察你是否掌握现代 SQL 的核心利器——窗口函数(Window Functions)。同时,也想看你是否具备举一反三的能力,能否给出在不支持窗口函数的老版本数据库(如 MySQL 5.7)中的替代解法,并能清晰地分析两种方案的优劣和性能差异。这背后是对 SQL 语义、执行计划和优化思维的综合检验。
核心概念
解决本题的核心是理解分区(Partitioning)和排名(Ranking)。我们需要先将数据按 产品 进行分组,再在每个组内按 销量 进行排序和排名。
1. 窗口函数 (Window Functions) 窗口函数对一组与当前行相关的表行(这个组被称为 "窗口")执行计算。它与聚合函数的主要区别是:聚合函数会把多行聚合成一行,而窗口函数在计算后仍为每行返回一个值。
其基本语法是:FUNCTION_NAME() OVER (PARTITION BY ... ORDER BY ...)
PARTITION BY product_id: 定义窗口,即按产品 ID 对数据进行分组。计算会在每个产品分组内独立进行。ORDER BY sales_amount DESC: 定义窗口内各行的排序规则,即按销量降序排列。
2. 排名函数对比 在 Top-N 问题中,选择哪个排名函数至关重要,因为它决定了如何处理销量并列的情况。
| 函数 | 描述 | 示例 (销量: 100, 90, 90, 80) | 何时使用 |
|---|---|---|---|
ROW_NUMBER() | 无论值是否相同,都分配连续的唯一排名。 | 1, 2, 3, 4 | 严格需要 N 条记录,不关心并列。 |
RANK() | 值相同时排名相同,但后续排名会跳过。 | 1, 2, 2, 4 | 经典的排名方式,并列算同一名次,总名次数会减少。 |
DENSE_RANK() | 值相同时排名相同,且后续排名是连续的。 | 1, 2, 2, 3 | 需要并列名次,且排名连续。例如找出前 3 个分数段。 |
对于本题 "排名前 3 的城市",如果销量为 (北京:100, 上海:90, 广州:90, 深圳:80),
- 使用
RANK()或DENSE_RANK(),WHERE rank <= 3会返回北京、上海、广州、深圳。 - 使用
ROW_NUMBER(),WHERE rank <= 3只会返回北京、上海、广州。 通常RANK()更符合 "前三名" 的业务直觉,我们会优先使用它。
3. 关联子查询 (Correlated Subquery)
在没有窗口函数的旧版数据库中,可以通过子查询来模拟排名。其核心思想是:对于表 sales 中的每一行 s1,去计算在同一个产品下,有多少行的销量 s2.sales_amount 大于等于 s1.sales_amount。这个计数值就是 s1 的排名。
1-- 伪代码2SELECT product_id, city_id, sales_amount3FROM sales s14WHERE (5 SELECT COUNT(DISTINCT s2.sales_amount)6 FROM sales s27 WHERE s2.product_id = s1.product_id AND s2.sales_amount >= s1.sales_amount8) <= 3;
这种方式逻辑复杂,且性能通常远低于窗口函数。
多种解法
假设我们有如下的 sales 表:
| product_id | city | sales |
|---|---|---|
| P1 | 北京 | 1000 |
| P1 | 上海 | 900 |
| P1 | 广州 | 900 |
| P1 | 深圳 | 800 |
| P1 | 杭州 | 700 |
| P2 | 上海 | 1200 |
| P2 | 重庆 | 1100 |
| P2 | 成都 | 1300 |
期望输出:
| product_id | city | sales | rk |
|---|---|---|---|
| P1 | 北京 | 1000 | 1 |
| P1 | 上海 | 900 | 2 |
| P1 | 广州 | 900 | 2 |
| P1 | 深圳 | 800 | 4 |
| P2 | 成都 | 1300 | 1 |
| P2 | 上海 | 1200 | 2 |
| P2 | 重庆 | 1100 | 3 |
最终筛选 rk <= 3 的结果。
解法 1:窗口函数 (推荐)
这是最现代、最高效、最简洁的解法。使用公用表表达式(CTE)可以使逻辑更清晰。
- 版本:✅ MySQL 8.0+ / PostgreSQL / Oracle / SQL Server;❌ MySQL 5.7 及以下版本不支持。
- SQL 代码:
sql1WITH RankedSales AS (2 SELECT3 product_id,4 city,5 sales,6 RANK() OVER (PARTITION BY product_id ORDER BY sales DESC) as rk7 FROM8 sales9)10SELECT11 product_id,12 city,13 sales,14 rk15FROM16 RankedSales17WHERE18 rk <= 3;
- 时间/空间复杂度:主要开销在于排序。如果内存充足,复杂度为 ,其中 N 是总行数。如果数据量大需要磁盘排序,性能会下降。空间复杂度取决于
PARTITION BY的 key 的基数和每个分区的大小。 - 索引依赖:在
sales表上建立复合索引(product_id, sales DESC)能极大地优化性能。数据库可以利用该索引的有序性,避免在内存或磁盘中对每个分区进行排序(filesort),从而将排序的开销降到最低。
解法 2:关联子查询 / 自连接
这是一种兼容老版本数据库的经典解法,但性能通常较差,适合在不支持窗口函数的环境或小数据量下使用。
-
版本:✅ 所有主流数据库版本。
-
SQL 代码:
sql1SELECT2 s1.product_id,3 s1.city,4 s1.sales5FROM6 sales s17LEFT JOIN8 sales s2 ON s1.product_id = s2.product_id AND s1.sales < s2.sales9GROUP BY10 s1.product_id, s1.city, s1.sales11HAVING12 COUNT(DISTINCT s2.sales) < 3;逻辑解释:我们将表自连接,条件是产品相同且
s2的销量比s1高。通过GROUP BY和HAVING COUNT(DISTINCT s2.sales) < 3,我们筛选出那些“最多只有 2 个其他城市的销量比它高”的记录,这等价于排名前三(处理并列情况)。 -
时间/空间复杂度:在没有合适索引的情况下,对于
sales表中的每一行,都需要扫描sales表来做比较,复杂度接近 。即使有索引,其性能也通常劣于窗口函数。 -
索引依赖:必须在
(product_id, sales)上建立索引,否则连接操作将导致极低的性能。
常见低分回答
- 误用
GROUP BY和LIMIT:SELECT product_id, city, sales FROM sales GROUP BY product_id ORDER BY sales DESC LIMIT 3;这种写法是完全错误的。GROUP BY product_id会导致每个产品只返回一行(具体哪一行取决于 MySQL 的ONLY_FULL_GROUP_BY设置),然后对这几行进行排序取前 3,完全不符合题意。 - 混淆排名函数:不解释为何选择
RANK()而不是ROW_NUMBER()或DENSE_RANK()。当面试官追问“如果销量并列怎么算”时,无法清晰回答,说明对细节理解不清。 - 在
WHERE子句中直接使用窗口函数:SELECT ... FROM sales WHERE RANK() OVER (...) <= 3;这是非法的 SQL 语法。窗口函数在SELECT和ORDER BY子句中执行,不能直接用于WHERE或GROUP BY。必须使用CTE或子查询来先计算排名,再进行过滤。 - 关联子查询性能噩梦:写出关联子查询解法,但在被问到性能时,无法指出其潜在的 风险,以及如何通过索引进行优化,说明缺乏生产经验。
- 忽略 NULL 值处理:如果
sales列可能为 NULL,ORDER BY sales DESC会将 NULL 值排在最前面还是最后面(取决于数据库实现),这会影响排名结果。高分回答应提及这一点并说明如何用NULLS LAST或NULLS FIRST控制。
工程实践与优化
- 索引是关键:对于解法 1,
INDEX(product_id, sales DESC)是性能的决定性因素。通过EXPLAIN查看执行计划,应确保没有Using filesort,这表示利用了索引的有序性。 - 执行计划分析:使用
EXPLAIN(或EXPLAIN ANALYZEin MySQL 8.0+/PostgreSQL)。对于窗口函数,你会看到Window: ...的信息。关键是看排序是否被优化掉。对于关联子查询,要看JOIN的type是否为ref或eq_ref,rows估算是否合理,避免type=ALL的全表扫描。 - 数据倾斜:如果某个
product_id的数据量远超其他产品(例如,一个爆款产品占了 90% 的销量记录),窗口函数的内存消耗会集中在这个大分区上,可能导致sort_buffer_size不足而使用磁盘临时文件,性能急剧下降。 - 物化与流式处理:
CTERankedSales可能会被物化成一个临时表。对于大数据量,这会消耗大量 I/O 和磁盘空间。一些先进的数据库优化器能够以流式方式处理窗口函数,避免完全物化,性能更好。 - 预聚合:在超大规模数据场景(如数仓中的事实表),直接在原始明细表上跑窗口函数可能非常慢。通常的做法是通过 ETL 任务,先按
(product_id, city)预聚合销量,生成一个更小的聚合表,再在这个小表上执行 Top-N 查询,速度会快几个数量级。
常见追问与延伸题目
-
追问:如果销量有并列,比如前三名有 5 个城市,都要展示吗?
- 回答要点:这取决于业务需求。
- 如果需要展示所有并列的城市,应该使用
RANK()或DENSE_RANK()。RANK() <= 3会把排名为 1, 2, 3 的所有城市都包含进来,即使总数超过 3 个。 - 如果严格只要 3 个城市,即使有并列也要舍弃,那么应该使用
ROW_NUMBER()。它会为并列的行分配一个随机但唯一的序号。 - 我在解法中使用了
RANK(),因为它更符合“排名”的直观理解,即分数相同的人名次相同。
- 如果需要展示所有并列的城市,应该使用
- 回答要点:这取决于业务需求。
-
追问:如果数据量增长到 10 亿级别,这条 SQL 性能怎样?怎么优化?
- 回答要点:
- 性能瓶颈:在 10 亿级别,窗口函数会遇到严峻挑战。主要瓶颈在于排序,即使有索引,单个大分区的排序也可能耗尽内存(
sort_buffer_size),导致大量的磁盘 I/O(filesort),查询可能需要几小时甚至失败。 - 优化方案:
- 离线预计算:这是最根本的方案。通过每日或每小时的批处理任务(如 Spark/Flink/Hive),提前计算好每个产品的 Top-N 城市,并将结果存入一个专门的结果表(如
product_top_cities)。线上查询直接读这个结果表,实现毫秒级响应。 - 分布式数据库/查询引擎:将数据存储在像 TiDB, OceanBase, Greenplum 这样的分布式数据库,或者使用 Presto, ClickHouse 等列式存储和分析引擎。它们能利用多台机器的 CPU 和内存并行计算,将大规模排序任务分而治之,从而在可接受的时间内完成查询。
- 近似查询:如果业务可以接受近似结果,可以使用一些近似查询算法(如 t-digest)来估算分位数,快速找到 Top-N,但这不适用于需要精确排名的场景。
- 离线预计算:这是最根本的方案。通过每日或每小时的批处理任务(如 Spark/Flink/Hive),提前计算好每个产品的 Top-N 城市,并将结果存入一个专门的结果表(如
- 性能瓶颈:在 10 亿级别,窗口函数会遇到严峻挑战。主要瓶颈在于排序,即使有索引,单个大分区的排序也可能耗尽内存(
- 回答要点:
-
追问:如果只要每个产品销量最高的那个城市,怎么写最高效?
- 回答要点:这是 "分组 Top-N" 问题中 N=1 的特例,有更高效的特定解法。
- 窗口函数法:依然可以使用
ROW_NUMBER() ... WHERE rk = 1。在有(product_id, sales DESC)索引的情况下,这已经非常高效。 - 经典
LEFT JOIN ... IS NULL法:这是一个非常巧妙且高效的解法,尤其在老版本 MySQL 中性能优异。逻辑:对于sql1SELECT s1.*2FROM sales s13LEFT JOIN sales s24 ON s1.product_id = s2.product_id AND s1.sales < s2.sales5WHERE s2.product_id IS NULL;s1中的每一行,尝试去找一个同产品下销量比它更高的s2。如果找不到(即s2.product_id IS NULL),说明s1就是销量最高的行。在(product_id, sales)上有索引时,这个LEFT JOIN的效率极高。 - 性能对比:在 N=1 的场景下,
LEFT JOIN法和优化良好的窗口函数法性能相当,有时LEFT JOIN甚至会略胜一筹,因为它不涉及完整的窗口帧计算。但在 N>1 时,窗口函数方案的扩展性、可读性完胜。
- 窗口函数法:依然可以使用
- 回答要点:这是 "分组 Top-N" 问题中 N=1 的特例,有更高效的特定解法。