求每条会话的页面停留时长(lead 函数计算下一条事件时间)。
Calculate the page view duration for each session, using the LEAD function to get the timestamp of the
考察要点
这道题面试官想看你是否精通 窗口函数(Window Functions),特别是 LEAD() 函数的用法。核心考察点在于:
- 数据分区与排序:你是否能正确使用
PARTITION BY将数据按会话(session)分组,并用ORDER BY在每个会话内按时间排序。 - 行间计算能力:你是否理解如何使用
LEAD()或其他方法(如自连接)来获取 "下一行" 的数据,以完成有时序依赖的计算。 - SQL 性能意识:你是否知道不同解法(窗口函数 vs. 自连接)的性能差异,以及如何通过索引进行优化。
核心概念
本题的核心是窗口函数,它在不改变原始行数的情况下,对一组与当前行相关的行(即“窗口”)进行计算。
1. LEAD() 函数
LEAD() 是一个窗口函数,用于访问当前行之后的某一行的数据。
- 语法:
sql1LEAD(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_id | session_id | user_id | page_name | event_time |
|---|---|---|---|---|
| 1 | S1 | 101 | homepage | 2023-10-26 10:00:00 |
| 2 | S1 | 101 | product | 2023-10-26 10:01:30 |
| 3 | S2 | 102 | homepage | 2023-10-26 11:05:00 |
| 4 | S1 | 101 | cart | 2023-10-26 10:02:00 |
| 5 | S2 | 102 | search | 2023-10-26 11:06:00 |
使用 LEAD() 获取下一个事件时间:
1SELECT2 *,3 LEAD(event_time, 1, NULL) OVER (PARTITION BY session_id ORDER BY event_time) AS next_event_time4FROM user_events;
输出结果:
| event_id | session_id | ... | event_time | next_event_time |
|---|---|---|---|---|
| 1 | S1 | ... | 2023-10-26 10:00:00 | 2023-10-26 10:01:30 |
| 2 | S1 | ... | 2023-10-26 10:01:30 | 2023-10-26 10:02:00 |
| 4 | S1 | ... | 2023-10-26 10:02:00 | NULL |
| 3 | S2 | ... | 2023-10-26 11:05:00 | 2023-10-26 11:06:00 |
| 5 | S2 | ... | 2023-10-26 11:06:00 | NULL |
可以看到,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 实现:
sql1WITH ranked_events AS (2 SELECT3 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_time8 FROM9 user_events10)11SELECT12 session_id,13 page_name,14 event_time,15 next_event_time,16 -- 计算时间差(秒),如果 next_event_time 为 NULL(即会话的最后一个页面),则停留时长为 017 COALESCE(TIMESTAMPDIFF(SECOND, event_time, next_event_time), 0) AS duration_seconds18FROM19 ranked_events20ORDER BY21 session_id, event_time;
- 时间/空间复杂度:
- 时间复杂度:。主要开销在于对每个分区进行排序。如果数据已按
(session_id, event_time)物理存储或索引,则复杂度可能接近 。 - 空间复杂度:。需要额外空间存储窗口计算的中间结果。
- 时间复杂度:。主要开销在于对每个分区进行排序。如果数据已按
- 索引依赖:
- 强烈建议在
(session_id, event_time)上创建复合索引。sql1CREATE INDEX idx_session_time ON user_events (session_id, event_time); - 该索引能极大地加速
PARTITION BY和ORDER BY操作,数据库可以直接从索引中读取预排序好的数据,避免昂贵的filesort操作。
- 强烈建议在
解法 2:使用自连接(Correlated Subquery)
在不支持窗口函数的老版本 MySQL 中,这是一种常见的替代方案,但性能通常较差。
- 版本支持:✅ 所有主流数据库版本。
- SQL 实现:
sql1SELECT2 t1.session_id,3 t1.page_name,4 t1.event_time,5 -- 通过关联子查询找到下一个事件的时间6 (7 SELECT MIN(t2.event_time)8 FROM user_events t29 WHERE t2.session_id = t1.session_id AND t2.event_time > t1.event_time10 ) AS next_event_time,11 COALESCE(TIMESTAMPDIFF(12 SECOND,13 t1.event_time,14 (15 SELECT MIN(t2.event_time)16 FROM user_events t217 WHERE t2.session_id = t1.session_id AND t2.event_time > t1.event_time18 )19 ), 0) AS duration_seconds20FROM21 user_events t122ORDER BY23 t1.session_id, t1.event_time;
- 时间/空间复杂度:
- 时间复杂度:在没有索引的情况下,接近 ,因为对于外部查询的每一行,内部子查询都可能扫描一遍表。
- 有
(session_id, event_time)索引时,子查询性能会大幅提升,整体复杂度可优化至 ,其中 K 是每个会话的平均长度。但通常仍慢于窗口函数。
- 索引依赖:
(session_id, event_time)上的复合索引是必须的,否则该查询将导致灾难性的性能问题。索引能让子查询中的WHERE和MIN()操作非常高效。
常见低分回答
-
LEAD()忘记PARTITION BY:sql1-- 错误:在所有数据中全局寻找下一条记录,而不是在会话内部2LEAD(event_time) OVER (ORDER BY event_time)这会导致会话 S1 的最后一个事件的
next_event_time错误地指向会话 S2 的第一个事件。 -
ORDER BY顺序错误或缺失:sql1-- 错误:没有 ORDER BY,窗口内顺序不确定,结果不可预测2LEAD(event_time) OVER (PARTITION BY session_id)SQL 标准规定
ORDER BY对于LEAD等依赖顺序的函数是必需的。如果遗漏,结果将是错误的或不确定的。 -
未处理
NULL值:sql1-- 不完善:对于会话的最后一个事件,duration_seconds 会是 NULL2TIMESTAMPDIFF(SECOND, event_time, next_event_time) AS duration_seconds虽然计算结果是
NULL在技术上没错,但在业务报表中通常期望看到0或其他明确的值。使用COALESCE或CASE WHEN处理NULL是更专业的做法。 -
使用
LAG()而非LEAD(): 这是一个常见的逻辑错误,LAG()是向前看(获取上一行),而题目要求向后看(获取下一行)。这会导致计算的是上一个页面的停留时长,而不是当前页面的。 -
在
WHERE子句中使用窗口函数:sql1-- 语法错误:窗口函数不能在 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并且没有有效利用索引,说明每一行都在触发一次低效的查找,必须立即优化索引。
- 好的计划:对于解法 1,
-
处理数据倾斜:如果存在某些
session_id拥有数百万条记录(如爬虫或机器人行为),单个分区会变得异常巨大,可能导致内存溢出。工程上需要:- 数据清洗:预先识别并过滤掉这些异常会话。
- 应用层切分:在应用层将超长会话切分为多个子会话。
- 分布式计算:使用 Spark SQL 或 Flink SQL 等大数据工具,它们能更好地处理大规模排序和数据倾斜。
-
会话的定义:在真实业务中,
session_id可能不存在。会话通常是根据“用户在一定时间内(如30分钟)无操作”来切分的。这种情况下,需要先用LAG()计算事件时间间隔,识别出超时的点,以此生成session_id,这是本题的一个进阶版本。
常见追问与延伸题目
-
追问:如果数据量增长 100 倍,达到百亿级别,这条 SQL 还能在线运行吗?怎么优化?
- 回答要点 1 (否定在线查询):不能。在百亿级别的数据上执行全量排序和窗口计算,即使有索引,IO 和计算开销也极大,无法满足在线查询的秒级响应要求。这已经超出了传统
OLTP数据库的实时分析能力范畴。 - 回答要点 2 (架构优化):需要转向大数据和
OLAP架构。- 离线预计算:使用 Spark、Flink 或 Hive 等工具,每天定时(T+1)跑批处理任务,计算好所有页面的停留时长,并将结果存入一个轻量的结果表(如存入 MySQL、Doris、ClickHouse)中,供业务查询。
- 流式计算:对于实时性要求高的场景,使用 Flink 或 Spark Streaming。当事件流进入时,在内存中维护每个用户的会话状态,实时计算并输出停留时长。
OLAP数据库:将数据导入 ClickHouse、Doris 等列式存储的OLAP数据库。它们为这类聚合和分析查询做了深度优化(如列存、向量化执行),查询性能远超 MySQL。即使在百亿数据上,聚合查询也可能在秒级完成。
- 回答要点 1 (否定在线查询):不能。在百亿级别的数据上执行全量排序和窗口计算,即使有索引,IO 和计算开销也极大,无法满足在线查询的秒级响应要求。这已经超出了传统
-
追问:如果我发现这条 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,但这治标不治本。
- 创建或修改索引:确保存在一个复合索引,其前缀列与
- 回答要点 1 (定位原因):
-
追问:如果换成 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 的优化器选择。总的来说,它为处理海量数据提供了可扩展的解决方案,但单次查询的延迟可能高于单机数据库。
- 分布式执行:TiDB 会将计算任务下推到存储节点(TiKV)上。