ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL Server多列重复值排查:从两两比较到CROSS APPLY动态方案

SQL Server多列重复值排查:从两两比较到CROSS APPLY动态方案 手头这张订单表有三个电话字段ship_phone、bill_phone、contact_phone。月初做数据质量检查老板随口问了一句“这些电话有没有串号”。我第一反应是把三列拉出来两两对比写完又觉得不对劲——同事真正担心的其实是同一个电话号码会不会在行与行之间、列与列之间反复出现。这类需求在 SQL Server 里非常高频我归纳成一句话排查多列之间的值是否重复。我实际遇到过的典型场景不少会员宽表里有 account_a、account_b、account_c 三个账号字段要判断同一行里是否出现两个账号相同ETL 映射表里存了 legacy_id、current_id、backup_id要确认这些 ID 在整个表范围内没有跨列重复订单表里放了多个渠道的流水号拿来做季度数据质量探查时得把所有出现重复的值连同行号、列名一起捞出来。这类排查说难不难但写法一不合适要么结果口径不对要么扔到大表上跑到怀疑人生。这篇就按场景分层把常用解法、执行计划层面会发生什么、以及可复用的动态 SQL 模板都整理出来方便后续直接抄作业。1. 先把问题问清楚多列重复到底有几种场景1.1 同一行内多列值互相重复这是最直观的场景一行里有 n 个字段需要判断其中任意两个字段的值是否相等。例如会员账号表里三个账号列中有任意两个相同就属于脏数据。这种问题本质上是“同行内比较”字段之间横向比较不涉及其他行。常用的写法是两两比较字段越多比较组合越爆炸三个字段有三种组合六个字段就有十五种组合所以后面我会推荐用行转列的方式统一处理。1.2 跨行跨列交叉引用重复比同行重复更隐蔽。某条记录的字段 A 可能没有和本行其他字段冲突但它一不小心出现在了另一条记录的字段 B 里。典型场景是 ID 映射表。假设一张表有三个 ID 列分别代表旧系统 ID、新系统 ID、备份 ID数据合并时最怕出现“张三的 legacy_id 变成了李四的 current_id”这种交叉污染在单行对比里完全看不出来必须放到全表范围去查。1.3 全局扫描型重复排查再往上一层就是“全部字段展开后对整个值集合做全局重复检测”。也就是把每行每列的值全部拆出来形成一张“字段值长表”然后在这个长表上按值分组统计凡是出现次数大于 1 的值就是重复值。这种排查方式是前两种的统一口径能同时覆盖“同行重复”和“跨行跨列交叉重复”也是我最终推荐用在数据质量脚本里的主方案。后面第 3 章会重点讲实现。2. 最直接方案同行内两两比较2.1 OR 三板斧先建一张表贯穿全文的例子都用它CREATE TABLE dbo.member_accounts ( member_id INT PRIMARY KEY, account_a VARCHAR(30), account_b VARCHAR(30), account_c VARCHAR(30) ); INSERT INTO dbo.member_accounts ( member_id, account_a, account_b, account_c ) VALUES (1, A1001, B1002, C1003), (2, A1004, A1004, C1005), (3, A1006, B1007, B1007), (4, A1008, B1009, NULL), (5, NULL, NULL, C1010);检查同一行内是否存在重复字段最简单是三列两两比较SELECT member_id, account_a, account_b, account_c FROM dbo.member_accounts WHERE account_a account_b OR account_a account_c OR account_b account_c;执行结果会返回第 2 行A1004 出现两次和第 3 行B1007 出现两次。第 5 行两个 NULL 不会命中这一点取决于口径下面专门说。这种写法的优点是直观适合字段数量少、口径明确的一次性排查。缺点是维护性差字段一多组合数量按阶乘增长SQL 会变得又长又容易漏。2.2 NULL 的取舍到底算不算重复NULL 不是值所以NULL NULL的结果是 UNKNOWN这个表达式不会成立。执行下面这条语句就能验证SELECT CASE WHEN NULL NULL THEN 1 ELSE 0 END AS is_same; -- 0如果业务人员说“两个账号都为空等同于是重复数据”那就要把 NULL 也纳入比较写成SELECT member_id, account_a, account_b, account_c FROM dbo.member_accounts WHERE (account_a account_b OR (account_a IS NULL AND account_b IS NULL)) OR (account_a account_c OR (account_a IS NULL AND account_c IS NULL)) OR (account_b account_c OR (account_b IS NULL AND account_c IS NULL));这么写会把第 5 行也捞出来。但我在实际项目里不建议默认这么做因为“空对空”在大多数业务场景里只是“数据未录入”并不是“重复录入”把空值当重复会让排查报告充满噪音。正确的做法是先定业务口径再选择对应的 SQL 分支。2.3 用 CASE WHEN 定位具体哪两个字段重复只把重复行捞出来还不够业务方往往会追问一句“到底哪两个字段重复了”。这时可以在 SELECT 里用 CASE WHEN 拼出诊断信息SELECT member_id, CONCAT_WS(; , CASE WHEN account_a account_b AND account_a IS NOT NULL THEN account_aaccount_b( account_a ) END, CASE WHEN account_a account_c AND account_a IS NOT NULL THEN account_aaccount_c( account_a ) END, CASE WHEN account_b account_c AND account_b IS NOT NULL THEN account_baccount_c( account_b ) END ) AS duplicate_detail FROM dbo.member_accounts WHERE account_a account_b OR account_a account_c OR account_b account_c;这样输出结果里就带上了重复的字段对和具体值后续给运营同事看报告时不用再翻原表确认。CONCAT_WS 在 SQL Server 2017 以上可用如果是 2016用普通拼接并自己处理 NULL 即可。3. 通用解法CROSS APPLY VALUES 把列拆成行3.1 为什么选 CROSS APPLY 而不是 UNPIVOT字段一多两两比较就不现实了。换个思路把一行三个字段拆成三行列名变成一列值变成另一列再统一分组。这个“宽表转长表”的操作在 SQL Server 里有两种常见实现UNPIVOT 和 CROSS APPLY VALUES。UNPIVOT 的写法如下SELECT member_id, col, val FROM dbo.member_accounts UNPIVOT ( val FOR col IN (account_a, account_b, account_c) ) AS u;UNPIVOT 有两个局限一是必须把列名列在 IN 子句里字段一变又要改二是只支持相同数据类型的列如果三列里有 INT 有 VARCHARUNPIVOT 会直接报转换错误。而且它默认跳过 NULL在某些场景反而让人迷惑。CROSS APPLY VALUES 就没有这些问题。它本质上是为左侧每一行生成若干行数据可以显式控制列名、可以对列做类型转换、可以决定是否过滤 NULL。它和 UNPIVOT 的逻辑等价但灵活得多。3.2 构造宽表转长表标准写法SELECT ma.member_id, v.col, v.val FROM dbo.member_accounts AS ma CROSS APPLY ( VALUES (account_a, ma.account_a), (account_b, ma.account_b), (account_c, ma.account_c) ) AS v(col, val) WHERE v.val IS NOT NULL;执行机制很简单扫描 member_accounts 每一行对每一行展开成三行输出。第 4 行由于 account_c 是 NULL展开后只剩两行第 5 行两个 NULL 被过滤只剩一行。最终的 val 集合就是全表所有非空账号值的全集。如果三列类型不一致可以在 VALUES 里统一转成 NVARCHAR(account_a, CONVERT(NVARCHAR(200), ma.account_a))统一类型在动态 SQL 里特别关键后面第 5 章会用到。3.3 分组统计定位重复值有了长表排查全局重复就只是一次 GROUP BY 的事WITH Unpivoted AS ( SELECT ma.member_id, v.col, v.val FROM dbo.member_accounts AS ma CROSS APPLY ( VALUES (account_a, ma.account_a), (account_b, ma.account_b), (account_c, ma.account_c) ) AS v(col, val) WHERE v.val IS NOT NULL ) SELECT val, COUNT(*) AS appear_total, COUNT(DISTINCT member_id) AS member_cnt, COUNT(DISTINCT col) AS column_cnt FROM Unpivoted GROUP BY val HAVING COUNT(*) 1 ORDER BY appear_total DESC;这个结果的信息量非常大appear_total大于 1代表这个值至少在两处出现member_cnt大于 1说明是跨行重复column_cnt大于 1说明一个值同时出现在多个列里如果 member_cnt 1 且 column_cnt 大于 1那就是同一行内的重复对应第 2 章的场景。以样例数据来说A1004 会得到 member_cnt1、column_cnt2一眼就能看出是同一行里 account_a 和 account_b 重复了。3.4 重复明细输出在哪一行哪一列GROUP BY 给出了统计指标但排查时业务方通常还想要明细这个重复值具体出现在哪个 member_id、哪个列里。这时可以在 CTE 或派生表外层加窗口函数或者直接把长表和重复名单做关联WITH Unpivoted AS ( SELECT ma.member_id, v.col, v.val FROM dbo.member_accounts AS ma CROSS APPLY ( VALUES (account_a, ma.account_a), (account_b, ma.account_b), (account_c, ma.account_c) ) AS v(col, val) WHERE v.val IS NOT NULL ), DuplicatedValues AS ( SELECT val FROM Unpivoted GROUP BY val HAVING COUNT(*) 1 ) SELECT u.val, u.member_id, u.col FROM Unpivoted AS u INNER JOIN DuplicatedValues AS d ON u.val d.val ORDER BY u.val, u.member_id, u.col;输出结果就是一张干净的问题清单重复值、所在行、所在列。如果老板只要一句摘要还可以把明细聚合成一行WITH Unpivoted AS (...) SELECT val, STRING_AGG( CONCAT(member_, member_id, ., col), ) AS locations, COUNT(*) AS appear_total FROM Unpivoted GROUP BY val HAVING COUNT(*) 1;STRING_AGG 同样要求 SQL Server 2017 以上2016 及更早版本可以用STUFF FOR XML PATH替代。我在生产环境里用到这一套时差不多都会在报告里同时给统计结果和明细结果一份自己核对一份发给业务侧。4. 进阶场景跨行交叉重复与组合重复4.1 列内重复的常规分组查法先说一个最简单的子场景单列内部跨行重复。比如要检查 account_a 列里有没有相同的账号出现多次SELECT account_a, COUNT(*) AS cnt FROM dbo.member_accounts WHERE account_a IS NOT NULL GROUP BY account_a HAVING COUNT(*) 1;这是所有重复排查的起点。但真实需求很少只盯着一列更多是“三列都要看”于是很多人图省事会把三列的查法 UNION ALL 到一块其实这正是第 3 章长表方案要替换掉的写法。短表 UNION ALL 在字段少时也能用但一有新增字段就要手动加一段 SQL维护成本明显更高。4.2 跨列交叉重复自连接的写法现在处理最隐蔽的交叉污染。假设有两个会员member_id 分别为 1 和 2如果会员 1 的 account_a 恰好等于会员 2 的 account_b就会造成账号冲突。查找这种问题需要自连接SELECT a.member_id AS member_a, b.member_id AS member_b, a.account_a AS match_value FROM dbo.member_accounts AS a INNER JOIN dbo.member_accounts AS b ON a.account_a b.account_b AND a.member_id b.member_id WHERE a.account_a IS NOT NULL;注意这里a.member_id b.member_id不仅排除自己跟自己比还保留了有向性。如果不关心方向可以把改成结果减少一半避免重复配对。如果要把六个方向A 对 B、A 对 C、B 对 A、B 对 C、C 对 A、C 对 B全部覆盖直接写在 ON 条件里会又臭又不直观ON a.account_a b.account_b OR a.account_a b.account_c OR a.account_b b.account_a OR ...我更推荐用 UNION ALL 把每种配对关系分开写执行计划反而更清楚SELECT a.member_id AS member_a, b.member_id AS member_b, a.account_a AS match_value FROM dbo.member_accounts AS a INNER JOIN dbo.member_accounts AS b ON a.account_a b.account_b AND a.member_id b.member_id WHERE a.account_a IS NOT NULL UNION ALL SELECT a.member_id AS member_a, b.member_id AS member_b, a.account_a AS match_value FROM dbo.member_accounts AS a INNER JOIN dbo.member_accounts AS b ON a.account_a b.account_c AND a.member_id b.member_id WHERE a.account_a IS NOT NULL; -- 其余配对方向同理这种写法还有个好处每个 SELECT 片段都可以单独调试定位问题更快。4.3 组合重复多列构成逻辑主键时的重复有些表没有显式主键业务上靠多个列的联合值唯一。比如订单明细表靠 order_id product_id 定位唯一记录。排查这种“组合重复”本质是对多列做 GROUP BYSELECT order_id, product_id, COUNT(*) AS cnt FROM dbo.order_details GROUP BY order_id, product_id HAVING COUNT(*) 1;如果要在明细里把每个重复行都标出来用 ROW_NUMBERWITH Ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY order_id, product_id ORDER BY (SELECT NULL) ) AS rn FROM dbo.order_details ) SELECT * FROM Ranked WHERE rn 1;ORDER BY (SELECT NULL)的意思是“不关心保留哪一行”在给重复行编号时这个技巧能避免必须选一个排序字段。如果要确定保留规则就改成 ORDER BY 某个时间字段比如保留最早插入的那条。组合重复和字段值重复不是一回事但它也属于“多列之间的重复”范畴项目里经常同时出现所以我把它放进统一排查框架里。5. 自动化排查动态 SQL 处理任意列集合5.1 从系统视图生成列清单如果一张表有几十个业务字段手工把列名写进 CROSS APPLY VALUES 太痛苦。SQL Server 提供了系统视图 INFORMATION_SCHEMA.COLUMNS可以自动拿到列清单SELECT COLUMN_NAME, ORDINAL_POSITION FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA dbo AND TABLE_NAME member_accounts AND COLUMN_NAME member_id ORDER BY ORDINAL_POSITION;把列名拼成(account_a, account_a)这种结构就能生成动态 SQL。这里至少有两个容易踩的坑一是要把主键列排除掉否则主键也会参与重复检测导致满屏都是“重复”二是如果目标列的数据类型不同必须在拼接时统一加 CONVERT否则执行时会出现隐式转换冲突。5.2 动态构造 VALUES 子句拼列清单的方式在 SQL Server 2017 以上可以使用 STRING_AGG在 2016 或更早版本要用 FOR XML PATH。我先给 2017 写法DECLARE schema NVARCHAR(128) Ndbo; DECLARE table NVARCHAR(128) Nmember_accounts; DECLARE id_col NVARCHAR(128) Nmember_id; DECLARE values_clause NVARCHAR(MAX); SELECT values_clause STRING_AGG( CONCAT( (, COLUMN_NAME, , CONVERT(NVARCHAR(200), , QUOTENAME(COLUMN_NAME), )) ), , CHAR(13) CHAR(10) ) WITHIN GROUP (ORDER BY ORDINAL_POSITION) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA schema AND TABLE_NAME table AND COLUMN_NAME id_col; SELECT values_clause AS values_clause;生成的文本大概是(account_a, CONVERT(NVARCHAR(200), [account_a])), (account_b, CONVERT(NVARCHAR(200), [account_b])), (account_c, CONVERT(NVARCHAR(200), [account_c]))这个片段可以直接嵌入 CROSS APPLY VALUES。如果是 SQL Server 2016STRING_AGG 不可用改用SELECT values_clause STUFF( ( SELECT , CHAR(13) CHAR(10) ( COLUMN_NAME , CONVERT(NVARCHAR(200), QUOTENAME(COLUMN_NAME) )) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA schema AND TABLE_NAME table AND COLUMN_NAME id_col ORDER BY ORDINAL_POSITION FOR XML PATH(), TYPE ).value(., NVARCHAR(MAX)), 1, 1, );FOR XML PATH 拼接文本时如果列名里含有特殊字符需要留意 XML 转义问题。相比之下 STRING_AGG 更省心所以如果数据库版本够新优先用 STRING_AGG。5.3 完整的动态排查模板把前面片段组装成一个可直接执行的动态 SQL 模板DECLARE schema NVARCHAR(128) Ndbo; DECLARE table NVARCHAR(128) Nmember_accounts; DECLARE id_col NVARCHAR(128) Nmember_id; DECLARE values_clause NVARCHAR(MAX); SELECT values_clause STRING_AGG( CONCAT( (, COLUMN_NAME, , CONVERT(NVARCHAR(200), , QUOTENAME(COLUMN_NAME), )) ), , CHAR(13) CHAR(10) ) WITHIN GROUP (ORDER BY ORDINAL_POSITION) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA schema AND TABLE_NAME table AND COLUMN_NAME id_col; DECLARE sql NVARCHAR(MAX) N ;WITH Unpivoted AS ( SELECT QUOTENAME(id_col) AS row_id, u.col, u.val FROM QUOTENAME(schema) N. QUOTENAME(table) N CROSS APPLY ( VALUES values_clause N ) AS u(col, val) WHERE u.val IS NOT NULL ) SELECT val, COUNT(*) AS appear_total, COUNT(DISTINCT row_id) AS row_cnt, COUNT(DISTINCT col) AS column_cnt FROM Unpivoted GROUP BY val HAVING COUNT(*) 1 ORDER BY appear_total DESC;; EXEC sys.sp_executesql sql;拿到结果后如果还想进一步输出明细把动态 SQL 末尾改成关联 DuplicatedValues 的版本即可。模板里特意用了CONVERT(NVARCHAR(200), ...)会把所有类型的值统一转成字符串再参与分组。有一点必须提醒这种动态 SQL 适合放在数据质量工具、一次性侦查脚本里不要直接丢给业务系统频繁跑。因为它要对整张表做全面展开行数翻了好几倍属于数据密集型操作。上线前先看数据量量太大就参考第 6 章的优化思路。6. 性能优化与避坑实录6.1 直接展开大表为什么慢CROSS APPLY VALUES 看起来清秀执行计划却是典型的“外表扫描 内层常量计算”。假设 member_accounts 有 1000 万行、三个字段参与展开输出就是最多 3000 万行。后面再做 GROUP BY 聚合内存和 TempDB 的压力都肉眼可见。我在实际项目中看到的最典型错误是拿着这套 SQL 直接去跑几千万行的流水表结果跑了十几分钟还不停因为 GROUP BY 的 hash 聚合把工作集全溢写到磁盘了。所以排查前先看数据规模给自己一个心理预期。6.2 临时表加索引是王道如果数据量很大别在源表上硬刚。先把展开结果落进临时表再在临时表上建索引、做聚合性能会明显更稳定SELECT ma.member_id AS row_id, v.col, v.val INTO #unpivoted FROM dbo.member_accounts AS ma CROSS APPLY ( VALUES (account_a, CONVERT(NVARCHAR(200), ma.account_a)), (account_b, CONVERT(NVARCHAR(200), ma.account_b)), (account_c, CONVERT(NVARCHAR(200), ma.account_c)) ) AS v(col, val) WHERE v.val IS NOT NULL; CREATE INDEX ix_val ON #unpivoted(val) INCLUDE (row_id, col); SELECT val, COUNT_BIG(*) AS appear_total FROM #unpivoted GROUP BY val HAVING COUNT_BIG(*) 1 ORDER BY appear_total DESC; DROP TABLE #unpivoted;临时表的代价是多一次写盘但后续聚合能从索引里取数据执行计划的可控性好很多。针对排查任务我通常还会在展开之前先用 WHERE 过滤掉明显无效的数据比如状态列等于“删除”的这样展开行数进一步降低。6.3 排序规则不一致导致查不出结果如果两个字段的排序规则Collation不同等值比较会直接报错。比如一个字段是Chinese_PRC_CI_AS另一个是SQL_Latin1_General_CP1_CI_AS查询会抛出“无法解决等于运算中的排序规则冲突”。即使不报错排序规则也可能让两边字符串比较行为不一致比如大小写敏感和大小写不敏感的字段做比较时结果和直觉不一样。稳妥做法是在比较时显式指定同一排序规则WHERE account_a COLLATE Chinese_PRC_CI_AS account_b COLLATE Chinese_PRC_CI_AS但更推荐在表设计阶段统一列级排序规则避免每条排查 SQL 都带 COLLATE。尤其是 ETL 项目里从多个源系统汇入数据时这种坑几乎必踩。6.4 自连接导致结果爆炸的雷第 4.2 节的自连接写法有性能隐患如果某个值出现频率很高连接结果集会急剧膨胀。举个例子account_a 里有 1 万个重复的A1000account_b 里也有 1 万个A1000一次自连接就会生成 1 亿行中间结果。对这类场景优先把匹配条件改成 EXISTS或者先对长表做分组把重复值找出来再回到原表去定位行而不是直接两表大连接。宁可多写几步也要避免中间结果失控。6.5 NULL 与尾部空格的业务口径统一排查前先确定两个口径第一NULL 是否参与重复第二字符串值是否允许出现左右空格。SQL Server 在普通比较时会忽略字符串尾部空格ABC和ABC 会被当作相等这可能导致误报。如果业务上认为带空格也算不一致就需要先做预处理SELECT member_id, LTRIM(RTRIM(account_a)) AS account_a, LTRIM(RTRIM(account_b)) AS account_b, LTRIM(RTRIM(account_c)) AS account_c INTO #clean FROM dbo.member_accounts;再在清洗后的表上跑长表展开。数据质量排查最怕“口径没对齐”结果一出来两边争论半天最后发现是空格问题那感觉非常糟糕。下面把常见问题整理成一张速查表方便快速定位场景推荐方法主要注意点同一行内字段重复CASE WHEN 或 CROSS APPLY明确 NULL 是否参与全字段值全局重复CROSS APPLY VALUES GROUP BY类型统一过滤 NULL跨行跨列交叉重复自连接或长表分组控制中间结果膨胀多列组合重复GROUP BY 多列 HAVING无主键时先确认逻辑键字段很多手工维护成本高动态 SQL 生成列清单排除主键关注权限大表直接跑不动临时表 索引尽量先过滤无效行7. 从“会排查”到“防重复”更稳的落地姿势7.1 CHECK 约束兜住同行重复如果业务规则非常明确“同一行内三个账号字段不能互相重复”那可以在表上加 CHECK 约束从源头拦截脏数据ALTER TABLE dbo.member_accounts ADD CONSTRAINT chk_account_no_dup_in_row CHECK ( ( account_a IS NULL OR account_b IS NULL OR account_c IS NULL OR account_a NOT IN (account_b, account_c) ) AND ( account_b IS NULL OR account_a IS NULL OR account_c IS NULL OR account_b NOT IN (account_a, account_c) ) AND ( account_c IS NULL OR account_a IS NULL OR account_b IS NULL OR account_c NOT IN (account_a, account_b) ) );这里有个 SQL 语义要强调CHECK 约束遇到表达式结果为 UNKNOWN 时会放行所以如果某列是 NULL整个括号就变成 UNKNOWN等于不拦截。利用这个特性我们能让约束只拦截“两个非空值相同”的情况。但需要注意CHECK 约束无法阻止跨行的交叉重复那是另一个层级的问题。7.2 计算列加唯一索引兜住简单的场景如果只需要快速识别同一行内是否重复可以加一个持久化计算列用来输出标记ALTER TABLE dbo.member_accounts ADD account_dup_flag AS CASE WHEN account_a account_b OR account_a account_c OR account_b account_c THEN DUP ELSE OK END PERSISTED; CREATE INDEX ix_member_accounts_dup_flag ON dbo.member_accounts(account_dup_flag);查询时直接按WHERE account_dup_flag DUP过滤省去每行重新计算。这个方法适合作为日常例行巡检的轻量索引但别指望它解决所有全局重复问题。7.3 ETL 校验流中的落地场景我见过很多团队把“排查多列重复”当作月末临时作业每次都靠人工跑脚本。更好的做法是把长表展开逻辑封装成存储过程或数据质量任务纳入 ETL 流程数据落临时区后先执行一遍长表展开和分组统计如果存在appear_total 1的值则把明细写入异常表标记批次失败异常表里记录批次号、表名、列名、重复值、出现行 ID、发现时间方便事后追责和修复。这样做的最大价值不只是“发现问题”而是让问题在进入正式环境之前就被拦截。我参与过的迁移项目里靠这道校验拦下过两次真实的数据污染一次是源系统在两个字段里录入了相同 ID一次是历史归档数据里出现了跨键冲突。事后看这两次问题如果进了生产库影响面会大很多。再补充一点如果要在日常任务里频繁使用动态 SQL 模板建议把第 5 章的内容封装成存储过程用参数传入 schema、table、id 列和要检查的列清单。存储过程内部做好列名白名单校验防止非法注入。动态 SQL 虽然好用但引号拼接一定要谨慎列名尽量用 QUOTENAME 包起来这是我从动态 SQL 踩坑里学到的底线原则。排查多列重复这件事说到底是“数据可观测性”的一部分。它不复杂但口径要清楚、方法要对、性能要稳。希望这套从同行比较到全局展开、再到动态 SQL 和防重复机制的思路能帮你在下一次数据质量检查时少走几步弯路。
返回列表