做 MySQL 开发的同学,几乎都会遇到同一个需求:数据去重。不仅面试官爱问,业务开发里也是高频场景——订单表重复下单、用户表重复注册、日志表重复采集,这些脏数据如果不清理,统计报表、分页列表、下游同步全都会出错。这篇文章我从实际开发角度,把 MySQL 里最常用的 3 种去重方式一次性讲透:DISTINCT 查询去重、GROUP BY 分组去重、ROW_NUMBER() 窗口函数加物理删除。适合正在做数据清洗、写报表 SQL、准备 MySQL 面试的朋友,看完可以直接拿去用。
1. 先搞清楚重复的定义,否则去重方向都是错的
1.1 业务上的重复,不等于整行完全重复
很多刚接触去重的人,第一反应是“找出一模一样的行然后删掉”。但真实业务里,完全重复的行非常少见,更常见的是业务关键字段重复。
我举个例子。订单表 orders 里有 id、user_id、order_no、amount、create_time。正常情况下一个订单号 order_no 只对应一条记录。但由于接口超时重试、批量导入脚本跑了两遍等原因,同一条订单可能被插入了两次,只有 id 不一样,其他字段完全相同,这是一种;还有一种是 order_no 相同,但 amount 或 create_time 因为后续更新产生了差异,这也叫重复。
所以去重前第一步,不是急着写 SQL,而是先确认:以哪些列作为重复判断依据。是 order_no 单字段,还是 user_id + order_date 这种组合字段?这一步搞错,后面全白做。
1.2 去重前先回答三个问题
我一般会要求自己和团队在动手前,先回答清楚三个问题:
- 判断重复的列是哪些? 比如
order_no,还是user_id + create_time。 - 重复数据保留哪一条? 保留最小的
id、最新的create_time、还是状态优先的那条?这个决定直接影响了 SQL 里的ORDER BY怎么写。 - 去重范围是全表还是局部? 是清洗全部历史数据,还是只处理最近一个月?范围越大,锁竞争和回滚风险越高。
这三个问题不明确,写出来的去重 SQL 大概率是错的。我见过不止一次,有人兴冲冲把重复数据删完,结果发现保留的是旧数据,新数据全没了。
1.3 先用一条 SQL 摸清重复量
在动任何 DELETE 之前,先做数据体检。下面这几条 SQL 几乎是每次去重前的标配:
sql复制-- 总行数
SELECT COUNT(*) AS total_cnt FROM orders;
-- 按订单号去重后的行数
SELECT COUNT(DISTINCT order_no) AS distinct_cnt FROM orders;
-- 查看重复组和重复次数
SELECT order_no, COUNT(*) AS cnt
FROM orders
GROUP BY order_no
HAVING cnt > 1
ORDER BY cnt DESC
LIMIT 20;
通过前两条能快速算出“多出来多少条”,第三条能直接看到重复最严重的订单号。这一步的成本很低,但能帮你决定后面用哪种方案,也方便删除后做验证对比。不要跳过,更不要直接拿个备份文件就开始删。
(提示:如果你的表已经有唯一约束,那理论上不会产生重复。出现重复数据,要么是约束没建,要么是 INSERT 时用了 IGNORE 或 ON DUPLICATE KEY UPDATE 之类的逻辑绕过了约束。)
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方式一:DISTINCT,适合“只查不删”的轻量去重
2.1 基本用法
DISTINCT 是最直观的去重方式,语义就是“把查询结果里重复的行合并成一行”。
sql复制SELECT DISTINCT city FROM users;
这条 SQL 能拿到所有不重复的城市列表。多列时要注意,它是对列组合去重,不是对单列去重:
sql复制SELECT DISTINCT city, age FROM users;
上面这句的意思是:city 和 age 都相同的行才被合并。如果你想看“所有不重复的城市”,那就不能把 age 放进去,否则同一个城市只要年龄不同,就会输出多行。
2.2 几个容易踩的细节
第一,DISTINCT 会把 NULL 当做一个普通值参与去重。也就是说,如果某列有 3 行是 NULL,SELECT DISTINCT 后只会出现一行 NULL。这一点在统计时要注意,比如 COUNT(DISTINCT col) 并不会把 NULL 计入数量。
第二,DISTINCT 不能只作用于部分列。像下面这种写法是语法错误:
sql复制-- 错误示例
SELECT id, DISTINCT order_no FROM orders;
因为 id 本身不重复,加上 DISTINCT order_no 后,结果集会变成什么?MySQL 直接不允许这种模棱两可的查询。
第三,COUNT(DISTINCT ...) 是非常高频的统计写法:
sql复制SELECT COUNT(DISTINCT order_no) FROM orders;
它和 SELECT DISTINCT 是两回事,一个是计数,一个是返回明细,但都叫“去重”。
2.3 DISTINCT 的边界在哪里
DISTINCT 最大的问题,是它只能用于“查询去重”,不能处理“重复行中到底保留哪一条”这种问题。
举个例子:你想查每个重复订单号下的所有记录,并且要保留最新的那条。DISTINCT 做不到,因为它只能把 order_no 一样的行合并,但合并后取的是哪一列的值?它没法指定。你得到的结果可能是随机的(实际上取决于执行计划),非常危险。
所以我的经验是:DISTINCT 适合报表统计、枚举值筛选、数据校验这类只关心“有哪些值”的场景。 一旦涉及到“哪一条算数”,就要换 GROUP BY 或窗口函数了。
3. 方式二:GROUP BY,既能查重又能做统计
3.1 用 HAVING 找出重复组
GROUP BY 的核心是按列分组,把同一组的多行合并,然后配合聚合函数做统计。查重复是它最经典的用法:
sql复制SELECT order_no, COUNT(*) AS cnt
FROM orders
GROUP BY order_no
HAVING cnt > 1;
这里重点说一下 WHERE 和 HAVING 的区别:WHERE 是在分组之前过滤,HAVING 是在分组之后过滤。COUNT(*) > 1 是分组之后才能算出来的条件,所以必须放 HAVING。
如果你要找出重复了 3 次以上的,改一下条件就行:
sql复制SELECT order_no, COUNT(*) AS cnt
FROM orders
GROUP BY order_no
HAVING cnt >= 3
ORDER BY cnt DESC;
3.2 分组后保留某一条:MIN 和 MAX 的配合
GROUP BY 比 DISTINCT 强的地方在于,它可以配合聚合函数决定“每组留下谁”。
比如,我们要统计每个用户的最新订单时间:
sql复制SELECT user_id, MAX(create_time) AS latest_time
FROM orders
GROUP BY user_id;
这是不是一种去重?是的。它把每个 user_id 的多条订单压缩成一行,只保留时间最新那条的 create_time。但这只是“值”层面的保留,你拿不到那一行的完整记录。
想让结果展示整行记录,可以分两步走:先用 GROUP BY 拿到你要保留的 id(比如每组最小的 id),再回表查询:
sql复制SELECT o.*
FROM orders o
JOIN (
SELECT MIN(id) AS keep_id
FROM orders
GROUP BY order_no
) t ON o.id = t.keep_id;
这种做法在 MySQL 5.7 和 8.0 里都通用,也是后面“物理删除重复数据”时临时表方案的基础逻辑。
3.3 注意 ONLY_FULL_GROUP_BY 的坑
MySQL 5.7 之后默认开启了 ONLY_FULL_GROUP_BY SQL 模式。这个模式的意思是:SELECT 后面的普通列,必须出现在 GROUP BY 里,或者被聚合函数包裹,否则直接报错。
sql复制-- 在 ONLY_FULL_GROUP_BY 开启时会报错
SELECT id, order_no, COUNT(*)
FROM orders
GROUP BY order_no;
因为 id 既没在 GROUP BY 里,也没被 MIN、MAX 包裹,数据库不知道要显示哪一行的 id。很多从 5.6 时代过来的人第一次在 5.7 上跑老 SQL 就会踩这个坑。
解决办法不是去关掉 ONLY_FULL_GROUP_BY,而是习惯用子查询、ANY_VALUE() 或聚合函数来明确表达意图。
3.4 GROUP BY 和 DISTINCT 怎么选
功能上,SELECT DISTINCT a, b FROM t 和 SELECT a, b FROM t GROUP BY a, b 的结果几乎一样。区别在于:
GROUP BY能配合COUNT、SUM、MAX做聚合统计;GROUP BY能配合HAVING过滤分组;- 如果只想去重且不需要统计,
DISTINCT语义更清晰,代码也更短。
性能上,两者的执行计划可能都会用到排序或临时表。不要凭感觉说“一定是 DISTINCT 快”或“一定是 GROUP BY 快”,数据量一大,还是要看 EXPLAIN 结果和实际执行时间。
4. 方式三:ROW_NUMBER() 窗口函数,把“保留哪一条”握在自己手里
4.1 给每个重复组内的行编号
ROW_NUMBER() 是 MySQL 8.0 开始支持的窗口函数。用法是配合 OVER() 子句,在分组内按指定顺序编号。
sql复制SELECT
id,
order_no,
ROW_NUMBER() OVER (
PARTITION BY order_no
ORDER BY id
) AS rn
FROM orders;
这段 SQL 的意思很直观:按 order_no 分组(PARTITION BY),组内按 id 从小到大编号,id 最小的那条编号是 1,其余的是 2、3、4……
这有什么好处?编号为 1 的那条,就是你想保留的那条。 至于“想保留哪条”,完全由 ORDER BY 控制:
- 想保留最新一条,
ORDER BY create_time DESC; - 想保留
id最大的,ORDER BY id DESC; - 想优先保留某个状态,可以在
ORDER BY里加多个条件。
灵活性比 GROUP BY + MIN(id) 高很多。
4.2 用 rn = 1 筛选出“每组保留行”
拿到编号后,外层包一层查询,过滤 rn = 1,就能得到去重后的明细:
sql复制SELECT *
FROM (
SELECT
id,
order_no,
ROW_NUMBER() OVER (
PARTITION BY order_no
ORDER BY id
) AS rn
FROM orders
) t
WHERE t.rn = 1;
如果只是想看哪些是重复数据,就改成 WHERE t.rn > 1。配合 id 列表,你就能精确知道要删哪些行。
4.3 物理删除:一条 SQL 删掉所有重复记录
实际清理数据时,更关心的是怎么删。MySQL 8.0 里可以这样写:
sql复制DELETE FROM orders
WHERE id IN (
SELECT id
FROM (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY order_no
ORDER BY id
) AS rn
FROM orders
) t
WHERE t.rn > 1
);
这里有个经典坑:MySQL 不允许直接从子查询里删除目标表。直接写成 DELETE FROM orders WHERE id IN (SELECT id FROM orders ...) 会报错:
code复制You can't specify target table 'orders' for update in FROM clause
解决办法就是我在上面写的:多包一层派生表,让 MySQL 觉得你不是在直接操作目标表。这个坑几乎每个用窗口函数做删除的人都会遇到,记一下就好。
4.4 MySQL 5.7 及以下版本怎么办
如果你还在用 5.7,又没有窗口函数,可以用自连接删除。经典写法是“保留每组最小 id”:
sql复制DELETE t1
FROM orders t1
JOIN orders t2
ON t1.order_no = t2.order_no
AND t1.id < t2.id;
逻辑是:只要存在同 order_no 且 id 比当前行更大的行,当前行就是“更旧的重复项”,删掉。最终每组只留下 id 最小的那条。
如果你要保留最新一条,就把条件反过来:
sql复制DELETE t1
FROM orders t1
JOIN orders t2
ON t1.order_no = t2.order_no
AND t1.id > t2.id;
自连接虽然能删,但性能隐患很大。orders 表如果没有在 order_no 上建索引,这个关联会变成全表扫描套全表扫描,百万级数据能把数据库拖垮。所以用自连接前,一定要先确认 order_no 有索引。
4.5 临时表方案,适合更谨慎的清理
比自连接更稳妥的,是先算“要保留哪些 id”,放进临时表,再拿原表去 LEFT JOIN 临时表删除。这个方案我会用在正式环境里,因为每一步都可验证。
sql复制-- 第一步:算出每组要保留的 id
CREATE TABLE tmp_keep AS
SELECT MIN(id) AS id
FROM orders
GROUP BY order_no;
-- 第二步:给临时表加主键,加速 join
ALTER TABLE tmp_keep ADD PRIMARY KEY (id);
-- 第三步:删除不在保留列表里的行
DELETE o
FROM orders o
LEFT JOIN tmp_keep k ON o.id = k.id
WHERE k.id IS NULL;
这个方案的好处是:删除前可以先 SELECT COUNT(*) FROM tmp_keep 确认保留数量,也可以把 DELETE 换成 SELECT o.* 先预览一遍将要删除的数据。容错率高很多。
5. 三种方式怎么选:对比性能、版本与适用场景
5.1 一张表看清三个方案
| 对比维度 | DISTINCT | GROUP BY | ROW_NUMBER() 窗口函数 |
|---|---|---|---|
| 核心能力 | 查询结果集去重 | 分组 + 聚合统计 | 组内编号,精确控制保留行 |
| 能否统计重复次数 | 不能 | 能 | 能 |
| 能否指定保留哪一条 | 不能 | 能,但只能通过 MIN/MAX 间接控制 | 能,ORDER BY 灵活控制 |
| 能否直接物理删除 | 不能 | 不能,需配合临时表 | 能,配合派生表子查询 |
| MySQL 版本要求 | 所有版本 | 所有版本 | 8.0+,5.7 用自连接/临时表替代 |
| 主要性能瓶颈 | 临时表、排序 | 临时表、分组排序 | 窗口函数临时表、排序 |
5.2 我的选型建议
根据需求场景,我一般这样选:
- 只要“有哪些值、有多少个”,比如统计用户城市分布、统计渠道列表,用
DISTINCT,代码最简洁。 - 要“分组统计、算每组的数量/金额/时间”,用
GROUP BY,这是它最擅长的事。 - 要“每组只留一条完整记录”,并且环境是 8.0,直接用
ROW_NUMBER(),可读性和可控性最好。 - 要“物理清理历史重复数据”,优先临时表方案,其次才是窗口函数或自连接,原因很简单:临时表方案每步都能验证。
5.3 性能优化和索引是关键
不管选哪个方案,最影响性能的往往是排序和临时表。优化思路是让去重列相关的排序能走索引。
比如 GROUP BY order_no,如果表上有 (order_no, id) 的联合索引,分组和排序就能更高效地完成,而不是把数据全部丢到临时表里排序。
如果是按 ROW_NUMBER() OVER (PARTITION BY order_no ORDER BY id) 去重,那 (order_no, id) 联合索引同样有意义。
执行计划一定要看:
sql复制EXPLAIN SELECT ...;
EXPLAIN DELETE ...;
重点看 type 是不是 index 或 ref,有没有 Using temporary 和 Using filesort。这两个词一旦出现,说明数据量变大时性能会明显下降。
6. 完整实战:清理订单表 100 万行重复记录的全过程
6.1 场景设定
假设有一张 orders 表,结构如下:
sql复制CREATE TABLE orders (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
order_no VARCHAR(64) NOT NULL,
amount DECIMAL(10, 2) NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
order_no 本应唯一,但历史原因产生了重复。表里现在有 100 万行,其中约 2000 个订单号重复,多余记录约 5000 行。目标:保留每组 id 最小的记录,删掉其他重复行,最后给 order_no 加唯一索引,防止再次产生重复。
6.2 第一步:确认重复规模
先跑体检 SQL:
sql复制SELECT
COUNT(*) AS total_cnt,
COUNT(DISTINCT order_no) AS distinct_cnt
FROM orders;
假设结果 total_cnt = 1000000,distinct_cnt = 995000,那多余记录就是 5000 行。
再看重复组:
sql复制SELECT order_no, COUNT(*) AS cnt
FROM orders
GROUP BY order_no
HAVING cnt > 1
ORDER BY cnt DESC
LIMIT 20;
这一步能确认重复最严重的订单号,也方便后面抽查验证。
6.3 第二步:备份
正式环境操作前备份是底线。最简单的方式是直接建一张备份表:
sql复制CREATE TABLE orders_bak_20250101 AS SELECT * FROM orders;
注意这种建表方式不会复制索引,只用于紧急情况下的数据恢复。如果表太大,空间不够,至少把要删的 id 备份下来:
sql复制CREATE TABLE dup_ids_bak AS
SELECT id
FROM orders
WHERE order_no IN (
SELECT order_no
FROM orders
GROUP BY order_no
HAVING COUNT(*) > 1
);
这样就算删错了,也能根据 id 把数据找回来。
6.4 第三步:选择删除方案
我推荐临时表方案,因为每一步都可控。先算出每组要保留的 id:
sql复制CREATE TABLE tmp_keep AS
SELECT MIN(id) AS id
FROM orders
GROUP BY order_no;
ALTER TABLE tmp_keep ADD PRIMARY KEY (id);
然后预览一下即将删除的数据量:
sql复制SELECT COUNT(*)
FROM orders o
LEFT JOIN tmp_keep k ON o.id = k.id
WHERE k.id IS NULL;
确认数量是 5000 左右后,再执行删除:
sql复制DELETE o
FROM orders o
LEFT JOIN tmp_keep k ON o.id = k.id
WHERE k.id IS NULL;
如果你用的是 MySQL 8.0,想直接用窗口函数也行:
sql复制DELETE FROM orders
WHERE id IN (
SELECT id
FROM (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY order_no
ORDER BY id
) AS rn
FROM orders
) t
WHERE t.rn > 1
);
两种方式的结果一样,选你更熟悉、更好解释给团队听的那种。
6.5 第四步:删除后验证
删除完成,不能直接收工。跑一遍验证:
sql复制SELECT COUNT(*) AS total_cnt FROM orders;
SELECT order_no, COUNT(*) AS cnt
FROM orders
GROUP BY order_no
HAVING cnt > 1;
第二个查询如果返回空,说明重复已经清干净了。再随机抽查几个业务订单号,确认保留的是 id 最小的那条,没有删错。
6.6 第五步:加唯一索引,防止再犯
去重是亡羊补牢,加唯一索引才是治本:
sql复制ALTER TABLE orders ADD UNIQUE KEY uk_order_no (order_no);
但有件事必须注意:如果表里还残留重复数据,加唯一索引会直接失败。所以一定要先完成第六步的验证,确认没有重复组后再加。
另外,100 万行的表加唯一索引虽然不至于锁死,但也会产生全表扫描和索引构建,建议在业务低峰期执行。MySQL 8.0 支持在线 DDL,但依然有额外开销。
如果表特别大(比如上亿行),上面的 ALTER TABLE 也可能造成长时间阻塞。生产环境更稳妥的做法是用 pt-online-schema-change 这类工具在线修改,但这里不展开。
6.7 删除超大数据量时的分批思路
如果重复数据很多,比如要删几十万行,一次性 DELETE 可能造成长时间锁表、主从延迟。分批删除是更好的选择。
思路很简单:先把要删的 id 全部放进一张临时表 tmp_del_ids,然后循环一批批删:
sql复制-- 每次取 5000 个待删除 id
DELETE FROM orders
WHERE id IN (
SELECT id
FROM (
SELECT id
FROM tmp_del_ids
ORDER BY id
LIMIT 5000
) t
);
-- 从临时表里删除已经处理过的 5000 个 id
DELETE FROM tmp_del_ids
ORDER BY id
LIMIT 5000;
把上面两条 SQL 放到脚本或存储过程里循环执行,直到 tmp_del_ids 被清空。每次删除后观察一下数据库的锁等待和主从延迟,如果压力大就把批大小调小到 1000。
这里有个小坑:LIMIT 在子查询里,MySQL 8.0 可以直接用,但子查询外面必须再包一层派生表,否则会报语法错误。这也是我在前面窗口函数删除时强调过的问题。
6.8 实战中的几个经验教训
整个过程里,我最想强调几条亲身踩过的坑:
第一,删除前一定要用 ORDER BY 明确保留规则。哪怕你觉得“按 id 最小保留”已经很明显,也要在 SQL 里写出来,而不是依赖默认行为。我在早期用 GROUP BY 方案时,就因为没明确 MIN(id),导致保留了旧数据。
第二,临时表方案里的 tmp_keep 一定要加主键。不加主键,后面 LEFT JOIN 会走全表扫描,2000 个保留 id 可能没问题,但如果保留行有几十万,性能差距会非常明显。
第三,加唯一索引之前,先用普通索引加速删除。如果删除 SQL 执行得很慢,先检查 order_no 上有没有普通索引。没有的话,先建一个普通索引,删除完成后再把它改成唯一索引:
sql复制-- 先建普通索引
ALTER TABLE orders ADD INDEX idx_order_no (order_no);
-- 删除重复数据...
-- 最后把普通索引改成唯一索引
ALTER TABLE orders DROP INDEX idx_order_no, ADD UNIQUE KEY uk_order_no (order_no);
这么做的好处是,避免在没有任何索引的情况下去执行一个需要 GROUP BY order_no 的清洗任务。反正我会在清理前先给 order_no 建普通索引,花不了多少时间,但后面的删除 SQL 会快一个数量级。
我个人在实际操作中的体会是:数据去重这件事,90% 的坑不在 SQL 本身,而在“没想清楚保留哪条”和“没做好备份”。DISTINCT、GROUP BY、ROW_NUMBER() 只是工具,真正值钱的是你对业务重复规则的理解。如果你也在处理线上重复数据,先把文章里第一步的“三个问题”答清楚,再动手删。
