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