写过很多年SQL,也面试过不少人,发现一个特别有意思的现象:能在简历上写"精通SQL"的候选人不少,但一遇到数据去重就只会甩 DISTINCT 和 GROUP BY,稍微绕一点的需求就卡住了。
今天就把这类问题一次聊透。这篇文章会从最基础的 DISTINCT 开始,一直讲到窗口函数、跨表去重、以及大数据量下的去重策略。无论你是写业务报表的数据分析师,还是做数据清洗的 ETL 工程师,或者是刚入门想系统搞懂 SQL 去重的同学,都能在里面找到能直接拿去用的方案。
说白了,去重的本质就一句话:搞清楚什么叫"重复",然后告诉数据库按什么规则去掉重复。 这句话听起来简单,但真正落地的时候全是细节。
1. 数据去重的底层逻辑:先搞清楚"重复"的定义
动手写 SQL 之前,我建议你先想清楚一个更根本的问题:你眼里的"重复"到底是什么?
1.1 完全重复与部分重复:两种截然不同的处理逻辑
先看最基础的情况。假设有一张用户订单表,里面有些行是完全一模一样的——所有字段的值都相同。这种情况大概率是数据采集时重复写入导致的,处理起来最简单,直接用 DISTINCT 就行。
sql复制SELECT DISTINCT user_id, order_id, amount, create_time
FROM user_orders;
这种"整行完全重复"的场景,DISTINCT 是最高效的解法,没有之一。它的逻辑就是把结果集中一模一样的行合并成一行。
但实际业务里,更常见的是另一种情况:部分字段重复,部分字段不同。比如同一个用户下了多笔订单,你想把每个用户第一次下单的时间取出来;或者同一个商品有多条采购记录,你想看每个商品最新的采购价。这时候就不能用 DISTINCT 了,因为每一行数据都不是完全相同的,只是它们在"某个字段或某几个字段"上有重复。
提示:
DISTINCT的语义是"只对查询返回的整行做去重",它管不到部分字段重复的场景。遇到这个需求,意味着你要换思路了。
1.2 去重背后的业务代价:保留哪一行是有讲究的
再深一层,当数据部分重复时,你不仅要决定"按哪些字段去重",还要决定"重复的行里保留哪一条"。
这在业务上是有讲究的。举个很典型的例子,用户账号表里,同一个身份证号可能对应多条注册记录,但你做数据分析时只需要"每个身份证号对应一条有效记录"。那问题来了:保留 created_at 最早的那条?还是 status 状态正常的那条?还是 level 等级最高的那条?
不同的保留策略,直接决定了 SQL 怎么圈定"要保留的那一行"。我在实际面试中经常用这个场景考人,目的是看对方是死记硬背了窗口函数的语法,还是真的理解"去重 = 分组 + 排序 + 选第一条"这个组合逻辑。
所以,写去重 SQL 之前,先花 30 秒问自己三件事:
- 按哪些字段判断重复?
- 重复的数据里,保留哪一行(或者哪些行)?
- 如果一个分组里保留多行,排序规则是什么?
这三件事想明白了,SQL 怎么写都有底气。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础手段:DISTINCT 与 GROUP BY 的正确打开方式
很多人把 DISTINCT 和 GROUP BY 混着用,觉得它们差不多,其实两者在语义和灵活性上差别很大。
2.1 DISTINCT:只适合整行去重和取不同值列表
DISTINCT 的典型使用场景是两个:
第一个是查看某列或某几列到底有多少种不同的取值。比如你要盘点目前系统里一共有多少个用户下了单:
sql复制SELECT DISTINCT user_id
FROM user_orders;
第二个是做简单的标签枚举,比如查看所有订单状态:
sql复制SELECT DISTINCT order_status
FROM user_orders;
但如果 DISTINCT 后面跟的列多了,你要小心理解它的含义。比如下面的 SQL:
sql复制SELECT DISTINCT user_id, order_status
FROM user_orders;
它返回的是"用户和状态的所有不同组合",而不是"不同的用户"。这俩结论差了十万八千里。我在实际工作中见过不少刚入门的朋友在这里栽跟头,把组合去重当成了单列去重。
另一个容易踩坑的地方是:DISTINCT 不能直接和聚合函数搭配使用(比如 SUM(DISTINCT revenue) 虽然语法上支持,但含义完全不同,它是去重后再求和,这个后续会说)。
2.2 GROUP BY:去重 + 聚合一步到位
当你要"按某列去重,同时对其他列做聚合统计"时,GROUP BY 才是正解。
比如,你想统计每个用户的订单数和总消费金额:
sql复制SELECT user_id, COUNT(order_id) AS order_cnt, SUM(amount) AS total_amount
FROM user_orders
GROUP BY user_id;
这里的核心逻辑是:GROUP BY user_id 把同一个用户的订单行归并成一组,然后 COUNT 和 SUM 对组内的所有行做聚合。所以 GROUP BY 不仅是去重工具,它是分组聚合的基石。
有人会问:我只要"每个用户一行"顺便看他的其他字段,不想做任何统计,能用 GROUP BY 吗?可以,但选非分组字段时要格外谨慎,因为不同的数据库对"非分组字段的选择"处理方式不一样。后面我会专门讲这个问题。
2.3 聚合函数与 NULL 值:去重过程中最容易被忽略的坑
统计数量时,COUNT(column) 和 COUNT(*) 的结果可能不一样,这个细节在去重场景里经常被忽略。
COUNT(*) 统计的是行数,包括所有字段值都为 NULL 的行;而 COUNT(order_id) 统计的是 order_id 不为 NULL 的行数。
举个实际例子:
sql复制SELECT user_id, COUNT(*) AS cnt1, COUNT(order_id) AS cnt2
FROM user_orders
GROUP BY user_id;
假设某用户在订单表里有 5 条记录,其中 2 条的 order_id 是 NULL(比如下单未成功生成单号),那么 cnt1 = 5,cnt2 = 3。如果你拿 cnt2 去判断"用户订单数",就会少算 2 单。
此外,COUNT(DISTINCT column) 这个写法也要留意,它会把该列中的 NULL 值排除在外,只统计非 NULL 的唯一值数量。绝大多数情况下这是符合预期的,但它毕竟和 COUNT(*) 的统计口径不一样,写业务报表时务必确认清楚。
3. 复杂场景一:分组去重取最新记录,窗口函数是首选
说完了基础手段,进入今天的重头戏。这类需求在真实业务里极其常见,我至少列几个亲历过的:
- 取每个用户最近一次登录记录
- 取每张订单最近一次状态变更记录
- 取每个商品当前最新的库存快照
- 取每个渠道最近一次投放的转化数据
这类需求有一个共性:按某个维度分组,组内按时间倒序,取第一条(或前 N 条)。
3.1 用 ROW_NUMBER() 实现“每组保留一条”
ROW_NUMBER() 窗口函数是这个场景的最佳解法。它先按你指定的字段分区(PARTITION BY),然后在每个分区内按指定规则排序(ORDER BY),从 1 开始给每行一个序号。最后外层查询只保留序号为 1 的行,就实现了"每组取第一条"。
看一个具体的例子。假设有一个订单状态变更日志表 order_status_log:
order_id:订单号status:订单状态changed_at:变更时间
现在要取每个订单当前最新状态:
sql复制WITH ranked AS (
SELECT order_id, status, changed_at,
ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY changed_at DESC) AS rn
FROM order_status_log
)
SELECT order_id, status, changed_at
FROM ranked
WHERE rn = 1;
这里面有两个关键点:
PARTITION BY order_id:告诉数据库"每个订单是独立的一组",组和组之间互不影响。ORDER BY changed_at DESC:每组内按变更时间从新到旧排序,rn = 1的自然就是最近一条。
如果你需要的是"每个订单最近的前三条状态记录",更简单,把外层条件改成 WHERE rn <= 3 就行。
3.2 对比传统做法:为什么建议你少用自连接
在窗口函数普及之前,这类需求通常靠自连接或子查询来实现。以"取每个订单最近一条状态"为例,传统写法大致长这样:
sql复制SELECT a.order_id, a.status, a.changed_at
FROM order_status_log a
INNER JOIN (
SELECT order_id, MAX(changed_at) AS max_changed_at
FROM order_status_log
GROUP BY order_id
) b
ON a.order_id = b.order_id AND a.changed_at = b.max_changed_at;
这种写法有个明显的隐患:如果同一个订单在同一个时间点(changed_at 相同)发生了多条状态变更,结果就会多出重复行。你得再加个条件去过滤,SQL 越写越长,维护起来也痛苦。
窗口函数则不关心时间是否相同,ROW_NUMBER() 会在同值时按后面的排序字段继续排,如果有需要可以用 ORDER BY changed_at DESC, id DESC 这类复合条件把顺序彻底定死,保证每组只有一条会被选中,逻辑干净利落。
注意:MySQL 从 8.0 才开始支持窗口函数,SQL Server 从 2005/2008 版本就有支持了。如果你还在维护一个 MySQL 5.7 之类的老库,这个解法会被语法限制挡住。我给你的建议是:优先推动升级,或者在应用层做去重,尽量不要用老式的自连接去凑。
3.3 并列排名场景:RANK() 和 DENSE_RANK() 的区别
如果业务要求的是"每组取出排名前 3 的记录,允许并列",那 ROW_NUMBER() 就不合适了,因为它会给每个行强制分配一个唯一的序号,永远不会出现并列。这时候要看 RANK() 和 DENSE_RANK()。
假设有一个考试成绩表 student_scores,里面存了学生各科成绩,你想知道每科前三名是谁:
sql复制SELECT subject, student_name, score,
RANK() OVER (PARTITION BY subject ORDER BY score DESC) AS rk
FROM student_scores;
RANK():同分同名,比如 100、99、99、98,排名就是 1、2、2、4。中间跳了一个号。DENSE_RANK():同分同名但不跳号,排名是 1、2、2、3。
ROW_NUMBER() 则是 1、2、3、4,并列也要分出先后。
这三个函数是去重场景里最核心的窗口函数,建议你把这组差异记牢。用错了,报表上的名次就会出问题。
4. 复杂场景二:多列组合去重与字段取舍策略
实际业务里,"重复"往往不是单列重复,而是多列组合重复。比如一个订单明细表里,order_id + product_id 组合起来才是唯一的,如果又有重复数据,就得按组合去重。
4.1 多列分组去重:GROUP BY 与窗口函数的组合
多列组合去重的思路,其实和单列去重一模一样,把多个字段都放进 PARTITION BY 或 GROUP BY 里就行。我拿一个真实场景来说明。
假设有一张库存快照表 inventory_snapshot,字段包括:
warehouse_id:仓库 IDproduct_id:商品 IDsnapshot_date:快照日期stock_qty:库存数量
由于上游同步任务偶发重跑,同一天里同一个仓库同一个商品可能被写入多条快照记录。现在要按"仓库 + 商品 + 日期"三个字段去重,保留最新写入的那条:
sql复制WITH ranked AS (
SELECT warehouse_id, product_id, snapshot_date, stock_qty,
ROW_NUMBER() OVER (
PARTITION BY warehouse_id, product_id, snapshot_date
ORDER BY create_time DESC
) AS rn
FROM inventory_snapshot
)
SELECT warehouse_id, product_id, snapshot_date, stock_qty
FROM ranked
WHERE rn = 1;
这里 ORDER BY create_time DESC 中的 create_time 是记录本身的写入时间,跟业务日期无关。多列分组去重最核心的要点就是:把所有判断重复的字段都塞进 PARTITION BY,不要漏。
4.2 非分组字段的选择:这是 GROUP BY 最容易出错的地方
我见过大量因为 GROUP BY 选错非分组字段而导致的隐性 bug。比如:
sql复制SELECT user_id, order_id, MAX(amount)
FROM user_orders
GROUP BY user_id;
这条 SQL 在很多数据库里能跑(尤其 MySQL 默认开了 ONLY_FULL_GROUP_BY 时可能直接报错),但它返回的 order_id 到底取自哪一行,其实是未定义的。MySQL 8.0 之前的版本大概率会返回"组内第一行碰巧扫到的那条",这个结果既不可控也不可预测,生产环境千万别这么干。
想要"取每个用户下单金额最大那笔的订单号",正确做法是窗口函数:
sql复制WITH ranked AS (
SELECT user_id, order_id, amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rn
FROM user_orders
)
SELECT user_id, order_id, amount
FROM ranked
WHERE rn = 1;
如果你想按"金额最大 + 时间最早"来圈定,那就把排序条件改成 ORDER BY amount DESC, create_time ASC。总之,聚合函数只负责算统计值,不要指望它顺便告诉你"这条统计值来自哪一行"。
4.3 需要同时保留多个字段时的三种解法
如果去重后还需要同时保留多个业务字段,有以下几种解法,按优先级排序:
第一,窗口函数 + 多条件排序。用 ROW_NUMBER() 定义好保留哪条,再把需要的字段都放外层查询里。这个前面已经写过了,最常见也最推荐。
第二,GROUP BY 配合聚合函数。如果你需要的字段值本身是可聚合的,比如最新时间用 MAX(changed_at),最大价格用 MAX(price),那直接用聚合函数就行。
第三,ANY_VALUE() 或 MIN()/MAX() 等兜底。在 MySQL 8.0 里,ANY_VALUE() 可以取组内任意一个非聚合字段的值,但这只适合"字段值确实都一样"的合法场景,比如用 GROUP BY user_id 时,同组内的 user_name 理论上相同,取哪条都无所谓。用它没问题,但不推荐在字段值本身有差异时使用。
5. 复杂场景三:跨表去重、集合运算与存在性判断
去了同一张表里的重,很多时候还不够。真实业务里你还需要"跨表去重"——比如取出一张表里"在另一张表中不存在"的记录,或者把两张表的数据合并后去掉交集。
5.1 用 EXISTS / NOT EXISTS 做跨表去重和排除
最典型的场景:找出所有下过单的用户,但用户表有大量重复注册记录,想拿到的是"有效下单用户列表"。
sql复制SELECT DISTINCT u.user_id, u.user_name
FROM users u
WHERE EXISTS (
SELECT 1
FROM user_orders o
WHERE o.user_id = u.user_id
);
你可能会问:这里为什么不用 IN?两者在某些场景下是等价的,但 EXISTS 有几个实际优势:
- 它是"半连接"语义,只要找到一条匹配记录就立刻返回,不需要扫描所有子查询结果,性能通常比
IN更稳定。 - 当子查询的结果集很大时,
EXISTS不会触发"构建大 IN 列表"的开销。 EXISTS对 NULL 的处理更直观,不会因为子查询里混入 NULL 产生不可预期的效果。
如果你想找的是"从未下过单的用户",把 EXISTS 换成 NOT EXISTS 就行,逻辑一模一样。
5.2 用 UNION 合并多表并去重 vs UNION ALL 保留全部
当你要把多张结构相同的表(比如按月分表的历史订单表)合并成一份数据时,UNION 和 UNION ALL 的选择会直接影响结果。
UNION 默认会对合并后的所有行做去重,相当于"合并 + 去重",而 UNION ALL 只是简单的拼接,不去重。
实际业务里,如果分表的数据本身就不会重复(比如按时间分表,每个订单只存在于一张表中),我强烈建议你用 UNION ALL。因为 UNION 的去重操作需要对全部数据做排序或哈希,非常消耗资源,数据量一大,性能差距是数量级的。
5.3 集合逻辑在去重中的应用:交集、差集与补集
除了合并,有时你还要算"两张表中重复的部分"(交集)或者"一张表中有而另一张表中没有的部分"(差集)。
主流数据库都提供了集合运算符:
INTERSECT:取交集EXCEPT(SQL Server / PostgreSQL 叫 EXCEPT,MySQL 8.0 也支持了,Oracle 叫 MINUS)
举一个实际操作过的例子。有两张名单表,一张是"白名单用户",另一张是"黑名单用户",你想找出既在白名单又在黑名单里的异常用户:
sql复制SELECT user_id FROM white_list
INTERSECT
SELECT user_id FROM black_list;
这种写法的可读性比 IN 子查询要好太多,一条语句把意图表达得非常清楚。
但要注意:INTERSECT 和 EXCEPT 默认也会做去重,返回的是"不同的值",而不是"所有匹配的行"。如果你要的是带业务明细的匹配行,还是得回到 EXISTS 或 INNER JOIN。
6. 实战:一个完整的数据清理案例拆解
前面讲了一堆函数和理论,现在我把一个真实的业务场景完整地串一遍。这个案例是我前几年做数据仓库时遇到的,很有代表性。
6.1 场景还原与需求描述
业务方给了一张"用户行为日志表" user_behavior_log,字段如下:
log_id:日志自增 IDuser_id:用户 IDevent_type:行为类型(click / view / purchase)event_time:行为发生时间ext_info:扩展字段(JSON 格式,可能为 NULL)
问题现状:因为埋点系统故障,日志被重复上报,且部分日志写入的时间(create_time 字段)存在前后 500 毫秒内的偏差。业务方要求:"我们要做用户行为分析,同一用户同一行为在同一秒内重复上报的数据,只保留一条,保留任意一条都行。"
6.2 方案设计与 SQL 实现
这个需求的"重复定义"是:user_id + event_type + 时间到秒 三个维度组合重复。保留策略是:每组留一条,哪条都行,所以可以用 ROW_NUMBER() 配合一个稳定的排序字段。
sql复制WITH deduped AS (
SELECT log_id, user_id, event_type, event_time, ext_info,
ROW_NUMBER() OVER (
PARTITION BY user_id, event_type, DATE_FORMAT(event_time, '%Y-%m-%d %H:%i:%s')
ORDER BY log_id
) AS rn
FROM user_behavior_log
)
DELETE FROM user_behavior_log
WHERE log_id IN (
SELECT log_id FROM deduped WHERE rn > 1
);
不过这里有个大坑:很多数据库(尤其 MySQL)不允许在同一张表上边查询边删除,也就是不能直接 DELETE FROM user_behavior_log WHERE log_id IN (SELECT ... FROM user_behavior_log)。你得先建一张临时表,把要删的 log_id 导进去,再联表删除。稳妥的做法是:
第一步,创建去重保留结果的临时表:
sql复制CREATE TABLE user_behavior_log_deduped AS
WITH deduped AS (
SELECT log_id, user_id, event_type, event_time, ext_info,
ROW_NUMBER() OVER (
PARTITION BY user_id, event_type, DATE_FORMAT(event_time, '%Y-%m-%d %H:%i:%s')
ORDER BY log_id
) AS rn
FROM user_behavior_log
)
SELECT log_id, user_id, event_type, event_time, ext_info
FROM deduped
WHERE rn = 1;
第二步,核对数据量,确认去重效果符合预期。
第三步,用新表替换旧表,或者把旧表清空后从临时表导回。
这个流程虽然多几步,但在生产环境里最可控,每一步都能验证数据,不会出现删错几百万行才发现的惨剧。
6.3 数据量验证与性能观察
去重之前,先查一下总数:
sql复制SELECT COUNT(*) AS total_cnt, COUNT(DISTINCT user_id) AS user_cnt
FROM user_behavior_log;
然后对比去重前后的行数,确认重复数据大概占多少比例。我在这个案例里实际操作时,原始表大约 8000 万行,按上述规则去重后剩 7300 万行,重复率约 9%。这种体量下,窗口函数跑起来大概花了 3 分钟左右,性能完全可接受。
提示:如果数据量到了亿级且去重规则复杂,可以先按
user_id分片处理,或者把数据抽取到一个独立的临时表里跑,避免对线上 OLTP 库造成太大压力。我的经验是:去重操作尽量在数仓/OLAP 环境跑,不要直接压在业务生产库上。
7. 大数据量下的去重策略与性能优化
上面的案例已经触及了一个问题:数据量大了之后,怎么保证去重 SQL 还能跑得动?这里单独开一节,集中讲性能相关的内容。
7.1 窗口函数在大数据量下的性能表现与优化思路
窗口函数虽然写法优雅,但它的执行逻辑是"分区 + 排序",也就是每个分区内都要做一次排序操作。如果去重字段的区分度很低(比如一张 1 亿行表,按一个只有 10 个不同值的字段分区),每个分区都有上千万行,排序压力会非常大。
优化思路有几个:
第一,尽量缩小排序字段的范围。排序用的字段越窄越好,比如优先用整型 ID 而不是长字符串。
第二,能用 GROUP BY 就用 GROUP BY。ROW_NUMBER() 需要排序才能给出序号,而 GROUP BY 只需要做哈希聚合,在数据量大时往往更快。所以如果你的需求只是"按组合字段去重,统计个数或保留可聚合字段",优先用 GROUP BY。
第三,考虑两阶段去重。先粗粒度去重一轮,再在粗粒度结果上精去重。比如先按 user_id + event_type + 日期 去重,再按秒级去重,这样可以显著降低中间结果量。
7.2 避开 SELECT DISTINCT 的滥用
SELECT DISTINCT 看着简单,但它的执行计划通常包含一次全量排序或哈希操作。如果你在几百个字段的大宽表上做 SELECT DISTINCT *,那等于对整张表所有列做一次全量去重,性能开销极大。
我见过很多开发同学在排查数据问题时,随手就是 SELECT DISTINCT * FROM table LIMIT 10,这种写法不仅慢,还容易误导判断。排查重复数据,更高效的做法是先定位重复的判定字段,然后用聚合函数看重复组数:
sql复制SELECT key_col_1, key_col_2, COUNT(*) AS duplicate_cnt
FROM your_table
GROUP BY key_col_1, key_col_2
HAVING COUNT(*) > 1
ORDER BY duplicate_cnt DESC
LIMIT 20;
这一条语句能告诉你:哪些组合字段重复了、重复了多少次、哪组重复得最厉害。比 DISTINCT * 有用得多。
7.3 索引设计与分区裁剪对去重查询的帮助
去重查询的性能,很大程度上取决于能否利用索引实现快速分组,而不是全表扫描。
如果你频繁按 user_id + event_type 分组去重,那就建议建一个对应的联合索引。索引的存在让数据库可以直接按索引顺序扫描分组,避免额外排序。
sql复制ALTER TABLE user_behavior_log ADD INDEX idx_user_event (user_id, event_type, event_time);
另外,如果表已经做了分区(比如按月分区),去重查询务必带上分区字段的过滤条件,让优化器直接裁剪掉无关分区。否则一个查询扫全表所有分区,再好的索引也帮不上忙。
8. 慢 SQL 与去重查询排障实战
去重查询写出来能跑只是第一步,跑得快、跑得稳才是真本事。这里分享一个从慢 SQL 到定位问题的通用排查流程。
8.1 先从执行计划看起
任何去重 SQL 变慢,我都建议先把执行计划拉出来看。在 MySQL 里就是 EXPLAIN,在 SQL Server 里是"显示估计的执行计划"或 SET SHOWPLAN_ALL ON,PostgreSQL 里是 EXPLAIN ANALYZE。
重点看三件事:
- 是不是存在全表扫描(type = ALL)。
- 排序操作(Using filesort / Sort)发生在哪一步,排序的数据量有多大。
- 临时表的使用情况(Using temporary),去重操作经常会产生临时表,如果临时表大到落盘,性能会断崖式下跌。
8.2 常见去重慢 SQL 分析与优化
我举一个实际的慢 SQL 案例。之前有个报表查询,要大表里去重取用户最新等级信息,SQL 长这样:
sql复制SELECT a.user_id, a.user_level
FROM user_level_log a
INNER JOIN (
SELECT user_id, MAX(create_time) AS max_t
FROM user_level_log
GROUP BY user_id
) b
ON a.user_id = b.user_id AND a.create_time = b.max_t;
线上跑一次要几分钟,原因是 user_level_log 表有上亿行,GROUP BY user_id 产生的临时表非常大,同时自连接又要把两个大结果集做 JOIN。
优化后的写法是窗口函数配合 CTE:
sql复制WITH ranked AS (
SELECT user_id, user_level,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) AS rn
FROM user_level_log
)
SELECT user_id, user_level
FROM ranked
WHERE rn = 1;
同一个需求,改写后执行时间从几分钟降到了 20 秒以内。核心区别就是:窗口函数只需要一次扫描 + 一次分区排序,而自连接需要两次扫描 + 一次 GROUP BY + 一次 JOIN。
8.3 排障思路总结:遇到慢查询先质疑这三件事
按我的经验,去重查询慢,90% 是下面三个原因:
第一,没有走索引,分组字段上没有可用索引,数据库被迫全表扫描再哈希或排序。
第二,排序太贵。ORDER BY 的字段不是索引覆盖的,导致数据库先取回数据再排序,数据量大时就全落盘了。
第三,临时表太大。GROUP BY 或 DISTINCT 产生的中间结果超出了内存临时表的上限(MySQL 里是 tmp_table_size 和 max_heap_table_size),被迫转为磁盘临时表,性能急剧下降。
排查时按这个顺序去看执行计划和数据库配置,通常都能快速定位。
8.4 去重 SQL 的常见“脏数据”翻车点
最后再提醒几个我在实际运维中频繁踩到的"脏数据"相关的坑,这些都是排障时容易被忽视的:
- 字段里有不可见字符。一些从 Excel 导入或手工录入的数据,字段值可能前后带着空格、Tab 或换行符,肉眼看起来一样,但
GROUP BY认为是不同的值。处理办法是先TRIM()再比较。 - 大小写不一致。用户名或编码字段里出现
ABC和abc,在默认排序规则下可能被看成不同值。MySQL 的排序规则可以在建库建表时指定,SQL Server 里也有 collation 的概念。如果业务上大小写不敏感,需要用LOWER()统一。 - NULL 参与去重。
GROUP BY会把所有 NULL 归到一组,但如果你用DISTINCT去重单列,NULL 最终只保留一个。此外,NOT IN子查询里如果包含了 NULL,结果集可能为空,这是经典的 SQL 陷阱,建议一律用NOT EXISTS替代。
我有一个习惯:做任何去重任务之前,先跑一条脏数据探查 SQL,看看判定字段的空值率、重复率、长度分布和前后空格情况。这一步往往能提前暴露一堆问题,省下后面排障的大把时间。
9. 从去重到全局的数据质量视角
聊到最后,想说点技术之外的东西。去重不是终点,它只是数据质量治理的一个环节。SQL 里的 DISTINCT、GROUP BY、ROW_NUMBER()、EXISTS 这些语法,本质上是帮你在结果层做"亡羊补牢"。如果上游数据在采集和写入时就出了系统性 bug,单靠 SQL 去重是治标不治本的。
我实际遇到过一个项目,因为埋点 SDK 版本问题,客户端重复上报日志、服务端没有做幂等处理,结果数仓里某个表每天重复行占比高达 15%。我们花了大半天写去重 SQL,把历史数据洗了一遍,但问题第二天又出现了。后来把服务端的幂等逻辑修掉,才真正解决。所以我的建议是:去重 SQL 可以解决眼前的分析需求,但一定要把"重复率高、重复原因"反馈给数据产出方,推动从源头修复,否则你每周都得洗一遍数据。
对于正在学 SQL 的朋友,去重相关的知识点既可以当成入门练习,也可以当成进阶试金石。你如果能把"某个字段去重"、"组合字段去重"、"每组取最新一条"、"跨表去重"这四类问题不看文档就能写出正确 SQL,那 SQL 的核心功底就算是过关了。遇到更复杂的需求,比如去重后要补明细、去重后要关联多张表、去重后要做漏斗分析,也无非是在这个底子上组合其他语法而已。
我个人这么些年的体会是:SQL 语法本身不难记,难的是理解业务语义和数据特征。你写出去的每一条去重规则,背后都对应一个明确的业务定义。下次写去重 SQL 之前,先跟业务方确认清楚"重复"对他意味着什么,这比任何高级语法都重要。
最后分享一个日常小技巧:当你拿不准一条去重逻辑在不同数据库上的行为差异时,别猜,直接把建表语句和数据样例导到本地的 SQLite 或 PostgreSQL 里验证。自己的实验环境错了不丢人,生产环境跑错了才麻烦。
