1. 为什么 SQL Server 去重总在“删错行”上翻车
SQL Server 删除重复数据这件事,听起来像一道送分题,真到生产库上执行,翻车姿势能凑齐一整本错题集。我见过最典型的一次:某张订单明细表因为程序重试逻辑没写好,同一笔订单被写进去三条,运营同学直接在 SSMS 里敲了DELETE TOP (2) FROM 订单明细,结果把另外两笔正常订单的记录一起带走了,恢复备份花了两个小时。
问题的根子在于,很多人把“去重”理解成“把重复的行删掉”,但数据库里根本没有“重复的行”这个概念——每一行都有自己独立的物理位置,哪怕所有字段值一模一样,它们也是两条不同的记录。所以真正要回答的问题是:保留哪一条,删掉哪几条。这个判断标准不明确,SQL 就写不对。
SQL Server 里常见的去重思路大致分三类。第一类是SELECT DISTINCT配合临时表重建,适合整行完全重复的场景,简单粗暴但会锁表。第二类是ROW_NUMBER()窗口函数,通过给每组重复数据编号,保留rn = 1的行,这是目前最推荐的做法,灵活且可控。第三类是GROUP BY加MIN/MAX聚合,适合只关心某几个关键字段唯一、其他字段取任意值的场景。老一点的代码里还能看到游标逐行删除的写法,那种方式在数据量上万之后基本等于自残,不推荐。
这里有个容易被忽略的点:去重之前一定要先“看清楚”。很多人上来就写 DELETE,连重复数据长什么样、重复了几组、每组几条都没查过。正确的顺序应该是先写查询语句把重复组捞出来,确认保留规则,再改写成删除语句。这个习惯能帮你避开九成以上的误删事故。
那这跟 TaoToken 有什么关系?关系在于,去重脚本往往不是一次性写完就完事的。你可能需要在不同环境(开发、测试、预发、生产)反复调试同一段 SQL,或者让 AI 帮你把一段游标逻辑改写成窗口函数版本。这时候如果每个模型都要单独配 Key、单独记 Base URL,调试效率会被拖垮。TaoToken 提供的是统一 Key 和统一 API 通道,一个 Key 就能在多个模型之间切换,特别适合这种“同一段 SQL 反复让不同模型审查”的场景。下面我会先把这个前置配置讲清楚,再进入具体的去重脚本。
2. TaoToken 统一 Key 与 API 通道的前置配置
在写去重脚本的过程中,我经常需要让模型帮我做几件事:把游标版本改写成ROW_NUMBER()版本、检查 DELETE 语句有没有漏掉 WHERE 条件、根据表结构生成对应的验证查询。如果每次都要去不同平台复制 Key、改 Base URL,光是配置就能耗掉半小时。TaoToken 的思路是把这些统一到一个入口,你只需要维护一份配置。
先说清楚它是什么:TaoToken 是一个 API 聚合通道,对外暴露统一的 Base URL 和 Key,内部帮你路由到不同的模型。对写 SQL 的人来说,最大的好处是你可以在同一个对话里先让一个模型审查逻辑,再换另一个模型验证边界条件,而不用改任何环境变量。适合谁?适合需要频繁切换模型做代码审查、SQL 优化、脚本生成的开发者,尤其是那种“一段 SQL 要过好几个模型”的工作流。
配置本身不复杂,核心就三样东西:Base URL、API Key、Model ID。Base URL 用https://taotoken.net/api,注意这个地址不带任何查询参数。API Key 在控制台生成,生成后只显示一次,记得存好。Model ID 根据你要用的模型填,比如claude-sonnet-4-20250514或者gpt-4o这类。
如果你用的是 Claude Code 这类命令行工具,配置会落到 settings 文件里。以 Claude Code 为例,配置文件通常在~/.claude/settings.json,内容长这样:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的TaoToken密钥", "ANTHROPIC_MODEL": "claude-sonnet-4-20250514" } }如果你用的是 Cline 这类 VS Code 插件,配置走的是 MCP 或 provider 设置,在插件设置里填 Base URL 和 Key 即可,Model ID 在模型下拉框里选。Codex 的话,配置落在~/.codex/auth.json,结构类似:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的TaoToken密钥", "model": "gpt-4o" }这里要提醒一句:Base URL 后面不要加/v1或者/chat/completions,通道会自己处理路径拼接。我见过有人手动拼成https://taotoken.net/api/v1/chat/completions,结果一直报 404,排查了半天。
配置好之后,你可以先用一个最简单的请求验证通道是否通。用 curl 测一下:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的TaoToken密钥" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "回复 OK 两个字母即可"}], "max_tokens": 10 }'如果返回里能看到choices数组和正常的 content,说明通道没问题。这一步很重要,因为后面所有去重脚本的调试都依赖这个通道,通道不通,后面全是白搭。
3. 可复制的去重脚本:ROW_NUMBER、GROUP BY、CTE 三套写法
现在进入正题。假设我们有一张订单明细表OrderDetails,结构如下:
CREATE TABLE OrderDetails ( DetailID INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(20) NOT NULL, ProductID INT NOT NULL, Quantity INT NOT NULL, CreatedAt DATETIME NOT NULL DEFAULT GETDATE() );程序重试导致同一组OrderNo + ProductID被写入了多条,现在要保留每组里DetailID最小的那条,删掉其余的。下面三套写法都能用,但适用场景不同。
3.1 ROW_NUMBER 窗口函数版本(推荐)
这是目前最推荐的写法,逻辑清晰,可控性强,而且能直接看到“哪些行会被删”。
先写查询版本,确认要删的行:
WITH DupRanked AS ( SELECT DetailID, OrderNo, ProductID, ROW_NUMBER() OVER ( PARTITION BY OrderNo, ProductID ORDER BY DetailID ASC ) AS rn FROM OrderDetails ) SELECT * FROM DupRanked WHERE rn > 1;这段查询会列出所有“非保留”的行。PARTITION BY后面跟的是判断重复的字段组合,ORDER BY决定保留哪一条——这里用DetailID ASC表示保留最早插入的那条。你可以把ORDER BY改成CreatedAt DESC来保留最新的一条,取决于业务需求。
确认查询结果没问题后,把SELECT *改成DELETE:
WITH DupRanked AS ( SELECT DetailID, ROW_NUMBER() OVER ( PARTITION BY OrderNo, ProductID ORDER BY DetailID ASC ) AS rn FROM OrderDetails ) DELETE FROM DupRanked WHERE rn > 1;注意这里直接DELETE FROM DupRanked,因为 CTE 是可更新的,删除会作用到底层表。执行前建议先包在事务里:
BEGIN TRAN; WITH DupRanked AS ( SELECT DetailID, ROW_NUMBER() OVER ( PARTITION BY OrderNo, ProductID ORDER BY DetailID ASC ) AS rn FROM OrderDetails ) DELETE FROM DupRanked WHERE rn > 1; -- 先看影响行数,确认无误再 COMMIT -- COMMIT; -- ROLLBACK;3.2 GROUP BY + MIN 版本
如果你只想保留每组里某个字段最小的那条,用GROUP BY加MIN也能实现,但有个坑:MIN(DetailID)只能保证 ID 最小,不能保证其他字段来自同一行。所以这种写法适合“其他字段无所谓”的场景。
DELETE FROM OrderDetails WHERE DetailID NOT IN ( SELECT MIN(DetailID) FROM OrderDetails GROUP BY OrderNo, ProductID );这段 SQL 的意思是:按OrderNo + ProductID分组,每组取最小的DetailID,然后删掉所有不在这个集合里的行。逻辑简单,但NOT IN在子查询结果集很大时性能会下降,而且如果DetailID允许 NULL,NOT IN会返回空结果集——这是个经典陷阱。所以用之前确认DetailID是主键、非空。
3.3 CTE + 自连接版本
还有一种写法是用 CTE 加自连接,适合需要保留“最新一条”且表里有时间戳的场景:
WITH KeepRows AS ( SELECT OrderNo, ProductID, MAX(CreatedAt) AS MaxCreatedAt FROM OrderDetails GROUP BY OrderNo, ProductID ) DELETE od FROM OrderDetails od INNER JOIN KeepRows k ON od.OrderNo = k.OrderNo AND od.ProductID = k.ProductID WHERE od.CreatedAt < k.MaxCreatedAt;这段的逻辑是:先找出每组的最新时间,然后删掉所有时间早于最新时间的行。注意如果同一组里有两条CreatedAt完全相同,这段 SQL 会两条都保留,不会误删。这是它的优点,也是它的局限——如果业务要求严格只留一条,得再加一层ROW_NUMBER兜底。
三套写法对比一下:
| 写法 | 适用场景 | 保留规则 | 性能 | 风险点 |
|---|---|---|---|---|
| ROW_NUMBER | 通用,推荐 | 任意字段排序 | 好 | 需理解窗口函数 |
| GROUP BY + MIN | 只关心 ID 最小 | 固定为 MIN | 中 | NOT IN 遇 NULL 失效 |
| CTE + 自连接 | 有时间戳 | 最新时间 | 中 | 时间相同则都保留 |
选哪套取决于你的业务规则。如果拿不准,先用ROW_NUMBER的查询版本把重复组捞出来,人工确认保留规则,再改写成 DELETE。
4. 执行前后行数对比与验证请求
去重脚本写完不代表完事,验证才是关键。我习惯用“三段式验证”:执行前记录行数、执行后对比行数、抽样检查保留行是否符合预期。
先记录执行前的状态:
-- 总行数 SELECT COUNT(*) AS TotalRows FROM OrderDetails; -- 重复组数量 SELECT COUNT(*) AS DupGroups FROM ( SELECT OrderNo, ProductID FROM OrderDetails GROUP BY OrderNo, ProductID HAVING COUNT(*) > 1 ) t; -- 重复行总数(超出每组一条的部分) SELECT SUM(cnt - 1) AS RowsToDelete FROM ( SELECT COUNT(*) AS cnt FROM OrderDetails GROUP BY OrderNo, ProductID HAVING COUNT(*) > 1 ) t;把这三个数字记下来。执行删除后,再跑一遍:
SELECT COUNT(*) AS TotalRowsAfter FROM OrderDetails; SELECT COUNT(*) AS DupGroupsAfter FROM ( SELECT OrderNo, ProductID FROM OrderDetails GROUP BY OrderNo, ProductID HAVING COUNT(*) > 1 ) t;预期结果是:TotalRowsAfter = TotalRows - RowsToDelete,且DupGroupsAfter = 0。如果对不上,说明删除逻辑有问题,立刻回滚。
抽样验证保留行:
-- 随机抽 5 组,看每组是否只剩一条 SELECT TOP 5 OrderNo, ProductID, COUNT(*) AS cnt FROM OrderDetails GROUP BY OrderNo, ProductID ORDER BY NEWID();如果每组cnt都是 1,说明去重成功。这一步别省,我见过ROW_NUMBER的PARTITION BY字段写错、导致删多了的情况,抽样能第一时间发现。
如果你想让模型帮你审查这段验证逻辑,可以把上面的 SQL 贴给 TaoToken 通道里的模型,让它检查有没有漏掉的边界条件。比如你可以问:“这段验证 SQL 在 OrderDetails 有 500 万行时会不会全表扫描导致锁表?”模型会给你具体的索引建议。这种“写完脚本让模型审一遍”的习惯,比你自己盯着屏幕看半小时有效得多。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
去重脚本本身出错的情况其实不多,更多时候是调用通道时出的问题。下面这几个报错我踩过不止一次,按出现频率排一下。
401 Unauthorized。这个最常见,原因通常是 Key 没填对、Key 过期、或者 Base URL 和 Key 不匹配。排查顺序:先确认Authorization头里Bearer后面有没有多余空格,再确认 Key 是不是从控制台复制完整了(有些 Key 很长,复制时容易漏掉尾部字符)。如果用的是 Claude Code,检查settings.json里ANTHROPIC_API_KEY的值有没有被引号包住、有没有换行符混进去。
local proxy failed。这个报错通常出现在你本地配了代理、但代理没启动或者端口不对的时候。注意这里说的是本地开发环境的网络配置问题,不是让你去搞什么特殊网络手段。排查方法:检查环境变量HTTP_PROXY/HTTPS_PROXY有没有设成失效的地址,或者干脆临时清掉这两个变量再试。如果你用的是公司内网,确认防火墙有没有拦掉对taotoken.net的访问。
reading choices 相关报错。这个一般出现在响应体解析阶段,报错信息里会带cannot read property 'choices' of undefined或者类似内容。原因通常是请求返回了非预期结构,比如返回了一个错误对象而不是正常的 completion 响应。排查方法:把 curl 请求的完整响应打出来看,如果返回的是{"error": {...}},说明请求本身有问题,先解决请求侧;如果返回的是空 body,检查Content-Type有没有设成application/json。
OAuth 相关报错。如果你用的是需要 OAuth 流程的工具(比如某些 IDE 插件),报错里可能出现OAuth token expired或invalid_grant。这种情况通常是 token 过期了,重新走一遍授权流程即可。如果反复过期,检查系统时间是否准确——时间偏差超过几分钟会导致 token 校验失败。
排查这些问题的通用思路是:先用 curl 发一个最小请求,确认通道本身通不通;通了再逐步加上你的业务参数。这样能把“通道问题”和“脚本问题”分开,避免在一个地方死磕。
6. 把去重脚本接入你的日常工作流
去重这件事,单次执行不难,难的是把它变成可重复、可审查的流程。我的做法是把去重脚本拆成三个文件:01_find_dups.sql(查询重复组)、02_delete_dups.sql(删除脚本,包在事务里)、03_verify.sql(验证脚本)。每次遇到重复数据问题,先跑 01 确认,再跑 02 删除,最后跑 03 验证。三个文件都进版本控制,下次遇到类似问题直接改表名和字段名就能复用。
如果你经常需要针对不同表写去重脚本,可以把ROW_NUMBER那套模板存成一个 snippet,改PARTITION BY和ORDER BY就行。更进一步,你可以把表结构贴给 TaoToken 通道里的模型,让它根据你的表结构生成对应的三段式脚本,你只需要审查保留规则对不对。这样能把写脚本的时间从半小时压缩到五分钟。
对于需要长期做数据清洗的团队,建议把去重逻辑做成存储过程,加上参数(表名、分区字段、排序字段),这样非技术同学也能通过传参调用。存储过程里记得加TRY...CATCH和事务回滚,避免删到一半失败留下烂摊子。
最后提醒一句:去重之前一定一定先备份。SELECT * INTO OrderDetails_Backup_20250101 FROM OrderDetails这行 SQL 花不了几秒钟,但能在你删错的时候救你一命。我见过太多人自信满满地直接 DELETE,然后对着空表发呆。备份不是胆小,是专业。