拼多多面试:找出每个分类下销量前 20% 的商品。
Pinduoduo interview: Find the top 20% best-selling products
考察要点
这道题是典型的 "Top-N per Group" 问题的变种,面试官想考察你对 SQL 分析能力的掌握程度,具体来说是:
- 窗口函数(Window Functions):是否熟练使用
NTILE,RANK,ROW_NUMBER等窗口函数进行组内排名和切片。这是解决此类问题的标准现代 SQL 方法。 - 复杂查询构建能力:是否能使用公用表表达式(
CTE)或子查询来组织逻辑,因为窗口函数的结果不能直接在WHERE子句中过滤。 - 性能优化意识:是否考虑到大数据量下的性能问题,并能提出基于索引的优化方案。
核心概念
解决本题的核心是理解窗口函数如何在每个分组(分区)内进行计算。
1. 表结构假设
我们假设有如下商品表 products:
| product_id | product_name | category_id | sales |
|---|---|---|---|
| 101 | A | 1 | 950 |
| 102 | B | 1 | 900 |
| 103 | C | 1 | 850 |
| 104 | D | 1 | 800 |
| 105 | E | 1 | 750 |
| 106 | F | 1 | 700 |
| 107 | G | 1 | 650 |
| 108 | H | 1 | 600 |
| 109 | I | 1 | 550 |
| 110 | J | 1 | 500 |
| 201 | K | 2 | 1200 |
| 202 | L | 2 | 1100 |
| 203 | M | 2 | 1000 |
| 204 | N | 2 | 900 |
| 205 | O | 2 | 800 |
2. 窗口函数 (Window Function)
窗口函数对一组与当前行相关的表行执行计算。它与聚合函数(如 SUM, COUNT)的关键区别在于:聚合函数会将多行合并为一行,而窗口函数会为每一行都返回一个值。
基本语法:FUNCTION_NAME() OVER (PARTITION BY ... ORDER BY ...)
PARTITION BY category_id: 这是“每个分类下”的关键。它将数据按category_id拆分成多个逻辑分区(窗口),函数在每个分区内独立计算。ORDER BY sales DESC: 这是“销量前”的关键。它定义了分区内行的排序方式,这里是按销量从高到低排。
3. 关键函数辨析
-
NTILE(n): 将每个分区中的有序行分配到n个大致相等的桶中,并返回桶编号(从 1 开始)。对于“前 20%”,我们可以使用NTILE(5),它将数据分为 5 个桶,每个桶占 20%。第一个桶 (NTILE(...) = 1) 就是我们需要的。- 注意:如果分区内的行数不能被
n整除,NTILE会让前面的桶比后面的桶多一个成员。例如,11 行分 5 桶,会分为3, 2, 2, 2, 2。
- 注意:如果分区内的行数不能被
-
RANK()vsROW_NUMBER():RANK(): 排名函数。如果销量相同,排名也相同,但后续排名会跳跃。例如:1, 2, 2, 4。ROW_NUMBER(): 行号函数。无论销量是否相同,都分配一个连续唯一的序号。例如:1, 2, 3, 4。COUNT(*) OVER (PARTITION BY ...): 窗口化的COUNT,可以计算出每个分区内的总行数,而不会折叠行。
多种解法
解法 1:使用 NTILE() 函数(最直接)
这种方法语义最清晰,直接表达了“将数据分为 5 份,取第 1 份”的意图。
- 版本:✅ MySQL 8.0+ / PostgreSQL / SQL Server / Oracle;❌ MySQL 5.7 不支持窗口函数。
- SQL 代码:
sql1WITH ranked_products AS (2 SELECT3 product_id,4 product_name,5 category_id,6 sales,7 NTILE(5) OVER (PARTITION BY category_id ORDER BY sales DESC) AS sales_quintile8 FROM9 products10)11SELECT12 product_id,13 product_name,14 category_id,15 sales16FROM17 ranked_products18WHERE19 sales_quintile = 1;
- 复杂度:时间复杂度为 ,其中 N 是总行数。主要开销在于
PARTITION BY ... ORDER BY ...涉及的排序操作。空间复杂度为 ,用于存储中间结果。 - 索引依赖:在
(category_id, sales DESC)上创建复合索引可以极大地提升性能。数据库可以利用该索引,避免对每个分区进行显式的排序操作。
解法 2:使用 RANK() 和 COUNT() 组合(更灵活)
这种方法通过计算排名和总数,然后判断排名是否在总数的前 20% 范围内。它在处理“并列”情况时行为更明确。
- 版本:✅ MySQL 8.0+ / PostgreSQL 等;❌ MySQL 5.7。
- SQL 代码:
sql1WITH product_stats AS (2 SELECT3 product_id,4 product_name,5 category_id,6 sales,7 RANK() OVER (PARTITION BY category_id ORDER BY sales DESC) AS rnk,8 COUNT(*) OVER (PARTITION BY category_id) AS total_in_category9 FROM10 products11)12SELECT13 product_id,14 product_name,15 category_id,16 sales17FROM18 product_stats19WHERE20 rnk <= total_in_category * 0.2;
- 复杂度:时间复杂度 ,空间复杂度 ,与解法 1 类似。
- 索引依赖:同样,
(category_id, sales DESC)上的复合索引是性能关键。 - 何时使用:当对边界情况(如销量并列)有精确要求时,此方法更优。例如,如果第 20% 和 21% 的商品销量相同,
RANK()会将它们都包含进来,这通常更符合业务逻辑。而NTILE()的行为可能因数据库实现而异,可能只包含一个。
解法 3:使用关联子查询(兼容老版本)
在不支持窗口函数的旧版 MySQL(如 5.7)中,只能使用这种效率较低的方法。
- 版本:✅ 所有 SQL 版本,包括 MySQL 5.7。
- SQL 代码:
sql1SELECT2 p1.product_id,3 p1.product_name,4 p1.category_id,5 p1.sales6FROM7 products p18WHERE (9 SELECT COUNT(*)10 FROM products p211 WHERE p2.category_id = p1.category_id AND p2.sales >= p1.sales12) <= (13 SELECT COUNT(*)14 FROM products p315 WHERE p3.category_id = p1.category_id16) * 0.2;
- 复杂度:时间复杂度近似 。对于
p1表的每一行,都需要执行两次关联子查询,性能极差,在数据量稍大时完全不可用。 - 索引依赖:
(category_id, sales DESC)上的索引是必须的,否则查询将无法完成。即使有索引,性能也远不如窗口函数。
常见低分回答
-
在
WHERE子句中直接使用窗口函数:sql1-- 错误!SQL 语法不允许2SELECT ... FROM products WHERE NTILE(5) OVER (...) = 1;原因:SQL 查询的逻辑执行顺序是
FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> WINDOW -> ORDER BY。WHERE子句在窗口函数计算之前执行,所以它看不到窗口函数的结果。必须使用CTE或子查询。 -
忘记
PARTITION BY:sql1-- 错误!在所有商品中排名,而不是分类内2NTILE(5) OVER (ORDER BY sales DESC)这会导致对整个表进行排名,而不是按分类进行,完全曲解了题目要求。
-
混淆
RANK()和ROW_NUMBER(): 使用ROW_NUMBER()可能会在销量并列时,随机地将一个商品排在 20% 的门槛内,另一个排在门槛外,这通常不符合业务直觉。RANK()或DENSE_RANK()能更好地处理并列情况。 -
对
NTILE的不均匀分配理解不清: 候选人可能认为NTILE(5)总是精确地划分 20% 的数据。面试官可能会追问:“如果一个分类有 7 个商品,你的查询会返回几个?” 正确答案是NTILE(5)会创建大小为2, 2, 1, 1, 1的桶,查询会返回 2 个商品(约 28.6%),并能解释这个行为。
工程实践与优化
- 索引是第一要义:对于此查询,
CREATE INDEX idx_category_sales ON products (category_id, sales DESC);是最重要的优化。它让数据库引擎能直接从索引中获取已分区和排序的数据,避免了昂贵的“filesort”操作。 - 执行计划分析 (
EXPLAIN):- 优化前:
EXPLAIN的Extra列可能会显示Using temporary; Using filesort,表示需要创建临时表并进行全量排序,成本很高。 - 优化后(有
(category_id, sales DESC)索引):Extra列可能什么都不显示,或者显示Using index(如果构成覆盖索引),表示排序操作被完全避免,性能极高。
- 优化前:
- 数据倾斜问题:在真实生产中(如拼多多),某个大类的商品数量可能是其他类的数万倍。查询性能会被这个最大的分类拖垮。解决方案通常是:
- 离线计算:对于这种分析类查询,不在
OLTP数据库(如 MySQL)上实时跑。而是通过 Spark/Flink 等大数据引擎,每天在数据仓库(如 Hive/ClickHouse)中预计算好排名,将结果存入一个结果表或 KV 存储(如 Redis),供线上业务快速查询。 - 查询拆分:如果必须在线查,可以识别出超大分类,对其采用特殊逻辑(如只允许查 Top 500),或者引导用户缩小范围。
- 离线计算:对于这种分析类查询,不在
- 物化
CTE:MySQL 8.0.21+ 对CTE的处理有优化,但旧版本可能会将CTE的结果“物化”成一个内部临时表。如果products表非常大,这个临时表会消耗大量内存和磁盘 I/O。
常见追问与延伸题目
-
追问:如果数据量增长 100 倍,达到数十亿级别,这条 SQL 性能怎样?怎么优化?
- 回答要点 1 (瓶颈分析):在线数据库(MySQL/PostgreSQL)将无法承受。 的排序复杂度在十亿级别数据上是灾难性的。数据库内存会爆,磁盘 I/O 会成为瓶颈,查询会超时或拖垮整个实例。
- 回答要点 2 (架构级优化):必须采用离线/近线分析架构。
- 数据同步:通过 CDC (Change Data Capture) 工具如 Canal/Debezium 将业务库数据实时/准实时同步到数据湖或数据仓库(如 Hudi/Iceberg on S3/HDFS)。
- 离线计算:使用分布式计算框架(Spark SQL, Flink SQL)每日或每小时执行上述窗口函数逻辑。这些框架为大规模排序做了优化。
- 结果服务化:将计算出的 Top 20% 商品列表存入高性能的线上服务系统,如 Redis (用
ZSET按分类存)、Elasticsearch 或 ClickHouse。线上应用直接查询这些预计算好的结果,实现毫秒级响应。
-
追问:如果
sales列不在products表,而在一个巨大的order_items订单明细表里,该如何查询?- 回答要点 1 (查询改写):这考察了
JOIN和GROUP BY的能力。查询需要分步走:- 首先,从
order_items表中按product_id聚合,计算出每个商品的总销量。 - 然后,将聚合结果与
products表连接,以获取category_id。 - 最后,将上述结果作为整体,应用我们之前讨论的窗口函数。
- 首先,从
- 回答要点 2 (SQL 示例):
sql1WITH product_total_sales AS (2 SELECT3 product_id,4 SUM(quantity) AS total_sales -- 或 SUM(price * quantity)5 FROM6 order_items7 -- 实际场景可能还需要按时间过滤,如近30天8 -- WHERE order_time > NOW() - INTERVAL 30 DAY9 GROUP BY10 product_id11),12ranked_products AS (13 SELECT14 p.product_id,15 p.category_id,16 pts.total_sales,17 NTILE(5) OVER (PARTITION BY p.category_id ORDER BY pts.total_sales DESC) AS sales_quintile18 FROM19 products p20 JOIN21 product_total_sales pts ON p.product_id = pts.product_id22)23SELECT ... FROM ranked_products WHERE sales_quintile = 1;
- 回答要点 1 (查询改写):这考察了
-
追问:如果换成 ClickHouse 这种
OLAP数据库,答案有什么不同?- 回答要点 1 (语法一致性):SQL 语法基本一致。ClickHouse 对窗口函数的支持也很好,解法 1 和解法 2 都可以直接运行。
- 回答要点 2 (性能和引擎差异):ClickHouse 的性能会比 MySQL 好几个数量级。
- 列式存储:ClickHouse 是列存数据库,对于只涉及
category_id,sales,product_id几列的分析查询,它只需读取这些列的数据,I/O 大幅减少。 - 向量化执行:它的查询引擎是向量化执行的,CPU 效率极高。
- 排序算法:其排序和聚合算法专为大规模数据设计。
- 列式存储:ClickHouse 是列存数据库,对于只涉及
- 回答要点 3 (特定函数):可以提及 ClickHouse 提供的一些特殊函数,例如
topK聚合函数,虽然不能直接解决“百分比”问题,但体现了你对OLAP数据库特性的了解。对于近似查询,ClickHouse 也有quantile等函数,可以非常高效地估算分位数。总之,在 ClickHouse 上,这个查询即使数据量巨大,也可能实现亚秒级响应,可以直接在线执行,无需复杂的离线预计算。