SQL 实战:复杂数据去重与唯一值提取
做数据开发这些年,去重这个需求几乎天天撞上。刚入行那会儿,我以为去重就是SELECT DISTINCT一把梭,直到被生产环境的“诡异数据”教育了几轮,才慢慢摸清楚去重背后的门道。今天这篇就把我实际踩过的坑、总结出来的套路,围绕“SQL数据去重”和“唯一值提取”这两个核心场景,一次性讲透。不管你是刚学SQL的新手,还是天天写查询的老手,这篇文章都值得你花十分钟看完,里面有不少常规文档里不会写的细节。
先说清楚这篇文章要解决什么问题。所谓数据去重,绝不只是把完全一样的行删掉那么简单。实际业务里,我们会遇到同一客户多条重复记录、订单快照表里有历史变更、明细表里外键重复导致笛卡尔积,这些场景都要求我们在不同维度上做去重。而唯一值提取,本质上是去重思路的延伸——你要的不是“少几行”,而是要精准拿到“每一种情况下的代表记录”。这背后涉及的手段包括DISTINCT、GROUP BY、窗口函数ROW_NUMBER(),以及不同数据库方言下的特殊写法。下面我用实际案例拆开讲。
1. 去重场景分析与思路选型
1.1 什么时候才真正需要去重
很多人一上来就写DISTINCT,但先别急,我们先判断一下这个去重是不是真的有必要。我给你列举几个我实际遇到过的场景,你看完就会发现,不同场景的去重逻辑完全不一样。
第一种是源数据本身有重复。比如业务系统异常重推、接口幂等没做好、ETL脚本重复执行,导致明细表里出现一模一样的记录。这种情况用DISTINCT或者GROUP BY把所有字段都聚合一遍,是最直接的做法。
第二种是业务主键重复但数据不同。比如一个客户有多条联系方式记录,你想保留最新的一条,或者保留状态为“有效”的那一条。这里简单DISTINCT解决不了,必须用窗口函数按业务规则排序后取第一条。
第三种是关联查询导致的重复。比如订单表关联订单明细表,一个订单有三条明细,关联后订单信息就重复了三遍。这种重复和源数据质量无关,是关联粒度不一致造成的,你要是对着订单维度去重,就会丢失明细信息。正确做法是先把明细聚合好,再关联主表。
第四种是跨表取数时的重复。比如从两张宽表里取客户信息,一张表客户ID有重复,另一张表没有,关联后就会多出很多行。这种情况下,你要做的不是去重,而是先对重复的那张表做清洗,再关联。
我的建议是:任何去重操作之前,先回答三个问题——去重的粒度是什么?保留哪一条记录?用什么规则判断“重复”?把这三个问题想清楚,再去写SQL,基本不会跑偏。
1.2 去重方案的选型逻辑
面对不同去重场景,SQL里可选的方案说多不多,说少不少,常见的就是DISTINCT、GROUP BY、窗口函数,以及部分数据库专有的QUALIFY(如Snowflake、Teradata)或者DISTINCT ON(如PostgreSQL)。选哪个,主要看三点:去重粒度、是否要保留附属于记录的其他字段、数据量大小。
这里给出我平时做选型时参考的判断逻辑,写成表格方便你对照。
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 多列完全重复,只去重不取额外字段 | DISTINCT |
语法简洁,逻辑清晰 |
| 按某几个字段分组,需要其他字段的聚合值 | GROUP BY |
可以直接配合SUM/MAX/MIN等聚合函数 |
| 按分组取每组最新/指定条件的记录 | 窗口函数ROW_NUMBER() |
支持自定义排序规则,逻辑灵活 |
| 只需要判断某个值是否存在 | EXISTS |
半连接语义,去重效率高,避免数据膨胀 |
| 去重后还需要分页排序 | GROUP BY + ORDER BY |
在MySQL、SQL Server中更可控 |
| PostgreSQL下按字段去重取整行 | DISTINCT ON |
比窗口函数更简洁,性能更好(注意与ORDER BY配合) |
看到这里你会发现,没有万能方案,只有“当前场景最合适”的方案。接下来我会对核心方案逐个拆解,讲清楚它们背后的执行逻辑、适用边界的坑,以及我实测下来的性能表现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心去重细节解析与实操要点
2.1 DISTINCT与GROUP BY:看似一样,其实差得远
先聊最基础的DISTINCT。它做的事是对结果集里所有暴露出来的列做组合去重。很多人会用SELECT DISTINCT col1, col2 FROM t,如果col1重复、col2不同,这两行不会被去重。这是非常容易踩坑的点——你以为DISTINCT会按col1去重,实际上它按col1+col2的组合去重。
GROUP BY和DISTINCT的区别更有意思。从执行计划来看,很多数据库引擎(比如MySQL 8.0的优化器)在DISTINCT和GROUP BY语义一致的情况下,会生成几乎相同的执行计划。但是GROUP BY有个DISTINCT没有的天然优势——它可以在分组的同时计算聚合值。比如你要统计每个客户的订单数和总金额,GROUP BY一行搞定;DISTINCT做不到这个事。
再说一个很多人忽略的点:DISTINCT在搭配ORDER BY时是有讲究的。在SQL Server里,SELECT DISTINCT col1 FROM t ORDER BY col2会直接报错,因为col2没有出现在SELECT列表中,排序字段和去重字段不一致,数据引擎不知道按哪条记录的col2来排序。这个规则在MySQL和PostgreSQL里也一样,报错信息可能略有不同,但核心逻辑一致。解决办法是:要么把col2也加到SELECT里,要么改成GROUP BY col1然后再聚合一个col2(比如MAX(col2))作为排序依据。
2.2 窗口函数:处理“保留哪一条”的终极武器
如果只需要把重复行去掉,DISTINCT够了。但业务上更常见的是:同一个维度有多条记录,我只想要其中一条。比如客户表里一个客户ID对应多个手机号,我要取最新绑定的手机号;再比如订单状态日志表里同一个订单有多次状态变更,我要取最新状态。这时候就要用窗口函数。
最经典的写法是:
sql复制SELECT *
FROM (
SELECT
t.*,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS rn
FROM customer_contact t
) tmp
WHERE rn = 1;
这里PARTITION BY决定分组的维度,也就是“什么算重复”;ORDER BY决定组内的优先级,也就是“重复时保留哪一条”。理解这两个子句,窗口函数去重就掌握一大半了。
窗口函数相比GROUP BY的优势,在于它能保留完整原始行记录,而不是只能保留聚合字段。比如我要把客户ID对应的手机号取出来,同时还要保留原始备注字段。GROUP BY写起来非常痛苦,得把所有非聚合字段都加到GROUP BY里,或者用MAX()、MIN()去凑。窗口函数一步到位。
不过,窗口函数也有一个必须注意的细节:ROW_NUMBER()生成的序号是连续的,哪怕遇到并列的情况,也会强制分出一二三名。如果你的业务要求“并列的都要保留”,那就得用RANK()或DENSE_RANK()。举个具体例子,排行榜要取分数最高的前两名,如果第一名有两个人,ROW_NUMBER()只会随便取一个,但RANK()会给出两个第1名,后续名次会跳过(1,1,3),DENSE_RANK()则不会跳过(1,1,2)。这个区别在去重场景里非常容易踩坑——比如提取“每个分类下评分最高的产品”,如果两个产品并列最高,你就得想清楚到底只取一个,还是两个都保留。
2.3 利用HAVING过滤重复组
有时候我们要的不是“去掉重复的”,而是“找出重复的”,这两个逻辑刚好相反,但同样属于数据去重的范畴。比如你要筛查客户表里手机号重复的记录,把重复的手机号和对应的客户ID揪出来。
写法是这样:
sql复制SELECT phone, COUNT(*) AS cnt
FROM customer
GROUP BY phone
HAVING COUNT(*) > 1;
HAVING在这里的作用是对聚合后的分组做过滤,它是在GROUP BY之后执行的。很多人会把WHERE和HAVING搞混——WHERE是在分组之前过滤原始行,HAVING是在分组之后过滤分组。你要找“重复组”,就必须先分组再过滤组内条数,所以HAVING是唯一合理的选择。
这个方法配合子查询,还能进一步把重复记录的完整行捞出来:
sql复制SELECT *
FROM customer
WHERE phone IN (
SELECT phone
FROM customer
GROUP BY phone
HAVING COUNT(*) > 1
)
ORDER BY phone;
这里有个性能隐患——如果phone字段没有索引,这条SQL会做两次全表扫描,数据量大时相当吃力。下面第4章会专门讲性能优化,这里先提个醒:一定要在这类高频查询的字段上建索引。
3. 复杂场景实操过程与核心实现
3.1 多字段组合去重的高效写法
实际业务里很少只按一个字段去重,更多是按多个字段组合判断是否为重复数据。比如同一个用户+同一个商品+同一个渠道,才算重复加购。这种场景下,DISTINCT可以写,但要列字段,而GROUP BY更灵活。
假设我们有一张加购记录表,包含user_id、product_id、channel三个字段,现在要统计去重后的加购记录数:
sql复制SELECT user_id, product_id, channel, COUNT(*) AS cnt
FROM cart_record
GROUP BY user_id, product_id, channel;
如果你只需要去重后的结果集本身,不关心重复次数,把COUNT(*)去掉就行,但GROUP BY的字段顺序其实会影响底层分组时的排序以及索引利用效率。以MySQL为例,GROUP BY列的顺序和走索引的顺序一致时,性能最好。比如索引是(user_id, product_id, channel),那你GROUP BY user_id, product_id, channel就能用到联合索引的前缀,避免文件排序。这一点我后面还会再强调。
再介绍一个技巧:如果你用的是PostgreSQL,可以直接用DISTINCT ON,它比窗口函数更简洁。比如:
sql复制SELECT DISTINCT ON (user_id, product_id)
user_id, product_id, channel, created_at
FROM cart_record
ORDER BY user_id, product_id, created_at DESC;
注意,DISTINCT ON后面的字段决定了去重维度,ORDER BY的前缀必须和它一致,然后可以额外加一个排序字段来决定保留哪一条记录。在我的实测里,这种写法在PG里走索引的性能经常优于窗口函数的写法,代码也更短。当然,MySQL和SQL Server没有这个语法,只能用窗口函数替代。
3.2 保留最新记录的经典方案(以订单状态为例)
订单状态变更表几乎每一个业务系统都有。我们常遇到的需求是:根据订单ID取每个订单当前最新状态。我习惯先给这个场景搭一个测试环境,做法很简单,就是你得有一张结构类似如下的表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| order_id | int | 订单ID |
| status | varchar(20) | 状态 |
| changed_at | datetime | 变更时间 |
然后写入几条同一订单不同状态的记录。接下来用窗口函数取最新一条:
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;
这段SQL的关键在于ROW_NUMBER()按订单分组,并按变更时间倒序编号,时间最新的就是第1名。
但这里有一个实际生产中很常见的问题:如果changed_at允许为空(NULL),排序结果可能会出乎意料。在绝大多数数据库里,默认排序时NULL会排在最前面(升序时)或最后面(降序时)。比如SQL Server的默认排序,NULL被视为最小值,所以ORDER BY changed_at DESC会让NULL记录排在最前面,导致窗口函数取到的“最新”实际上是一条没有时间的记录。要避免这个坑,可以加上NULLS LAST(PostgreSQL)或CASE WHEN changed_at IS NULL THEN 0 ELSE 1 END表达式来调整排序优先级。
3.3 去重之后做字符串聚合:GROUP_CONCAT的妙用
去重后做字符串聚合,是另一个非常常见但很多教程不讲透彻的场景。比如一个客户对应多个标签,我们需要把标签用逗号拼成一行。如果标签本身有重复,还要求在拼接前去重。
在MySQL里是这样写的:
sql复制SELECT
customer_id,
GROUP_CONCAT(DISTINCT tag ORDER BY tag SEPARATOR ',') AS tags
FROM customer_tag
GROUP BY customer_id;
注意GROUP_CONCAT支持DISTINCT关键字,先在组内去重,再拼接。ORDER BY tag可以控制拼接顺序,SEPARATOR指定分隔符,默认是逗号。
SQL Server没有GROUP_CONCAT,要用STRING_AGG(SQL Server 2017+),也同样支持DISTINCT:
sql复制SELECT
customer_id,
STRING_AGG(DISTINCT tag, ',') WITHIN GROUP (ORDER BY tag) AS tags
FROM customer_tag
GROUP BY customer_id;
PostgreSQL的STRING_AGG用法和SQL Server类似,只是不支持DISTINCT直接在参数里写,需要先对数据去重,比如在子查询里用SELECT DISTINCT customer_id, tag,再做聚合。
还有一个容易忽略的细节:GROUP_CONCAT默认最大长度是1024字节(MySQL),如果拼接结果超过这个长度会被静默截断,这是很多人在报表里发现标签少了的真正原因。解决办法是在会话级执行SET SESSION group_concat_max_len = 102400;,调大上限。
3.4 跨表关联时的去重:先聚合再关联
这是我认为最值得单独拿出来讲的一个场景,因为几乎每个用SQL做报表的人都被它坑过。
假设你有一张订单表orders,一张订单明细表order_items。一个订单可能在明细表里关联出三条商品记录。如果你想统计每个订单的金额和下单时间,直接JOIN会出现如下结果:
sql复制SELECT
o.order_id,
o.order_date,
o.amount,
i.item_id
FROM orders o
LEFT JOIN order_items i ON o.order_id = i.order_id;
如果订单A有三个明细,订单A的order_date和amount会被复制三次。一旦你接下来SUM(amount),就会把订单金额算三遍,最终的报表金额会离谱地膨胀。这不是SQL语法问题,而是关联粒度造成的逻辑重复。
正确做法是:在所有聚合统计之前,先把明细表按订单号做聚合,得到每个订单的明细数量、明细金额合计,再去关联主表:
sql复制SELECT
o.order_id,
o.order_date,
o.amount,
item_stats.item_count,
item_stats.item_total_amount
FROM orders o
LEFT JOIN (
SELECT
order_id,
COUNT(*) AS item_count,
SUM(price * quantity) AS item_total_amount
FROM order_items
GROUP BY order_id
) item_stats ON o.order_id = item_stats.order_id;
这个思路在做报表时非常关键。很多时候你发现“汇总数和明细数对不上”,本质都是因为关联层级没理清。先聚合明细,再关联主表,这个顺序千万不要颠倒。即使某些数据库优化器能自动去重,那也是它生成的执行计划恰好一致,并不代表你的SQL逻辑本身是正确的。我们在做数据核对时,不能依赖优化器的行为,必须依靠自己能解释的业务逻辑。
3.5 JSON数组或半结构化数据中去重
现在MySQL和PostgreSQL都原生支持JSON,很多业务场景会把标签、属性信息放在JSON字段里,这时候去重就变成了JSON数组去重。
在MySQL里,可以用JSON_TABLE把JSON数组展开成行,然后DISTINCT,再聚合回JSON数组。比如一张产品表里有个tags字段,内容是["手机", "数码", "手机", "促销"],需要把重复的“手机”去掉。一个可行的方法是用JSON_EXTRACT配合循环处理,但有点繁琐。更通用的办法是使用JSON_TABLE(MySQL 8.0+):
sql复制SELECT
product_id,
JSON_ARRAYAGG(DISTINCT tag) AS unique_tags
FROM product,
JSON_TABLE(product.tags, '$[*]' COLUMNS (tag VARCHAR(50) PATH '$')) AS jt
GROUP BY product_id;
这里的逻辑是:先用JSON_TABLE把JSON数组拍平成一张多行表,每一行是一个标签,然后外层按product_id分组,用JSON_ARRAYAGG(DISTINCT ...)聚合去重。
PostgreSQL的写法稍微不同,但思路一致,利用jsonb_array_elements_text将数组展开为行,DISTINCT后再jsonb_agg拼回:
sql复制SELECT
product_id,
(SELECT jsonb_agg(DISTINCT tag)
FROM jsonb_array_elements_text(product.tags) AS tag) AS unique_tags
FROM product;
这种场景在实际业务中越来越常见,因为做标签系统、推荐系统的团队都喜欢把标签存在JSON字段里。但我要提醒一句:JSON字段虽然方便,但在查询和去重上的性能通常不如标准化之后的关联表。如果你的标签字段经常要做去重、统计、过滤,从长远看还是建议拆成一张标签表。
4. 性能优化与常见排查
4.1 大数据量下,DISTINCT为什么那么慢
很多人吐槽SELECT DISTINCT在大表上跑不起来,十几分钟都没结果。我排查过不少类似问题,发现慢的根本原因往往不是去重本身,而是执行过程中的临时表或排序开销。
DISTINCT在绝大多数数据库里,都需要对结果集做一次排序或者哈希去重。当结果集很大时,排序会涉及磁盘临时文件,性能断崖式下降。你可以从执行计划里看到Using temporary、Using filesort(MySQL)或者Sort、Hash Match(SQL Server)等字样。要解决这个问题,有几个实操方向:
一是减少参与去重的列数。很多人喜欢SELECT DISTINCT *,大量无谓字段参与比较,严重拖慢速度。只保留业务上必须去重的字段,能让去重成本大幅下降。
二是走索引。如果DISTINCT的字段是有序索引的前缀字段,数据库可以直接扫描索引有序地输出去重结果,不需要额外的排序。这就是我前面反复强调,GROUP BY和联合索引顺序一致时性能好的原因。举个例子,如果你常用GROUP BY category,给category建一个二级索引,执行计划就会变成索引扫描,而不是全表扫描+临时表。
三是改写为EXISTS。如果你只是想判断“某个ID是否存在”,不要用SELECT DISTINCT id FROM t WHERE ...,而是用WHERE EXISTS (SELECT 1 FROM ...),这样一旦命中一条记录就会停止扫描,性能要好很多。
我给出一个MySQL下通过索引优化去重的示意,假设表结构如下:
sql复制CREATE TABLE order_item (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
INDEX idx_order_product (order_id, product_id)
) ENGINE=InnoDB;
当你执行:
sql复制SELECT DISTINCT order_id FROM order_item WHERE product_id = 100;
这个查询只能看到product_id,而索引前缀是order_id,没法直接用上,数据库大概率回表再过滤。更好的索引顺序是(product_id, order_id),这样WHERE product_id = 100过滤后,order_id正好顺序排列,去重可以直接从索引有序输出,效率高出一大截。这个细节在面试里也经常考,理解了索引前缀规则,你写出来的去重SQL会有质变。
4.2 经典问题案例:去重后统计结果不对
在你做数据校验时,可能会遇到这种情况:用SELECT COUNT(*) FROM t查出来是10000行,用SELECT COUNT(DISTINCT user_id) FROM t查出来是8000,但你怎么核对都对不上,感觉差了不止2000。这时候问题通常不是你SQL写错,而是统计口径没对齐。
最常见的错误是:你以为COUNT(DISTINCT user_id)等价于“每个用户只算一次”,但如果你同时筛选了其他条件,或者关联了别的表,结果就会受到筛选条件和连接关系的影响。比如订单表里一个用户有多个订单,你COUNT(DISTINCT user_id)得到的是“有订单的用户数”,而不是“订单数”。这个业务语义必须搞清楚。
还有一种情况:COUNT(DISTINCT field)会把NULL值排除在外。如果某个字段存在NULL,你会发现不同写法统计出来的结果不一样。比如:
COUNT(DISTINCT col):统计非空唯一值,忽略NULLCOUNT(DISTINCT IFNULL(col, '未知')):把NULL统一成一个已知值再统计
在做唯一值提取时,要明确是否需要把NULL也作为一种“值”来对待。大多数业务场景下NULL表示“未知”,不应该参与唯一值统计;但有些场景(比如客户渠道来源)NULL代表着“未归因”,你希望NULL也算一个分组,那就需要用COALESCE来兜底。
另外要特别提醒的是:去重之后再做聚合,和先去重再分组,结果可能完全不同。比如你想统计每个用户首次下单的渠道分布,如果你先在订单表里取每个用户最新订单,再去GROUP BY channel,得到的是“最新订单的渠道分布”;如果你直接GROUP BY user_id, channel然后取MAX时间,得到的是“每个用户每个渠道的最新时间”,语义完全不一样。这类问题我在指导新人时非常常见,建议写任何SQL之前,都把输出的每一行代表什么业务含义写清楚。
4.3 常见问题排查速查表
把平时日常工作中最常遇到的相关问题整理成一个速查表,方便你排查时对照。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| SELECT DISTINCT 和 ORDER BY 栏位不一致报错 | 排序字段不在去重结果集中 | 排序字段加入SELECT,或用聚合函数包一层 |
| 去重后行数比预期少很多 | 字段组合被当成了唯一键 | 检查去重维度,确认是否把高维度字段也加入去重 |
| 窗口函数去重保留了错误的记录 | 排序字段存在NULL,打乱了优先级 | 用CASE或NULLS LAST控制NULL排序位置 |
| 关联查询后SUM金额翻倍 | 主表与明细表关联造成行数膨胀 | 先聚合明细,再关联主表 |
| 大表DISTINCT执行极慢 | 结果集巨大触发排序/临时表 | 减少列数、建覆盖索引、用EXISTS改写 |
| GROUP_CONCAT结果被截断 | MySQL默认长度限制1024字节 | 调大group_concat_max_len |
| JSON字段数组里去重失败 | JSON数组函数用法不对 | 展开成行后DISTINCT,再聚合回去 |
| 去重后NULL是否算一种值结果不一致 | 不同函数对NULL处理方式不同 | 用IFNULL/COALESCE显式指定NULL的策略 |
| 统计去重后的数量和明细对不上 | 业务口径不一致 | 先明确每一行的业务含义,再写SQL |
| 数据库版本不同语法不兼容 | 各库方言差异 | 提前确认基础功能、函数在不同版本中的支持情况 |
这张表是我在实际工作中沉淀出来的,不算全,但覆盖了百分之八九十的日常去重问题。遇到对不上的情况,先对照表里查一遍,通常能快速定位。
5. 不同数据库方言的去重特殊性
作为写SQL的人,你可能不只用一种数据库。MySQL、PostgreSQL、SQL Server、Oracle,它们的去重语法各有差异,很多SQL在这些库之间并不能直接迁移。这一节我把常见差异整理一下,方便你跨库开发时参考。
5.1 MySQL与MariaDB
MySQL里的去重主要靠DISTINCT、GROUP BY、窗口函数(8.0+),没有DISTINCT ON。如果你的版本是5.7或更早,窗口函数用不了,取每组最新记录的常规做法是利用变量模拟,或者通过自连接+MAX实现。变量写法性能不错但可读性差,业务复杂时容易出错,这里不推荐新手使用;建议能升级到8.0就升级,很多去重逻辑会简单很多。
MySQL的GROUP BY在默认配置(sql_mode没开ONLY_FULL_GROUP_BY)下有一个“老毛病”:它允许SELECT列表里出现不在GROUP BY中的字段,而这种情况下MySQL会随机返回某一行的值,不保证每次结果一样。所以即使语法不报错,你也千万别依赖这种写法去取“某个分组里的一个代表值”。要保证语义稳定,要么严格开启ONLY_FULL_GROUP_BY,要么老老实实用窗口函数。
5.2 PostgreSQL
PostgreSQL在去重方面相对优秀,因为它提供了DISTINCT ON,这是很多从MySQL迁移过来的开发者最爱用的语法。不过要注意,DISTINCT ON必须和ORDER BY配合,并且ORDER BY的前缀顺序要和DISTINCT ON里的字段顺序保持一致,否则会报错。如果你既想用DISTINCT ON,又想按另一个字段排序,记得把这个排序字段加在ORDER BY的末尾。
PostgreSQL对NULL的处理也很灵活,支持ORDER BY changed_at DESC NULLS LAST。遇到NULL字段排序导致去重错误时,这个语法可以救你。
5.3 SQL Server
SQL Server里窗口函数支持非常成熟,日常去重我基本都用ROW_NUMBER()。另外SQL Server 2017以上提供了STRING_AGG,处理去重拼接很方便。还有一个细节:SQL Server默认的排序规则(Collation)可能影响字符串去重的结果。比如在SQL_Latin1_General_CP1_CI_AS排序规则下,'ABC' 和 'abc' 会被认为是相同的,去重时保留一个;而在某些二进制的排序规则下,它们会被当作不同的值。如果你的业务对英文大小写有严格要求,建表时就要明确指定排序规则,否则去重逻辑可能在不知不觉中发生变化。
5.4 Oracle
Oracle除了常用窗口函数,还有一个专门用于去重的分析函数ROW_NUMBER()之外的概念叫KEEP (DENSE_RANK FIRST ORDER BY ...),它可以直接在聚合时按某个排序规则取第一条,不需要子查询。比如:
sql复制SELECT
department_id,
MIN(salary) KEEP (DENSE_RANK FIRST ORDER BY hire_date) AS first_hired_salary
FROM employees
GROUP BY department_id;
这种写法在取“每个部门最早入职员工的薪资”时非常简洁。另外,Oracle的DISTINCT对NULL的处理和大多数数据库一致——COUNT(DISTINCT col)不会统计NULL。整体机制和标准SQL相差不大。
5.5 各数据库语法对照表
为了让你快速迁移SQL,我把常见去重语法的数据库差异整理成表格:
| 功能 | MySQL | PostgreSQL | SQL Server | Oracle |
|---|---|---|---|---|
| 基础去重 | DISTINCT | DISTINCT | DISTINCT | DISTINCT |
| 按维度取第一条 | ROW_NUMBER() OVER | DISTINCT ON / ROW_NUMBER() | ROW_NUMBER() OVER | ROW_NUMBER() OVER / KEEP |
| 并列保留 | RANK() / DENSE_RANK() | RANK() / DENSE_RANK() | RANK() / DENSE_RANK() | RANK() / DENSE_RANK() |
| 字符串聚合去重 | GROUP_CONCAT(DISTINCT) | string_agg(DISTINCT)需要子查询去重 | STRING_AGG(DISTINCT) | LISTAGG(DISTINCT)(注意版本) |
| NULL排序控制 | 需CASE表达式 | 原生支持NULLS FIRST/LAST | 需CASE表达式 | 原生支持NULLS FIRST/LAST |
| 判断存在性 | EXISTS | EXISTS | EXISTS | EXISTS |
这张表你可以在切换数据库时直接拿来对。“基础去重”和“判断存在性”在所有数据库里都一致,而“取第一条”“并列保留”“字符串聚合”这些高级功能,不同库差异最大,是最容易踩坑的地方。
6. 项目实战:完整的数据清洗去重流程
光讲单个知识点不够,我最后用一个相对完整的例子,把从发现问题到最终验证的整个去重流程串起来。这是一个我在真实项目里做过的数据清洗场景,脱敏后分享给你。
6.1 场景还原:会员标签表里的重复数据
某次接到一个数据清洗需求,说会员标签表里有大量重复数据,导致下游营销系统发消息时同一会员被多次触达。表结构大概是member_tag:member_id、tag_id、tag_name、source_system、created_at。初步统计,这张表有约300万行,预计需要清理的重复数据可能占三成。
原表里,同一个会员从不同来源系统录入过的标签可能重复,比如A系统和B系统都可能给同一个会员打上“高净值客户”的标签。下游发送营销消息时,按会员维度和标签维度取数,就会出现多条相同标签,造成重复触达。
我拿到场景后,先不做任何操作,而是先摸清重复分布。
第一步,确定去重粒度。这里的业务语义是:一个会员+一个标签,只能保留一条记录,视为唯一。也就是说,去重维度是(member_id, tag_id)。
第二步,确定保留规则。同一会员同一标签如果有多条记录,保留created_at最新的一条,这样能保证标签来源是最新维护的记录。
第三步,找出重复数据量级。我写了一条查询:
sql复制SELECT COUNT(*) AS duplicate_count
FROM (
SELECT member_id, tag_id
FROM member_tag
GROUP BY member_id, tag_id
HAVING COUNT(*) > 1
) t;
如果这条查出来是0,那说明没有重复,可以收工。但实际情况是查出来有大概92万组重复,需要清理。
6.2 备份、清洗与验证三步走
面对生产表的数据清洗,我习惯按“备份、清洗、验证”三步走,而不是直接DELETE。
先建备份表:
sql复制CREATE TABLE member_tag_bak_20250101 AS
SELECT * FROM member_tag;
然后生成待删除记录的ID列表,这里用窗口函数标记每条记录的保留优先级:
sql复制SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY member_id, tag_id
ORDER BY created_at DESC, id DESC
) AS rn
FROM member_tag;
注意,我在ORDER BY里加了一个id DESC作为次级排序,目的是在时间完全相同的情况下,保留最新插入的那条记录,保证后续操作结果可重复、可解释。
接着将上面查询结果中的rn > 1的记录删除。由于直接删除大表数据会非常慢,也容易造成锁表时间过长,我更推荐在数据量大的时候先建一张临时表,只保留rn = 1的数据,然后truncate原表,再Insert回原表。如果你的数据量不大,直接用DELETE JOIN也可以,但500万行以上的表,我更建议用临时表替换的方式。
清洗完成之后,一定要做验证。用前面同样的SQL重新查一遍重复组数量,确认归零;再抽查几个会员ID,看看保留的记录是不是预期中最新的那条;最后对比一下清理前后的总行数,确保差值与预估值一致。
数据清洗完之后,为了防止后续再产生重复数据,我给这张表加了唯一约束。如果业务上允许,为(member_id, tag_id)建立唯一索引是釜底抽薪的解决办法:
sql复制ALTER TABLE member_tag
ADD UNIQUE INDEX uk_member_tag (member_id, tag_id);
如果业务上确实需要保留历史多版本并存,那就加一个active标志位,从查询层面只取active = 1的记录。这里要说明的是,具体选择加唯一约束还是软删除,取决于业务上是否还有追溯历史版本的需求。从我的经验看,如果这张表是纯标签映射关系,加唯一索引更省心;如果是可以作为审计证据的记录,那软删除或保留历史更合适。
6.3 游标批量处理特定场景
有的去重场景不只是在查询层做,而是要直接修改源表数据。比如把重复的手机号清空掉,只保留最新一条记录的手机号。这种UPDATE类操作,在数据量较大时,如果一条SQL直接更新几百万行,容易锁表并拖垮在线业务。
我遇到过一次类似情况,当时用了分批更新的方式,具体思路是这样:先找出所有需要更新的id列表,然后按主键范围分批执行UPDATE,每批2000行,批次之间停几毫秒,降低对数据库的压力。在SQL Server里,一个常见的写法是用CURSOR循环或者WHILE循环按批次处理;在MySQL里,也可以分批JOIN临时表来更新。不过要注意,CURSOR虽然直观,但在行数很大时性能很低,能用集合操作写清楚的就尽量用集合操作,游标只是最后手段。
下面给出一个用游标处理去重更新的SQL Server示例——把重复手机号中非最新记录的手机号置为NULL:
sql复制DECLARE @id INT;
DECLARE cur CURSOR FOR
SELECT id
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY phone
ORDER BY created_at DESC
) AS rn
FROM customer
) t
WHERE rn > 1;
OPEN cur;
FETCH NEXT FROM cur INTO @id;
WHILE @@FETCH_STATUS = 0
BEGIN
UPDATE customer SET phone = NULL WHERE id = @id;
FETCH NEXT FROM cur INTO @id;
END;
CLOSE cur;
DEALLOCATE cur;
需要提醒的是,游标更新只是解决“慢批量更新”的一种手段。如果数据量极大,你还要考虑分批事务、日志文件的增长等问题。实际操作时,我会把更新脚本放在业务低峰期,同时加上事务控制,确保一旦出错可以完整回滚。
7. 我的最后一点实操体会
写了这么多,回归到最根本的一句话:去重从来不是SQL技巧问题,而是业务语义问题。
再复杂的SQL写法,本质都是在回答三个数据问题——哪个维度算重复、保留哪一条、其他记录怎么处理。只要这三个问题的答案清晰,SQL实现反而是顺理成章的事。
在实际项目里,我见过太多人纠结于用DISTINCT还是ROW_NUMBER(),却很少先想清楚自己的数据粒度到底是什么。这导致SQL写出来能跑,结果却没人敢信。所以我建议你,拿到任何去重需求,先花十分钟把业务规则理清楚,最好画一张表格,写清楚“分组字段、排序字段、保留规则”,再动手写SQL。你会发现,九成以上的去重问题,在纸面上就已经解决了一半。
另外,再分享一个小技巧:凡是写比较复杂的去重SQL,我都会先建一个几十行的小测试表,把边界情况(比如时间相同、字段为NULL、大小写不一致)造进去,验证SQL的输出符合预期后,再放到生产环境跑。这个习惯帮我挡住了很多次可能导致线上数据事故的误操作。
关于SQL数据去重与唯一值提取,这次就分享到这里。希望这些思路和代码能让你以后面对重复数据时更从容,少走一些我当年走过的弯路。
