ROW_NUMBER()可为无主键表编号,但无法直接删除,因缺少唯一标识定位行;须借助ctid/rowid或哈希构造锚点,再通过CTE+子查询安全删除。

没主键也没唯一索引,ROW_NUMBER() 仍能编号,但不能直接删
ROW_NUMBER() 本身不依赖主键,它只按 PARTITION BY 和 ORDER BY 生成临时序号。问题在于:没有唯一标识(如 id 或 ctid),你就无法在 DELETE 语句中精准定位并删除“第 2 行”——因为多行内容完全相同,数据库无法区分哪条该留、哪条该删。
常见错误是写成:
DELETE FROM t WHERE rn > 1
这会报错(PostgreSQL 提示 “window functions are not allowed in WHERE”),或被 MySQL 拒绝(8.0+ 也不允许直接在 WHERE 中用窗口函数)。
- 必须通过 CTE 或子查询先算出要删的行标识(如
ctid、rowid或拼接哈希) - 若表真无任何物理锚点(连
ctid都不可用),就别硬删,改用CREATE TABLE AS SELECT DISTINCT重建 - SQLite 不支持
ctid,且 3.25+ 才有窗口函数,老版本只能导出后 dedup
用 ctid 或 rowid 临时锚定物理位置(PostgreSQL / SQLite / Oracle)
PostgreSQL 的 ctid 是每行的物理地址,稳定、唯一、无需索引,适合无主键表的去重定位。
示例(保留每组重复中物理位置最小的那行):
WITH ranked AS (
SELECT ctid,
ROW_NUMBER() OVER (
PARTITION BY col1, col2, col3
ORDER BY ctid
) AS rn
FROM raw_table
)
DELETE FROM raw_table
WHERE ctid IN (SELECT ctid FROM ranked WHERE rn > 1);-
ctid不可被索引,但用于小到中等规模表(百万级以内)足够快 - 别用
ORDER BY ctid DESC试图“留最新”,ctid只反映插入顺序,不等于业务时间 - SQLite 用
rowid替代:ORDER BY rowid;Oracle 用ROWID(大写) - 执行前务必加事务:
BEGIN; ... ROLLBACK;,防止误删全表
字段拼接哈希作为排序锚点(跨数据库通用)
当 ctid/rowid 不可用,或你想按业务语义“稳定排序”(比如让相同内容每次排序结果一致),就得构造确定性排序键。
核心是避免 || 拼接时遇到 NULL 导致整值为 NULL:
ROW_NUMBER() OVER (
PARTITION BY col1, col2
ORDER BY md5(
COALESCE(col1::text, '') || '|' ||
COALESCE(col2::text, '') || '|' ||
COALESCE(col3::text, '')
)
)- PostgreSQL 用
md5(),MySQL 用sha2(..., 256),SQL Server 用HASHBYTES('SHA2_256', ...) - 分隔符
|防止'ab'+'c'和'a'+'bc'哈希冲突 - SQLite 不支持
md5(),可用quote(col1) || quote(col2)或升级到 3.38+ 用json_object() - 哈希计算开销大,千万级表慎用;优先考虑加
ctid或补唯一列
真正安全的底线:先查再删,永远不跳过验证
无主键表去重不是“写完就跑”的操作,而是高风险数据变更。最容易被忽略的不是语法,而是验证节奏。
- 第一步永远是:
SELECT COUNT(*) FROM (your ranked subquery) t WHERE rn > 1,确认要删多少行 - 第二步抽样检查:
SELECT *, rn FROM (subquery) t WHERE rn > 1 LIMIT 5,看删的真是重复行,不是因NULL或类型隐式转换导致的误分组 - 如果
PARTITION BY字段含大量NULL,PostgreSQL 默认把它们归为一组,MySQL 可能分开——用IS NULL显式控制分组边界 - 线上执行必须套事务,且备份语句比优化更重要:
CREATE TABLE raw_table_bak AS SELECT * FROM raw_table;

















