最稳妥的做法是用row_number()给每组内行编号,只保留序号为1的那行;需用子查询包裹避免直接嵌套删除报错,并确保id非空或改用not exists防null失效。

用 ROW_NUMBER() 标记重复行再删
直接在 GROUP BY 查询里加 DELETE 会报错,因为 SQL 不允许对含聚合或分组的查询结果执行删除。必须把重复判定逻辑转成可定位具体行的方式。最稳妥的做法是用窗口函数 ROW_NUMBER() 给每组内行编号,只保留序号为 1 的那行。
假设表 users 中 email 字段重复,要按 email 去重,保留 id 最小的记录:
DELETE FROM users
WHERE id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
FROM users
) t
WHERE t.rn = 1
);
-
PARTITION BY email定义分组依据,每组内email相同 -
ORDER BY id决定哪一行被标为rn = 1;换成created_at DESC就能留最新的一条 - 别直接在子查询里
DELETE FROM (...)—— 多数数据库(如 MySQL 8.0+、PostgreSQL)不支持对同一张表的嵌套引用,必须包一层子查询 alias - 如果表没主键或
id不唯一,得用ctid(PostgreSQL)或%%physloc%%(SQL Server)等物理标识,但风险更高
MySQL 低版本(5.7 及以前)的替代写法
MySQL 5.7 不支持在子查询中引用目标表,上面的写法会报错 You can't specify target table 'users' for update in FROM clause。得绕过这个限制:
CREATE TEMPORARY TABLE tmp_keep AS SELECT MIN(id) AS id FROM users GROUP BY email; DELETE FROM users WHERE id NOT IN (SELECT id FROM tmp_keep); DROP TEMPORARY TABLE tmp_keep;
- 不能用
DELETE ... JOIN直接删,因为JOIN后的去重逻辑容易误删——比如LEFT JOIN条件写错,可能把所有行都删掉 - 临时表必须带
TEMPORARY,否则并发时可能冲突;且生命周期只在当前会话有效 - 如果数据量大,
MIN(id)聚合本身快,但后续NOT IN在空值存在时会失效——确保id列非空,或改用NOT EXISTS
PostgreSQL 中用 ctid 避免依赖业务字段
当没有可靠唯一字段(比如只有几个文本列全重复),又不想加主键,可以用 PostgreSQL 的系统列 ctid 定位物理行:
DELETE FROM users WHERE ctid NOT IN ( SELECT min(ctid) FROM users GROUP BY email );
-
ctid是行在磁盘上的位置标识,每次VACUUM后可能变化,所以仅适合一次性清理,不能用于长期逻辑 - 不能在复制环境或逻辑备库上依赖
ctid,它不保证主从一致 - 如果表启用了
row security policies或有BEFORE DELETE触发器,ctid删除仍会触发它们
删之前务必先备份或用 SELECT 验证
重复数据清理是不可逆操作。哪怕语句看起来简单,也极容易因条件写错导致整表清空。
- 永远先跑一遍对应
SELECT:比如把DELETE换成SELECT *,确认输出的确实是想删的那些行 - 在生产环境执行前,至少做一次
pg_dump -t users或mysqldump --no-create-info db users > backup.sql - 如果表有外键引用,
DELETE可能失败;先查pg_constraint或INFORMATION_SCHEMA.KEY_COLUMN_USAGE确认依赖关系 - 高并发场景下,建议加
FOR UPDATE SKIP LOCKED(PostgreSQL)或用应用层加锁,避免其他事务同时修改同一组数据
真正麻烦的不是语法怎么写,而是判断“哪些算重复”——业务定义模糊时,email 全小写比对?空格要不要 trim?时间字段精度取到秒还是毫秒?这些细节一旦漏掉,删完才发现留下的不是“干净数据”,而是另一种脏数据。











