SQL 实战编程题(GitHub 高频题) · 时间序列与增长分析
用户首次下单到第二次下单的时间间隔,分桶统计。
Distribution of the time interval between a user's first and second orders.
答案语言
考察要点
这道题面试官想看你是否掌握窗口函数(特别是 ROW_NUMBER() 或 LEAD())进行复杂的用户行为序列分析。同时,考察你对时间函数(如 TIMESTAMPDIFF)的精确使用,以及使用 CASE 语句进行数据分桶的能力。最终,这体现了你从原始数据中提炼业务指标的综合 SQL 工程能力。
核心概念
本题的核心是将每个用户的订单按时间排序,并从中抽取出第一和第二个订单,计算其时间差,最后进行聚合统计。
-
窗口函数 (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)可以直接获取到下一次下单时间。
- 与
-
时间间隔计算:
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。
-
数据分桶 (Bucketing):
- 使用
CASE表达式将连续的数值(如下单间隔天数)映射到离散的类别中。这是数据分析中常用的技巧。 - SQL 示例:
sql1CASE2 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
- 使用
-
公用表表达式 (
CTE- Common Table Expression):- 使用
WITH关键字定义一个临时的、命名的结果集。CTE可以让复杂的查询逻辑更清晰、更易读,也方便复用。本题中,用CTE来存储排序后的订单是最佳实践。
- 使用
多种解法
假设我们有订单表 orders:
| order_id | user_id | order_time |
|---|---|---|
| 1 | 101 | 2023-01-01 10:00:00 |
| 2 | 102 | 2023-01-02 11:00:00 |
| 3 | 101 | 2023-01-03 12:00:00 |
| 4 | 103 | 2023-01-04 13:00:00 |
| 5 | 101 | 2023-01-15 14:00:00 |
| 6 | 102 | 2023-02-10 15:00:00 |
期望输出:
| interval_bucket | user_count |
|---|---|
| 1-7日 | 1 |
| >30日 | 1 |
解法 1:使用窗口函数 ROW_NUMBER() + 条件聚合
这是最通用和清晰的现代 SQL 解法。
- 版本: ✅ MySQL 8.0+ / PostgreSQL / SQL Server / Oracle
- SQL 代码:
sql1WITH ranked_orders AS (2 -- 步骤1: 为每个用户的订单按时间排序3 SELECT4 user_id,5 order_time,6 ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time ASC) AS rn7 FROM8 orders9),10user_first_second_order AS (11 -- 步骤2: 提取每个用户的第1和第2个订单时间12 SELECT13 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_time16 FROM17 ranked_orders18 WHERE19 rn <= 2 -- 优化:只关心前两个订单20 GROUP BY21 user_id22)23-- 步骤3: 计算时间间隔,分桶并统计24SELECT25 CASE26 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_count32FROM33 user_first_second_order34WHERE35 second_order_time IS NOT NULL -- 只统计有第二次下单的用户36GROUP BY37 interval_bucket;
- 时间/空间复杂度: 窗口函数需要排序,时间复杂度为 ,其中 N 是订单总数。如果索引满足排序需求,则可以优化到 。空间复杂度为 用于存储中间结果。
- 索引依赖: 在
orders(user_id, order_time)上建立复合索引至关重要。这能让PARTITION BY和ORDER BY操作极大地受益,避免全表扫描和内存中排序,甚至可能实现索引覆盖。
解法 2:使用窗口函数 LEAD()
此解法更简洁,直接在一次窗口计算中拿到下一次下单时间。
- 版本: ✅ MySQL 8.0+ / PostgreSQL / SQL Server / Oracle
- SQL 代码:
sql1WITH order_with_next_time AS (2 -- 步骤1: 找出每个用户的首次下单及其对应的下一次下单时间3 SELECT4 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 rn10 FROM11 orders12)13-- 步骤2: 筛选首次下单记录,计算间隔,分桶并统计14SELECT15 CASE16 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_count22FROM23 order_with_next_time24WHERE25 rn = 1 AND next_order_time IS NOT NULL -- 必须是首次下单,且存在下一次下单26GROUP BY27 interval_bucket;
- 时间/空间复杂度: 与解法 1 相同,主要开销在窗口函数的排序上,为 。
- 索引依赖: 同样,
orders(user_id, order_time)上的复合索引是性能的关键。
解法 3:使用自连接(兼容老版本)
在不支持窗口函数的旧版 MySQL(如 5.7)中,可以使用自连接模拟。
- 版本: ✅ 所有 SQL 版本
- SQL 代码:
sql1WITH first_orders AS (2 -- 步骤1: 找到每个用户的首次下单时间3 SELECT4 user_id,5 MIN(order_time) AS first_order_time6 FROM orders7 GROUP BY user_id8),9second_orders AS (10 -- 步骤2: 找到每个用户在首次下单之后的最早下单时间(即第二次下单)11 SELECT12 o.user_id,13 MIN(o.order_time) AS second_order_time14 FROM orders AS o15 JOIN first_orders AS fo16 ON o.user_id = fo.user_id AND o.order_time > fo.first_order_time17 GROUP BY o.user_id18)19-- 步骤3: 连接首次和二次下单信息,计算间隔,分桶并统计20SELECT21 CASE22 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_count28FROM29 first_orders AS fo30JOIN31 second_orders AS so ON fo.user_id = so.user_id32GROUP BY33 interval_bucket;
- 时间/空间复杂度: 性能通常劣于窗口函数。涉及多次聚合和连接,复杂度较高。在
orders(user_id, order_time)索引良好的情况下,性能尚可接受,但数据量大时会明显变慢。 - 索引依赖:
orders(user_id, order_time)索引对GROUP BY和JOIN条件o.order_time > fo.first_order_time的性能至关重要。
常见低分回答
- 使用相关子查询:在
SELECT列表中用子查询查找第二次下单时间。这会导致对orders表的重复扫描,性能极差,是典型的 N+1 查询问题。sql1-- 反例:性能极差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) - 错误使用
DATEDIFFvsTIMESTAMPDIFF:DATEDIFF(date1, date2)只比较日期部分,忽略时间。如果首次下单在2023-01-01 23:59,第二次在2023-01-02 00:01,DATEDIFF结果为 1 天,但实际间隔仅 2 分钟。应使用TIMESTAMPDIFF(unit, ...)获取更精确的间隔。 - 忽略只有一次下单的用户:在计算间隔后直接进行统计,没有用
WHERE second_order_time IS NOT NULL或等效逻辑进行过滤,会导致NULL值参与计算或分组,产生错误结果。 - 分桶逻辑边界不清:
CASE语句的条件重叠或有遗漏。例如,写成WHEN interval <= 7和WHEN interval <= 30,会导致所有小于等于 7 的都被第一个条件捕获。正确的写法是使用BETWEEN或> AND <=明确区间。 - 对
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 ANALYZEin MySQL 8.0+) 查看查询计划。- 对于窗口函数解法,关注
Extra列是否显示Using window function。 - 对于自连接解法,关注
JOIN的type是否为ref或eq_ref,而不是ALL,并检查key是否用对了索引。
- 对于窗口函数解法,关注
- 避免大事务:如果此查询是某个更新流程的一部分,确保它不会锁定大量行或长时间运行,从而阻塞其他业务。分析查询应尽量在只读副本上执行。
常见追问与延伸题目
-
追问:如果数据量增长 100 倍,达到百亿级别,这条 SQL 性能怎样?怎么优化?
- 回答要点 1 (瓶颈分析):百亿级别数据下,即使有索引,窗口函数或自连接的实时计算也会因为巨大的 I/O 和 CPU 开销而变得不可行。排序操作会消耗大量内存和临时磁盘空间,可能导致数据库实例OOM或响应极慢。
- 回答要点 2 (架构级优化):必须放弃实时计算的思路,转向预计算+数据仓库的架构。
- 数据ETL:将生产库(如 MySQL)的订单数据通过 CDC (Change Data Capture) 工具(如 Debezium, Canal)实时同步到数据湖(如 HDFS, S3)。
- 批处理/流处理:使用 Spark、Flink 或 Hive 等大数据计算引擎,在数据仓库中进行离线计算。可以每日或每小时运行一个批处理作业,计算出所有用户的首次/二次下单间隔,并将结果存入一张轻量的结果表(如存在 ClickHouse, Doris, 或另一张 Hive 表中)。
- 查询服务:业务方的查询请求直接访问这个预计算好的结果表,查询性能会是毫秒级。
-
追问:这个逻辑能扩展到计算“第二次下单到第三次下单”的间隔吗?或者任意第 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 次自连接或嵌套子查询,代码难以维护,性能会急剧下降。这凸显了窗口函数的优越性。
- 回答要点 1 (窗口函数扩展性):是的,窗口函数解法有极好的扩展性。
-
追问:如果换成
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 是标准做法。
- 列式存储:它们都是列式数据库,对于只涉及少数几列(