1. 需求拆解:到底什么叫“多列之间的值重复”
1.1 业务场景:一个让人头疼的客户表
先说说我遇到的一个典型场景。某次接手一个CRM系统历史数据清洗任务,客户表里有 手机号、备用电话、微信、邮箱 四列。业务方提了一个需求:这四列里,任何一列的值只要在另外任何一列中出现过,就要把这批客户标记出来,后续做数据合并。也就是说,同一个手机号既出现在A客户的“手机号”字段,又出现在B客户的“备用电话”字段,那这两个客户很可能就是同一个人。
我第一次看到这个需求时第一反应是“这不就是查重复值吗,GROUP BY 不就完事了”。但操作起来才发现,单列查重复简单,跨列查重复涉及的逻辑远比想象中复杂。因为这四列属于同一种“联系方式”属性,但它们被拆分成了四个独立字段,SQL里没有现成的“把多列当成一组值”的语法,需要自己想办法把这些列“摊平”成单列再做去重判断。
1.2 三种常见需求形态,别搞混
根据我过去的经验,业务方说“排查多列之间的值是否重复”时,实际上可能指三种完全不同的需求,写SQL之前必须先确认清楚:
| 需求形态 | 具体含义 | 典型输出 |
|---|---|---|
| 形态一:查出重复值清单 | 把出现在两列及以上的具体值找出来 | 值本身,比如 '13800138000' |
| 形态二:找出重复值对应的原始记录 | 定位到哪些行、哪些列包含了重复值 | 主键、列名、值 |
| 形态三:判断单行内部是否有重复 | 一行之内,列A的值是否等于列B或列C | 行号 + 是否重复标记 |
这篇文章主要围绕前两种,因为实际工作中遇到最多。第三种通常用 CASE WHEN 就能解决,难度不大。
1.3 为什么直接 SELECT DISTINCT 行不通
很多刚接触这个需求的同学第一反应是两个方案:要么 SELECT DISTINCT col1, col2, col3,要么对每列单独 GROUP BY。但前者查的是“行的组合是否重复”,后者查的是“单列内部是否有重复值”,都无法解决“值跨列出现”这个核心问题。
举个最简单的例子,表里有两条记录:
| ID | col1 | col2 |
|---|---|---|
| 1 | A | B |
| 2 | B | C |
如果只对 col1 查重复,结果为空;只对 col2 查重复,结果也为空。但事实上,值 B 同时出现在了第一条记录的 col2 和第二条记录的 col1 里。这就是跨列重复,也是这个需求真正的难点所在。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现方案:UNION ALL 摊平法
2.1 思路详解:把二维表变成一维集合
解决跨列重复最核心的思路就是列转行。把每一列的数据从原始表里拆出来,纵向拼接在一起,形成一个“值集合”,然后再对这个集合做分组统计。这样原本分散在不同列里的值就合并到了同一个字段里,后续 Group By 就能直接统计出现次数。
这个思路跟生活里整理杂物很像:你把衣柜、抽屉、收纳箱里的所有衣服都翻出来堆在床上,再从这堆衣服里找有没有同款。摊平的过程就是用 UNION ALL 把多个查询结果纵向拼接。
用代码表示,核心套路长这样:
sql复制SELECT col1 AS val FROM table_name
UNION ALL
SELECT col2 FROM table_name
这里必须用 UNION ALL,不能用 UNION。两者区别在于:UNION 会去重,UNION ALL 保留所有行。如果用了 UNION,等于把重复值提前给过滤掉了,后续统计次数就永远只能是1,什么都查不出来。
2.2 完整实现:一个查询查出所有重复值
假设有一张联系人表 contact,结构如下:
sql复制CREATE TABLE contact (
id INT PRIMARY KEY,
name NVARCHAR(50),
phone VARCHAR(20),
backup_phone VARCHAR(20),
wechat VARCHAR(50),
email VARCHAR(100)
);
需求:找出 phone、backup_phone、wechat、email 四列中,出现在两列及以上的所有值。
sql复制;WITH AllValues AS (
SELECT id, 'phone' AS col_name, phone AS val FROM contact WHERE phone IS NOT NULL AND phone <> ''
UNION ALL
SELECT id, 'backup_phone', backup_phone FROM contact WHERE backup_phone IS NOT NULL AND backup_phone <> ''
UNION ALL
SELECT id, 'wechat', wechat FROM contact WHERE wechat IS NOT NULL AND wechat <> ''
UNION ALL
SELECT id, 'email', email FROM contact WHERE email IS NOT NULL AND email <> ''
)
SELECT
val AS duplicate_value,
COUNT(DISTINCT id) AS affected_rows,
COUNT(*) AS appear_times,
STRING_AGG(col_name, ',') AS appeared_in_columns
FROM AllValues
GROUP BY val
HAVING COUNT(DISTINCT id) > 1
OR COUNT(DISTINCT col_name) > 1
ORDER BY appear_times DESC;
这里有几个细节值得展开说:
第一,为什么 WHERE 条件要排除 NULL 和空字符串。 SQL 里 NULL 参与任何聚合比较都会得到 UNKNOWN,两个 NULL 不会被认为是相等的。如果不排除 NULL,统计结果不会出错,但会有大量“空值”被归为一组,造成干扰。另外,很多业务系统里空字符串和 NULL 都存在,它们在数据含义上都是“没有”,但在分组时会被当成两个不同的值(因为 NULL 不会跟任何值匹配,而空字符串会跟空字符串匹配),所以一并排除掉。
第二,为什么 HAVING 里既判断 COUNT(DISTINCT id) 又判断 COUNT(DISTINCT col_name)。 这两者表达的口径不同。COUNT(DISTINCT id) > 1 表示“同一个值出现在多行记录里”,COUNT(DISTINCT col_name) > 1 表示“同一个值出现在多列里”。但这里有一个业务细节:如果同一个人的手机号既填在 phone 列,又填在 backup_phone 列,而这个人恰好只有一条记录,那么 COUNT(DISTINCT id) 就是1,但 COUNT(DISTINCT col_name) 是2,这种情况也属于“多列之间的值重复”,必须被识别出来。
第三,为什么用 STRING_AGG 而不是直接列出列名。 因为同一个值可能跨多列出现,也可能多次出现在同一列的不同行里。STRING_AGG 会把涉及的列名拼接成一个字符串,比如 phone,backup_phone,方便一眼看出重复分布。SQL Server 2017 及以上版本支持 STRING_AGG,如果是 2016 或更早版本,可以用 FOR XML PATH 替代,后面会专门讲到。
2.3 如果你用的是 SQL Server 2008/2012/2016
上面的 STRING_AGG 在旧版本里是不能用的。很多企业还在用 2008 R2 或者 2012,这套写法需要调整。替代方案是 FOR XML PATH:
sql复制SELECT
val AS duplicate_value,
COUNT(DISTINCT id) AS affected_rows,
COUNT(*) AS appear_times,
STUFF((
SELECT ',' + col_name
FROM (
SELECT 'phone' AS col_name, phone AS val FROM contact
UNION ALL
SELECT 'backup_phone', backup_phone FROM contact
UNION ALL
SELECT 'wechat', wechat FROM contact
UNION ALL
SELECT 'email', email FROM contact
) AS t
WHERE t.val = grouped.val
FOR XML PATH('')
), 1, 1, '') AS appeared_in_columns
FROM (
SELECT phone AS val FROM contact WHERE phone IS NOT NULL AND phone <> ''
UNION ALL
SELECT backup_phone FROM contact WHERE backup_phone IS NOT NULL AND backup_phone <> ''
UNION ALL
SELECT wechat FROM contact WHERE wechat IS NOT NULL AND wechat <> ''
UNION ALL
SELECT email FROM contact WHERE email IS NOT NULL AND email <> ''
) AS grouped
GROUP BY val
HAVING COUNT(DISTINCT id) > 1 OR COUNT(DISTINCT col_name) > 1;
这写法看起来复杂,实际上结构并不难。外层查询负责分组统计,FOR XML PATH 子查询负责把同一个值涉及的所有列名拼接起来。性能通常也不错,因为子查询里的过滤条件 WHERE t.val = grouped.val 会走索引。
3. 更进一步:把重复值定位到具体行和列
3.1 业务价值:光知道“哪些值重复”还不够
仅仅得到重复值清单,很多时候并不能直接落地。比如查出来手机号 13800138000 在 phone 和 backup_phone 两列里都出现了,但业务方还需要知道:到底是哪几行、哪个字段填了这个号码。这样才能去修改数据、合并客户、清理脏数据。
所以在实际项目里,我通常会把第一步的结果再加工成一张“重复明细表”,核心逻辑是:先在 CTE 里摊平数据并统计出重复值,再回原表关联。
3.2 实现代码:输出主键、列名、值,三要素齐全
sql复制;WITH AllValues AS (
SELECT id, 'phone' AS col_name, phone AS val FROM contact WHERE phone IS NOT NULL AND phone <> ''
UNION ALL
SELECT id, 'backup_phone', backup_phone FROM contact WHERE backup_phone IS NOT NULL AND backup_phone <> ''
UNION ALL
SELECT id, 'wechat', wechat FROM contact WHERE wechat IS NOT NULL AND wechat <> ''
UNION ALL
SELECT id, 'email', email FROM contact WHERE email IS NOT NULL AND email <> ''
),
DuplicateValues AS (
SELECT val
FROM AllValues
GROUP BY val
HAVING COUNT(DISTINCT id) > 1 OR COUNT(DISTINCT col_name) > 1
)
SELECT
av.id,
c.name,
av.col_name,
av.val
FROM AllValues av
INNER JOIN DuplicateValues dv ON av.val = dv.val
INNER JOIN contact c ON av.id = c.id
ORDER BY av.val, av.id;
这段查询的思路一层套一层:第一步把所有列摊平成 (id, 列名, 值) 的结构,第二步从摊平结果中找出重复值,第三步回到摊平结果里把涉及重复值的所有明细行捞出来。
输出结果形如:
| id | name | col_name | val |
|---|---|---|---|
| 1 | 张三 | phone | 13800138000 |
| 5 | 李四 | backup_phone | 13800138000 |
| 5 | 李四 | zhangsan_007 |
从这结果就能直观看到:手机号 13800138000 同时出现在张三的 phone 列和李四的 backup_phone 列,而且李四的微信号也跟这个号码相关。后面怎么处理,业务方一目了然。
3.3 更直观的展示:让重复结果“一列一列”对齐
如果数据量不大,且希望一目了然地看到“这个值在哪些列出现过”,并不想输出多行明细,可以用下面的方式把结果压缩成一行一列:
sql复制;WITH AllValues AS (
SELECT id, 'phone' AS col_name, phone AS val FROM contact WHERE phone IS NOT NULL AND phone <> ''
UNION ALL
SELECT id, 'backup_phone', backup_phone FROM contact WHERE backup_phone IS NOT NULL AND backup_phone <> ''
UNION ALL
SELECT id, 'wechat', wechat FROM contact WHERE wechat IS NOT NULL AND wechat <> ''
UNION ALL
SELECT id, 'email', email FROM contact WHERE email IS NOT NULL AND email <> ''
)
SELECT
val,
MAX(CASE WHEN col_name = 'phone' THEN id END) AS phone_id,
MAX(CASE WHEN col_name = 'backup_phone' THEN id END) AS backup_phone_id,
MAX(CASE WHEN col_name = 'wechat' THEN id END) AS wechat_id,
MAX(CASE WHEN col_name = 'email' THEN id END) AS email_id
FROM AllValues
GROUP BY val
HAVING COUNT(DISTINCT id) > 1 OR COUNT(DISTINCT col_name) > 1;
这个写法利用了 MAX(CASE WHEN ...) 做行转列。每组重复值只输出一行,每一列显示对应的 id,如果没有出现则为 NULL。这种结果格式在给业务方汇报时很受欢迎,因为可以直接截图丢到沟通群里,不需要额外解释。
4. 特殊场景与更高效的替代方案
4.1 列数量很多怎么办:动态SQL自动展开
上面所有写法都需要在 SQL 里手动写每列的名字。如果表里有 10 列、20 列需要比对,语句会变得非常冗长,而且容易漏写、写错。这种情况下可以用系统视图自动生成摊平查询。
SQL Server 的 sys.columns 系统视图可以拿到一张表的所有列名。配合动态 SQL,可以自动生成 UNION ALL 语句:
sql复制DECLARE @table_name NVARCHAR(128) = 'contact';
DECLARE @sql NVARCHAR(MAX);
SELECT @sql = STRING_AGG(
'SELECT id, ''' + c.name + ''' AS col_name, CAST(' + QUOTENAME(c.name) + ' AS NVARCHAR(MAX)) AS val FROM ' + @table_name + ' WHERE ' + QUOTENAME(c.name) + ' IS NOT NULL AND ' + QUOTENAME(c.name) + ' <> ''''',
' UNION ALL '
)
FROM sys.columns c
WHERE c.object_id = OBJECT_ID(@table_name)
AND c.name IN ('phone', 'backup_phone', 'wechat', 'email'); -- 指定要排查的列
SET @sql = '
;WITH AllValues AS (
' + @sql + '
)
SELECT
val,
COUNT(DISTINCT id) AS affected_rows,
COUNT(DISTINCT col_name) AS affected_columns
FROM AllValues
GROUP BY val
HAVING COUNT(DISTINCT id) > 1 OR COUNT(DISTINCT col_name) > 1
ORDER BY affected_rows DESC;
';
EXEC sp_executesql @sql;
这段代码需要注意几点:
QUOTENAME(c.name)会给列名加方括号,防止列名是关键字或含特殊字符时报错。CAST(... AS NVARCHAR(MAX))是必要的,因为不同列的数据类型可能不同,比如手机号是 VARCHAR、邮箱可能是 NVARCHAR。不统一类型的话,UNION ALL 可能会报“类型不匹配”的错误。- 如果列特别多且不是所有列都需要比对,最好在 sys.columns 的查询里用
c.name IN (...)或c.name NOT IN (...)过滤一下,避免把无意义的主键、时间字段也摊平进去。 - 在 SQL Server 2016 以下版本,
STRING_AGG不可用,需要改用FOR XML PATH拼接字符串,方法来:
sql复制SELECT @sql = STUFF((
SELECT ' UNION ALL SELECT id, ''' + c.name + ''', CAST(' + QUOTENAME(c.name) + ' AS NVARCHAR(MAX)) FROM ' + @table_name + ' WHERE ' + QUOTENAME(c.name) + ' IS NOT NULL AND ' + QUOTENAME(c.name) + ' <> '''''
FROM sys.columns c
WHERE c.object_id = OBJECT_ID(@table_name)
ORDER BY c.column_id
FOR XML PATH('')
), 1, 11, '');
4.2 只想知道“同一行内是否有重复列值”
有一种场景是排查数据录入质量问题:某一行里,phone 和 backup_phone 填的是同一个号码,这种属于录入不规范。这种单行内的比较不需要 UNION ALL,直接 CASE WHEN 就能解决:
sql复制SELECT
id,
name,
phone,
backup_phone,
CASE
WHEN phone = backup_phone AND phone IS NOT NULL THEN 'phone与backup_phone重复'
WHEN phone = wechat AND phone IS NOT NULL THEN 'phone与wechat重复'
WHEN backup_phone = wechat AND backup_phone IS NOT NULL THEN 'backup_phone与wechat重复'
ELSE '无重复'
END AS duplicate_flag
FROM contact
WHERE phone = backup_phone
OR phone = wechat
OR backup_phone = wechat;
如果列很多,这个 CASE WHEN 写起来也会很长,但逻辑简单直白。还有一个比较巧妙的写法,利用 SELECT DISTINCT 的个数来判断一行内有多少个互不相同的值:
sql复制SELECT id
FROM contact
CROSS APPLY (
SELECT COUNT(DISTINCT val) AS distinct_cnt
FROM (
VALUES
(phone),
(backup_phone),
(wechat),
(email)
) AS v(val)
WHERE val IS NOT NULL AND val <> ''
) AS d
WHERE d.distinct_cnt < 4; -- 如果去重后数量小于总列数,说明有重复
这里 VALUES 构造了一个没有列名的表表达式,配合 CROSS APPLY 在每行内做一个去重计数。如果填了4列,实际去重后只有3个不同的值,就说明有一对是重复的。这个写法比几百行 CASE WHEN 简洁得多,而且列越多优势越明显。
4.3 大表场景:性能问题的解法
数据量大的情况下,UNION ALL 摊平法最大的性能瓶颈在于它会扫描表多次——每一列一次全表扫描或者索引扫描。如果表有 1 亿行,4 个列,那就相当于扫了 4 遍表。
针对这个问题,有几个优化思路:
思路一:先缩窄数据范围。 在摊平之前先 WHERE 条件过滤掉明显不可能重复的数据。比如我们只关心近三个月修改过的数据,就加 WHERE update_time >= DATEADD(MONTH, -3, GETDATE())。这个条件加到每个 UNION ALL 子查询里,能有效减少扫描量。
思路二:使用临时表分步处理。 把摊平结果先存到临时表,给 val 列加索引,再对临时表做分组统计。虽然前期摊平还是要扫表,但后续的重复判断会在数据量大幅缩小后的临时表上进行,速度会快很多:
sql复制IF OBJECT_ID('tempdb..#AllValues') IS NOT NULL DROP TABLE #AllValues;
SELECT id, 'phone' AS col_name, phone AS val INTO #AllValues FROM contact WHERE phone IS NOT NULL AND phone <> '';
INSERT INTO #AllValues SELECT id, 'backup_phone', backup_phone FROM contact WHERE backup_phone IS NOT NULL AND backup_phone <> '';
INSERT INTO #AllValues SELECT id, 'wechat', wechat FROM contact WHERE wechat IS NOT NULL AND wechat <> '';
INSERT INTO #AllValues SELECT id, 'email', email FROM contact WHERE email IS NOT NULL AND email <> '';
CREATE INDEX IX_AllValues_val ON #AllValues(val);
SELECT val, COUNT(DISTINCT id) AS affected_rows
FROM #AllValues
GROUP BY val
HAVING COUNT(DISTINCT id) > 1 OR COUNT(DISTINCT col_name) > 1;
思路三:利用索引覆盖。 如果所有需要排查的列都在同一个索引里,SQL Server 可以用索引扫描代替全表扫描,代价会小很多。比如建一个包含 (phone, backup_phone, wechat, email) 的复合索引,即使没有WHERE条件,查询优化器也倾向于用这个索引,因为索引比堆表或聚集索引小得多。
4.4 另一种思路:用 UNPIVOT 做列转行
除了 UNION ALL,SQL Server 还提供了官方的列转行运算符 UNPIVOT。它的写法更简洁,但有几个限制:
sql复制SELECT id, col_name, val
FROM contact
UNPIVOT (
val FOR col_name IN (phone, backup_phone, wechat, email)
) AS upvt;
UNPIVOT 的优点是代码短,缺点是它会在转行时自动过滤掉 NULL 值(这个特性恰好符合我们排除 NULL 的需求),但不能排除空字符串,而且不同列的类型必须一致,否则会报错。在很多场景下,UNPIVOT 是一个不错的替代方案。
不过我个人还是更习惯用 UNION ALL。原因有两个:一是 UNION ALL 的语义更直观,二是当需要对不同列做不同处理(比如某列要截断、某列要转换格式)时,UNION ALL 更容易调整——每个子查询都是独立的 SELECT,想怎么改就怎么改。UNPIVOT 一旦写好,想给其中一列单独加条件就比较别扭。
5. 常见问题与排查技巧实录
5.1 结果比预期少:是不是 NULL 和空字符串在作怪
排查重复值时最常遇到的问题就是结果“莫名其妙”变少。原因基本都是两种:
第一种,列里存在 NULL,但某个 JOIN 或分组操作把它过滤掉了。比如上面用 UNPIVOT 的时候,NULL 会被自动忽略。如果业务上 NULL 和空字符串都代表“无”,这不影响结果;但如果业务上 NULL 与空字符串有区别,就需要先做 ISNULL(col, '') 转换。
第二种,数据看起来相同但实际不一样。最常见的是不可见字符,比如字符串前后的空格、Tab、换行符。两行数据一个是 'ABC',一个是 'ABC '(末尾有空格),用 GROUP BY 分组时会被当成两个不同的组,重复就查不出来。VARCHAR 类型比较时会忽略末尾空格,但 NVARCHAR 在拼接、匹配时行为可能跟预期不一致。排查时可以先用 LEN(col) 和 DATALENGTH(col) 做对比,如果 LEN 相同但 DATALENGTH 不同,说明存在中文字符或不可见字符,要再用 REPLACE 把空格、Tab 清理掉再比较。
5.2 结果比预期多:业务口径没对齐
有一次我把重复值清单发给业务方,对方说“这不是重复,这两个字段本来就允许填一样的内容”。仔细一聊才知道,他们系统里 backup_phone 有时就是故意跟 phone 填一样的,用于容灾备份。所以排查之前一定要跟业务确认清楚“哪些列之间不允许重复”,而不是拍脑袋把所有列都放进去。这也是为什么我在前面的示例里都保留了 WHERE c.name IN (...) 这种过滤条件,建议只把业务意义上互斥的列纳入排查范围。
5.3 类型不一致导致 UNION ALL 报错
不同列的数据类型经常不统一:一列是 VARCHAR(20),另一列是 NVARCHAR(100),还有一列可能是 INT 或者 DATETIME。UNION ALL 对类型有要求,SQL Server 会尝试做隐式转换,但转换失败就会报错。
一个稳妥的做法是在每次 SELECT 时统一用 CAST(column AS NVARCHAR(MAX)) 转换。虽然会带来轻微的性能损耗,但换来的稳定性是值得的。特别是在做动态 SQL 时,因为列类型是运行时才知道的,统一 CAST 是必须的。
5.4 排查结果要导出给业务方,注意脱敏
如果排查的资金、手机号、邮箱这些敏感字段,导出结果时要先做脱敏处理。比如手机号中间四位打码:
sql复制SELECT
STUFF(phone, 4, 4, '****') AS masked_phone
FROM contact;
或者在最终结果里只保留 LEFT(val, 3) + '****' + RIGHT(val, 4)。这个细节看起来不起眼,但真的遇到过因为把全量手机号发给合作方导致的数据泄露事件,所以特别提醒一句。
5.5 排重后想直接清理数据,怎么保留一条
查出来重复之后,常常需要保留一条、删除其他。SQL Server 里可以用窗口函数 ROW_NUMBER() 给同一组重复值编号,保留最小的 id:
sql复制;WITH AllValues AS (
SELECT id, 'phone' AS col_name, phone AS val FROM contact WHERE phone IS NOT NULL AND phone <> ''
UNION ALL
SELECT id, 'backup_phone', backup_phone FROM contact WHERE backup_phone IS NOT NULL AND backup_phone <> ''
UNION ALL
SELECT id, 'wechat', wechat FROM contact WHERE wechat IS NOT NULL AND wechat <> ''
UNION ALL
SELECT id, 'email', email FROM contact WHERE email IS NOT NULL AND email <> ''
),
Ranked AS (
SELECT
id,
val,
ROW_NUMBER() OVER (PARTITION BY val ORDER BY id) AS rn
FROM AllValues
)
DELETE c
FROM contact c
WHERE c.id IN (
SELECT id FROM Ranked WHERE rn > 1
);
执行前务必先备份,或者把 DELETE 改成 SELECT 预览一下将要删除哪些数据。这个坑我踩过,手一抖把不该删的数据删了,恢复了半天。
6. 从一次真实项目排障说起
文章最后分享一个个人经历,也是因为这个经历,我才系统整理了这套排查方法。
那是一个数据迁移项目,客户反馈他们的会员表在迁移后出现大量重复数据。我第一反应是“迁移脚本写错了,导致数据插了两遍”。但查了下游明细表,发现数据量并没有翻倍,而会员表里重复的并不是整行,而是“手机号”和“邮箱”之间相互重复——A会员的手机号是B会员的邮箱,C会员的备用电话又是A会员的手机号。这种情况用常规的“全字段 Group By 查重”根本发现不了。
后来就是用上面说的 UNION ALL 摊平法,把 手机号、备用电话、邮箱、微信号 四列合并成一个值集合,再统计出现次数,结果一下子查出了 2000 多条跨字段重复记录。进一步对每条重复记录打上“列名+ID”标签后,发现根源是旧系统在一个输入框里既允许填手机号也允许填邮箱,导数据时没有做格式区分,导致同一个值被随机填进了不同字段。
所以如果你遇到的数据重复问题看起来很诡异、常规方法查不出来,很大概率就是跨列重复。这时候别急着改数据,先把多列摊平、把重复关系摸清楚,再决定怎么清洗。这套方法我后来用在了十几个项目里,基本覆盖了大多数多列重复排查场景,希望也能帮到你。
