必须用ROW_NUMBER()标记重复行并保留最新一条,因其能强制唯一排序;误用RANK()或DENSE_RANK()会导致并列第一而残留重复;需结合PARTITION BY与ORDER BY DESC,并妥善处理空值、时区及业务逻辑验证。

用 ROW_NUMBER() 标记重复行并保留最新一条
窗口函数本身不能直接删除数据,但可以精准识别哪些是“该删的重复归档记录”。核心思路是:按业务分组(如 user_id、archive_date)排序,用 ROW_NUMBER() 给每组内的记录编号,把旧的(非第一条)标为待删。
常见错误是误用 RANK() 或 DENSE_RANK() —— 它们对相同值会赋予相同序号,导致多条“并列第一”,删掉后仍残留重复。必须用 ROW_NUMBER() 强制唯一排序。
示例场景:历史归档表 archive_log 中,同一 task_id 可能多次归档,只留 created_at 最大的那条:
SELECT * FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY task_id
ORDER BY created_at DESC
) AS rn
FROM archive_log
) t WHERE rn > 1;这条查询能安全预览将被删除的记录,确认无误后再执行删除。
在 DELETE 中嵌套窗口函数需借助 CTE 或子查询
MySQL 8.0+、PostgreSQL、SQL Server 支持在 DELETE 中用 CTE 引用窗口结果;但 MySQL 5.7 或旧版 PostgreSQL 不支持直接在 DELETE 里写窗口函数,必须用子查询或临时表中转。
安全删除写法(以 PostgreSQL / MySQL 8.0+ 为例):
WITH duplicates AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY task_id
ORDER BY created_at DESC
) AS rn
FROM archive_log
)
DELETE FROM archive_log
WHERE id IN (SELECT id FROM duplicates WHERE rn > 1);注意点:
-
id必须是主键或唯一标识字段,避免误删 - 若表无主键,改用
ctid(PostgreSQL)或加临时唯一标识列,否则无法精确定位 - 大表操作前务必加索引:
(task_id, created_at)复合索引能显著提升PARTITION BY+ORDER BY效率
归档时间字段为空或时区不一致会导致排序错乱
历史归档数据常有 created_at IS NULL 或混用本地时间/UTC 时间,此时 ORDER BY created_at DESC 会让空值排在最前(PostgreSQL 默认)或最后(MySQL),导致本该保留的新记录被标为 rn > 1。
应对方式:
- 显式处理空值:
ORDER BY created_at DESC NULLS LAST(PostgreSQL),MySQL 则用ORDER BY created_at IS NULL, created_at DESC - 统一时区:归档前确保所有时间存为 UTC,或在窗口函数中用
CONVERT_TZ()/AT TIME ZONE标准化 - 若归档逻辑依赖事件顺序而非时间戳,考虑引入单调递增的
version或seq_no字段替代时间排序
物理删除前建议先软删或备份关键字段
归档记录一旦误删很难恢复,尤其跨多系统同步的场景。比直接 DELETE 更稳妥的做法是先加软删标记:
UPDATE archive_log
SET deleted = true, deleted_at = NOW()
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY task_id
ORDER BY created_at DESC
) AS rn
FROM archive_log
) t WHERE rn > 1
);后续观察几天无异常,再批量 DELETE WHERE deleted = true。如果连软删都怕出问题,至少导出待删记录的 id 和 task_id 到临时表,作为回滚依据。
真正难的不是写对窗口函数,而是确认“哪条才算最新”——业务定义模糊、时间精度不足、归档触发时机不一致,都会让 ORDER BY 失效。别跳过这一步验证。

















