找出表中重复出现的数据。
Find duplicate data in a table.
考察要点
这道题看似简单,但面试官想考察你对 SQL 数据聚合和筛选能力的掌握深度。核心是看你是否能熟练运用 GROUP BY ... HAVING 和 窗口函数 这两种核心武器。同时,通过这道题还能延伸考察你对 SQL 执行效率、索引优化以及处理大规模数据问题的思考能力。
核心概念
解决此问题的核心在于“分组”与“计数”。我们需要按可能重复的列将数据分组,然后统计每个组内的行数,最后筛选出行数大于 1 的组。
1. GROUP BY 与 HAVING
GROUP BY: 将表中的行按照指定的列(或多列)进行分组,所有在分组列上具有相同值的行会被聚合到一条记录中。HAVING: 用于在GROUP BY聚合之后对结果集进行过滤。WHERE子句在聚合前过滤行,而HAVING在聚合后过滤组。- 执行顺序:
FROM->WHERE->GROUP BY->HAVING->SELECT->ORDER BY。
数据示例:
假设我们有一张 employees 表:
| id | name | |
|---|---|---|
| 1 | Alice | [email protected] |
| 2 | Bob | [email protected] |
| 3 | Alice | [email protected] |
| 4 | Charlie | [email protected] |
| 5 | Bob | [email protected] |
| 6 | Bob | [email protected] |
使用 GROUP BY email HAVING COUNT(*) > 1 可以找出重复的 email 值:
1SELECT email, COUNT(*) AS count2FROM employees3GROUP BY email4HAVING COUNT(*) > 1;
输出:
| count | |
|---|---|
| [email protected] | 2 |
| [email protected] | 3 |
2. 窗口函数 (Window Functions)
- 定义: 窗口函数对一组与当前行相关的表行(这个组被称为“窗口”或“分区”)执行计算。与聚合函数不同,它不会将多行压缩为一行,而是为结果集中的每一行返回一个值。
PARTITION BY: 这是窗口函数中的GROUP BY。它将行分成多个分区,函数在每个分区内独立计算。- 常用函数:
ROW_NUMBER(),RANK(),DENSE_RANK(),COUNT() OVER (...)。
数据示例:
使用 COUNT(*) OVER (PARTITION BY email) 可以为每一行计算其所在分区的总行数。
1SELECT id, name, email,2 COUNT(*) OVER (PARTITION BY email) AS email_count3FROM employees;
输出:
| id | name | email_count | |
|---|---|---|---|
| 1 | Alice | [email protected] | 2 |
| 3 | Alice | [email protected] | 2 |
| 2 | Bob | [email protected] | 3 |
| 5 | Bob | [email protected] | 3 |
| 6 | Bob | [email protected] | 3 |
| 4 | Charlie | [email protected] | 1 |
通过筛选 email_count > 1 的行,即可得到所有重复的记录。
多种解法
假设问题是:找出 employees 表中 email 重复的所有记录。
解法 1:GROUP BY + HAVING + JOIN / IN
这个思路是先找出哪些 email 是重复的,然后再根据这些 email 值捞出所有原始记录。
- 标注版本:✅ 所有 SQL 数据库通用。
- SQL 实现:
1-- 使用 IN 子查询2SELECT id, name, email3FROM employees4WHERE email IN (5 SELECT email6 FROM employees7 GROUP BY email8 HAVING COUNT(*) > 19);1011-- 使用 JOIN (通常性能更优)12SELECT e1.id, e1.name, e1.email13FROM employees e114JOIN (15 SELECT email16 FROM employees17 GROUP BY email18 HAVING COUNT(*) > 119) AS duplicates ON e1.email = duplicates.email;
- 时间/空间复杂度:
GROUP BY通常需要对email列进行排序或哈希,复杂度为 或 。JOIN或IN子查询的查找,如果email列有索引,则效率很高。- 整体复杂度主要取决于
GROUP BY的操作。
- 索引依赖: 在
email列上建立索引可以极大地加速GROUP BY和JOIN/IN的过程。
解法 2:窗口函数
这是更现代、更简洁的解法,一步到位,无需自连接或子查询。
- 标注版本:✅ MySQL 8.0+ / PostgreSQL / Oracle / SQL Server;❌ MySQL 5.7 不支持。
- SQL 实现:
1-- 使用 CTE (通用表表达式) 提高可读性2WITH NumberedRows AS (3 SELECT4 id,5 name,6 email,7 -- 计算每个 email 分区内的行数8 COUNT(*) OVER (PARTITION BY email) AS email_count,9 -- 也可以用 ROW_NUMBER() 来标记,方便后续删除操作10 ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn11 FROM12 employees13)14SELECT15 id,16 name,17 email18FROM19 NumberedRows20WHERE21 email_count > 1;
- 时间/空间复杂度: 窗口函数通常需要对
PARTITION BY的列进行排序,时间复杂度为 。它需要额外的空间来存储窗口计算的中间结果。 - 索引依赖: 在
email列上建立索引可以加速PARTITION BY的排序过程。
解法 3:自连接 (Self-Join)
这是一种比较“古典”的解法,在没有窗口函数的旧版本数据库中可以使用。逻辑是:对于表中的每一行,去查找是否存在另一行,它们的 email 相同但 id 不同。
- 标注版本:✅ 所有 SQL 数据库通用。
- SQL 实现:
1SELECT DISTINCT e1.*2FROM employees e13JOIN employees e24 ON e1.email = e2.email AND e1.id <> e2.id;
- 时间/空间复杂度: 如果没有索引,这是一个笛卡尔积式的比较,复杂度高达 ,性能极差。即使
email和id上有索引,其性能通常也不如前两种方法。DISTINCT操作也需要额外的排序或哈希开销。 - 索引依赖: 必须在
email和id列上建立索引,否则对于大数据量是灾难性的。
解法对比:
- 推荐度: 窗口函数 >
GROUP BY> 自连接。 - 性能: 对于大多数现代数据库,窗口函数和
GROUP BY性能相当,具体取决于优化器。自连接通常最慢。 - 可读性与扩展性: 窗口函数代码最清晰,且非常容易扩展(例如,要删除重复项只保留一个,只需在
WHERE条件中用rn > 1)。
常见低分回答
- 只找出重复的值,没找出重复的行:
SELECT email FROM employees GROUP BY email HAVING COUNT(*) > 1;这个答案不完整,因为它只返回了['[email protected]', '[email protected]'],而题目要求找出所有重复的记录行。 - 误用
WHERE代替HAVING:SELECT email, COUNT(*) FROM employees GROUP BY email WHERE COUNT(*) > 1;这是语法错误,因为WHERE子句在GROUP BY之前执行,不能使用聚合函数。 - 使用极其低效的关联子查询:
SELECT * FROM employees e1 WHERE (SELECT COUNT(*) FROM employees e2 WHERE e2.email = e1.email) > 1;这个查询对主表的每一行都会执行一次子查询,导致 级别的计算量,数据量稍大就会超时。 - 自连接时忘记排除自身:
SELECT e1.* FROM employees e1 JOIN employees e2 ON e1.email = e2.email;这会把每一行都和自己匹配上,导致所有记录都被错误地认为是重复的。必须加上e1.id <> e2.id或e1.id < e2.id这样的条件。 - 忽略复合唯一键:如果重复是由多个列共同决定的(例如
first_name和last_name),仍然只按单一列(如email)查找,这没有完全理解业务场景。正确的做法是GROUP BY first_name, last_name或PARTITION BY first_name, last_name。
工程实践与优化
- 索引是关键:无论哪种方案,在用于判断重复的列(如
email)上建立索引是性能优化的第一步。对于GROUP BY和PARTITION BY,索引可以避免全表扫描和昂贵的filesort。 - 查看执行计划:使用
EXPLAIN分析你的查询。- 对于
GROUP BY,理想的Extra字段应该看到Using index,避免Using temporary; Using filesort。 - 对于
JOIN,确保type是ref或eq_ref,而不是ALL。
- 对于
- 删除重复数据:这是最常见的后续操作。使用窗口函数是目前最安全和高效的方式。
注意:在生产环境执行sql1-- MySQL 8.0+2WITH CTE AS (3 SELECT id,4 ROW_NUMBER() OVER(PARTITION BY email ORDER BY id ASC) as rn5 FROM employees6)7DELETE FROM employees8WHERE id IN (SELECT id FROM CTE WHERE rn > 1);
DELETE前,务必开启事务 (START TRANSACTION;),并先用SELECT验证要删除的数据是否正确,确认无误后再执行DELETE和COMMIT。 - 预防胜于治疗:在表设计阶段,就应该为需要保持唯一的列(如
email)添加UNIQUE约束。ALTER TABLE employees ADD UNIQUE INDEX idx_uniq_email (email);这能从根本上防止脏数据产生。
常见追问与延伸题目
-
追问:如果要删除这些重复数据,只保留
id最小的那一条,你会怎么写 SQL?- 回答要点: 这正是窗口函数
ROW_NUMBER()的用武之地。我会使用一个CTE,通过PARTITION BY对重复的列分组,并按id升序 (ORDER BY id ASC) 生成行号rn。这样,每个分组中id最小的记录rn就是 1,其余的rn都大于 1。最后,我只需要DELETE掉rn > 1的所有记录即可。SQL 如上“工程实践”部分所示。
- 回答要点: 这正是窗口函数
-
追问:如果这张表有 10 亿行,内存有限,你的
GROUP BY或窗口函数查询可能会因为排序而耗尽内存或临时磁盘空间。你会如何处理?- 回答要点:
- 分治策略:我不会尝试一次性对 10 亿行数据进行
GROUP BY。我会采用分而治之的策略。例如,如果email的基数很大,我可以按email的哈希值或首字母进行切分,WHERE email LIKE 'a%',分批次地找出重复项。 - 外部工具/大数据方案:对于这个量级的数据,更专业的做法是使用大数据处理框架,如 Spark SQL 或 Hive。它们天生为分布式计算和处理海量数据而设计,能够将数据分散到多台机器上并行计算
GROUP BY或PARTITION BY,避免单点内存瓶颈。 - 优化数据库配置:如果必须在单机数据库上处理,我会检查并调优
sort_buffer_size,tmp_table_size,max_heap_table_size等参数,但这只是缓兵之jg,不能根本解决问题。
- 分治策略:我不会尝试一次性对 10 亿行数据进行
- 回答要点:
-
追问:
GROUP BY和窗口函数PARTITION BY在查找重复项时,你更倾向于用哪个?为什么?- 回答要点:
- 场景决定: 如果我只需要知道哪些值是重复的,或者只需要重复项的计数,我会用
GROUP BY ... HAVING,因为它意图明确,且在某些数据库版本中可能优化得更好。 - 获取完整行: 如果我需要获取所有重复的完整行记录,或者需要进行“保留一行,删除其他”的操作,我绝对会选择窗口函数。因为它可以在一次扫描中完成标记,代码更简洁,逻辑更清晰,避免了
GROUP BY之后再进行一次JOIN或IN子查询,通常性能也更好。 - 现代 SQL 标准: 窗口函数是更现代、更强大的 SQL 特性,展现了对新标准 SQL 的熟悉程度,在面试中是加分项。
- 场景决定: 如果我只需要知道哪些值是重复的,或者只需要重复项的计数,我会用
- 回答要点: