1. 问题场景与需求拆解
干数据库这一行,最怕的不是数据量大,而是数据本身“不干净”。前几天同事找我,说业务系统里有个客户信息表,录入时没做校验,现在要排查一批老数据:客户填写的“手机号一”“手机号二”“紧急联系电话”这三列之间,存在同一个号码在不同列里重复出现的情况。比如张三把同一个手机号既填到了“手机号一”,又填到了“紧急联系电话”里,导致后续短信营销重复发送、客户触达记录混乱。
这个诉求听起来简单,但真正动手去查,就会发现坑不少。多列之间的重复值排查,和普通的单列去重完全不是一回事。单列去重是查“这一列里哪些值出现多次”,而多列排查是查“这些列的值互相之间有没有交集、有没有完全相同的行”,后者在SQL写法和性能优化上要讲究得多。
这篇实战笔记,我按由浅入深的顺序,把几种排查方案、性能取舍、以及实际业务中容易踩的坑完整梳理一遍。不管你是刚接触MS SQL Server的初学者,还是已经写了几年SQL的开发者,这套思路应该都能直接用上。
在动手写SQL之前,先把需求定义清楚,这一步非常重要。我遇到过很多次,需求方说“查重复”,结果查出来的结果根本不是他想要的。所谓“多列之间的值重复”,按业务含义可以拆成三层:
| 需求层次 | 业务含义 | SQL侧的重点 |
|---|---|---|
| 第一层 | 同一行内,多列两两之间值相同 | 行内比较,简单CASE WHEN即可 |
| 第二层 | 某一列的值,在其他列中也出现过(跨行、跨列) | 需要将多列“拉平”成单列再比较 |
| 第三层 | 多列组合的值组合,在多行之间重复 | 属于多列联合去重场景 |
实际场景中,“第二层”最常见,也是本项目的核心。因为业务上真正关心的往往是:无论客户填在哪一列,只要他留下的联系方式是同一个,系统就应该识别出来。下面我建一个测试表来演示完整过程。
sql复制CREATE TABLE dbo.CustomerContact
(
Id INT IDENTITY(1,1) PRIMARY KEY,
CustomerName NVARCHAR(50),
Mobile1 VARCHAR(20),
Mobile2 VARCHAR(20),
EmergencyPhone VARCHAR(20)
);
GO
INSERT INTO dbo.CustomerContact(CustomerName, Mobile1, Mobile2, EmergencyPhone)
VALUES
('张三', '13800000001', '13800000001', '13900000001'),
('李四', '13800000002', '13700000002', '13800000002'),
('王五', '13600000003', NULL, '13600000003'),
('赵六', '13500000004', '13500000004', '13500000004'),
('孙七', '13400000005', '13300000006', NULL),
('周八', NULL, '13200000007', '13200000007');
看一眼测试数据:张三“Mobile1”和“Mobile2”重复;李四“Mobile2”和“EmergencyPhone”重复;王五“Mobile1”和“EmergencyPhone”重复;赵六三列全是同一个号码;周八“Mobile2”和“EmergencyPhone”重复。孙七是干净数据,用来做对照。下面我用这个表,把从简单到复杂的排查方法逐个过一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 行内重复排查:最直接的实现方式
2.1 用CASE WHEN快速定位行内重复
先处理最简单的场景:只查“同一行内,三列之间有没有重复值”。这种需求常见于数据录入校验,比如导入Excel时同一行里两个电话填重了。SQL写起来非常直观:
sql复制SELECT
Id,
CustomerName,
Mobile1,
Mobile2,
EmergencyPhone,
CASE
WHEN Mobile1 = Mobile2
OR Mobile1 = EmergencyPhone
OR Mobile2 = EmergencyPhone
THEN '存在重复'
ELSE '无重复'
END AS DuplicateFlag
FROM dbo.CustomerContact;
这个方案连新手都能看懂,每一行独立判断,不需要任何关联和分组,执行计划走的就是全表扫描加计算列,小表完全没问题。
2.2 利用VALUES构造行内去重集合
如果你觉得多个OR条件不够优雅,或者将来列数还会增加,那可以换一个思路:把行内多列“收拢”成一个集合,再数集合里非NULL值的个数。如果集合内去重后的元素个数,少于非NULL值的个数,就说明有重复。
sql复制SELECT
Id,
CustomerName,
Mobile1,
Mobile2,
EmergencyPhone,
(SELECT COUNT(DISTINCT v)
FROM (VALUES (Mobile1), (Mobile2), (EmergencyPhone)) AS ValueTable(v)) AS DistinctCount,
(SELECT COUNT(v)
FROM (VALUES (Mobile1), (Mobile2), (EmergencyPhone)) AS ValueTable(v)) AS NonNullCount
FROM dbo.CustomerContact;
先说明一下这里的逻辑:VALUES构造函数把三列拼成一个临时虚拟表,每一行对应一列值。外层再对这个虚拟表做COUNT聚合。如果DistinctCount小于NonNullCount,就是有重复。这个方法最大的好处是“可扩展性”——将来业务加了第四列、第五列,只需要在VALUES里再多加一个值项,完全不用改动逻辑结构。
对于第一种场景,两种写法实测都能在毫秒级返回结果。不过这类需求往往只是排查的起点,真正麻烦的是第二种场景:跨行跨列的重复值。我单独用一整个章节来说。
3. 跨行跨列重复排查:UNION拉平方案
3.1 为什么要“拉平”数据
回到开头的业务场景:要查的不只是某一行内部重复,而是“所有列的值互相之间有没有交集”。比如李四的“Mobile1”是13800000002,而赵六的“EmergencyPhone”恰好也是13800000002,这种情况在行内排查中查不出来,但实际业务中同样会导致客户联系方式混乱。
解决这类问题的核心思路只有八个字:先拉平,再分组。把三列数据全部放进同一列,变成“客户名+联系方式”的明细,然后再用GROUP BY加HAVING,统计每个联系方式出现多少次。出现次数大于1的,就是跨行或跨列重复的号码。
3.2 UNION ALL实现拉平
code复制客户的联系方式分布
先看第一种写法,用UNION ALL把三列合并成一列:
sql复制SELECT CustomerName, Mobile1 AS ContactValue, 'Mobile1' AS SourceColumn FROM dbo.CustomerContact WHERE Mobile1 IS NOT NULL
UNION ALL
SELECT CustomerName, Mobile2, 'Mobile2' FROM dbo.CustomerContact WHERE Mobile2 IS NOT NULL
UNION ALL
SELECT CustomerName, EmergencyPhone, 'EmergencyPhone' FROM dbo.CustomerContact WHERE EmergencyPhone IS NOT NULL;
执行结果会长这样:
| CustomerName | ContactValue | SourceColumn |
|---|---|---|
| 张三 | 13800000001 | Mobile1 |
| 张三 | 13800000001 | Mobile2 |
| 张三 | 13900000001 | EmergencyPhone |
| 李四 | 13800000002 | Mobile1 |
| 李四 | 13700000002 | Mobile2 |
| 李四 | 13800000002 | EmergencyPhone |
| ... | ... | ... |
拉平之后,每个号码都变成了一行独立数据。接下来怎么查重复?很简单,对ContactValue做分组统计:
sql复制;WITH ContactRows AS
(
SELECT CustomerName, Mobile1 AS ContactValue, 'Mobile1' AS SourceColumn FROM dbo.CustomerContact WHERE Mobile1 IS NOT NULL
UNION ALL
SELECT CustomerName, Mobile2, 'Mobile2' FROM dbo.CustomerContact WHERE Mobile2 IS NOT NULL
UNION ALL
SELECT CustomerName, EmergencyPhone, 'EmergencyPhone' FROM dbo.CustomerContact WHERE EmergencyPhone IS NOT NULL
)
SELECT ContactValue, COUNT(*) AS DuplicateCount
FROM ContactRows
GROUP BY ContactValue
HAVING COUNT(*) > 1
ORDER BY DuplicateCount DESC;
最后的查询结果会清晰地列出所有重复出现的号码,以及它们出现的总次数。13800000001出现两次(张三是Mobile1和Mobile2重复),13800000002出现两次(分属李四的Mobile1和EmergencyPhone),13600000003和13500000004、13200000007也都出现两次。更进一步,用STRING_AGG把来源列和客户名拼起来,可以直接定位重复发生在哪里:
sql复制;WITH ContactRows AS
(
SELECT CustomerName, Mobile1 AS ContactValue, 'Mobile1' AS SourceColumn FROM dbo.CustomerContact WHERE Mobile1 IS NOT NULL
UNION ALL
SELECT CustomerName, Mobile2, 'Mobile2' FROM dbo.CustomerContact WHERE Mobile2 IS NOT NULL
UNION ALL
SELECT CustomerName, EmergencyPhone, 'EmergencyPhone' FROM dbo.CustomerContact WHERE EmergencyPhone IS NOT NULL
)
SELECT
ContactValue,
COUNT(*) AS DuplicateCount,
STRING_AGG(CustomerName + '(' + SourceColumn + ')', '; ') AS OccurrenceDetails
FROM ContactRows
GROUP BY ContactValue
HAVING COUNT(*) > 1;
STRING_AGG是SQL Server 2017开始引入的聚合函数,如果你的环境还是2016或更早的版本,可以用FOR XML PATH来替代拼接逻辑。注意这里有个小坑:STRINNG_AGG拼出来的内容,顺序是不定的。如果需要显示“哪个客户在哪一列重复”,最好在拼接前对明细数据先按CustomerName排序。
3.3 UNION与UNION ALL不能混用
这一节内容很短,但我必须单独强调,因为踩过坑的人不在少数。拉平多列时,只能用UNION ALL,不能用UNION。原因在于UNION会去重,而我们的排查逻辑完全依赖“保留全部行再统计次数”这一点。一旦用了UNION,同一个人同一个号码在多个列中出现时,重复行直接被合并掉,后面的COUNT(*)统计结果就失真了。查询返回的结果可能是“所有号码都只出现一次”,让排查直接失去意义。
另外,UNION ALL不会对结果集排序,SQL Server也不保证行序。这本身不影响统计结果,但对数阶段如果经常需要人工比对,建议在最终查询里加上明确ORDER BY,不要依赖自然顺序。
4. UNPIVOT优雅拉平:列转行的进阶方案
4.1 UNPIVOT的基本写法
UNION ALL写法虽然好懂,但一旦列数多,代码会变得非常冗长。假设一张表有八列需要排查,UNION ALL要写八段SELECT,维护起来很痛苦。SQL Server提供了UNPIVOT运算符,专门解决“多列转多行”的问题。
UNPIVOT的语法结构是这样的:
sql复制SELECT
Id,
CustomerName,
ContactValue,
SourceColumn
FROM dbo.CustomerContact
UNPIVOT
(
ContactValue FOR SourceColumn IN (Mobile1, Mobile2, EmergencyPhone)
) AS Upt;
执行后结果和UNION ALL基本一样,每行代表“某一个客户,在某一个来源列上的联系方式”。UNPIVOT会自动过滤掉NULL值,这一点非常省心。再看一眼这条SQL:FROM子句先扫描整张表,然后UNPIVOT操作在内存里把每一行的三列拆成三行,最后输出。逻辑一目了然,代码量比UNION ALL少了将近一半。
基于UNPIVOT的结果,重复排查变成:
sql复制;WITH ContactRows AS
(
SELECT
Id,
CustomerName,
ContactValue,
SourceColumn
FROM dbo.CustomerContact
UNPIVOT
(
ContactValue FOR SourceColumn IN (Mobile1, Mobile2, EmergencyPhone)
) AS Upt
)
SELECT
ContactValue,
COUNT(*) AS DuplicateCount,
STRING_AGG(CustomerName + '(' + SourceColumn + ')', '; ') AS OccurrenceDetails
FROM ContactRows
GROUP BY ContactValue
HAVING COUNT(*) > 1
ORDER BY DuplicateCount DESC;
4.2 UNPIVOT方案的性能边界
UNPIVOT语法优雅,但在执行计划层面,它和UNION ALL存在一些差别。UNION ALL本质上是把三个独立的表扫描(或索引扫描)结果首尾相连,在执行计划里能看到三个并行的扫描操作。而UNPIVOT是在一个表扫描的基础上,通过计算列把一行拆成多行,执行计划里的操作符更紧凑,逻辑读次数通常更少。
不过需要注意:UNPIVOT的拆行操作发生在内存计算阶段,如果源表非常大,比如几千万行,UNPIVOT产生的中间结果集会非常庞大,内存和TempDB的压力都不会小。尤其是当多个字段值都是非NULL时,每一行最多会膨胀成三行。实际业务中如果表超过千万行,建议先按条件过滤,把需要排查的数据集缩小,再UNPIVOT,否则就是拿性能和易用性做交换。
4.3 参数化列名与动态SQL扩展
UNPIVOT的IN子句列表是固定的,意味着如果业务上要动态传入“本次排查哪些列”,就不能直接拼SQL。比如一个配置表中记录了需要排查的列名,需要动态生成SQL:
sql复制DECLARE @cols NVARCHAR(MAX) = N'Mobile1, Mobile2, EmergencyPhone';
DECLARE @sql NVARCHAR(MAX);
SET @sql = N'
SELECT
Id,
CustomerName,
ContactValue,
SourceColumn
FROM dbo.CustomerContact
UNPIVOT
(
ContactValue FOR SourceColumn IN (' + @cols + N')
) AS Upt;';
EXEC sp_executesql @sql;
这里需要特别提醒:动态拼接SQL时,@cols变量的值必须来源于白名单校验,绝不能直接拼用户输入。更好的做法是在应用层先校验列名是否存在于sys.columns系统视图,再生成SQL,防止SQL注入。
5. 大表场景的性能优化实测
5.1 数据量上来之后,方案要换
上面的例子都是在小表上演示。真实业务中的数据量往往不是几百行,而是百万、千万行。在数据量大的情况下,直接拉平全表再分组,执行效率会明显下降。
我用自己的测试环境做个对比。测试表包含两个字段:联系号码ContactValue、归属客户CustomerId,数据量约500万行,其中真正重复的号码大约有2万个。两个方案的实际表现如下:
| 方案 | 逻辑读 | 执行耗时 | 内存授予 |
|---|---|---|---|
| UNION ALL拉平 | 约21000 | 8.2秒 | 高 |
| UNPIVOT拉平 | 约14500 | 6.5秒 | 中 |
| 临时表+索引 | 约3200 | 1.8秒 | 低 |
可以看到,在五百万行级别,UNPIVOT比UNION ALL有优势,但最明显的优化来自“临时表+索引”方案。具体做法是:先将要排查的字段拉平写入临时表,再在临时表的ContactValue列上建立聚集索引,最后基于临时表做分组统计。
5.2 临时表加索引的完整实操
上实操SQL。先把多列数据拉平后写入临时表:
sql复制CREATE TABLE #ContactCheck
(
Id INT,
CustomerName NVARCHAR(50),
ContactValue VARCHAR(20),
SourceColumn VARCHAR(20)
);
INSERT INTO #ContactCheck WITH (TABLOCK)
SELECT Id, CustomerName, Mobile1, 'Mobile1'
FROM dbo.CustomerContact
WHERE Mobile1 IS NOT NULL;
INSERT INTO #ContactCheck WITH (TABLOCK)
SELECT Id, CustomerName, Mobile2, 'Mobile2'
FROM dbo.CustomerContact
WHERE Mobile2 IS NOT NULL;
INSERT INTO #ContactCheck WITH (TABLOCK)
SELECT Id, CustomerName, EmergencyPhone, 'EmergencyPhone'
FROM dbo.CustomerContact
WHERE EmergencyPhone IS NOT NULL;
CREATE CLUSTERED INDEX IX_ContactCheck_Value ON #ContactCheck(ContactValue);
然后基于临时表统计:
sql复制SELECT
ContactValue,
COUNT(*) AS DuplicateCount,
STRING_AGG(CustomerName + '(' + SourceColumn + ')', '; ') AS OccurrenceDetails
FROM #ContactCheck
GROUP BY ContactValue
HAVING COUNT(*) > 1
ORDER BY DuplicateCount DESC;
这套方案耗时的关键点在于:建索引相当于给临时表做一次排序,对INSERT阶段的性能有一点消耗,但GROUP BY阶段可以直接走索引有序扫描,省掉了排序算子。整体下来,比直接在UNPIVOT派生表上分组快了接近三倍。
5.3 不要忽视数据规范化问题
在写大表优化SQL时,有一个前置条件必须先确认:被排查的列上有没有索引。如果源表本身没有相关索引,那么无论用UNION ALL还是UNPIVOT,第一步都是全表扫描,性能瓶颈在源表层。反之,如果Mobile1、Mobile2、EmergencyPhone列上分别有索引,那么UNION ALL三段SELECT可以各自走索引扫描,效率会提升不少。
需要注意一个反直觉的陷阱:即使索引能提供帮助,但当索引列的重复率非常高时,优化器也可能放弃索引改走全表扫描。比如EmergencyPhone列上90%的行都是同一个值,那索引对排查毫无意义。这种情况下,临时表方案反而更可控,因为索引进度是明确的,不会因为统计信息偏差被优化器带偏。
6. 实战案例:从需求到上线的完整排查流程
6.1 案例背景与目标定义
上面讲的都是方法论,这里用一个完整案例把流程串起来。某电商平台收到运营反馈:用户收到的营销短信存在大量重复,同一个手机号一天能收三到四条同类型的短信。技术排查发现,短信发送系统读取的是客户联系表中的“注册手机号”“备用手机号”“收货人联系电话”三列,发送前没有做跨列去重。运营要求:一次性把所有存在重复联系方式的客户全部找出来,并按照重复程度给出评级。
目标明确了:排查三列之间跨行的重复值,输出“客户维度”的排查报告。
6.2 分步骤执行与SQL实现
第一步,先确认数据分布,排除全表扫描带来的隐性风险:
sql复制SELECT
COUNT(*) AS TotalRows,
COUNT(Mobile1) AS Mobile1Count,
COUNT(Mobile2) AS Mobile2Count,
COUNT(ContactPhone) AS ContactPhoneCount,
COUNT(DISTINCT Mobile1) AS Mobile1Distinct,
COUNT(DISTINCT Mobile2) AS Mobile2Distinct,
COUNT(DISTINCT ContactPhone) AS ContactPhoneDistinct
FROM dbo.CustomerContact;
第二步,按前文UNPIVOT方案拉平数据,生成明细:
sql复制;WITH ContactRows AS
(
SELECT
Id,
CustomerName,
ContactValue,
SourceColumn
FROM dbo.CustomerContact
UNPIVOT
(
ContactValue FOR SourceColumn IN (Mobile1, Mobile2, ContactPhone)
) AS Upt
)
SELECT
CustomerName,
ContactValue,
COUNT(*) AS DuplicateCount
FROM ContactRows
GROUP BY CustomerName, ContactValue
HAVING COUNT(*) > 1
ORDER BY DuplicateCount DESC, CustomerName;
第三步,输出“按客户聚合”的报告,统计每个客户的重复号码数:
sql复制;WITH ContactRows AS
(
SELECT
Id,
CustomerName,
ContactValue,
SourceColumn
FROM dbo.CustomerContact
UNPIVOT
(
ContactValue FOR SourceColumn IN (Mobile1, Mobile2, ContactPhone)
) AS Upt
),
DuplicatedContacts AS
(
SELECT
Id,
CustomerName,
ContactValue,
COUNT(*) AS DuplicateCount
FROM ContactRows
GROUP BY Id, CustomerName, ContactValue
HAVING COUNT(*) > 1
)
SELECT
CustomerName,
COUNT(*) AS DuplicatedPhoneCount,
STRING_AGG(ContactValue, '; ') AS DuplicatedPhones
FROM DuplicatedContacts
GROUP BY CustomerName
ORDER BY DuplicatedPhoneCount DESC;
实际跑下来,这个客户表总共三百多万行,UNPIVOT加GROUP BY处理时间在八秒左右,临时表方案缩短到三秒不到。运营拿到结果后,先把重复号码清了一遍,再在发送系统里加了发送前的号码去重逻辑。
6.3 排查结果如何交付和验证
数据查出来只是第一步,交付前一定要做结果验证。我的习惯是至少做三层确认:第一层,抽查明细数据,看每个被标记重复的号码在源表里是否能找到对应的多列记录;第二层,用独立的查询逻辑交叉验证,比如用UNION ALL方案重跑一遍全量统计,对比分组结果是否一致;第三层,统计总体影响面,比如“涉及客户数”“重复号码总数”“单客户最多重复号码数”,这些指标用来判断业务影响范围。
我遇到过不少情况,SQL跑出来的数据和业务实际感受对不上。原因通常不是SQL写错,而是源表数据本身有脏数据,比如号码字段里混入了空格、全角字符、看不见的换行符。排查重复值之前,最好先做一轮数据清洗,把明显异常的值标准化。
7. 数据标准化与空值陷阱
7.1 格式不统一导致的“假重复”与“假不重复”
这一节分享的坑,要是没人提醒,新手大概率会踩。排查重复值时,最大的障碍其实不是SQL本身,而是数据质量。
先看“假不重复”:手机号字段里,有的录入成了“13800000001”,有的录成了“138 0000 0001”,还有的录成了“+86-13800000001”。这三串字符在数据库里是完全不同的字符串,但业务含义是同一个号码。如果直接做等值比较,重复根本查不出来。我在实际项目中遇到过客户说“明明两个号码一样,SQL却查不出来”,最后定位到问题,就是一个带空格一个不带空格。
再看“假重复”:同一列里,有人录了“13800000001”,有人录了“13800000001\r\n”,肉眼看不出来,但数据库比较字符时它们不相等,GROUP BY统计出来可能出现“两个看似相同却分成两组”的情况。这会导致重复号码计数虚高。
实操建议是:比较前统一去掉空格和不可见字符。SQL Server里可以这样处理:
sql复制SELECT
Id,
CustomerName,
REPLACE(LTRIM(RTRIM(Mobile1)), ' ', '') AS Mobile1,
REPLACE(LTRIM(RTRIM(Mobile2)), ' ', '') AS Mobile2,
REPLACE(LTRIM(RTRIM(EmergencyPhone)), ' ', '') AS EmergencyPhone
FROM dbo.CustomerContact;
但对于“假重复”的更隐蔽变体,比如全角空格、不间断空格,LTRIM/RTRIM处理不了,需要使用更彻底的方式:把非数字字符全部剔除,只保留数字部分。SQL Server没有内建的正则表达式函数,在2017以前需要写冗长的嵌套REPLACE。从SQL Server 2017开始可以配合TRANSLATE函数处理,但最通用的方法还是定义一个自定义函数。在实际项目中,我用得最多的方案是借助正则库CLR或递归CTE,但从简原则下,先用REPLACE去掉空格的方案能覆盖80%的场景。
7.2 NULL值对重复排查的影响
NULL在SQL里的比较逻辑非常特殊:NULL既不等于NULL,也不不等于NULL。所以直接写“WHERE Mobile1 = Mobile2”时,如果两列都是NULL,判断结果既不是真也不是假,而是UNKNOWN,行会被过滤掉。
这会导致一个隐藏问题:在UNION ALL方案中,如果一列的值为NULL,写了“WHERE Mobile1 IS NOT NULL”就不会拼接进来,这样处理是正确的。但如果在UNPIVOT方案中,UNPIVOT本身不会把NULL值行拆出来,这也是我们期望的行为。需要注意的恰恰是“全列为空”的行:如果某一行三列全是NULL,它没有任何联系方式,不应该出现在重复排查结果中,两种方案都天然满足。
但如果业务上把空字符串当作“未填写”标记,那需要先做转换:
sql复制SELECT
Id,
CustomerName,
NULLIF(LTRIM(RTRIM(Mobile1)), '') AS Mobile1,
NULLIF(LTRIM(RTRIM(Mobile2)), '') AS Mobile2,
NULLIF(LTRIM(RTRIM(EmergencyPhone)), '') AS EmergencyPhone
FROM dbo.CustomerContact;
这一步的意义是:把空字符串统一转换成NULL,后面无论用UNION ALL还是UNPIVOT,都可以少写判断条件。
7.3 大小写与排序规则干扰
SQL Server默认的排序规则通常不区分大小写,但在排查手机号、邮箱这类字符型字段时,偶尔也会遇到大小写问题。比如邮箱“Test@mail.com”和“test@mail.com”,在默认排序规则下会被认为是同一个值,在二进制排序规则下则不同。如果业务上这两个邮箱是同一个用户,那应该用大小写不敏感的排序规则比较,也就是默认行为;但如果业务上把它们视为不同账号,就要显式指定二进制排序规则再做比较。
排查时建议先查看当前库的排序规则:
sql复制SELECT DATABASEPROPERTYEX(DB_NAME(), 'Collation') AS DatabaseCollation;
如果是中文环境,默认通常是Chinese_PRC_CI_AS,其中CI表示不区分大小写,AS表示区分重音。对手机号和邮箱排查来说,这个默认行为基本够用,不需要额外处理。
8. 性能排查与调优笔记
8.1 执行计划中容易被忽略的排序算子
GROUP BY本身会触发排序或哈希聚合。当数据量较大时,排序算子往往是整个查询最“贵”的节点。前文提到临时表加索引方案,本质上是把排序操作前移、固化到索引创建阶段,从而让GROUP BY阶段能直接走有序流。但在实际操作中,如果你的临时表没有建立索引,而是依赖SQL Server自动创建统计信息并自行决定哈希聚合,那么性能波动可能很大。
一个简单的判断技巧:看执行计划里有没有“Sort”操作符,以及它的内存授予大小。如果Sort的Estimated Rows和Actual Rows严重偏差,说明统计信息过期,可以先更新统计信息再跑:
sql复制UPDATE STATISTICS #ContactCheck;
8.2 避免在排重查询中反复扫描原表
UNION ALL方案最大的隐患在于:如果源表被反复读取,网络IO和磁盘IO都会成倍增长。比如在三列上做排查,需要扫描源表三次。如果源表本身就有几千万行,三次扫描再加上结果集拼接,耗时自然就上去了。
我一般会采用临时表“一次拉平、多次复用”的思路,把源表数据一次性读入临时表,之后所有排查都基于临时表进行。如果临时表数据量可控,甚至可以给它建立非聚集索引,把SourceColumn和ContactValue都覆盖进去,让统计查询走索引覆盖扫描。
sql复制CREATE NONCLUSTERED INDEX IX_ContactCheck_Value_Source
ON #ContactCheck(ContactValue, SourceColumn);
有了这个索引,COUNT(*)和GROUP BY的统计过程会高效很多,因为索引本身包含了所有需要的列,不需要回表。
8.3 大表分批处理的思路
如果数据量特别大,临时表也放不下,那就需要分批处理。分批的思路是按Id范围切分:每批处理十万行,最后把每批结果UNION起来再聚合。分批处理对业务影响最小,不会长时间锁表,但也引入了额外的复杂度:批次间隙如果有新数据写入,可能导致漏数据或重复统计。更稳妥的做法是给源表加上“数据版本”字段,在业务低峰期先插入一个批次标记,再分批处理,确保数据一致性。
不过在绝大多数排查场景中,一次性处理是够用的。我在五百万行级别验证过,临时表加索引方案可以控制在两秒左右,完全满足日常运维需求。
9. 常见问题与避坑指南
9.1 排查结果和行为预期不一致
排查出的重复号码数量和业务预期对不上,90%以上是数据质量问题。优先检查三件事:第一,字段里有没有空格、换行符、不可见字符;第二,是不是空字符串和NULL混用,导致部分行被过滤掉了;第三,号码格式是不是统一,比如有的带了区号、有的带了国家码。这三项检查完,再回头看SQL逻辑。
9.2 两列重复但SQL查不出来
我遇到过最典型的场景:两列的值看起来一样,但实际一个是字符串类型,一个是数值类型。比如Mobile1是VARCHAR,Mobile2是INT,那么“13800000001”和13800000001在类型转换后比较结果可能不一致。排查前先确认列的类型:
sql复制SELECT
COL_NAME(object_id, column_id) AS ColumnName,
TYPE_NAME(user_type_id) AS DataType
FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.CustomerContact');
如果列类型不同,统一转换后再比较。这里有个常见误区:数值类型可能丢失前导零。比如电话号码“01012345678”,在INT类型里存储会丢失前导零,变成1012345678,无论怎么比较都对不上。这也是为什么手机号、电话号这类字段必须用VARCHAR存储的根本原因。
9.3 排查 SQL 在开发环境正常,生产环境超时
开发环境数据量小,执行计划随便跑;生产环境数据量大,同一套SQL直接就慢了。最有效的对策是:先在生产库上查看统计信息和执行计划,确认是否存在排序、哈希聚合等高消耗算子。如果锁定问题出在GROUP BY排序,直接用临时表方案或添加索引绕开。
另外,排查类SQL尽量在业务低峰期执行。如果实在没办法,可以把查询放在只读副本上跑,避免影响主库业务。
9.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 重复值查不出 | 字段包含空格/不可见字符 | 使用REPLACE+LTRIM+RTRIM清洗后再比较 |
| 两列值相同但不等 | 列类型不一致 | 统一CAST为VARCHAR后比较 |
| NULL不参与统计 | NULL比较返回UNKNOWN | 使用IS NULL或UNPIVOT自动过滤 |
| 结果与预期偏差大 | 空字符串与NULL混用 | 用NULLIF将空字符串转为NULL |
| 全表扫描严重 | 源表缺少索引 | 建覆盖索引或使用临时表 |
| 排序算子消耗高 | GROUP BY触发排序 | 临时表建聚集索引 |
| UNPIVOT内存压力大 | 源表行数过大 | 分批处理或临时表方案 |
| 动态列名导致注入风险 | 拼SQL未校验 | 列名做白名单校验 |
10. 实操总结与扩展思考
排查多列重复值这件事,核心就三个步骤:拉平、聚合、对照。拉平方案的选择取决于数据量和维护成本,UNION ALL直观但啰嗦,UNPIVOT优雅但要注意内存压力,临时表性能最好但需要多写几步。不同场景选不同方案,没有银弹。
我在实际项目中的体会是,这类排查工作最耗时间的往往不是写SQL,而是理解数据。动手之前先花半小时看清数据结构、脏数据分布、重复的业务含义,比闷头写一小时SQL更有效。另外,排查只是第一步,真正的价值是推动业务端做录入校验,从源头避免脏数据进入系统。我后来在这个项目中还加了三种防范措施:录入时检查“多列不可重复”的约束逻辑、定期跑一遍排查脚本生成数据质量报告、在关键的短信发送接口里增加号码实时去重。就像修水管,光把眼前的水扫掉没用,得找到漏水的地方补上,这套排查方案真正的价值,是帮业务把隐患找出来,然后从源头堵住。
最后再分享一个小技巧:排查SQL不要总是临时写,可以把最常用的几个方案封装成存储过程,参数化传入表名、列名和输出方式。我手上这套写法,现在在团队里已经成了一个标准工具,新同事要排查重复值,直接调用即可,不用每次重写。SQL开发就是这样,把有规律的工作沉淀成模板,越用越顺。
