订阅更新

SQL 怎么找出买齐所有指定商品的用户?

面试问 252 约 4 分钟读完

本文目录
  1. 面试怎么回答
  2. 先把“买过”定义清楚
  3. 写法一:命中种类数等于目标种类数
  4. 用四个用户检查,就能看出区别
  5. 写法二:不存在“他没买到的目标”
  6. 一个典型场景:重复购买拿到了资格
  7. 目标为空,是最容易漏掉的边界
  8. 参考资料

面试怎么回答

这是集合包含,也叫关系除法。先明确目标商品集合和有效购买口径,再只统计命中的不同目标商品,数量等于目标集合大小才算买齐。买了额外商品不影响包含关系,重复购买不能增加种类数。另一种写法是双重 NOT EXISTS,表示不存在用户没买到的目标商品。目标为空时,要从约定的用户全集和业务规则出发处理。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

运营要找一批用户:指定的 A、B、C 三种商品,他们必须全部买过,买过其他商品也没关系。这道题很像 GROUP BY 加 COUNT,但“买了三次”和“买齐三种”之间,差着一整个集合判断。

买齐全部是集合包含问题:统计命中的不同目标商品,或证明不存在尚未买到的目标。

先把“买过”定义清楚

是历史上支付成功过,还是活动期内购买?发生退款后还算不算?同一商品买多个规格,按 SPU、SKU 还是活动商品编码判断?这些不是写完 SQL 后补充的小细节,而是输入集合的定义。

下面约定 target_products 是活动指定商品,valid_purchases 是按统一时间、支付和退款规则清洗后的有效购买记录,商品标识均非空。题目要求“包含全部目标”,不要求用户只买这些商品。

写法一:命中种类数等于目标种类数

先把目标集合去重,再限定购买记录只能统计目标商品。下面使用通用 SQL 写法,适用于 PostgreSQL 17:

WITH target AS (
  SELECT DISTINCT product_id
  FROM target_products
  WHERE product_id IS NOT NULL
), matched AS (
  SELECT p.user_id, p.product_id
  FROM valid_purchases p
  JOIN target t ON p.product_id = t.product_id
)
SELECT user_id
FROM matched
GROUP BY user_id
HAVING COUNT(DISTINCT product_id) = (
  SELECT COUNT(*) FROM target
);

这里的 DISTINCT 不是随手加的保险,而是把购买明细转换为用户已经覆盖的商品种类。买十次 A 仍然只覆盖 A,买了 D 也不应该增加对 A、B、C 的覆盖数量。

目标集合也要去重,否则活动配置重复一条 B,目标数变成四,而用户最多只能命中三种。生产里非空商品标识应作为质量约束,不能静默过滤了配置错误还宣称活动规则正确。

用四个用户检查,就能看出区别

用户 有效购买记录 是否买齐 A、B、C
U1 A、A、A 否,缺 B、C
U2 A、B、C 是
U3 A、B、C、D 是,额外购买不影响
U4 A、B 否,缺 C

如果用 COUNT(*) = 3,U1 会被误选;如果统计用户买过的所有不同商品,再要求等于三,U3 又会被错删。一个是把次数当种类,一个是忘了先限制目标范围。

写法二:不存在“他没买到的目标”

另一种表达是双重 NOT EXISTS。candidate_users 为约定的候选用户全集,且一人一行;target 与上一段一样,是去重后的非空目标集合:

WITH target AS (
  SELECT DISTINCT product_id
  FROM target_products
  WHERE product_id IS NOT NULL
)
SELECT u.user_id
FROM candidate_users u
WHERE NOT EXISTS (
  SELECT 1
  FROM target t
  WHERE NOT EXISTS (
    SELECT 1
    FROM valid_purchases p
    WHERE p.user_id = u.user_id
      AND p.product_id = t.product_id
  )
);

从内往外读:找不到购买记录的目标商品,就是用户缺少的商品;如果连这样的目标都不存在,才算买齐。这通常称为关系除法问题,不是要求写一个名叫 DIVIDE 的函数。

两种表达谁更快不能凭语法判断,要看优化器改写、目标集合大小、数据分布和执行计划。目标很小时,先筛购买范围再聚合往往容易控制数据量,但仍需实际验证。

一个典型场景:重复购买拿到了资格

比如活动资格任务只用购买行数等于目标数。一位用户连续购买三次 A,也被发进“集齐三件”名单。最初可能怀疑活动配置串了,检查配置却发现 A、B、C 都没错。

问题出在统计单位:SQL 数的是购买记录,不是命中的商品种类。此前测试每个用户每种商品只有一行,恰好掩盖错误。修改时应把重复购买、额外商品、目标重复和退款后失效纳入测试,并保留缺失商品名单,便于解释资格为什么不成立。

目标为空,是最容易漏掉的边界

如果没有任何目标,双重 NOT EXISTS 会返回候选用户全集,因为不存在未满足的目标;第一种聚合写法则因没有 matched 行而不返回用户。两者在这个边界上不等价。

数学上的空集包含关系,不应替活动系统做业务决策。可以约定空目标视为配置错误并阻断,也可以明确所有候选人满足;但不能默默采用某一种 SQL 的偶然输出。

参考资料

刷题进度保存在当前浏览器