一个很常见的需求是:在 SQL 里删除重复行,只保留一条。很多人第一反应想到窗口函数,毕竟 ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) 能轻松给数据排出行号。但有个问题——窗口函数不能直接用在 DELETE 的 WHERE 子句里。直接写 DELETE FROM t WHERE ROW_NUMBER() OVER (...) = 1 是行不通的,数据库会直接报错,告诉你窗口函数只能出现在 SELECT 或 ORDER BY 子句中。
所以,正确的思路是先通过子查询或 CTE 把行号算出来,然后在外层 DELETE 里根据条件(比如 rn > 1)去删除。这里的核心逻辑分两部分:PARTITION BY 定义什么算“重复”,ORDER BY 决定你留下哪一条(比如时间最晚的那一条)。
窗口函数本身不能直接删除数据
这个问题值得先明确一下。你不能在 DELETE 语句的 WHERE 条件里直接调用窗口函数,比如 DELETE FROM t WHERE ROW_NUMBER() OVER (...) = 1 这种写法就是犯规的,数据库会抛出错误提示:“Windowed functions can only appear in the SELECT or ORDER BY clauses”。窗口函数允许出现的位置就那么几个:SELECT 子句、ORDER BY 子句,某些数据库里还能用于 GROUP BY,但绝不支持用在 WHERE 或 DELETE 的过滤条件中。
真正可行的路径是:先用窗口函数把要删的行“找出来”,然后把结果作为子查询或公共表表达式(CTE)提供给 DELETE 使用。这是一个必须记住的基本约束。
用 CTE + ROW_NUMBER() 删除重复行保留最新一条
这是最常见的使用场景。某个业务键存在重复记录(比如同一个 user_id 有多条数据),你想按照某个时间字段(比如 updated_at)只保留最新的一条,把其他的都删掉。
- 必须用
WITH定义 CTE,在里面计算ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC) - DELETE 操作需要基于 CTE 的别名来执行。PostgreSQL 和 SQL Server 对这种用法支持比较直接;MySQL 8.0+ 的话,需要用
DELETE ... FROM cte JOIN table这种写法绕一下 - 排序方向很关键:用
DESC才能让最新记录排到第 1 位,否则就会误删你要留的那条 - 以 PostgreSQL 为例,标准写法是这样的:
WITH ranked AS ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY updated_at DESC ) rn FROM users)DELETE FROM usersUSING rankedWHERE users.id = ranked.id AND ranked.rn > 1;
MySQL 8.0 中需绕过 CTE 直接删除的限制
MySQL 有一个比较“坑”的限制:不允许在 DELETE 中直接引用同一个表的 CTE,否则会报错——“You can't specify target table for update in FROM clause”。换句话说,你不能让它自己在自己身上动刀子。解决方案通常有两种:
- 采用派生表(子查询再套一层)
- 用临时表保存要删除的 id 列表
- 推荐第一种,因为不需要显式创建临时表,更简洁
- 比如删除重复邮箱中创建时间较早的记录,可以这样写:
DELETE FROM usersWHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY email ORDER BY created_at ASC ) rn FROM users ) t WHERE t.rn > 1);
注意这里最外层套了一层 SELECT id FROM ( ... ) t,这层包装是必不可少的,少了它 MySQL 就会报错。
性能和事务安全提醒
窗口函数本身的性能还算不错,但一旦和 DELETE 搭配起来,有两个容易忽略的地方需要特别留意:
- 在执行删除前,务必确认
PARTITION BY指定的字段上有索引。否则窗口函数内部做排序的开销会非常大,尤其是在大表上 - DELETE 是 DML 操作,会锁行甚至锁表。建议在业务低峰期执行,或者在数据量特别大时分批次删除(例如加上
LIMIT 1000然后循环执行) - 一个值得养成的好习惯:永远先用 SELECT 验证一下 CTE 或子查询的结果对不对。比如先跑一下
SELECT * FROM (CTE) WHERE rn > 1,确认要删的确实是那些该删的行 - 另外需要注意的是,某些旧版本的数据库,比如 PostgreSQL 的 USING 语法在不同版本之间有细微差别,生产环境执行前最好在同版本环境下做一次实测
说到底,窗口函数只是一个“找人”的工具,真正执行删除操作的是 DELETE。找得准不准,全看你的 PARTITION BY 和 ORDER BY 写得对不对——工具本身再强大,也得看怎么用它。
