实现一个简单的推荐:基于共同购买(co-purchase)求最相似商品 Top 5。
Implement a simple recommendation: Find the Top 5 most similar products based on co-purchase.
考察要点
这道题面试官想看你是否掌握复杂查询的构建能力、窗口函数的使用以及SQL 性能优化的思维。具体来说,考察点包括:
- 自连接(Self-Join):通过将一张表与自身连接,来处理表内实体之间的关系,这是解决“共同”类问题的核心。
- 分组聚合(
GROUP BY):用于计算共现次数(co-occurrence),即相似度得分。 - 窗口函数(Window Functions):特别是
ROW_NUMBER()或RANK(),用于在每个商品的分组内进行排序,并筛选出 Top N。 - SQL 优化意识:能否意识到自连接在大数据量下的性能瓶颈,并提出索引、预计算等优化策略。
核心概念
本题的核心是计算商品之间的“相似度”,并按相似度对每个商品进行推荐。我们首先需要定义相似度。
1. 定义相似度:共购次数 最直观的相似度是“共同购买的用户数”。如果商品 A 和商品 B 同时被 100 个不同的用户购买过,我们就认为它们的相似度得分为 100。
2. 数据模型
假设我们有一张订单详情表 order_items。为了简化问题,我们只关心哪个用户购买了哪个商品,可以抽象为一张 purchases 表。
-
purchases表结构sql1CREATE TABLE purchases (2 user_id INT,3 item_id VARCHAR(20),4 -- 为了简化,假设 user_id 和 item_id 组合唯一5 PRIMARY KEY (user_id, item_id)6); -
示例数据 | user_id | item_id | | :------ | :------ | | 1 | A | | 1 | B | | 1 | C | | 2 | A | | 2 | C | | 3 | B | | 3 | D |
3. 核心思路:自连接 + 分组计数
要找出共同购买的商品对,需要对 purchases 表进行自连接。
1graph TD2 subgraph user_id = 13 A --- B4 A --- C5 B --- C6 end7 subgraph user_id = 28 A --- C9 end10 subgraph user_id = 311 B --- D12 end
通过 ON p1.user_id = p2.user_id,我们可以找到同一用户购买的所有商品对。
例如,对于 user_id = 1,我们会得到 (A,B), (A,C), (B,C) 以及它们的翻转对和自身对。
关键过滤条件:p1.item_id < p2.item_id
这个条件非常重要,它有三个作用:
- 去重:避免同时计算
(A, B)和(B, A),使计算量减半。 - 去自反:避免
(A, A)这种无意义的对自己推荐。 - 性能:减少了中间结果集的大小。
之后,通过 GROUP BY p1.item_id, p2.item_id 并使用 COUNT(DISTINCT p1.user_id) 即可计算出每对商品 (item1, item2) 的共购用户数。
4. 核心工具:窗口函数 ROW_NUMBER()
为了给每个商品找出 Top 5 相似商品,我们需要对相似度得分进行分组排名。
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY similarity DESC)
PARTITION BY item_id:将数据按商品 ID 分成多个“窗口”,在每个窗口内独立排名。ORDER BY similarity DESC:在每个窗口内,按相似度得分从高到低排序。ROW_NUMBER():为排好序的每一行分配一个从 1 开始的连续排名。
多种解法
假设有 purchases(user_id, item_id) 表。
解法 1:使用窗口函数(推荐,主流方案)
这种解法思路清晰,利用现代 SQL 特性,是目前最优雅、最高效的纯 SQL 解法。
-
版本:✅ MySQL 8.0+ / PostgreSQL / Oracle / SQL Server;❌ MySQL 5.7 不支持窗口函数。
-
思路:
- 自连接
purchases表,使用p1.item_id < p2.item_id技巧找出无重复的商品对。 - 按商品对分组,计算共购用户数(
similarity)。 - 由于步骤 1 的结果是不对称的(只有
(A,B)没有(B,A)),使用UNION ALL将其扩展为完整的相似度矩阵。 - 使用
ROW_NUMBER()窗口函数对每个商品的相似商品进行排名。 - 筛选出排名前 5 的结果。
- 自连接
-
SQL 实现:
sql1WITH2-- 步骤1 & 2: 计算非对称的商品共购对及次数3CoPurchasePairs AS (4 SELECT5 p1.item_id AS item1,6 p2.item_id AS item2,7 COUNT(DISTINCT p1.user_id) AS similarity8 FROM9 purchases p110 JOIN11 purchases p2 ON p1.user_id = p2.user_id AND p1.item_id < p2.item_id12 GROUP BY13 p1.item_id, p2.item_id14),15-- 步骤3: 构建完整的对称相似度矩阵16FullSimilarity AS (17 SELECT item1, item2, similarity FROM CoPurchasePairs18 UNION ALL19 SELECT item2 AS item1, item1 AS item2, similarity FROM CoPurchasePairs20),21-- 步骤4: 使用窗口函数进行分组排名22RankedSimilarity AS (23 SELECT24 item1,25 item2,26 similarity,27 ROW_NUMBER() OVER (PARTITION BY item1 ORDER BY similarity DESC, item2 ASC) as rnk -- item2 ASC 保证排序稳定性28 FROM29 FullSimilarity30)31-- 步骤5: 筛选出每个商品的 Top 532SELECT33 item1 AS item_id,34 item2 AS similar_item_id,35 similarity36FROM37 RankedSimilarity38WHERE39 rnk <= 540ORDER BY41 item_id, similarity DESC; -
复杂度:
- 时间复杂度:主要取决于自连接和分组聚合。如果
purchases表有 N 行,每个用户平均购买 K 个商品,自连接产生的中间行数约为 。后续排序复杂度为 ,其中 M 是商品对的数量。这是一个计算密集型操作。 - 空间复杂度:需要存储
CTE的中间结果,空间消耗较大。
- 时间复杂度:主要取决于自连接和分组聚合。如果
-
索引依赖:在
purchases表上建立(user_id, item_id)的复合索引至关重要,可以极大地加速JOIN操作。
解法 2:使用 LATERAL JOIN(更符合直觉的现代方案)
LATERAL JOIN (在 SQL Server 中称为 APPLY) 可以为外部查询的每一行执行一次子查询,非常适合 Top-N per group 的问题。
-
版本:✅ MySQL 8.0.14+ / PostgreSQL 9.3+;❌ MySQL 5.7 及早期版本不支持。
-
思路:
- 从
purchases表中获取所有不重复的商品列表。 - 对于列表中的每一个商品
i,使用LATERALJOIN执行一个子查询。 - 子查询计算商品
i与其他所有商品的共购次数,并直接ORDER BY ... LIMIT 5取出前 5 名。
- 从
-
SQL 实现:
sql1SELECT2 items.item_id,3 similar_items.similar_item_id,4 similar_items.similarity5FROM6 (SELECT DISTINCT item_id FROM purchases) AS items7CROSS JOIN LATERAL (8 SELECT9 p2.item_id AS similar_item_id,10 COUNT(DISTINCT p1.user_id) AS similarity11 FROM12 purchases p113 JOIN14 purchases p2 ON p1.user_id = p2.user_id15 WHERE16 p1.item_id = items.item_id -- 关联外部查询的当前商品17 AND p2.item_id != items.item_id18 GROUP BY19 p2.item_id20 ORDER BY21 similarity DESC, p2.item_id ASC22 LIMIT 523) AS similar_items24ORDER BY25 items.item_id, similar_items.similarity DESC; -
复杂度:
- 时间复杂度:对于 I 个独立商品,会执行 I 次子查询。如果数据库优化器不能有效处理,其总成本可能高于解法 1。但其逻辑清晰,且避免了
UNION ALL和显式的窗口函数。 - 空间复杂度:中间结果集较小,因为
LIMIT 5在子查询内部就完成了筛选。
- 时间复杂度:对于 I 个独立商品,会执行 I 次子查询。如果数据库优化器不能有效处理,其总成本可能高于解法 1。但其逻辑清晰,且避免了
-
索引依赖:需要
(item_id, user_id)和(user_id, item_id)两个方向的索引来优化内外两个表的连接。
解法 3:兼容老版本(MySQL 5.7)的模拟排名
在没有窗口函数的旧版本 MySQL 中,通常使用用户变量或自连接来模拟排名,但这些方法复杂、易错且性能较差。
- 版本:✅ MySQL 5.7 (不推荐)
- 思路:使用用户变量。先将所有相似度计算出来并排序,然后在扫描过程中用变量来模拟
ROW_NUMBER()的分区和计数。 - SQL 实现:
sql1-- 步骤1和2与解法1相同,先创建完整的相似度矩阵(这里用临时表)2CREATE TEMPORARY TABLE FullSimilarity AS3SELECT item1, item2, similarity FROM (4 SELECT p1.item_id AS item1, p2.item_id AS item2, COUNT(DISTINCT p1.user_id) AS similarity5 FROM purchases p1 JOIN purchases p2 ON p1.user_id = p2.user_id AND p1.item_id < p2.item_id6 GROUP BY p1.item_id, p2.item_id7) AS CoPurchasePairs8UNION ALL9SELECT item2, item1, similarity FROM (10 SELECT p1.item_id AS item1, p2.item_id AS item2, COUNT(DISTINCT p1.user_id) AS similarity11 FROM purchases p1 JOIN purchases p2 ON p1.user_id = p2.user_id AND p1.item_id < p2.item_id12 GROUP BY p1.item_id, p2.item_id13) AS CoPurchasePairs2;1415-- 步骤3: 使用用户变量模拟 ROW_NUMBER()16SELECT17 item_id,18 similar_item_id,19 similarity20FROM (21 SELECT22 item1 AS item_id,23 item2 AS similar_item_id,24 similarity,25 @rn := IF(@current_item = item1, @rn + 1, 1) AS rnk,26 @current_item := item127 FROM28 (SELECT @rn := 0, @current_item := '') AS init,29 FullSimilarity30 ORDER BY31 item1, similarity DESC, item2 ASC32) AS ranked33WHERE34 rnk <= 5;
- 注意:这种用户变量的用法在 MySQL 8.0 中已被官方声明为不推荐,其执行顺序无法保证,可能在未来版本失效。面试中可以提及此法作为历史方案,但强调其风险和局限性。
常见低分回答
- 忘记排除自身和重复对:
JOIN purchases p2 ON p1.user_id = p2.user_id。这会导致一个商品与自身匹配,并且(A,B)和(B,A)被重复计算,结果不准且效率低下。 - 全局 Top 5 而非分组 Top 5:在计算完所有商品对的相似度后,直接
ORDER BY similarity DESC LIMIT 5。这只会返回全局最相似的 5 对商品,而不是“为每个商品”推荐。 - 错误使用
COUNT:使用COUNT(*)而不是COUNT(DISTINCT user_id)。如果一个用户在一次会话中多次购买同一商品(例如,purchases表记录的是订单行项目),COUNT(*)会夸大共购关系。COUNT(DISTINCT user_id)才准确反映了共购的用户基数。 - 在
WHERE子句中使用HAVING的逻辑:写出... GROUP BY item1, item2 HAVING rnk <= 5。这是错误的,因为HAVING在窗口函数执行之前进行过滤,rnk此时还不存在。必须在子查询或CTE中生成排名列,然后在外部查询的WHERE子句中过滤。 - 对 NULL 的处理不当:如果
user_id或item_id可能为 NULL,JOIN和GROUP BY的行为会受影响。虽然本题场景下通常不为 NULL,但在其他问题中忽略 NULL 是常见错误。
工程实践与优化
- 性能瓶颈:核心瓶颈在于
purchases表的自连接。如果purchases表有数十亿行,这个 SQL 查询在生产数据库上是不可行的,它会消耗大量 CPU 和 IO,甚至锁住表,导致数据库崩溃。 - 解决方案:离线计算 + 服务化
- 批处理系统:这种计算密集型任务应使用离线大数据处理框架(如 Spark、Flink 或 Hive)来完成。这是一个经典的 MapReduce 问题(WordCount 的变种:ItemPairCount)。计算逻辑与 SQL 类似,但可以水平扩展到上百台机器。
- 预计算与物化:每天或每小时运行一次批处理任务,将计算出的 Top 5 相似商品结果存储到一个专门的结果表(物化视图)中,例如
item_similarity(item_id, similar_item_id, rank, score)。 - 在线服务:线上推荐服务直接查询这个预计算好的结果表,查询非常简单快速:
SELECT similar_item_id, score FROM item_similarity WHERE item_id = ? ORDER BY rank。这能做到毫秒级响应。
- 执行计划分析 (
EXPLAIN)- 对于解法 1,关注
EXPLAIN的输出。JOIN的type应该是ref(使用索引),key应该指向(user_id, item_id)索引。Extra字段如果出现Using temporary; Using filesort,说明中间结果集很大,需要磁盘临时表和文件排序,是严重的性能问题。
- 对于解法 1,关注
- 索引策略
- 必须在
purchases(user_id, item_id)上创建复合索引。user_id在前,因为它是JOIN的条件,可以快速定位到同一用户的所有购买记录。
- 必须在
- 增量计算
- 对于超大规模数据,每次都全量计算成本高昂。可以考虑增量计算:只计算新产生的购买数据带来的相似度变化,并更新到全量结果中。这需要更复杂的算法和架构。
常见追问与延伸题目
-
追问:如果
purchases表数据量增长 100 倍,达到百亿级别,这条 SQL 会怎么样?你如何设计架构来解决这个问题?- 回答要点 1 (SQL 的末日):直接表明这条 SQL 将无法在任何单机关系型数据库上执行。它会因为巨大的中间结果集(百亿 x 百亿级别)耗尽内存、磁盘空间和 CPU 时间,并可能导致数据库长时间无响应。
- 回答要点 2 (架构设计):
- 数据流:将用户行为日志(购买记录)实时采集到消息队列(如 Kafka)。
- 离线计算:使用分布式计算框架(如 Spark)消费 Kafka 数据。编写 Spark 作业实现相同的自连接、分组、排序逻辑。Spark 的分布式内存计算和数据分区能力可以轻松处理百亿级数据。
- 结果存储:将计算出的 Top-N 相似度结果存储在高性能的 KV 存储(如 Redis)或列式存储(如 HBase/Cassandra)中,键为
item_id,值为一个包含 Top-N 相似商品列表的 JSON 或 protobuf 对象。 - 在线查询:推荐服务通过 API 查询 Redis/HBase,根据
item_id在 时间内获取推荐列表,实现低延迟响应。 - 调度:整个离线计算流程由 Airflow 或 Azkaban 等工作流调度系统按天或按小时调度执行。
-
追问:除了共购次数,还有哪些更高级的相似度度量?如何用 SQL 实现?
- 回答要点 1 (Jaccard 相似度):Jaccard 相似度 = (A和B的交集) / (A和B的并集)。在我们的场景里,即
COUNT(同时购买A和B的用户) / COUNT(购买过A或购买过B的用户)。它能惩罚热门商品,因为热门商品的并集分母会很大。 - 回答要点 2 (SQL 实现 Jaccard):需要额外计算每个商品的总购买用户数。
sql1-- 步骤1: 计算每个商品被多少独立用户购买2WITH ItemUserCounts AS (3 SELECT item_id, COUNT(DISTINCT user_id) AS user_count4 FROM purchases5 GROUP BY item_id6),7-- 步骤2: 计算共购次数 (同解法1)8CoPurchasePairs AS (9 SELECT p1.item_id AS item1, p2.item_id AS item2, COUNT(DISTINCT p1.user_id) AS intersection_count10 FROM purchases p1 JOIN purchases p2 ON p1.user_id = p2.user_id AND p1.item_id < p2.item_id11 GROUP BY p1.item_id, p2.item_id12)13-- 步骤3: 合并计算 Jaccard14SELECT15 cp.item1,16 cp.item2,17 cp.intersection_count / (iuc1.user_count + iuc2.user_count - cp.intersection_count) AS jaccard_similarity18FROM CoPurchasePairs cp19JOIN ItemUserCounts iuc1 ON cp.item1 = iuc1.item_id20JOIN ItemUserCounts iuc2 ON cp.item2 = iuc2.item_id;21-- 后续的排序和筛选逻辑与之前类似
- 回答要点 3 (余弦相似度):可以提及更复杂的余弦相似度,将用户向量化,但这通常很难用纯 SQL 高效实现,更适合在 Spark 中使用 MLlib 等库来计算。
- 回答要点 1 (Jaccard 相似度):Jaccard 相似度 = (A和B的交集) / (A和B的并集)。在我们的场景里,即
-
追问:如果我把
p1.item_id < p2.item_id改成p1.item_id != p2.item_id,会对查询有什么影响?- 回答要点 1 (结果正确性):结果在逻辑上仍然可以做对,但计算过程会变得低效。
!=会同时产生(A, B)和(B, A)两个组合。 - 回答要点 2 (性能影响):
- 计算量加倍:
GROUP BY需要处理的商品对数量会翻倍,导致聚合计算的开销增大。 - 需要额外处理:在后续步骤中,你得到的相似度矩阵已经是对等的,无需再做
UNION ALL。但前期的计算浪费是实实在在的。 - 最终结论:
p1.item_id < p2.item_id是一个非常关键的优化,它利用对称性将计算量减半,是衡量候选人是否细心和具备优化思维的重要标志。而!=虽然直观,但不是最优实践。
- 计算量加倍:
- 回答要点 1 (结果正确性):结果在逻辑上仍然可以做对,但计算过程会变得低效。