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

MySQL 窗口函数按时间去重时如何保留最新行

来源:17golang原创

时间:2026-09-15 07:09:41 312浏览 收藏

日志表、订单状态表、同步快照表经常会出现同一个业务键的多条记录。要按时间去重并保留最新行,最稳妥的写法是:用 ROW_NUMBER() 在每个业务分组内按时间倒序编号,再在外层筛选 rn = 1。不要直接用 GROUP BY 配合 MAX(updated_at),那样很容易丢掉与最新时间对应的其他列。

官方地址:https://dev.mysql.com/doc/refman/8.4/en/window-functions-usage.html

要点速览
  • PARTITION BY 决定“同一组”是什么,ORDER BY 决定哪一行排在最前。
  • 时间相同时追加唯一键倒序,避免同一批数据每次返回不同记录。
  • 窗口函数结果要放进 CTE 或派生表,再由外层过滤 rn = 1

先把“同一条记录”和“最新”定义清楚

我处理这类数据时,第一步不是写 SQL,而是先确认去重键。例如一个账号下同一个业务编码只能留一行,就用 account_id, biz_key 作为分组键;如果同一编码在不同租户之间互不相同,还必须把 tenant_id 加进去。分组键少了,会把本不该合并的记录删掉。

“最新”通常对应 updated_at,但创建时间、采集时间、状态生效时间的含义并不一样。还要确认它是否允许 NULL,以及精度是否只有秒级。如果多个记录共享同一个时间,必须准备一个唯一且稳定的次排序列,常见的是自增 id

用 ROW_NUMBER() 给每个分组排出第一行

下面的例子按账号和业务键去重,保留更新时间最大的完整记录。窗口函数本身仍会为每一行产生结果,所以先在 CTE 中增加序号,再从外层取第一行。

WITH ranked AS (
  SELECT
    id, account_id, biz_key, updated_at, payload,
    ROW_NUMBER() OVER (
      PARTITION BY account_id, biz_key
      ORDER BY updated_at DESC, id DESC
    ) AS rn -- 时间相同则用唯一 id 稳定决胜
  FROM activity_log
)
SELECT id, account_id, biz_key, updated_at, payload
FROM ranked
WHERE rn = 1; -- 外层过滤,保留每组排名第一的完整行

PARTITION BY 只负责划分窗口,不会像 GROUP BY 那样把行合并;ORDER BY updated_at DESC 让最新时间排在前面,id DESC 则处理同一时间的并列。MySQL 手册也明确说明,ROW_NUMBER() 在没有排序时编号是不确定的,因此不要省略窗口内的排序。

MySQL ROW_NUMBER 按 account_id 和 biz_key 分组并连接更新时间与唯一 id 的静态查询结构图
图1:MySQL 去重查询结构示意图;业务键进入分组边界,更新时间和唯一 id 共同决定组内的稳定排序。

并列时间、NULL 和性能取舍要单独处理

如果只写 ORDER BY updated_at DESC,同一组里时间相同的行都是并列项,数据库没有义务替你选择固定的一行。追加 id DESC 后,规则就变成“时间更新者优先,时间相同取 id 较大者”。如果业务上不允许任意决胜,则应增加版本号或明确的生效序列,而不是把选择交给执行计划。

降序排序时 NULL 的位置也要确认。MySQL 中升序 NULL 排在前面,降序 NULL 排在后面;如果 NULL 代表“未知”而不是“最旧”,可以显式写出业务规则,例如用 CASE WHEN updated_at IS NULL THEN 1 ELSE 0 END 作为首个排序表达式,并在注释中说明原因。

数据量较大时,窗口排序仍可能需要处理大量候选行。先用明确的时间范围、租户条件或状态条件缩小输入,再观察 EXPLAIN,通常比盲目添加索引更可靠。索引列顺序要围绕真实过滤条件和分组键设计,不能因为查询里出现了 ORDER BY 就假设一定可以完全避免排序。

MySQL 去重结果中的业务键、更新时间、唯一 id、CTE 序号和 rn 等于 1 的静态关系图
图2:最新行保留边界示意图;并列决胜、NULL 规则和外层 rn=1 共同决定最终留下哪条完整记录。

上线前用四项检查确认结果

检查项要确认的内容
分组键是否包含租户、账号和业务对象的全部唯一维度
时间字段是否真代表最新状态,时区、精度和 NULL 语义是否明确
并列规则是否有唯一键、版本号或业务序列作为次排序
结果复核随机抽取一个分组,检查留下的行确实对应最大时间及决胜列

如果只是展示最新状态,这个查询通常足够;如果要把去重结果写回新表或删除历史行,则应先备份、统计重复组数量,并把写操作放到可回滚的变更流程里。

常见问题

为什么不能直接用 MAX(updated_at)?

MAX() 只能得到最大时间,不能自动带回该时间对应的完整行;窗口编号可以保留整行字段。

ROW_NUMBER() 和 RANK() 怎么选?

要每组严格保留一行,用 ROW_NUMBER();如果并列记录都应该保留,再考虑 RANK()DENSE_RANK()

为什么 rn 不能直接写在 WHERE 里?

窗口函数在当前查询层生成结果,先放入 CTE 或派生表,再由外层按 rn 过滤,边界最清楚。

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