在数据库的日常运维与业务开发中,数据冗余是一个极为常见的问题。由于前端表单校验不严谨、后台并发控制缺失或是早期数据迁移脚本编写不规范,都可能导致数据库表中混入大量完全相同或部分字段重复的记录。这些重复行不仅会无端消耗宝贵的存储空间,还会导致统计报表数据失真,严重影响业务逻辑的正常执行。因此,掌握如何优雅且高效地消除重复行,是每一位后端开发工程师与数据库管理员的必备技能。

基础查询去重:DISTINCT与GROUP BY的对比与应用
当我们只需要在查询结果中消除重复行,而不需要修改底层数据表时,SQL标准提供了两种非常经典的方案:DISTINCT关键字和GROUP BY子句。虽然它们在某些场景下能达到相同的展示效果,但底层执行逻辑与适用范围却有着明显的差异。
DISTINCT关键字的作用范围是整个查询出的结果集。当它被放置在SELECT关键字之后时,数据库引擎会对所有被查询的列进行组合比对,一旦发现完全一致的行组合就会将其剔除,仅保留其中一行。这种方式的优点在于语法极其简洁,非常适合用于获取某张表中不重复的枚举值列表。然而,它的局限性在于无法灵活地对某一列单独去重而同时展示其他列的原始数据,且在处理大数据量时,由于内部需要进行全表扫描和临时表排序,性能开销不容忽视。
-- 使用DISTINCT查询所有不重复的部门与岗位组合 SELECT DISTINCT department, position FROM employees;
相比之下,GROUP BY子句则显得更为强大与灵活。它最初的设计目的是为了配合聚合函数(如COUNT、SUM、MAX等)进行分组统计,但同样可以用来实现去重效果。当我们在不使用聚合函数的情况下仅仅写出GROUP BY时,数据库会按照指定的列进行分组,每组只输出第一行代表记录。与DISTINCT不同,GROUP BY允许我们在SELECT列表中引入不在GROUP BY子句中的字段(在非严格模式下),这为复杂的数据提取提供了可能。
-- 使用GROUP BY实现去重,并统计每个分组的记录数 SELECT department, COUNT(*) as duplicate_count FROM employees GROUP BY department HAVING duplicate_count > 1;
在实际开发中,如果仅仅是为了查询去重,优先考虑使用GROUP BY,因为它在后续扩展统计功能时无需修改SQL结构,具有更好的可维护性。同时,在带有索引的列上执行GROUP BY操作时,MySQL能够利用索引的有序性避免生成临时表,从而大幅提升查询性能。
数据清洗实战:安全删除表中多余的重复记录
查询去重只是治标,真正要净化数据库,必须将物理存储的重复数据删除。许多初学者在尝试删除重复数据时,经常会遇到MySQL报出的1093号错误:You can't specify target table for update in FROM clause。这个错误的意思是不能在UPDATE或DELETE语句中直接查询要修改的目标表。这是因为在同一条语句中,数据库引擎既要扫描表数据进行比对,又要同时修改这张表的结构与内容,极易造成死锁或读取脏数据。
为了绕过这个限制,最稳妥的做法是将子查询的结果存入一个临时表,或者通过给子查询结果起一个别名的方式,让MySQL在内部先派生出一张虚拟表,然后再执行外层的删除操作。这种方案的核心思路是:先通过GROUP BY找出重复记录组中的最小ID或最大ID(即需要保留的记录),然后删除不在这个保留集合中的所有行。
-- 删除重复邮箱的记录,仅保留id最小的那一条
DELETE FROM users
WHERE email IN (
SELECT email FROM (
SELECT email FROM users GROUP BY email HAVING COUNT(email) > 1
) AS tmp1
)
AND id NOT IN (
SELECT id FROM (
SELECT MIN(id) AS id FROM users GROUP BY email HAVING COUNT(email) > 1
) AS tmp2
);
上述SQL语句虽然能够安全执行,但嵌套了两层子查询,在百万级数据量的表中执行会非常缓慢。对于大表去重,更推荐的方案是使用表连接来替代IN子查询。通过让目标表与需要保留的ID集合进行LEFT JOIN,筛选出连接失败(即未匹配上保留ID)的记录进行删除,能够显著降低执行时间,并减少锁表的范围。
-- 使用LEFT JOIN优化删除操作,提升执行效率
DELETE u1
FROM users u1
LEFT JOIN (
SELECT MIN(id) AS min_id FROM users GROUP BY email
) u2 ON u1.id = u2.min_id
WHERE u2.min_id IS NULL;
需要注意的是,在进行物理删除之前,务必对原表进行完整的数据备份。尤其是在生产环境中执行此类操作时,建议采用分批次删除的策略,每次删除少量记录后暂停几秒,以降低主库压力并避免主从延迟。
高阶去重利器:窗口函数ROW_NUMBER的高效运用
在MySQL 8.0及更高版本中,引入了对窗口函数的支持。窗口函数的出现,彻底改变了复杂SQL查询的编写方式,尤其是在处理重复数据去重、取组内最新记录等场景时,展现出了无与伦比的优雅与高效。其中,ROW_NUMBER()函数是最常用于去重操作的工具。
ROW_NUMBER()的作用是为查询结果集中的每一行分配一个连续的整数序号。结合PARTITION BY子句,我们可以指定按照某个或某些字段进行分组,然后在每个组内进行排序并编号。对于重复数据,只要它们在分组字段上值相同,就会被归入同一个分区。此时,我们可以利用组内排序给它们打上序号,通常将序号大于1的记录视为需要清理的重复项。
-- 使用CTE和窗口函数标记并删除重复数据
WITH RankedUsers AS (
SELECT
id,
email,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY id ASC) AS rn
FROM users
)
DELETE FROM users
WHERE id IN (
SELECT id FROM RankedUsers WHERE rn > 1
);
这段代码利用了通用表表达式(CTE)先对全表数据进行扫描,按照email字段分区,并在每个分区内按id从小到大排序生成行号。外层查询直接删除行号大于1的记录,也就是保留了每个email对应的最小id记录。这种写法逻辑清晰,可读性极强,完全避免了多层嵌套子查询带来的逻辑混乱。
除了删除重复数据,窗口函数在保留组内最新记录方面同样表现出色。例如,在一个用户操作日志表中,同一个用户可能有多条操作记录,我们希望只保留每个用户最近一次的操作记录并清理掉历史冗余。此时,只需将ORDER BY id ASC修改为ORDER BY created_at DESC,即可保留最新时间戳的记录,将行号大于1的旧记录安全剔除。这种方法不仅适用于单列去重,也完全适用于多列联合去重,只需在PARTITION BY后跟上多个列名即可。