广告位 · 页头(后台-模块管理-新建模块后替换此区域)

SQL如何关联查询最近的一条记录?利用窗口函数结合Join操作

在订单管理、审计日志、设备状态采集等业务里,经常需要把主表和明细表关联起来,但明细表每个主键对应多行历史记录,只想要时间最新的一条。比如订单表 orders 保存订单基础信息,order_status_log 记录每一次状态变更,业务方要求输出每个订单当前状态与最后更新时间。这个需求看似简单,实际写 SQL 时如果直接 GROUP BY 再 JOIN,很容易出现状态和时间来自不同行、或者最新记录取错的问题。根本原因在于 GROUP BY 聚合后只保留分组键和聚合结果,非聚合列的值与最新时间不一定对应。要准确关联最近一条记录,需要引入窗口函数先做行级排序,再与主表 Join。

SQL如何关联查询最近的一条记录?利用窗口函数结合Join操作

一、直接GROUP BY的经典错误与原因

先准备两张演示表。orders 存放订单主数据,order_status_log 保存订单状态流水,同一个 order_id 在流水表里可能出现多次。

CREATE TABLE orders (
  order_id BIGINT PRIMARY KEY,
  customer_name VARCHAR(50),
  created_at DATETIME
);
CREATE TABLE order_status_log (
  log_id BIGINT PRIMARY KEY,
  order_id BIGINT,
  status VARCHAR(20),
  changed_at DATETIME,
  note VARCHAR(200)
);

不少开发者一开始会想到用 GROUP BY 加 MAX 聚合来拿最新时间,写法大致如下:

SELECT o.order_id,
       o.customer_name,
       MAX(l.changed_at) AS last_changed_at,
       l.status
FROM orders o
LEFT JOIN order_status_log l ON l.order_id = o.order_id
GROUP BY o.order_id, o.customer_name, l.status;

这段 SQL 的问题非常明显:GROUP BY 中包含了 l.status,导致一个订单只要有多个状态,就会返回多行,每个状态对应一行,根本无法满足“每个订单只要最近一条”的需求。如果去掉 GROUP BY 中的 l.status,在开启 ONLY_FULL_GROUP_BY 的 MySQL 里会直接报错,因为 l.status 既不是分组键也不是聚合列;即便关闭严格模式勉强执行,status 的值也可能与 MAX(l.changed_at) 来自不同的物理行,出现状态和时间不匹配的随机结果。这说明 GROUP BY 聚合天然无法保证非聚合列与极值列的行级一致性,必须换用其他思路。

二、使用ROW_NUMBER窗口函数定位最新行

窗口函数可以在一行上同时返回原始列和排序编号,而且不会像 GROUP BY 那样压缩行。ROW_NUMBER 按指定分区和排序规则,为每行生成一个连续编号。下面的查询以 order_id 分区,按 changed_at 倒序、log_id 倒序排序,编号为 1 的行就是该订单最近一次变更记录。

SELECT log_id,
       order_id,
       status,
       changed_at,
       ROW_NUMBER() OVER (
         PARTITION BY order_id
         ORDER BY changed_at DESC, log_id DESC
       ) AS rn
FROM order_status_log;

这里加上 log_id DESC 作为次级排序,是为了避免两条日志的 changed_at 完全相同又带有随机性时,窗口函数返回不确定的结果。ROW_NUMBER、RANK、DENSE_RANK 三个函数都能做编号,但只有 ROW_NUMBER 能保证每个分区内编号严格唯一,不会出现并列第一;RANK 和 DENSE_RANK 在时间相同时会返回多个相同序号,可能让后续 JOIN 出现重复行。因此如果业务要求“仅取一条最近记录”,应优先使用 ROW_NUMBER()。

拿到编号后,需要把查询结果包成子查询或 CTE,再筛选 rn = 1。这样可以避免在最终结果里出现多个同一订单的历史行,也便于后续和主表做关联。

WITH latest_log AS (
  SELECT log_id,
         order_id,
         status,
         changed_at,
         note,
         ROW_NUMBER() OVER (
           PARTITION BY order_id
           ORDER BY changed_at DESC, log_id DESC
         ) AS rn
  FROM order_status_log
)
SELECT log_id, order_id, status, changed_at, note
FROM latest_log
WHERE rn = 1;

三、窗口函数结合Join完成主表关联

有了每个订单唯一的最新流水记录,就可以把这部分结果当临时表与 orders 主表做 JOIN。实际业务通常要求输出订单基础字段加当前状态,下面给出完整写法。注意这是 LEFT JOIN,并且 rn = 1 条件要放在 ON 子句中,而不是 WHERE 中。如果把 rn = 1 放到 WHERE,LEFT JOIN 的结果里没有流水的订单会因为过滤条件产生 NULL 被剔除,等效成 INNER JOIN,丢失没有状态变更的订单。

WITH latest_log AS (
  SELECT order_id,
         status,
         changed_at,
         note,
         ROW_NUMBER() OVER (
           PARTITION BY order_id
           ORDER BY changed_at DESC, log_id DESC
         ) AS rn
  FROM order_status_log
)
SELECT o.order_id,
       o.customer_name,
       o.created_at,
       l.status       AS current_status,
       l.changed_at   AS last_changed_at,
       l.note         AS last_note
FROM orders o
LEFT JOIN latest_log l
  ON l.order_id = o.order_id
 AND l.rn = 1;

MySQL 8.0、PostgreSQL、SQL Server、Oracle 都支持这种窗口函数加 CTE 的写法,逻辑基本一致。但如果还在使用 MySQL 5.7 或其他不支持窗口函数的老版本,可以改用相关子查询来模拟取最近一条。相关子查询的写法容易理解,但数据量大时性能通常不如窗口函数方案,因为每条主表记录都可能触发一次排序和回表。

SELECT o.order_id,
       o.customer_name,
       l.status AS current_status,
       l.changed_at AS last_changed_at
FROM orders o
LEFT JOIN order_status_log l
  ON l.log_id = (
       SELECT l2.log_id
       FROM order_status_log l2
       WHERE l2.order_id = o.order_id
       ORDER BY l2.changed_at DESC, l2.log_id DESC
       LIMIT 1
     );

PostgreSQL 还提供了更简化的 DISTINCT ON 语法,可以直接按 order_id 去重并保留排序后的第一条。它的可读性不错,但可移植性较差,迁移到其他数据库时仍需要改成窗口函数方案。

SELECT DISTINCT ON (order_id) order_id, status, changed_at, note
FROM order_status_log
ORDER BY order_id, changed_at DESC, log_id DESC;

四、性能优化与执行计划要点

窗口函数方案的核心代价在排序阶段。数据库需要先按 order_id 分区,再按 changed_at 和 log_id 排序,如果 order_status_log 表很大,建议在流水表上建立复合索引。比较理想的索引是 (order_id, changed_at, log_id),有条件的话可以尝试降序索引 (order_id, changed_at DESC, log_id DESC)。不过优化器是否使用降序索引与数据库版本、执行计划代价估算有关,并不保证一定能消除 Sort 节点。

在实际业务中可以把“最近记录”的时间范围再往前推一点,例如只对最近 90 天的流水做窗口函数编号,超出范围的历史数据可视业务需求直接忽略或使用归档查询。这样能显著降低排序数据量。如果流水表已有 partition by 范围分区,也可以利用分区裁剪让窗口函数只在少数分区上计算。

另外要注意 changed_at 为 NULL 的情况。NULL 在 ORDER BY 中默认排在最前,如果 ORDER BY changed_at DESC 需要 NULL 排最后,可以写成 ORDER BY changed_at DESC NULLS LAST,但不同数据库语法略有差异。若时间字段同时存在时区混乱,建议在写入前统一成 UTC 或使用带时区的时间类型,避免跨机房、跨地区数据落库后排序失真。最后提醒一点:必须给排序字段加上唯一键或主键作为次级排序条件,否则时间相同的多条记录会带来随机结果,表面上 SQL 能跑,数据核对时却可能出现偶发不一致。

广告位 · 文章底部(后台-模块管理替换此区域)