登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  MySQL

MySQL 窗口函数按分组取每组最新记录的实现方法

来源:17golang原创

时间:2026-09-15 20:06:46 185浏览 收藏

我在整理订单状态报表时,最容易写错的不是“按时间倒序”,而是“每组只留一条”这一句。直接给每个客户排序并不能自动减少结果行,GROUP BY 也会把明细列的选择变得含糊。更稳的做法是先用 ROW_NUMBER() 在每个分组内编号,再由外层查询筛出 rn = 1

要点速览
  • PARTITION BY 决定“每组”的边界,ORDER BY 决定最新记录的顺序。
  • 时间相同不能只依赖数据库的自然顺序,应追加唯一键作为稳定的第二排序条件。
  • 窗口函数先产生编号,外层查询再过滤;过滤条件和索引要放在正确的数据边界上。

先把“每组最新”拆成三个字段

下面假设表 order_status_events 保存订单状态事件,customer_id 是分组键,updated_at 表示事件时间,event_id 是唯一递增标识。实际业务中,分组键也可能是门店、设备或项目,关键是先说清楚一组到底代表什么。

问题对应 SQL 位置判断标准
按谁分组PARTITION BY customer_id同一客户的事件进入同一窗口
什么叫最新ORDER BY updated_at DESC时间越晚排名越靠前
时间相同怎么办event_id DESC用唯一键固定最终选择
MySQL ROW_NUMBER 按 customer_id 分组并按 updated_at 和 event_id 排序的查询结构说明图
图1:MySQL 窗口分组与稳定排序的结构说明图,展示分组键、排序键和行号的静态关系,不是运行截图。

用 ROW_NUMBER() 给每个分组编号

窗口函数不会像 GROUP BY 那样把一组折叠成一行,而是为每条明细计算一个结果。MySQL 官方语法中,PARTITION BY 划分窗口,窗口内的 ORDER BY 负责排序;因此可以把“取最新”写成下面的 CTE。

WITH ranked_events AS (
    SELECT
        customer_id,
        order_id,
        status,
        updated_at,
        event_id,
        ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY updated_at DESC, event_id DESC
        ) AS rn -- 先按时间,再用唯一键稳定打破并列
    FROM order_status_events
)
SELECT
    customer_id,
    order_id,
    status,
    updated_at
FROM ranked_events
WHERE rn = 1 -- 外层过滤每组的第一名
ORDER BY customer_id;

这里的 rn = 1 不是在同一层窗口表达式旁边直接过滤,而是放在外层查询。这样写也方便后续把 rn 改成“每组最新三条”。

时间相同仍要给出确定结果

只写 ORDER BY updated_at DESC 时,如果同一客户有两条事件的时间完全相同,它们在窗口排序中属于并列记录,哪一条得到第一名就不再是明确的业务规则。生产查询应追加能唯一确定顺序的字段,例如 event_id、雪花 ID 或可靠的写入序号。

如果业务口径是“每个订单最新状态”,窗口应改为 PARTITION BY order_id;如果是“每个客户每种状态各取一条”,则要写成 PARTITION BY customer_id, status。这不是换一个字段的小修饰,而是在改变结果的粒度,最好先用三四组样例数据核对。

MySQL 窗口查询从明细行到 rn 等于 1 结果集的边界关系说明图
图2:从明细事件到 rn = 1 结果集的边界说明图,强调外层筛选、并列时间和不同分组粒度的关系,不是运行截图。

过滤范围、索引和复核清单

如果只查最近 30 天的事件,应把时间条件放进 CTE,让窗口函数只对目标范围编号:

WITH ranked_events AS (
    SELECT
        customer_id, order_id, status, updated_at, event_id,
        ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY updated_at DESC, event_id DESC
        ) AS rn -- 窗口只处理已经过滤的数据
    FROM order_status_events
    WHERE updated_at >= '2026-08-16 00:00:00' -- 示例时间边界
)
SELECT customer_id, order_id, status, updated_at
FROM ranked_events
WHERE rn = 1;

索引可以从过滤列、分组列和排序列的组合开始评估,例如 (updated_at, customer_id, event_id) 或按实际过滤方式调整;窗口排序是否仍需要额外工作,要以 EXPLAIN 的访问路径和数据规模为准,不能只凭索引名字判断。

  • 核对分组列是否真的是业务口径,而不是误把 order_id 当成客户分组。
  • 给时间相同的样例补上唯一键,确认重复执行得到同一条记录。
  • 先限定数据范围再编号,避免把历史全量数据带进当前报表。

常见问题

为什么不能直接 GROUP BY customer_id 取 updated_at?

MAX(updated_at) 只能得到最大时间,不能自动把同一行的 order_idstatus 一起带出来。先编号再取第一名,才能保留完整明细。

RANK() 能不能替代 ROW_NUMBER()?

如果并列时间需要全部保留,可以考虑 RANK();如果要求每组严格一行,就使用 ROW_NUMBER() 并补充唯一排序键。

窗口函数是不是一定比相关子查询快?

不能一概而论。窗口写法更直接,但最终仍受过滤范围、数据量、索引和排序代价影响,应结合实际数据用 EXPLAIN 比较。

这类查询的核心不是记住一段固定 SQL,而是先确定分组粒度,再用完整排序条件把“最新”定义成一个确定结果。只要 PARTITION BY、排序键和外层 rn = 1 三处口径一致,后续扩展到每组 Top N 也只是调整筛选条件。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>