在订单、日志、用户行为等场景中,根据时间或序号取出每个分组里的最后一条数据,几乎是日常SQL开发绕不开的需求。一个用户可能有多笔订单,业务希望看到每位用户最近一次下单的时间、金额和状态;一台设备会持续上报状态,分析时需要每个设备最新一条记录。很多人会想到窗口函数 LAST_VALUE,认为只要按分组字段分区、按时间排序,就能直接返回该分组的最后一条,但实际执行结果常常与预期完全相反。本文通过一个订单表示例,说明 LAST_VALUE 为什么会返回重复的累计结果,以及如何正确实现分组取尾行。

从订单表错误查询说起
假设有一张订单表 orders,结构如下:
CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, amount DECIMAL(10,2), status VARCHAR(20), created_at DATETIME NOT NULL ); INSERT INTO orders (id, user_id, amount, status, created_at) VALUES (1, 101, 20.00, 'paid', '2023-01-01 10:00:00'), (2, 101, 35.50, 'paid', '2023-01-02 11:30:00'), (3, 101, 12.00, 'cancelled', '2023-01-03 09:15:00'), (4, 102, 80.00, 'paid', '2023-01-02 14:00:00'), (5, 102, 50.00, 'paid', '2023-01-04 16:45:00');
如果按照直觉直接使用 LAST_VALUE,通常会写出下面这段SQL:
SELECT
id,
user_id,
amount,
status,
LAST_VALUE(amount) OVER (
PARTITION BY user_id
ORDER BY created_at
) AS last_amount
FROM orders;
查询结果中,用户101的每一行 last_amount 分别是20.00、35.50、12.00,用户102的每一行分别是80.00、50.00。看似第三行和最后一行才是组内最后一个金额,但函数并没有只返回尾行数据,而是把每一行当前能看到的最后一个值重复输出到了对应行上。这样不会减少结果集行数,也不能直接得到每个用户的最后一条记录。
核心问题并不是 LAST_VALUE 本身算错了数,而是SQL使用该函数时容易忽略窗口框架的默认行为。要处理分组取尾行,必须理解窗口函数中分区、排序和框架三者之间的关系。框架决定了一个窗口函数在每一行上能够访问哪些行;如果不显式写 ROWS 或 RANGE 子句,数据库会采用一个从分区起点到当前行的滑动范围,这正是错误产生的根本原因。
LAST_VALUE默认窗口的滑动陷阱
窗口函数在执行时会先按照 PARTITION BY 分区,再按照 ORDER BY 排序,然后在每行上计算一个窗口。如果没有写框架子句,SQL标准定义的默认窗口是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。这里的 CURRENT ROW 在当前排序键唯一时,意味着窗口只包含从分区的第一行到当前行。于是当处理用户101的第二笔订单时,LAST_VALUE 只能看到前两行,它的结果就是第二笔订单的金额;处理第三笔时只能看到前三行,结果自然是第三笔。直到分区最后一行,窗口才覆盖整个分区,最后一行上的 LAST_VALUE 才真正等于组内最后一笔金额。
这种默认框架对于 SUM、COUNT、AVG 等累加函数非常合适,可以方便地生成累计金额、累计数量。但 LAST_VALUE 要的是分区末尾那个值,却默认只能看到当前行,开头和中间行都没有机会访问组内最后一条。换句话说,滑动窗口在不断向右扩展,而尾部数据对每一行来说都是未知的,除非把窗口边界声明到分区末尾。
另一个容易混淆的点是 RANGE 和 ROWS 的区别。RANGE 按排序键的值范围定义窗口,如果 ORDER BY 的字段存在重复值,相同排序键的多行会同时进入窗口,导致同一组内出现相同键的行共享同样的框架。例如按日期排序时,同一天的多笔订单会被视为同一窗口范围,这可能进一步掩盖问题。而 ROWS 按物理行号定义窗口,行为更加直观:ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 表示从分区第一行到最后一行全部纳入窗口。
要让 LAST_VALUE 返回每个分组的最后一条,最简单的改法就是显式扩大窗口:
SELECT
id,
user_id,
amount,
status,
LAST_VALUE(amount) OVER (
PARTITION BY user_id
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_amount
FROM orders;
这样每一行的窗口都覆盖整个用户分区,LAST_VALUE 在所有行上返回相同的结果,也就是组内最后一条的金额。但这仍然会保留原表的全部行,如果只需要每个分组一条记录,就需要再通过子查询或 DISTINCT 进一步压缩。并且这种方式在数据量大时会把大量重复值计算出来,效率不一定理想。接下来看看更直接、更常用的分组取尾行方案。
更可靠的分组取最后一条方案
使用 ROW_NUMBER() 给每个分组内的记录编号,是绝大多数数据库中实现分组取尾行最直观的方法。先按 PARTITION BY 分区,再按时间倒序排序,第一行就是最后一条:
SELECT id, user_id, amount, status
FROM (
SELECT
id,
user_id,
amount,
status,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at DESC
) AS rn
FROM orders
) t
WHERE rn = 1;
这种写法只返回每个用户一行,并且可以通过改变 ORDER BY 的排序方向灵活定义最后一条或最早一条。它的优点是不需要处理 LAST_VALUE 的窗口框架问题,逻辑清晰,执行计划通常也比较稳定。缺点是需要额外的子查询或CTE,并且在分区内数据量极大时,排序成本不可忽略。
另一个思路是用 FIRST_VALUE 配合倒序排序。因为 FIRST_VALUE 默认窗口就是 UNBOUNDED PRECEDING AND CURRENT ROW,当前行永远能访问分区第一行,所以只要把排序倒过来,分区第一行自然变成原先的最后一条:
SELECT
id,
user_id,
amount,
status,
FIRST_VALUE(amount) OVER (
PARTITION BY user_id
ORDER BY created_at DESC
) AS last_amount
FROM orders;
不过和 LAST_VALUE 扩大窗口一样,这个查询仍然返回全部行,如果只要每组一条,还需要按 id 或其他唯一键进一步过滤。相比之下,ROW_NUMBER 方案更贴近最终取数需求。
在PostgreSQL中还可以使用 DISTINCT ON 来达到同样效果,语法更简洁:
SELECT DISTINCT ON (user_id) id, user_id, amount, status FROM orders ORDER BY user_id, created_at DESC;
DISTINCT ON 会按 ORDER BY 指定的顺序保留每个分组的第一行。MySQL和SQL Server没有直接对应的语法,但可以使用相关子查询或自连接模拟。相关子查询的写法如下:
SELECT o.id, o.user_id, o.amount, o.status FROM orders o WHERE o.id = ( SELECT o2.id FROM orders o2 WHERE o2.user_id = o.user_id ORDER BY o2.created_at DESC LIMIT 1 );
这种写法在分组数量少、子查询能走索引时表现不错,但如果外层表行数很大,子查询可能被反复执行,性能会明显下降。各个方案没有绝对优劣,需要根据实际表结构、索引和数据分布来选择。
性能对比与实际使用建议
先看执行计划层面的差异。ROW_NUMBER 方案一般会触发一次分区排序,如果 user_id 和 created_at 上有复合索引,数据库可以按索引顺序扫描,避免额外排序。MySQL 8.0、PostgreSQL、SQL Server、Oracle等支持窗口函数的数据库都能比较高效地执行。相关子查询方案则依赖外层每一行执行一次内层查询,如果没有合适的索引,复杂度可能接近O(N²),在万级以上数据量时需要格外小心。
LAST_VALUE 加 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 虽然语义正确,但会为每一行计算一个覆盖整个分区的窗口,产生大量重复的中间结果。如果业务只是要每组一行,往往还要再包一层过滤,增加了SQL复杂度和资源消耗。除非你已经有一个窗口结果需要复用到多个表达式,否则它不是分组取尾行的首选。
下表汇总了几种常见写法的特点:
| 方案 | 返回行数 | 适用数据库 | 性能特点 |
|---|---|---|---|
| LAST_VALUE扩大窗口 | 全部行 | MySQL、PostgreSQL、SQL Server等 | 需再过滤,重复值多 |
| ROW_NUMBER | 每组一行 | 主流数据库 | 稳定,适合大多数场景 |
| FIRST_VALUE倒序 | 全部行 | 主流数据库 | 类似LAST_VALUE,仍需过滤 |
| DISTINCT ON | 每组一行 | PostgreSQL | 语法简洁 |
| 相关子查询 | 每组一行 | MySQL等 | 索引友好时可接受 |
实际开发中,如果只需要展示每个分组最后一条,建议优先使用 ROW_NUMBER(),因为它表达意图明确,排序方向可控,也容易处理并列排序键。ORDER BY 里最好加上一个唯一字段作为最终比较项,例如 created_at DESC, id DESC,否则当时间相同的时候,多行可能都获得序号1,WHERE rn = 1 会返回多条不确定记录。加入唯一键可以保证结果稳定,也能避免不同数据库对相同排序键行返回顺序不一致的问题。
最后要注意,LAST_VALUE 并不是设计缺陷,它只是滑动窗口默认行为不符合分组取尾行的直觉。理解窗口框架之后,既可以正确使用扩大窗口的方法,也可以选择更高效的 ROW_NUMBER 路线。写SQL时如果发现窗口函数结果与预期不一致,第一反应可以检查是否漏掉了框架子句。
LAST_VALUE窗口函数分组最后一条修改时间:2026-10-04 03:14:36