ROW_NUMBER() 可标记每组最旧记录并删除,需按 created_at 和 id 升序编号取 rn=1;MySQL 5.7 以下需用自连接模拟;PostgreSQL 推荐 CTE;删前必须测试 SELECT 和 LIMIT,防范误删。

用 ROW_NUMBER() 标记每组最旧记录再删除
直接 DELETE 无法在子查询中引用自身表(MySQL 8.0+ 除外),所以得靠窗口函数先识别出“每组中最旧的那条”——通常指 created_at 或 id 最小的记录。核心思路是:给每组按时间/ID升序编号,取 ROW_NUMBER() = 1 的行。
注意:必须确保排序字段能唯一确定“最旧”,否则并列时结果不可控。如果 created_at 有重复,建议加上 id 作为第二排序条件。
DELETE FROM orders
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at ASC, id ASC
) AS rn
FROM orders
) t
WHERE rn = 1
);
MySQL 5.7 或更老版本不支持窗口函数怎么办
老版本只能靠关联子查询或自连接模拟“每组最小值”。但要注意:若组内多条记录共享同一个最小 created_at,这种写法会删掉全部,不是只删一条。
- 用
(user_id, created_at)联合唯一标识最旧时间点,再找该时间点中id最小的那条 - 避免直接
WHERE created_at = (SELECT MIN(created_at) ...),它可能误删多条
DELETE o1 FROM orders o1
INNER JOIN orders o2
ON o1.user_id = o2.user_id
AND (
o1.created_at > o2.created_at
OR (o1.created_at = o2.created_at AND o1.id > o2.id)
);
PostgreSQL 中用 CTE 更安全
CTE 可读性好,且能明确控制只删一条。相比嵌套子查询,它天然规避了 MySQL 那类“不能在子查询中修改目标表”的报错。
关键点:WITH 子句必须带别名(如 t),且 DELETE 的 USING 要引用该别名;ctid 不推荐依赖,优先用主键。
WITH oldest AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY updated_at ASC, id ASC
) AS rn
FROM products
)
DELETE FROM products
USING oldest
WHERE products.id = oldest.id AND oldest.rn = 1;
删之前务必加 WHERE 条件和 LIMIT 测试
真实环境里,没加限制的 DELETE 是高危操作。哪怕逻辑正确,也可能因数据倾斜导致删掉远超预期的行数。
- 先用
SELECT替换DELETE,确认结果集大小和内容 - 加
LIMIT 10(仅限测试)观察是否真删的是最旧的 - 生产库操作前,确保已对目标表做了
pg_dump或mysqldump - 如果表很大,考虑分批删,避免长事务锁表
真正麻烦的不是语法,而是“最旧”定义模糊时产生的歧义——比如时间精度丢失、时区未归一、NULL 值参与排序。这些细节往往在测试数据里看不出来。

















