SQL 数据分析 · 指南 · 2026年10月30日 · 13 分钟阅读
SQL 查找重复数据与删除重复行:GROUP BY、ROW_NUMBER 去重,附例子

在 SQL 里查找重复数据,就是按「应该唯一」的列分组,保留出现不止一次的组:GROUP BY col1, col2 HAVING COUNT(*) > 1。要看到完整的重复行,或者删掉多余副本只留一条,就在每个组内用 ROW_NUMBER() OVER (PARTITION BY col1, col2 ORDER BY ...) 给行编号:编号 1 是要保留的那一条,编号 2 及以上就是重复行。难点不在语法,而在于决定哪些列定义了「同一行」,以及该留下哪一份。
所有例子都基于 Nomto SQL 课程的航空公司数据库,使用 PostgreSQL 语法,运行在 Nomto SQL 练习场所用的 DuckDB 引擎上,每张结果表都是真实输出。练习场是只读的,所以文末的 DELETE 语句只展示语法;用来预览会删除哪些行的 SELECT 都实际运行过。
先定义什么是重复
app_events 表里有 1,494,723 条来自手机 App 和网站的事件:打开 App、搜索、查看报价、购买。每一行都有自己的 event_id,所以检查完全相同的副本什么也找不到:
SELECT COUNT(*) AS all_rows,
(SELECT COUNT(*) FROM (SELECT DISTINCT * FROM app_events) d) AS distinct_rows
FROM app_events;
| all_rows | distinct_rows |
|---|---|
| 1494723 | 1494723 |
这并不代表表是干净的。手机断网时,App 会重新发送事件,服务器用新的 event_id 和更晚的 received_at 再存一次。而事件本身,也就是某个客户端在某一时刻做了某件事,是同一个。所以这里的业务键是 (client_id, event_time, event_name),代理列 event_id 和 received_at 必须排除在比较之外。
这是任何查重工作的第一个决定:列出按数据含义必须唯一的列。把 ID 列放进比较,所有重复都会被藏起来。
查找单列中的重复值
最简单的情况是一列。哪些乘客姓名出现在不止一张机票上?
SELECT passenger_name, COUNT(*) AS tickets
FROM tickets
GROUP BY passenger_name
HAVING COUNT(*) > 1
ORDER BY tickets DESC
LIMIT 3;
| passenger_name | tickets |
|---|---|
| ALEKSANDR IVANOV | 842 |
| ALEKSANDR KUZNECOV | 755 |
| SERGEY IVANOV | 634 |
有 15,389 个姓名重复出现。但这些都不是数据错误:366,733 张机票只有 22,220 个不同的姓名,常见名字本来就有很多人同名,就像中文里的「张伟」。GROUP BY 把相同值的行合并,HAVING 在计数之后筛选分组(为什么 WHERE COUNT(*) > 1 行不通,见 GROUP BY 和 HAVING 的区别)。力扣上的「查找重复的电子邮箱」就是这个写法。
查找多列组合的重复
把业务键的每一列都放进 GROUP BY。app_events 里被重发的事件:
SELECT client_id, event_time, event_name, COUNT(*) AS copies
FROM app_events
GROUP BY client_id, event_time, event_name
HAVING COUNT(*) > 1
ORDER BY event_time
LIMIT 5;
| client_id | event_time | event_name | copies |
|---|---|---|---|
| 0586c43b2bb9 | 2026-07-01 00:09:01 | checkout_start | 2 |
| 0d823672b3be | 2026-07-01 00:11:20 | app_open | 2 |
| 00a725eaf49b | 2026-07-01 00:14:34 | add_passenger | 2 |
| 05416737ab4d | 2026-07-01 00:20:59 | select_fare | 2 |
| 184daf99054d | 2026-07-01 00:34:39 | add_passenger | 2 |
一共有多少重复?
把同一个查询包一层再求和。重复的组数和多余的行数是两回事,报告里经常混为一谈:
SELECT COUNT(*) AS duplicate_groups,
SUM(copies) AS rows_in_groups,
SUM(copies - 1) AS extra_rows
FROM (
SELECT client_id, event_time, event_name, COUNT(*) AS copies
FROM app_events
GROUP BY client_id, event_time, event_name
HAVING COUNT(*) > 1
) d;
| duplicate_groups | rows_in_groups | extra_rows |
|---|---|---|
| 10689 | 21378 | 10689 |
这里每个重复事件都恰好有两份,所以涉及 21,378 行,但只有 10,689 行需要删除。一行检查也能得到同样的答案,而不用列出各组:
SELECT COUNT(*) AS rows,
COUNT(DISTINCT (client_id, event_time, event_name)) AS unique_events,
COUNT(*) - COUNT(DISTINCT (client_id, event_time, event_name)) AS extra
FROM app_events;
| rows | unique_events | extra |
|---|---|---|
| 1494723 | 1484034 | 10689 |
COUNT(DISTINCT (a, b, c)) 这种括号里写一行的形式在 PostgreSQL 和 DuckDB 里可用;MySQL 和 Hive 写成 COUNT(DISTINCT a, b, c)。SQL Server 的 COUNT(DISTINCT ...) 只允许一个表达式,那里就用子查询的写法。
重复出现不一定是重复数据
业务键定义得不仔细,你就会「找到」其实是真实数据的重复。同一航班上的同名乘客看起来很可疑:
SELECT tf.flight_id, t.passenger_name,
COUNT(*) AS tickets,
COUNT(DISTINCT t.passenger_id) AS passengers
FROM ticket_flights tf
JOIN tickets t ON t.ticket_no = tf.ticket_no
GROUP BY tf.flight_id, t.passenger_name
HAVING COUNT(*) > 1
ORDER BY tickets DESC, tf.flight_id
LIMIT 3;
| flight_id | passenger_name | tickets | passengers |
|---|---|---|---|
| 298 | ALEKSANDR IVANOV | 5 | 5 |
| 30577 | ALEKSANDR IVANOV | 5 | 5 |
| 250 | SERGEY KUZNECOV | 4 | 4 |
这样的「航班 + 姓名」组合有 12,169 个,但每张机票的 passenger_id 都不同:298 号航班上有五个不同的人都叫 Aleksandr Ivanov。在 COUNT(*) 旁边加一个 COUNT(DISTINCT id),就能快速区分真正的重复和同名同姓。
显示完整的重复行
GROUP BY 每组只返回一行,其他列就丢了。要看到每一份副本的所有列,改用窗口函数计数,它会保留所有行(更多见 SQL 窗口函数详解):
SELECT event_id, client_id, event_time, received_at, event_name,
COUNT(*) OVER (PARTITION BY client_id, event_time, event_name) AS copies
FROM app_events
QUALIFY copies > 1
ORDER BY event_time, client_id, received_at
LIMIT 6;
| event_id | client_id | event_time | received_at | event_name | copies |
|---|---|---|---|---|---|
| 139 | 0586c43b2bb9 | 2026-07-01 00:09:01 | 2026-07-01 00:09:03 | checkout_start | 2 |
| 140 | 0586c43b2bb9 | 2026-07-01 00:09:01 | 2026-07-01 00:09:04 | checkout_start | 2 |
| 170 | 0d823672b3be | 2026-07-01 00:11:20 | 2026-07-01 00:11:21 | app_open | 2 |
| 172 | 0d823672b3be | 2026-07-01 00:11:20 | 2026-07-01 00:11:23 | app_open | 2 |
| 207 | 00a725eaf49b | 2026-07-01 00:14:34 | 2026-07-01 00:14:34 | add_passenger | 2 |
| 299 | 00a725eaf49b | 2026-07-01 00:14:34 | 2026-07-01 00:23:54 | add_passenger | 2 |
现在规律一目了然:同一时刻、同一操作,但第二份到得更晚。在全部 10,689 对中,重发发生在原事件之后 1 秒到 10 分钟之间,中位数是 24 秒。
QUALIFY 对窗口函数做筛选,就像 HAVING 对聚合做筛选。DuckDB、Snowflake、BigQuery 和 Databricks 支持它;PostgreSQL、MySQL 和 SQL Server 不支持,那里就把窗口放进子查询,在外层筛选:SELECT * FROM (SELECT ..., COUNT(*) OVER (...) AS copies FROM app_events) x WHERE copies > 1。
更老的办法是把原表和 GROUP BY ... HAVING 的结果按所有键列 JOIN 起来。哪里都能用,但要读两遍表。
用 ROW_NUMBER 保留第一条
去重的做法是在每组内给副本编号,保留编号 1。OVER 里的 ORDER BY 决定哪一份胜出;这里是最早收到的那份,并用 event_id 作决胜列,保证结果可复现:
WITH ranked AS (
SELECT event_id, client_id, event_time, received_at, event_name,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
)
SELECT rn, COUNT(*) AS rows
FROM ranked
GROUP BY rn
ORDER BY rn;
| rn | rows |
|---|---|
| 1 | 1484034 |
| 2 | 10689 |
WHERE rn = 1 得到 1,484,034 条事件的干净表;WHERE rn > 1 恰好是要删除的 10,689 行。如果 ORDER BY 不确定,每次运行数据库可能留下不同的副本。
留哪一份取决于数据。对重发的事件,最早的那条是原件。对多次导入的客户表,通常要最新的版本:ORDER BY updated_at DESC。对完整程度不同的行,可以先按「有没有邮箱」排序。为什么去重必须用 ROW_NUMBER 而不是 RANK,见 ROW_NUMBER、RANK、DENSE_RANK 的区别。
为什么重要:指标虚高
重复数据很少让查询报错,它只会悄悄把计数抬高。按平台统计的购买次数,原始值和去重后对比:
WITH ranked AS (
SELECT platform,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
WHERE event_name = 'purchase'
)
SELECT platform,
COUNT(*) AS raw_purchases,
COUNT(*) FILTER (WHERE rn = 1) AS real_purchases,
COUNT(*) - COUNT(*) FILTER (WHERE rn = 1) AS duplicates
FROM ranked
GROUP BY platform
ORDER BY platform;
| platform | raw_purchases | real_purchases | duplicates |
|---|---|---|---|
| android | 21052 | 20844 | 208 |
| ios | 14262 | 14161 | 101 |
| web | 16538 | 16473 | 65 |
Android 的重发最多:原始购买数大约高了 1%,是网页端 0.4% 的两倍多。量不大,但足以让不同平台之间的转化率或 A/B 测试对比发生偏移。
DISTINCT ON:PostgreSQL 里每个键取一行
PostgreSQL 和 DuckDB 有一个「每组取第一行」的快捷写法:DISTINCT ON (key) 按 ORDER BY 保留每个键的第一行。ORDER BY 必须以 DISTINCT ON 里的列开头:
SELECT DISTINCT ON (client_id, event_time, event_name)
event_id, client_id, event_time, event_name, received_at
FROM app_events
WHERE event_name = 'purchase'
ORDER BY client_id, event_time, event_name, received_at, event_id
LIMIT 3;
| event_id | client_id | event_time | event_name | received_at |
|---|---|---|---|---|
| 143160 | 000034d98b23 | 2026-07-05 10:26:38 | purchase | 2026-07-05 10:26:39 |
| 1108749 | 0001232bce1c | 2026-08-02 03:08:12 | purchase | 2026-08-02 03:08:13 |
| 815068 | 00013b82c7af | 2026-07-24 22:56:23 | purchase | 2026-07-24 22:56:23 |
在全表上运行,它返回的正是和 rn = 1 相同的 1,484,034 行。DISTINCT ON 更短,但 MySQL、SQL Server、Oracle 和 Hive 都没有,而且它只能「留一条」;ROW_NUMBER 还能留两条,或者只标记重复而不删除。
普通的 SELECT DISTINCT 只有在所有选中的列都相同时才去重,所以它只对完全相同的副本有用,在这里毫无作用,因为 event_id 和 received_at 不同。
删除重复行
删除任何东西之前,先运行列出「受害者」的 SELECT,核对数量。对重发的事件,要删除的是 rn > 1 的行:
SELECT COUNT(*) AS rows_to_delete
FROM app_events
WHERE event_id IN (
SELECT event_id
FROM (
SELECT event_id,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
) r
WHERE rn > 1
);
| rows_to_delete |
|---|
| 10689 |
这个数和 GROUP BY 检查得到的 extra_rows 一致,所以删除会从每一对里恰好去掉一份。把它改写成 DELETE,写法因数据库而异。
PostgreSQL 和 DuckDB
表里有唯一 ID 时,用同一个子查询按 ID 删除:
DELETE FROM app_events
WHERE event_id IN (
SELECT event_id
FROM (
SELECT event_id,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
) r
WHERE rn > 1
);
如果 PostgreSQL 的表完全没有唯一列(完全相同的副本),就用隐藏的物理行地址 ctid 区分副本:
DELETE FROM t a
USING t b
WHERE a.key_col = b.key_col
AND a.ctid > b.ctid; -- keeps the copy with the smallest ctid
在 DuckDB 里,重建表往往比删除更简单:
CREATE OR REPLACE TABLE app_events AS
SELECT * FROM app_events
QUALIFY ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) = 1;
SQL Server
SQL Server 允许通过只读一张表的 CTE 来删除,这是最短的写法:
WITH ranked AS (
SELECT ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
)
DELETE FROM ranked
WHERE rn > 1;
MySQL
MySQL 不允许 DELETE 的子查询直接从同一张表里查询(错误 1093,「You can’t specify target table for update in FROM clause」)。改为 JOIN 一个派生表。MySQL 8.0 及以上,有窗口函数:
DELETE e
FROM app_events e
JOIN (
SELECT event_id,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
) r ON r.event_id = e.event_id
WHERE r.rn > 1;
MySQL 5.7 没有窗口函数,就用自连接:如果有另一行相同键的记录更早到达,就删掉这一行。力扣「删除重复的电子邮箱」的经典答案也是这个思路(自连接的更多用法见 SQL 自连接详解)。
DELETE e1
FROM app_events e1
JOIN app_events e2
ON e2.client_id = e1.client_id
AND e2.event_time = e1.event_time
AND e2.event_name = e1.event_name
AND (e2.received_at < e1.received_at
OR (e2.received_at = e1.received_at AND e2.event_id < e1.event_id));
Hive 和 Spark SQL
数据仓库里的普通 Hive 表通常不支持按行 DELETE,常见做法是用 ROW_NUMBER 筛出 rn = 1 的行,覆盖写回表或分区:
INSERT OVERWRITE TABLE app_events
SELECT event_id, client_id, member_id, event_time, received_at,
platform, event_name, properties
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY client_id, event_time, event_name
ORDER BY received_at, event_id
) AS rn
FROM app_events
) r
WHERE rn = 1;
无论用哪种数据库,都要在事务里执行删除,把受影响的行数和 SELECT 的结果对比,确认无误再提交。在大表上,分批删除或重建表可以避免长时间锁表。
防止重复再次出现
删除修复的是过去,约束修复的是未来。在业务键上建唯一索引,数据库就会拒绝第二份副本:
CREATE UNIQUE INDEX app_events_dedup
ON app_events (client_id, event_time, event_name);
然后让导入程序容忍重复,而不是直接失败:PostgreSQL 和 DuckDB 用 INSERT ... ON CONFLICT DO NOTHING,MySQL 用 INSERT IGNORE 或 INSERT ... ON DUPLICATE KEY UPDATE,SQL Server 用 MERGE。还有重复数据时建索引会失败,所以要先清理。如果你改不了源表,就用 ROW_NUMBER 模式在视图或中间层模型里去重,让所有报表都指向它。
还有一个坑:GROUP BY 把所有 NULL 放进同一组,所以键列为 NULL 的行会被当成彼此重复。而 a.col = b.col 的自连接永远匹配不上 NULL,会漏掉它们。PostgreSQL 的唯一索引默认把 NULL 视为互不相同(PostgreSQL 15 新增了 NULLS NOT DISTINCT 来改变这一点)。相信计数之前,先决定你要哪种行为。更多见 SQL NULL 值处理。
常见问题
SQL 怎么查找重复记录?
按应该唯一的列分组,再用 HAVING COUNT(*) > 1 筛选:SELECT col1, col2, COUNT(*) FROM t GROUP BY col1, col2 HAVING COUNT(*) > 1。要看完整的行,就用 COUNT(*) OVER (PARTITION BY col1, col2),筛选大于 1 的行。
怎么删除重复数据只保留一条?
用 ROW_NUMBER() OVER (PARTITION BY key ORDER BY ...) 给每组内的行编号,删除编号大于 1 的行。ORDER BY 决定哪一份留下,所以要让它确定,比如按最早的时间戳,再加 ID 作决胜列。
不删除数据,怎么去掉重复?
只选第一份:在 ROW_NUMBER 子查询上加 WHERE rn = 1,在 PostgreSQL 和 DuckDB 里用 DISTINCT ON,如果所有列都相同就用 SELECT DISTINCT。把它包成视图,报表读干净的版本,原始表保持不动。
为什么 SELECT DISTINCT 去不掉我的重复?
DISTINCT 比较所有选中的列。只要有一列不同,比如 ID 或导入时间戳,这两行就不算相同,都会保留。只选键列,或者用 ROW_NUMBER 每个键取一整行。
大表删除重复最快的方法是什么?
如果只删一小部分行,按 ID 删除通常就够了。如果重复占比很大,往往更快的做法是新建一张只含 rn = 1 行的表,替换旧表,再加上唯一索引,免得问题再次出现。
其他语言: English, Español, Português (Brasil), Tiếng Việt