必须先select预览再delete,因row_number()仅按partition by和order by机械编号,不保证逻辑正确;漏tenant_id会跨租户误删,null值需显式处理,order by须稳定(如加二级排序),且各数据库delete语法差异大,硬套必报错。

ROW_NUMBER()删重前必须先SELECT预览
直接执行DELETE等于开盲盒,90%的误删都源于没验证逻辑。窗口函数本身不保证“哪条该留”,只按你写的PARTITION BY和ORDER BY机械编号。比如PARTITION BY email但漏了tenant_id,多租户系统里就会跨租户删数据。
正确做法是把DELETE里的子查询或CTE原样改成SELECT,跑一遍看结果:
SELECT id, email, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn FROM users- 重点检查:
rn = 1的行是不是你真想保留的(比如id是否最小、时间是否最新) - 查出
rn > 1的行数是否符合预期,避免因NULL值导致漏判(email IS NULL的组不会被PARTITION BY email捕获)
PARTITION BY字段必须覆盖全部重复判定条件
业务上“重复”的定义决定PARTITION BY写什么。只写email却忽略phone,会导致手机号不同但邮箱相同的用户被误删;反之,多加一个无关字段如updated_at,会让本该合并的重复行分散成多个组,删不干净。
常见场景判断:
- 去重依据是“邮箱+手机号” →
PARTITION BY email, phone - 租户隔离场景 → 必须加上
tenant_id,否则A租户用户会删掉B租户同邮箱用户 - 时间戳字段含NULL →
ORDER BY updated_at DESC NULLS LAST显式控制NULL排在末尾,避免rn = 1选中NULL记录
ORDER BY必须明确且稳定
ORDER BY不是可选项,它直接决定rn = 1归属。用ORDER BY created_at ASC想留最早记录,结果写了DESC就全反了;更隐蔽的是时间精度问题——并发写入导致多条记录created_at完全相同,此时仅靠时间排序,ROW_NUMBER()每次执行分配的序号可能不同。
解决方案:
- 二级排序兜底:
ORDER BY created_at DESC, id DESC(主键或自增ID确保唯一性) - 避免单独用
datetime字段排序,尤其当数据库时间精度为秒时 - MySQL 8.0+ 中
ORDER BY (SELECT NULL)不推荐,语义模糊且部分版本行为不稳定
不同数据库的DELETE语法陷阱
同一段带ROW_NUMBER()的逻辑,在MySQL、PostgreSQL、SQL Server里写法完全不同,硬套会报错。
典型差异:
- PostgreSQL:用
DELETE FROM users USING (cte),不能写DELETE FROM cte - SQL Server:支持
WITH cte AS (...) DELETE FROM cte WHERE rn > 1 - MySQL 8.0+:不支持直接删CTE,得用
DELETE t FROM users t INNER JOIN cte r ON t.id = r.id WHERE r.rn > 1 - MySQL 5.7及更早:窗口函数不可用,只能改用自连接,且
DELETE u1 FROM users u1 JOIN users u2语法才有效
最容易被忽略的是锁表现象:大表执行时,自连接方案会锁全表两次,而CTE方案在PostgreSQL里可能触发Seq Scan加锁,生产环境务必在低峰期操作并设超时。











