本页内容受版权保护 · 已添加水印 · 禁止任何形式转载
SQL 实战编程题(GitHub 高频题) · 复杂业务分析

求每条会话的页面停留时长(lead 函数计算下一条事件时间)。

Calculate the page view duration for each session, using the LEAD function to get the timestamp of the

答案语言

考察要点

这道题面试官想看你是否精通 窗口函数(Window Functions),特别是 LEAD() 函数的用法。核心考察点在于:

  1. 数据分区与排序:你是否能正确使用 PARTITION BY 将数据按会话(session)分组,并用 ORDER BY 在每个会话内按时间排序。
  2. 行间计算能力:你是否理解如何使用 LEAD() 或其他方法(如自连接)来获取 "下一行" 的数据,以完成有时序依赖的计算。
  3. SQL 性能意识:你是否知道不同解法(窗口函数 vs. 自连接)的性能差异,以及如何通过索引进行优化。

核心概念

本题的核心是窗口函数,它在不改变原始行数的情况下,对一组与当前行相关的行(即“窗口”)进行计算。

1. LEAD() 函数

LEAD() 是一个窗口函数,用于访问当前行之后的某一行的数据。

  • 语法:
    sql
    1LEAD(expression, offset, default_value) OVER (
    2 [PARTITION BY partition_expression, ...]
    3 ORDER BY sort_expression [ASC|DESC], ...
    4)
  • 参数说明:
    • expression: 要返回值的列或表达式。
    • offset: 相对于当前行的偏移量,默认为 1(即下一行)。
    • default_value: 如果偏移量超出了分区的边界(例如,分区的最后一行没有下一行),则返回此默认值。如果未指定,默认为 NULL。
    • OVER (PARTITION BY ... ORDER BY ...): 窗口函数的标志。
      • PARTITION BY session_id: 定义窗口的边界。它将数据按 session_id 分成多个独立的“桶”,LEAD() 函数的计算将在每个桶内独立进行。
      • ORDER BY event_time: 定义桶内行的顺序。这是 LEAD() 判断“上一行”和“下一行”的依据。

数据示例

假设我们有如下 user_events 表:

event_idsession_iduser_idpage_nameevent_time
1S1101homepage2023-10-26 10:00:00
2S1101product2023-10-26 10:01:30
3S2102homepage2023-10-26 11:05:00
4S1101cart2023-10-26 10:02:00
5S2102search2023-10-26 11:06:00

使用 LEAD() 获取下一个事件时间:

sql
1SELECT
2 *,
3 LEAD(event_time, 1, NULL) OVER (PARTITION BY session_id ORDER BY event_time) AS next_event_time
4FROM user_events;

输出结果:

event_idsession_id...event_timenext_event_time
1S1...2023-10-26 10:00:002023-10-26 10:01:30
2S1...2023-10-26 10:01:302023-10-26 10:02:00
4S1...2023-10-26 10:02:00NULL
3S2...2023-10-26 11:05:002023-10-26 11:06:00
5S2...2023-10-26 11:06:00NULL

可以看到,LEAD 在 session_id='S1' 和 'S2' 两个分区内独立工作。每个分区按 event_time 排序后的最后一行,其 next_event_time 为 NULL。

多种解法

解法 1:使用窗口函数 LEAD()(最优解)

这是最直接、最高效、可读性最好的现代 SQL 解法。

  • 版本支持:✅ MySQL 8.0+ / PostgreSQL / Oracle / SQL Server;❌ MySQL 5.7 及以下版本不支持。
  • SQL 实现:
    sql
    1WITH ranked_events AS (
    2 SELECT
    3 session_id,
    4 page_name,
    5 event_time,
    6 -- 使用 LEAD() 获取同一个 session 内的下一个事件时间
    7 LEAD(event_time, 1) OVER (PARTITION BY session_id ORDER BY event_time) AS next_event_time
    8 FROM
    9 user_events
    10)
    11SELECT
    12 session_id,
    13 page_name,
    14 event_time,
    15 next_event_time,
    16 -- 计算时间差(秒),如果 next_event_time 为 NULL(即会话的最后一个页面),则停留时长为 0
    17 COALESCE(TIMESTAMPDIFF(SECOND, event_time, next_event_time), 0) AS duration_seconds
    18FROM
    19 ranked_events
    20ORDER BY
    21 session_id, event_time;
  • 时间/空间复杂度:
    • 时间复杂度:O(Nlog⁡N)O(N \log N)。主要开销在于对每个分区进行排序。如果数据已按 (session_id, event_time) 物理存储或索引,则复杂度可能接近 O(N)O(N)。
    • 空间复杂度:O(N)O(N)。需要额外空间存储窗口计算的中间结果。
  • 索引依赖:
    • 强烈建议在 (session_id, event_time) 上创建复合索引。
      sql
      1CREATE INDEX idx_session_time ON user_events (session_id, event_time);
    • 该索引能极大地加速 PARTITION BY 和 ORDER BY 操作,数据库可以直接从索引中读取预排序好的数据,避免昂贵的 filesort 操作。

解法 2:使用自连接(Correlated Subquery)

在不支持窗口函数的老版本 MySQL 中,这是一种常见的替代方案,但性能通常较差。

  • 版本支持:✅ 所有主流数据库版本。
  • SQL 实现:
    sql
    1SELECT
    2 t1.session_id,
    3 t1.page_name,
    4 t1.event_time,
    5 -- 通过关联子查询找到下一个事件的时间
    6 (
    7 SELECT MIN(t2.event_time)
    8 FROM user_events t2
    9 WHERE t2.session_id = t1.session_id AND t2.event_time > t1.event_time
    10 ) AS next_event_time,
    11 COALESCE(TIMESTAMPDIFF(
    12 SECOND,
    13 t1.event_time,
    14 (
    15 SELECT MIN(t2.event_time)
    16 FROM user_events t2
    17 WHERE t2.session_id = t1.session_id AND t2.event_time > t1.event_time
    18 )
    19 ), 0) AS duration_seconds
    20FROM
    21 user_events t1
    22ORDER BY
    23 t1.session_id, t1.event_time;
  • 时间/空间复杂度:
    • 时间复杂度:在没有索引的情况下,接近 O(N2)O(N^2),因为对于外部查询的每一行,内部子查询都可能扫描一遍表。
    • 有 (session_id, event_time) 索引时,子查询性能会大幅提升,整体复杂度可优化至 O(Nlog⁡K)O(N \log K),其中 K 是每个会话的平均长度。但通常仍慢于窗口函数。
  • 索引依赖:
    • (session_id, event_time) 上的复合索引是必须的,否则该查询将导致灾难性的性能问题。索引能让子查询中的 WHERE 和 MIN() 操作非常高效。

常见低分回答

  1. LEAD() 忘记 PARTITION BY:

    sql
    1-- 错误:在所有数据中全局寻找下一条记录,而不是在会话内部
    2LEAD(event_time) OVER (ORDER BY event_time)

    这会导致会话 S1 的最后一个事件的 next_event_time 错误地指向会话 S2 的第一个事件。

  2. ORDER BY 顺序错误或缺失:

    sql
    1-- 错误:没有 ORDER BY,窗口内顺序不确定,结果不可预测
    2LEAD(event_time) OVER (PARTITION BY session_id)

    SQL 标准规定 ORDER BY 对于 LEAD 等依赖顺序的函数是必需的。如果遗漏,结果将是错误的或不确定的。

  3. 未处理 NULL 值:

    sql
    1-- 不完善:对于会话的最后一个事件,duration_seconds 会是 NULL
    2TIMESTAMPDIFF(SECOND, event_time, next_event_time) AS duration_seconds

    虽然计算结果是 NULL 在技术上没错,但在业务报表中通常期望看到 0 或其他明确的值。使用 COALESCE 或 CASE WHEN 处理 NULL 是更专业的做法。

  4. 使用 LAG() 而非 LEAD(): 这是一个常见的逻辑错误,LAG() 是向前看(获取上一行),而题目要求向后看(获取下一行)。这会导致计算的是上一个页面的停留时长,而不是当前页面的。

  5. 在 WHERE 子句中使用窗口函数:

    sql
    1-- 语法错误:窗口函数不能在 WHERE 子句中使用
    2SELECT ... FROM user_events WHERE LEAD(...) OVER (...) IS NOT NULL;

    窗口函数的执行顺序在 WHERE 之后,因此不能用于过滤。必须先在子查询或 CTE 中计算出窗口函数的结果,然后在外部查询中进行过滤。

工程实践与优化

  • 索引是性能的关键:对于此类查询,CREATE INDEX idx_session_time ON user_events (session_id, event_time); 是第一优化原则。这个索引完美匹配了 PARTITION BY session_id 和 ORDER BY event_time,数据库可以进行高效的 "Index Scan",避免全表扫描和内存/磁盘排序。

  • 执行计划分析(EXPLAIN):

    • 好的计划:对于解法 1,EXPLAIN 结果中 Extra 字段不应出现 Using filesort。如果索引 (session_id, event_time) 被有效利用,窗口函数可以直接在索引的有序流上计算。
    • 坏的计划:对于解法 2,如果 EXPLAIN 显示子查询是 DEPENDENT SUBQUERY 并且没有有效利用索引,说明每一行都在触发一次低效的查找,必须立即优化索引。
  • 处理数据倾斜:如果存在某些 session_id 拥有数百万条记录(如爬虫或机器人行为),单个分区会变得异常巨大,可能导致内存溢出。工程上需要:

    1. 数据清洗:预先识别并过滤掉这些异常会话。
    2. 应用层切分:在应用层将超长会话切分为多个子会话。
    3. 分布式计算:使用 Spark SQL 或 Flink SQL 等大数据工具,它们能更好地处理大规模排序和数据倾斜。
  • 会话的定义:在真实业务中,session_id 可能不存在。会话通常是根据“用户在一定时间内(如30分钟)无操作”来切分的。这种情况下,需要先用 LAG() 计算事件时间间隔,识别出超时的点,以此生成 session_id,这是本题的一个进阶版本。

常见追问与延伸题目

  1. 追问:如果数据量增长 100 倍,达到百亿级别,这条 SQL 还能在线运行吗?怎么优化?

    • 回答要点 1 (否定在线查询):不能。在百亿级别的数据上执行全量排序和窗口计算,即使有索引,IO 和计算开销也极大,无法满足在线查询的秒级响应要求。这已经超出了传统 OLTP 数据库的实时分析能力范畴。
    • 回答要点 2 (架构优化):需要转向大数据和 OLAP 架构。
      • 离线预计算:使用 Spark、Flink 或 Hive 等工具,每天定时(T+1)跑批处理任务,计算好所有页面的停留时长,并将结果存入一个轻量的结果表(如存入 MySQL、Doris、ClickHouse)中,供业务查询。
      • 流式计算:对于实时性要求高的场景,使用 Flink 或 Spark Streaming。当事件流进入时,在内存中维护每个用户的会话状态,实时计算并输出停留时长。
      • OLAP 数据库:将数据导入 ClickHouse、Doris 等列式存储的 OLAP 数据库。它们为这类聚合和分析查询做了深度优化(如列存、向量化执行),查询性能远超 MySQL。即使在百亿数据上,聚合查询也可能在秒级完成。
  2. 追问:如果我发现这条 SQL 跑得很慢,EXPLAIN 显示 Using filesort,该如何定位和修复?

    • 回答要点 1 (定位原因):Using filesort 意味着 MySQL 无法利用索引的有序性来满足 ORDER BY 子句,必须在内存或临时磁盘文件中进行额外的排序操作。原因通常是:
      • 缺少合适的索引:最常见的原因。没有 (session_id, event_time) 索引。
      • 索引列顺序错误:如果索引是 (event_time, session_id),对于 PARTITION BY session_id ORDER BY event_time,效果会差很多,因为数据首先按时间排序,而不是会话。
      • PARTITION BY 和 ORDER BY 的列来自不同表:在 JOIN 查询中,如果分区和排序的列分散在多个表中,优化器很难利用单个表的索引。
    • 回答要点 2 (修复方法):
      • 创建或修改索引:确保存在一个复合索引,其前缀列与 PARTITION BY 的列匹配,后续列与 ORDER BY 的列匹配。即 (session_id, event_time)。
      • 检查数据类型:确保查询中的常量和列的数据类型一致,避免隐式类型转换导致索引失效。
      • 调整 sort_buffer_size:如果排序不可避免,可以适当调大此参数,让更多排序在内存中完成,减少磁盘 IO,但这治标不治本。
  3. 追问:如果换成 PostgreSQL 或 TiDB,这条 SQL 的表现会有什么不同?

    • 回答要点 1 (PostgreSQL):PostgreSQL 的窗口函数实现非常成熟且高效。对于此查询,其表现通常会优于 MySQL。PG 的优化器在处理复杂查询和利用索引方面有很好的声誉。语法完全兼容,性能可能会更好。
    • 回答要点 2 (TiDB):TiDB 是一个分布式数据库。这条 SQL 语法上完全兼容 MySQL。但其执行方式完全不同:
      • 分布式执行:TiDB 会将计算任务下推到存储节点(TiKV)上。PARTITION BY 和 ORDER BY 会触发分布式排序。数据首先在各个 TiKV 节点上局部排序,然后汇总到计算节点(TiDB Server)进行最终的全局排序和窗口计算。
      • 性能考量:对于大规模数据,TiDB 的水平扩展能力是其巨大优势。但网络开销和数据 shuffle(洗牌)是其代价。查询性能取决于数据分布、机器资源和 TiDB 的优化器选择。总的来说,它为处理海量数据提供了可扩展的解决方案,但单次查询的延迟可能高于单机数据库。