本页内容受版权保护 · 已添加水印 · 禁止任何形式转载
SQL 实战编程题(GitHub 高频题) · 时间序列与增长分析

用户首次下单到第二次下单的时间间隔,分桶统计。

Distribution of the time interval between a user's first and second orders.

答案语言

考察要点

这道题面试官想看你是否掌握窗口函数(特别是 ROW_NUMBER() 或 LEAD())进行复杂的用户行为序列分析。同时,考察你对时间函数(如 TIMESTAMPDIFF)的精确使用,以及使用 CASE 语句进行数据分桶的能力。最终,这体现了你从原始数据中提炼业务指标的综合 SQL 工程能力。

核心概念

本题的核心是将每个用户的订单按时间排序,并从中抽取出第一和第二个订单,计算其时间差,最后进行聚合统计。

  1. 窗口函数 (Window Functions):

    • 与 GROUP BY 这种将多行聚合成一行的函数不同,窗口函数为结果集的每一行计算一个值,这个值基于与当前行相关的“窗口”内的行。
    • ROW_NUMBER() OVER (PARTITION BY col1 ORDER BY col2): 是本题的关键。
      • PARTITION BY user_id: 将数据按 user_id 分成多个独立的窗口(分区)。
      • ORDER BY order_time: 在每个窗口内,按 order_time 对订单进行排序。
      • ROW_NUMBER(): 为排序后的每一行分配一个从 1 开始的连续整数。
    • LEAD(col, offset, default) OVER (...): 另一个高效的函数,可以获取当前行之后第 offset 行的 col 列的值。LEAD(order_time, 1) OVER (PARTITION BY user_id ORDER BY order_time)可以直接获取到下一次下单时间。
  2. 时间间隔计算:

    • TIMESTAMPDIFF(unit, start_datetime, end_datetime): MySQL 中用于计算两个日期时间差的函数,可以指定单位(如 SECOND, MINUTE, HOUR, DAY),比 DATEDIFF 更精确,因为 DATEDIFF 只比较日期部分。
    • 示例: TIMESTAMPDIFF(DAY, '2023-10-26 10:00:00', '2023-10-27 09:00:00') 结果是 0,而 TIMESTAMPDIFF(HOUR, ...) 结果是 23。本题场景下,按天计算间隔通常用 DAY。
  3. 数据分桶 (Bucketing):

    • 使用 CASE 表达式将连续的数值(如下单间隔天数)映射到离散的类别中。这是数据分析中常用的技巧。
    • SQL 示例:
      sql
      1CASE
      2 WHEN interval_days <= 1 THEN '1日内'
      3 WHEN interval_days > 1 AND interval_days <= 7 THEN '1-7日'
      4 WHEN interval_days > 7 AND interval_days <= 30 THEN '7-30日'
      5 ELSE '>30日'
      6END AS interval_bucket
  4. 公用表表达式 (CTE - Common Table Expression):

    • 使用 WITH 关键字定义一个临时的、命名的结果集。CTE 可以让复杂的查询逻辑更清晰、更易读,也方便复用。本题中,用 CTE 来存储排序后的订单是最佳实践。

多种解法

假设我们有订单表 orders:

order_iduser_idorder_time
11012023-01-01 10:00:00
21022023-01-02 11:00:00
31012023-01-03 12:00:00
41032023-01-04 13:00:00
51012023-01-15 14:00:00
61022023-02-10 15:00:00

期望输出:

interval_bucketuser_count
1-7日1
>30日1

解法 1:使用窗口函数 ROW_NUMBER() + 条件聚合

这是最通用和清晰的现代 SQL 解法。

  • 版本: ✅ MySQL 8.0+ / PostgreSQL / SQL Server / Oracle
  • SQL 代码:
    sql
    1WITH ranked_orders AS (
    2 -- 步骤1: 为每个用户的订单按时间排序
    3 SELECT
    4 user_id,
    5 order_time,
    6 ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time ASC) AS rn
    7 FROM
    8 orders
    9),
    10user_first_second_order AS (
    11 -- 步骤2: 提取每个用户的第1和第2个订单时间
    12 SELECT
    13 user_id,
    14 MAX(CASE WHEN rn = 1 THEN order_time END) AS first_order_time,
    15 MAX(CASE WHEN rn = 2 THEN order_time END) AS second_order_time
    16 FROM
    17 ranked_orders
    18 WHERE
    19 rn <= 2 -- 优化:只关心前两个订单
    20 GROUP BY
    21 user_id
    22)
    23-- 步骤3: 计算时间间隔,分桶并统计
    24SELECT
    25 CASE
    26 WHEN DATEDIFF(second_order_time, first_order_time) <= 1 THEN '1日内'
    27 WHEN DATEDIFF(second_order_time, first_order_time) <= 7 THEN '2-7日'
    28 WHEN DATEDIFF(second_order_time, first_order_time) <= 30 THEN '8-30日'
    29 ELSE '>30日'
    30 END AS interval_bucket,
    31 COUNT(user_id) AS user_count
    32FROM
    33 user_first_second_order
    34WHERE
    35 second_order_time IS NOT NULL -- 只统计有第二次下单的用户
    36GROUP BY
    37 interval_bucket;
  • 时间/空间复杂度: 窗口函数需要排序,时间复杂度为 O(Nlog⁡N)O(N \log N),其中 N 是订单总数。如果索引满足排序需求,则可以优化到 O(N)O(N)。空间复杂度为 O(N)O(N) 用于存储中间结果。
  • 索引依赖: 在 orders(user_id, order_time) 上建立复合索引至关重要。这能让 PARTITION BY 和 ORDER BY 操作极大地受益,避免全表扫描和内存中排序,甚至可能实现索引覆盖。

解法 2:使用窗口函数 LEAD()

此解法更简洁,直接在一次窗口计算中拿到下一次下单时间。

  • 版本: ✅ MySQL 8.0+ / PostgreSQL / SQL Server / Oracle
  • SQL 代码:
    sql
    1WITH order_with_next_time AS (
    2 -- 步骤1: 找出每个用户的首次下单及其对应的下一次下单时间
    3 SELECT
    4 user_id,
    5 order_time,
    6 -- 获取该用户按时间排序的下一条订单记录的时间
    7 LEAD(order_time, 1) OVER (PARTITION BY user_id ORDER BY order_time ASC) AS next_order_time,
    8 -- 使用 ROW_NUMBER 确保我们只处理首次下单
    9 ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time ASC) AS rn
    10 FROM
    11 orders
    12)
    13-- 步骤2: 筛选首次下单记录,计算间隔,分桶并统计
    14SELECT
    15 CASE
    16 WHEN DATEDIFF(next_order_time, order_time) <= 1 THEN '1日内'
    17 WHEN DATEDIFF(next_order_time, order_time) <= 7 THEN '2-7日'
    18 WHEN DATEDIFF(next_order_time, order_time) <= 30 THEN '8-30日'
    19 ELSE '>30日'
    20 END AS interval_bucket,
    21 COUNT(user_id) AS user_count
    22FROM
    23 order_with_next_time
    24WHERE
    25 rn = 1 AND next_order_time IS NOT NULL -- 必须是首次下单,且存在下一次下单
    26GROUP BY
    27 interval_bucket;
  • 时间/空间复杂度: 与解法 1 相同,主要开销在窗口函数的排序上,为 O(Nlog⁡N)O(N \log N)。
  • 索引依赖: 同样,orders(user_id, order_time) 上的复合索引是性能的关键。

解法 3:使用自连接(兼容老版本)

在不支持窗口函数的旧版 MySQL(如 5.7)中,可以使用自连接模拟。

  • 版本: ✅ 所有 SQL 版本
  • SQL 代码:
    sql
    1WITH first_orders AS (
    2 -- 步骤1: 找到每个用户的首次下单时间
    3 SELECT
    4 user_id,
    5 MIN(order_time) AS first_order_time
    6 FROM orders
    7 GROUP BY user_id
    8),
    9second_orders AS (
    10 -- 步骤2: 找到每个用户在首次下单之后的最早下单时间(即第二次下单)
    11 SELECT
    12 o.user_id,
    13 MIN(o.order_time) AS second_order_time
    14 FROM orders AS o
    15 JOIN first_orders AS fo
    16 ON o.user_id = fo.user_id AND o.order_time > fo.first_order_time
    17 GROUP BY o.user_id
    18)
    19-- 步骤3: 连接首次和二次下单信息,计算间隔,分桶并统计
    20SELECT
    21 CASE
    22 WHEN DATEDIFF(so.second_order_time, fo.first_order_time) <= 1 THEN '1日内'
    23 WHEN DATEDIFF(so.second_order_time, fo.first_order_time) <= 7 THEN '2-7日'
    24 WHEN DATEDIFF(so.second_order_time, fo.first_order_time) <= 30 THEN '8-30日'
    25 ELSE '>30日'
    26 END AS interval_bucket,
    27 COUNT(fo.user_id) AS user_count
    28FROM
    29 first_orders AS fo
    30JOIN
    31 second_orders AS so ON fo.user_id = so.user_id
    32GROUP BY
    33 interval_bucket;
  • 时间/空间复杂度: 性能通常劣于窗口函数。涉及多次聚合和连接,复杂度较高。在 orders(user_id, order_time) 索引良好的情况下,性能尚可接受,但数据量大时会明显变慢。
  • 索引依赖: orders(user_id, order_time) 索引对 GROUP BY 和 JOIN 条件 o.order_time > fo.first_order_time 的性能至关重要。

常见低分回答

  1. 使用相关子查询:在 SELECT 列表中用子查询查找第二次下单时间。这会导致对 orders 表的重复扫描,性能极差,是典型的 N+1 查询问题。
    sql
    1-- 反例:性能极差
    2SELECT ... FROM orders o1 WHERE ...
    3(SELECT MIN(o2.order_time) FROM orders o2 WHERE o2.user_id = o1.user_id AND o2.order_time > o1.order_time)
  2. 错误使用 DATEDIFF vs TIMESTAMPDIFF:DATEDIFF(date1, date2) 只比较日期部分,忽略时间。如果首次下单在 2023-01-01 23:59,第二次在 2023-01-02 00:01,DATEDIFF 结果为 1 天,但实际间隔仅 2 分钟。应使用 TIMESTAMPDIFF(unit, ...) 获取更精确的间隔。
  3. 忽略只有一次下单的用户:在计算间隔后直接进行统计,没有用 WHERE second_order_time IS NOT NULL 或等效逻辑进行过滤,会导致 NULL 值参与计算或分组,产生错误结果。
  4. 分桶逻辑边界不清:CASE 语句的条件重叠或有遗漏。例如,写成 WHEN interval <= 7 和 WHEN interval <= 30,会导致所有小于等于 7 的都被第一个条件捕获。正确的写法是使用 BETWEEN 或 > AND <= 明确区间。
  5. 对 GROUP BY 和窗口函数理解混淆:试图在 GROUP BY 的聚合结果上直接使用窗口函数,或者反之,不理解两者的执行顺序和作用范围。

工程实践与优化

  • 索引是生命线:对于 orders 表,必须建立 (user_id, order_time) 的复合索引。EXPLAIN 的结果中,key 字段应为该索引,Extra 字段不应出现 Using filesort 或 Using temporary。
  • 数据量级问题:当 orders 表达到亿级或更高时,即使有索引,实时计算也可能超时或对主库造成压力。
    • 解决方案:物化视图或预计算。创建一个用户粒度的聚合表 user_order_summary,包含 user_id, first_order_time, second_order_time 等字段。通过离线任务(如每日批处理)或流式处理(如 Flink/Spark Streaming)增量更新这张小得多的表。所有分析查询都基于这张聚合表,实现毫秒级响应。
  • 执行计划分析:使用 EXPLAIN (或 EXPLAIN ANALYZE in MySQL 8.0+) 查看查询计划。
    • 对于窗口函数解法,关注 Extra 列是否显示 Using window function。
    • 对于自连接解法,关注 JOIN 的 type 是否为 ref 或 eq_ref,而不是 ALL,并检查 key 是否用对了索引。
  • 避免大事务:如果此查询是某个更新流程的一部分,确保它不会锁定大量行或长时间运行,从而阻塞其他业务。分析查询应尽量在只读副本上执行。

常见追问与延伸题目

  1. 追问:如果数据量增长 100 倍,达到百亿级别,这条 SQL 性能怎样?怎么优化?

    • 回答要点 1 (瓶颈分析):百亿级别数据下,即使有索引,窗口函数或自连接的实时计算也会因为巨大的 I/O 和 CPU 开销而变得不可行。排序操作会消耗大量内存和临时磁盘空间,可能导致数据库实例OOM或响应极慢。
    • 回答要点 2 (架构级优化):必须放弃实时计算的思路,转向预计算+数据仓库的架构。
      • 数据ETL:将生产库(如 MySQL)的订单数据通过 CDC (Change Data Capture) 工具(如 Debezium, Canal)实时同步到数据湖(如 HDFS, S3)。
      • 批处理/流处理:使用 Spark、Flink 或 Hive 等大数据计算引擎,在数据仓库中进行离线计算。可以每日或每小时运行一个批处理作业,计算出所有用户的首次/二次下单间隔,并将结果存入一张轻量的结果表(如存在 ClickHouse, Doris, 或另一张 Hive 表中)。
      • 查询服务:业务方的查询请求直接访问这个预计算好的结果表,查询性能会是毫秒级。
  2. 追问:这个逻辑能扩展到计算“第二次下单到第三次下单”的间隔吗?或者任意第 N 次到第 N+1 次?

    • 回答要点 1 (窗口函数扩展性):是的,窗口函数解法有极好的扩展性。
      • 使用 ROW_NUMBER() 的解法:只需修改 CASE 语句,提取 rn = N 和 rn = N+1 的 order_time 即可。
      • 使用 LEAD() 的解法:LEAD 函数本身就是计算“下一个”的,所以逻辑几乎不变。要找到第 N 次下单到第 N+1 次的间隔,只需在 CTE 中筛选 WHERE rn = N 的记录即可。
    • 回答要点 2 (自连接的局限性):自连接方法在这种场景下会变得极其复杂和低效。计算 N 到 N+1 需要进行 N 次自连接或嵌套子查询,代码难以维护,性能会急剧下降。这凸显了窗口函数的优越性。
  3. 追问:如果换成 OLAP 数据库如 ClickHouse 或 BigQuery,答案有什么不同?

    • 回答要点 1 (SQL 语法):SQL 语法基本一致。ClickHouse 和 BigQuery 都支持标准的窗口函数,所以解法 1 和解法 2 的 SQL 代码几乎可以直接运行。
    • 回答要点 2 (性能与索引):核心区别在于底层架构和性能。
      • 列式存储:它们都是列式数据库,对于只涉及少数几列(user_id, order_time)的分析查询,I/O 会大大减少,因为不需要读取整行数据。
      • 无需传统索引:ClickHouse 有自己的排序键(ORDER BY 子句)和稀疏的一级索引,BigQuery 则通过其分布式架构和存储格式自动优化。你通常不需要像在 MySQL 中那样精细地创建和管理 B+ 树索引。
      • 执行效率:查询会利用大规模并行处理(MPP)能力,在多个节点上并行计算,即使是百亿级数据,也能在秒级到分钟级返回结果,无需预计算(对于Ad-hoc分析)。因此,在 OLAP 场景下,直接运行窗口函数 SQL 是标准做法。