DELETE不能直接套ROW_NUMBER(),因数据库禁止对同一表既读又写;必须用带别名的CTE封装窗口函数,且需满足PARTITION BY覆盖所有去重列、ORDER BY含稳定排序列、DELETE直接引用CTE别名三个条件。

为什么DELETE不能直接套ROW_NUMBER()
SQL Server(以及 PostgreSQL、Oracle 等)明确禁止在 DELETE 语句的 WHERE 子句中直接使用窗口函数。你如果写成这样:
DELETE FROM users WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
FROM users
) t WHERE rn > 1
);会收到类似 Invalid column name 'rn' 或 Cannot specify target table 'users' for update in FROM clause 的错误。根本原因是:数据库不允许在同一个语句中,对一张表既读又写,而子查询里的 users 和外层 DELETE FROM users 被视为同一目标。
CTE 不是“语法糖”,它是绕过该限制的必要桥梁——它把窗口计算结果封装成一个可更新的逻辑视图,让 DELETE FROM cte_name 有明确、隔离的操作对象。
CTE + ROW_NUMBER() 的写法必须满足三个硬条件
漏掉任意一个,轻则删错,重则全表清空:
-
PARTITION BY必须包含所有判定“重复”的列,例如去重依据是user_id和product_id,就得写PARTITION BY user_id, product_id;只写一个会扩大分组范围 -
ORDER BY必须含能稳定排序的列(通常是主键或时间戳),例如ORDER BY created_at DESC保留最新、ORDER BY id ASC保留最早;只写ORDER BY email会导致同组内排序不可控,rn = 1的行每次执行都可能不同 - CTE 必须带别名(哪怕只是
AS t),且DELETE FROM t必须直接引用该别名;写成DELETE FROM users WHERE id IN (SELECT id FROM t)就退回了不支持的子查询模式
删之前不查,等于闭眼开枪
真实生产事故里,80% 的误删源于没跑预览语句。你必须把 DELETE 换成 SELECT * 先看清楚:
WITH duplicates AS (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY email ORDER BY id ASC
) AS rn
FROM users
)
SELECT * FROM duplicates WHERE rn > 1;特别注意两点:
- NULL 值在
PARTITION BY中默认被视为相等——email IS NULL的两行会被归为一组,即使业务上“未知邮箱”不等于“相同邮箱”。如需规避,改用PARTITION BY ISNULL(email, '') - 如果表无主键或时间字段,
ORDER BY (SELECT NULL)虽语法通过,但无法保证哪条被保留,属于高危操作,应优先补主键
不同数据库的兼容性差异很实际
同一段 CTE + ROW_NUMBER() 语句,在不同数据库行为不同:
- SQL Server 和 PostgreSQL 支持
WITH cte AS (...) DELETE FROM cte WHERE ...,但 PostgreSQL 要求 CTE 后紧跟 DELETE,中间不能有其他语句 - MySQL 8.0+ 支持 CTE,但旧版(5.7 及更早)完全不支持,只能退化为自连接或临时表
- PostgreSQL 还可走
ctid路线:DELETE FROM t USING (SELECT ctid FROM (SELECT ctid, ROW_NUMBER() ...) AS x WHERE rn > 1) AS y WHERE t.ctid = y.ctid,但它依赖物理存储位置,高并发下可能误删
最稳的路径,就是始终用带别名的 CTE 包裹,ORDER BY 锚定唯一列,并在 DELETE 前强制执行一次 SELECT 验证——这些步骤不是繁琐,而是防止你下次加班修数据的底线。

















