Fullstack · 第二轮 System Design 后的 SQL 追问

Salesforce SQL:找从未消费过的用户

Salesforce 咖啡点单面经另问从未消费过的用户;本文用 NOT EXISTS 反连接定义 paid 订单缺失,并核对取消单、重复订单与 NULL 边界。

题目重点:Salesforce 咖啡点单面经另问从未消费过的用户;本文用 NOT EXISTS 反连接定义 paid 订单缺失,并核对取消单、重复订单与 NULL 边界。

题意与假设

这道练习题保留了面经中值得训练的核心约束,并将需要澄清的条件单独列出。现场拿到变体时,先和面试官确认这些条件。

  • 沿用 users(user_id,name) 与 orders(order_id,user_id,status,paid_at_utc,net_amount_cents);训练版把‘消费过’定义为至少有一条 status='paid' 的订单,不限定时间。
  • 只有 cancelled、pending 或购物车记录的用户仍算从未消费;已支付后来退款是否仍算消费过需要面试官裁定,本版按历史 paid 事实算已消费。
  • users.user_id 是非空主键,orders.user_id 可有索引;孤儿订单不应存在,若存在也不影响遍历 users 的结果。

解题路线

  1. 从 users 出发,对每个用户判断是否不存在符合 status='paid' 的订单。NOT EXISTS 是反半连接,找到第一条匹配即可判定排除,不会因用户有多条订单而复制结果。
  2. 不要只对 orders 做 GROUP BY,因为没有任何订单的用户根本不会出现在 orders 中;也不要用 `NOT IN (SELECT user_id...)` 而不核对 NULL 语义,子查询中出现 NULL 可导致意外的 UNKNOWN 结果。
  3. 若产品把净额为正才算消费,可在子查询补 net_amount_cents>0;若退款后要恢复‘未消费’状态,则应先建立支付与退款净额历史口径,本题题目没有足够信息自动选择。

手动走一遍

训练例:用户 A 无订单,B 只有 cancelled,C 有一条 paid,D 有 paid 后退款。按本版‘历史曾 paid’定义,返回 A、B;C、D 不返回。若需求改为当前净消费>0,D 的归类可能改变,需明确退款模型。

SQL 参考实现

下面的代码按上文假设实现,适合本地运行并追加自己的测试用例。

代码区可横向滚动;使用键盘时,先聚焦代码区再按方向键查看。

SELECT u.user_id, u.name
FROM users AS u
WHERE NOT EXISTS (
  SELECT 1
  FROM orders AS o
  WHERE o.user_id = u.user_id
    AND o.status = 'paid'
)
ORDER BY u.user_id;

复杂度

U 为用户数、O 为订单数。若 orders 上有 (user_id,status) 索引,数据库可对每用户做有界存在性查询,成本取决于索引选择与分布;无索引的朴素相关子查询可能接近 O(UO)。数据库也可能用 hash anti-join 将订单扫描一次再检查用户,空间约与有 paid 订单的用户数有关。以 EXPLAIN 验证真实计划。

易错点与追问

容易失分的边界

  • 从 orders 表起步,漏掉完全没有订单的用户。
  • 把 cancelled/pending 订单算成已消费,造成误排除。
  • 用 LEFT JOIN 后把 status 过滤放在 WHERE,破坏外连接语义。
  • 用 NOT IN 但忽略 nullable user_id 的三值逻辑陷阱。
  • 把‘从未消费’误写为‘上个月未消费’,时间范围与第一道 SQL 混用。

继续深挖

  • 另一篇全栈面经问‘从未下过单’,比‘从未消费’更严格:移除 NOT EXISTS 子查询中的 status='paid' 条件,任何取消或未支付订单也会把该用户排除;先确认业务到底要哪种口径。
  • 若要找最近 90 天未消费用户,在子查询加入半开时间范围,并明确时间按支付完成还是下单。
  • 若用户在不同租户可复用 ID,反连接必须同时匹配 tenant_id,否则跨租户订单会误判。
  • 若‘消费’定义为累计净额>0,需按用户聚合支付与退款,再做条件筛选,NOT EXISTS 单条 paid 不再足够。
✓已复制微信号!