导读:本期聚焦于雪花创作的《SQL如何获取分组最后一条数据?LAST_VALUE的滑动窗口陷阱解析》,敬请观看详情。取每个分组最后一条记录是SQL开发中的高频需求,比如查询每个用户最新一笔订单。但有人会直接选择LAST_VALUE窗口函数,以为加上PARTITION BY和ORDER BY就能拿到每组最后一行,结果却发现每行都返回同组当前累计的最后一个值,而不是整组的最后一条。问题根源在于窗口函数默认的滑动窗口框架是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,也就是从分区起点到当前行的范围。ORDER BY之后,LAST_VALUE只能看到当前行及之前的行,自然无法返回尾行。解决思路包括显式声明 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING,或者用ROW_NUMBER排序后筛出序号为1的记录。本文会结合订单表场景拆解这个陷阱,给出可执行SQL示例,并对比不同写法在MySQL、PostgreSQL等数据库中的适用性和性能差异。

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

SQL如何获取分组最后一条数据?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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/1004/65364.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。