FIRST_VALUE / LAST_VALUE 的使用?为什么 LAST_VALUE 经常和你期望的不一样(默认窗口为 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)?
How to use FIRST_VALUE / LAST_VALUE? Why does LAST_VALUE often produce
考察要点
这道题面试官想看你是否真正理解 SQL 窗口函数的工作机制,而不仅仅是会用 OVER() 的基本语法。考察的核心是**窗口框架(Window Frame)**的概念,特别是 ROWS 和 RANGE 的区别,以及在 ORDER BY 存在时,LAST_VALUE 的默认框架 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 如何导致非预期的结果。这能有效地区分出对窗口函数有深度理解的候选人。
核心概念
窗口函数在一个与当前行相关的“窗口”或行集上执行计算。FIRST_VALUE 和 LAST_VALUE 是两个典型的窗口函数。
FIRST_VALUE(expr):返回窗口框架内第一行的expr的值。LAST_VALUE(expr):返回窗口框架内最后一行的expr的值。
它们的行为完全取决于**窗口框架(Window Frame)**的定义。窗口框架是 OVER() 子句的一部分,用于定义计算的行范围。
窗口框架语法
1OVER (2 [PARTITION BY partition_expression, ... ]3 [ORDER BY sort_expression [ASC | DESC], ... ]4 [frame_clause]5)
其中 frame_clause 的完整语法是:
{ ROWS | RANGE } BETWEEN <frame_start> AND <frame_end>
ROWS:基于物理行数定义边界,如ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING表示前一行、当前行、后一行。RANGE:基于逻辑值定义边界,它包含与当前行在ORDER BY列上具有相同值的所有对等行(peers)。
LAST_VALUE 的陷阱:默认框架
当 OVER() 子句中包含 ORDER BY 但未显式指定 frame_clause 时,数据库会使用一个默认的框架:
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
这意味着窗口从分区的开始一直延伸到当前行。
- 对于
FIRST_VALUE,这个默认框架没有问题,因为它总是能看到分区的第一行。 - 对于
LAST_VALUE,由于框架的终点是CURRENT ROW,所以它能看到的“最后一行”永远只是“当前行”自己。这就是为什么LAST_VALUE的结果常常看起来和当前行的值一样。
示例说明
假设我们有员工薪资表 employees:
| id | dept | name | salary |
|---|---|---|---|
| 1 | Eng | Alice | 90000 |
| 2 | Eng | Bob | 120000 |
| 3 | Eng | Carol | 100000 |
| 4 | Sales | David | 85000 |
| 5 | Sales | Eve | 110000 |
目标:找出每个部门内,薪资最低和最高的员工。
错误用法(使用默认框架):
1SELECT2 name,3 dept,4 salary,5 FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary ASC) AS lowest_salary_earner,6 LAST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary ASC) AS highest_salary_earner_WRONG7FROM employees;
输出结果:
| name | dept | salary | lowest_salary_earner | highest_salary_earner_WRONG |
|---|---|---|---|---|
| Alice | Eng | 90000 | Alice | Alice |
| Carol | Eng | 100000 | Alice | Carol |
| Bob | Eng | 120000 | Alice | Bob |
| David | Sales | 85000 | David | David |
| Eve | Sales | 110000 | David | Eve |
分析:
lowest_salary_earner结果正确,因为默认框架从分区开头 (UNBOUNDED PRECEDING) 开始,FIRST_VALUE总能找到薪资最低的 Alice (Eng) 和 David (Sales)。highest_salary_earner_WRONG结果错误。对于 Eng 部门的 Carol,她的窗口是[Alice, Carol],所以LAST_VALUE返回Carol。对于 Bob,他的窗口是[Alice, Carol, Bob],LAST_VALUE返回Bob。结果完全不符合预期。
正确用法(指定完整框架):
要让 LAST_VALUE 看到整个分区,必须将框架扩展到分区的末尾。
1-- 正确的 Frame Clause2ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
1SELECT2 name,3 dept,4 salary,5 FIRST_VALUE(name) OVER (6 PARTITION BY dept ORDER BY salary ASC7 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING8 ) AS lowest_salary_earner,9 LAST_VALUE(name) OVER (10 PARTITION BY dept ORDER BY salary ASC11 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING12 ) AS highest_salary_earner_CORRECT13FROM employees;
正确输出:
| name | dept | salary | lowest_salary_earner | highest_salary_earner_CORRECT |
|---|---|---|---|---|
| Alice | Eng | 90000 | Alice | Bob |
| Carol | Eng | 100000 | Alice | Bob |
| Bob | Eng | 120000 | Alice | Bob |
| David | Sales | 85000 | David | Eve |
| Eve | Sales | 110000 | David | Eve |
多种解法(获取分区最后一个值)
解法 1:使用 LAST_VALUE 并显式指定完整窗口框架
这是最直接、最符合语义的解法。
- 适用版本:✅ MySQL 8.0+, PostgreSQL, Oracle, SQL Server
- SQL 代码:
sql1SELECT2 name,3 dept,4 salary,5 LAST_VALUE(name) OVER (6 PARTITION BY dept7 ORDER BY salary ASC8 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING9 ) AS highest_salary_earner10FROM employees;
- 复杂度:时间复杂度取决于排序,如果索引支持,则为 ;否则为 。空间复杂度为 ,其中 是最大分区的大小,因为可能需要缓存整个分区。
- 索引依赖:在
(dept, salary)上建立复合索引可以避免排序,极大提升性能。
解法 2:使用 FIRST_VALUE 并反向排序
一个非常聪明且高效的技巧,通过颠倒 ORDER BY 的顺序,将“最后一个值”变成“第一个值”来获取。
- 适用版本:✅ MySQL 8.0+, PostgreSQL, Oracle, SQL Server
- SQL 代码:
sql1SELECT2 name,3 dept,4 salary,5 FIRST_VALUE(name) OVER (6 PARTITION BY dept7 ORDER BY salary DESC8 ) AS highest_salary_earner9FROM employees;
- 复杂度:与解法 1 相同。时间 或 ,空间 。
- 索引依赖:在
(dept, salary DESC)上建立索引效果最佳。如果只有(dept, salary ASC)索引,数据库可能需要反向扫描索引(Index Backward Scan)或进行排序。
解法 3:使用 MAX 或 MIN 作为窗口函数
如果只是想获取分区中的最大/最小值,而不是基于排序的最后一个值,可以直接使用聚合函数作为窗口函数。
- 适用版本:✅ MySQL 8.0+, PostgreSQL, Oracle, SQL Server
- SQL 代码:
sql1-- 注意:这不等价于 LAST_VALUE,它只返回最大薪资值,而不是最高薪资者的名字2SELECT3 name,4 dept,5 salary,6 MAX(salary) OVER (PARTITION BY dept) AS highest_salary_in_dept7FROM employees;
- 说明:这种方法不需要
ORDER BY和frame_clause,性能通常很好。但它无法直接获取“最高薪资员工的名字”,只能获取“最高薪资”这个值。要获取名字,仍需结合FIRST_VALUE/LAST_VALUE或其他技巧。
常见低分回答
- 不知道默认框架:只说
LAST_VALUE是取最后一个值,当被问及为何结果不对时无法解释,暴露了对窗口框架知识的缺失。 - 混淆
ROWS和RANGE:无法清晰解释两者的区别,尤其是在ORDER BY列有重复值时的不同行为。 - 用
MAX()聚合函数替代:回答说“可以用MAX()窗口函数”,但没有意识到MAX(name)在 SQL 中通常是无效的(除非是特定方言),并且MAX(salary)只能取到值,取不到对应的行信息。 - 使用低效的自连接或子查询:试图用关联子查询来模拟,这在数据量大时性能极差(近似 )。
sql1-- 性能灾难2SELECT3 e1.name,4 e1.dept,5 e1.salary,6 (SELECT e2.name7 FROM employees e28 WHERE e2.dept = e1.dept9 ORDER BY e2.salary DESC10 LIMIT 1) AS highest_salary_earner11FROM employees e1;
- 错误地指定框架:例如写成
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING,这对于FIRST_VALUE是错误的。
工程实践与优化
- 索引是关键:窗口函数的性能瓶颈通常在
PARTITION BY和ORDER BY。务必在(partition_key, order_key)上建立复合索引。例如本例中的CREATE INDEX idx_dept_salary ON employees (dept, salary);。 - 观察执行计划:使用
EXPLAIN查看执行计划。如果Extra列(MySQL)出现Using filesort,说明排序无法利用索引,需要优化。理想情况下,数据应通过索引有序地提供给窗口函数处理器。在 PostgreSQL 中,查看WindowAgg节点下的Sort节点。 - 分区大小与内存:当窗口框架需要访问整个分区时(如
UNBOUNDED FOLLOWING),数据库需要将整个分区的数据加载到内存或溢出到磁盘。如果分区极大(例如,一个用户的所有历史订单),可能导致严重性能问题。此时应考虑:- 业务上是否能缩小分区范围(如只看近一年的订单)。
- 增加服务器
work_mem(PostgreSQL) 或相关 buffer size。
ROWSvsRANGE的选择:在ORDER BY列可能存在重复值时,ROWS的行为更符合“物理位置”的直觉,通常是更安全、更可预测的选择。RANGE的逻辑行为虽然强大,但也更容易引入意想不到的计算结果。
常见追问与延伸题目
-
追问:
LEAD/LAG和FIRST_VALUE/LAST_VALUE有什么区别?- 回答要点:
LEAD和LAG访问的是相对位置的行(前/后第 N 行),而FIRST_VALUE和LAST_VALUE访问的是窗口框架内的绝对位置的行(第一行/最后一行)。 - 场景:计算环比(上一个周期/下一个周期的值)用
LAG/LEAD。计算与整个分区基准(如最高/最低值)的差异用FIRST_VALUE/LAST_VALUE。
- 回答要点:
-
追问:刚才提到了
ROWS和RANGE,请举一个例子说明它们在ORDER BY列值重复时的具体差异。- 回答要点:假设薪资表中有两个人的薪资都是 100000。
- 数据:
(Alice, 90k),(Carol, 100k),(Chris, 100k),(Bob, 120k) - 当处理到
Carol这一行时:ORDER BY salary ... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:框架是[Alice, Carol]。LAST_VALUE是Carol。ORDER BY salary ... RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:框架是[Alice, Carol, Chris],因为它包含所有薪资等于CURRENT ROW(Carol) 的对等行Chris。LAST_VALUE的结果将是Chris(取决于数据库对对等行的内部排序)。这个不确定性是使用RANGE时需要特别小心的。
- 数据:
- 回答要点:假设薪资表中有两个人的薪资都是 100000。
-
追问:如果我用的 MySQL 5.7,它不支持窗口函数,你怎么实现获取每个部门最高薪资员工的需求?
- 回答要点:这是一个考察 SQL 基础和解决问题能力的经典问题。
- 方案 1 (
JOIN+ 子查询):先用子查询找到每个部门的最高薪资,然后将原表与这个结果进行JOIN。sql1SELECT2 e.name,3 e.dept,4 e.salary5FROM6 employees e7JOIN8 (SELECT dept, MAX(salary) AS max_salary FROM employees GROUP BY dept) AS max_sal9ON10 e.dept = max_sal.dept AND e.salary = max_sal.max_salary; - 方案 2 (关联子查询与
NOT EXISTS):对于表e1中的每一行,如果不存在另一个同部门的员工e2其薪水比e1更高,那么e1就是薪水最高的员工之一。sql1SELECT2 e1.name,3 e1.dept,4 e1.salary5FROM6 employees e17WHERE NOT EXISTS (8 SELECT 19 FROM employees e210 WHERE e2.dept = e1.dept AND e2.salary > e1.salary11); - 方案 3 (用户变量模拟):这是 MySQL 特有的技巧,通过排序和用户变量来模拟
ROW_NUMBER()。虽然巧妙但不推荐在生产中使用,因为它依赖于执行顺序,且在 MySQL 8.0+ 中已被官方不推荐。