1. 面试官为什么总爱问“重复记录怎么删”
SQL 面试里有一类题几乎逢考必出:给你一张用户表,里面有几条姓名和手机号完全一样的记录,让你先查出来,再删掉多余的,只保留一条。看起来简单,但真正写起来,很多人会卡在“保留哪一条”“怎么保证不误删”“删完怎么验证”这几个点上。
这个问题的核心检索词就是SQL 查询重复记录和SQL 删除重复记录。它考的不是你会不会写DELETE,而是你对GROUP BY、HAVING、窗口函数、子查询这几块的理解是否扎实。面试官想看到的是:你能不能先定位重复,再安全地删除,最后用行数对比证明结果正确。
我试过在真实项目里处理一张 200 万行的订单表,重复数据来自上游同步任务重跑,当时用ROW_NUMBER()配合 CTE 一次性清理干净,整个过程不到 3 秒。所以这类题不是纸上谈兵,它是实打实的生产技能。
先明确一个概念:重复记录分两种。第一种是完全重复,所有字段的值都一模一样;第二种是关键字段重复,比如name相同但age不同,业务上认为这算重复,需要保留一条。两种情况的处理方式不一样,面试时最好主动区分,这样能加分。
下面我用一张具体的表来演示。假设表名是users,结构如下:
CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(50), phone VARCHAR(20), city VARCHAR(50) ); INSERT INTO users VALUES (1, '张三', '13800001111', '北京'), (2, '张三', '13800001111', '北京'), (3, '李四', '13900002222', '上海'), (4, '李四', '13900002222', '上海'), (5, '王五', '13700003333', '广州'), (6, '张三', '13800001111', '北京');这张表里,id=1,2,6是完全重复的,id=3,4也是完全重复的。接下来所有操作都基于这张表展开。
2. 用 GROUP BY + HAVING 定位重复记录
2.1 查询完全重复的记录
第一步永远是“先查再删”。直接删是危险的,你得先看清楚有多少重复、重复在哪。用GROUP BY加HAVING是最直观的方式:
SELECT name, phone, city, COUNT(*) AS cnt FROM users GROUP BY name, phone, city HAVING COUNT(*) > 1;执行结果:
| name | phone | city | cnt |
|---|---|---|---|
| 张三 | 13800001111 | 北京 | 3 |
| 李四 | 13900002222 | 上海 | 2 |
这里GROUP BY把所有字段都列上,HAVING COUNT(*) > 1筛出出现次数大于 1 的组合。注意WHERE不能替代HAVING,因为聚合函数的结果必须在分组之后才能过滤,这是面试常问的细节。
2.2 查询关键字段重复的记录
如果业务只关心name重复,不管其他字段,那就只对name分组:
SELECT name, COUNT(*) AS cnt FROM users GROUP BY name HAVING COUNT(*) > 1;结果会显示张三 3 条、李四 2 条。这时候你拿到的是“哪些 name 重复了”,但还没拿到具体是哪几行。要拿到完整行,需要把子查询套回去:
SELECT * FROM users WHERE name IN ( SELECT name FROM users GROUP BY name HAVING COUNT(*) > 1 ) ORDER BY name, id;这样就能看到所有重复 name 对应的完整记录,方便你决定保留哪一条。面试时如果只写前半段,面试官可能会追问“那具体是哪几行重复”,所以这一步最好主动补上。
2.3 用窗口函数标记重复行
GROUP BY适合“查有多少”,但如果你想知道“每一行是不是重复的”,窗口函数更合适:
SELECT *, ROW_NUMBER() OVER (PARTITION BY name, phone, city ORDER BY id) AS rn FROM users;结果里rn=1的是每组的第一条,rn>1的就是多余记录。这个rn字段是后面删除操作的关键。PARTITION BY后面跟的就是你判断重复的依据字段,ORDER BY id决定保留哪一条——这里保留 id 最小的。
3. 可复制的去重配置与删除语句
3.1 删除完全重复、保留最小 id
最稳妥的写法是用 CTE 加ROW_NUMBER(),先标记再删除:
WITH ranked AS ( SELECT id, ROW_NUMBER() OVER (PARTITION BY name, phone, city ORDER BY id) AS rn FROM users ) DELETE FROM users WHERE id IN (SELECT id FROM ranked WHERE rn > 1);这条语句的逻辑是:按name, phone, city分组,组内按id升序编号,编号大于 1 的就是要删的。执行后id=2,4,6会被删除,剩下id=1,3,5。
如果你用的是 MySQL 5.7 以下版本,不支持 CTE,可以改成自连接:
DELETE u1 FROM users u1 JOIN users u2 ON u1.name = u2.name AND u1.phone = u2.phone AND u1.city = u2.city AND u1.id > u2.id;这条语句的意思是:只要存在一条 id 更小的相同记录,当前这条就删掉。效果和上面一致。
3.2 删除关键字段重复、保留最小 id
如果只按name去重,把PARTITION BY改成name即可:
WITH ranked AS ( SELECT id, ROW_NUMBER() OVER (PARTITION BY name ORDER BY id) AS rn FROM users ) DELETE FROM users WHERE id IN (SELECT id FROM ranked WHERE rn > 1);执行后每个 name 只保留 id 最小的那条,张三保留 id=1,李四保留 id=3,王五保留 id=5。
3.3 在 TaoToken 通道下让模型帮你生成和校验 SQL
写 SQL 的时候,尤其是窗口函数嵌套删除这种,很容易漏掉边界条件。我习惯用 TaoToken 的统一 API 通道调模型来帮我生成初稿,再自己审一遍。配置很简单,三件套是 Base URL、API Key、Model ID。
如果你用的是 Cline 这类支持 MCP 的编辑器插件,配置片段如下:
{ "mcpServers": { "taotoken": { "url": "https://taotoken.net/api", "headers": { "Authorization": "Bearer 你的API_KEY" }, "model": "claude-sonnet-4-20250514" } } }如果你用的是 Claude Code,可以在项目根目录的.claude/settings.json里写:
{ "apiBaseUrl": "https://taotoken.net/api", "apiKey": "你的API_KEY", "model": "claude-sonnet-4-20250514" }Codex 用户则在~/.codex/auth.json里配置:
{ "base_url": "https://taotoken.net/api", "api_key": "你的API_KEY", "model": "claude-sonnet-4-20250514" }配好之后,你可以直接把表结构和需求丢给模型:“这张表按 name 去重,保留 id 最小的,给我一条 PostgreSQL 的 DELETE 语句,并解释为什么不能用 GROUP BY 直接删。”模型会给你一条带 CTE 的语句,还会提醒你DELETE不能直接跟聚合函数。拿到之后你在测试库跑一遍,确认行数变化符合预期,再上生产。
API Key 在控制台的 API Keys 页面生成,模型对话入口可以先用模型对话页面快速验证通道是否通。
4. 验证请求与执行前后行数对比
4.1 执行前先记录行数
删除之前一定要先查总数,这是验证的基础:
SELECT COUNT(*) AS total_before FROM users;假设结果是 6。删除之后再查一次:
SELECT COUNT(*) AS total_after FROM users;如果结果是 3,说明删掉了 3 条重复记录,符合预期。这个前后对比是面试时展示你严谨性的好机会,很多人只写删除语句,忘了验证。
4.2 用查询确认剩余记录
光看行数还不够,还要确认剩下的是不是每组最小 id:
SELECT * FROM users ORDER BY id;预期结果:
| id | name | phone | city |
|---|---|---|---|
| 1 | 张三 | 13800001111 | 北京 |
| 3 | 李四 | 13900002222 | 上海 |
| 5 | 王五 | 13700003333 | 广州 |
如果结果和这个一致,说明去重成功。如果发现某个 name 一条都没了,那说明PARTITION BY或ORDER BY写错了,需要回滚重来。
4.3 用模型辅助校验 SQL 逻辑
有时候你自己写的 SQL 跑出来结果对,但逻辑上可能有隐患。比如ROW_NUMBER()的ORDER BY如果用了非唯一字段,可能导致每次执行保留的行不一样。这时候可以让模型帮你审一遍:
请检查这条 SQL 是否存在不确定性:
ROW_NUMBER() OVER (PARTITION BY name ORDER BY id),如果 id 有重复会怎样?
模型会告诉你,如果id有重复,ROW_NUMBER()的结果可能不稳定,建议加一个唯一字段做 tie-breaker。这种校验在面试里也能体现你的深度。
5. 本篇常见报错与排查
5.1 报错:You can't specify target table for update in FROM clause
这是 MySQL 的经典报错。你写:
DELETE FROM users WHERE id NOT IN ( SELECT MIN(id) FROM users GROUP BY name );MySQL 不允许在DELETE的子查询里直接引用被删的表。解决办法是套一层派生表:
DELETE FROM users WHERE id NOT IN ( SELECT id FROM ( SELECT MIN(id) AS id FROM users GROUP BY name ) AS tmp );或者直接用 CTE 加ROW_NUMBER(),绕开这个问题。
5.2 报错:local proxy failed 或 401
如果你在 TaoToken 通道下调模型生成 SQL 时报local proxy failed,先检查 Base URL 是不是写成了https://taotoken.net/api,有没有多写斜杠或漏掉/api。报401则是 API Key 无效或过期,去控制台的 API Keys 页面重新生成一个,替换配置里的 Key。注意 Key 不要提交到 Git,用环境变量或本地配置文件。
5.3 报错:reading choices 时解析失败
这个通常出现在流式响应里,模型返回的 JSON 结构和你客户端预期的不一致。检查你的 Model ID 是否写对,比如claude-sonnet-4-20250514不要写成claude-sonnet-4。如果用的是 Cline,确认 MCP 配置里的model字段和实际调用的模型一致。改完配置后重启编辑器插件再试。
5.4 删除后行数没变
如果DELETE执行了但行数没变,先看是不是没有COMMIT。有些客户端默认手动提交,你需要显式执行COMMIT;。另外检查WHERE条件是不是写成了rn = 1,那样删的是要保留的行,结果会反过来。执行前先用SELECT把要删的 id 列出来,确认无误再改成DELETE。
5.5 OAuth 相关报错
如果你在 Claude Code 里配置了 TaoToken 但仍然走 OAuth 流程报错,检查settings.json里是否同时存在旧的 OAuth 配置。把冲突的字段删掉,只保留apiBaseUrl、apiKey、model三项。保存后重启 Claude Code,让它重新读取配置。
6. 把去重能力接到你的日常开发流里
面试题只是入口,真正有价值的是把“查重—去重—验证”这套流程固化下来。我的做法是:在 TaoToken 的 Coding Plan 里建一个常用提示词模板,每次遇到重复数据问题,直接调模型生成 SQL 初稿,然后自己在测试库跑一遍行数对比。这样既快又不容易出错。
如果你经常处理数据清洗,建议把ROW_NUMBER()这套写法记牢,它比GROUP BY加自连接更直观,也更容易扩展到“保留最新一条”“保留金额最大一条”这类变体。需要长期做编码和 Agent 任务的话,Coding Plan 的额度比按次调用更划算。
最后留一个实用技巧:删除前先CREATE TABLE users_backup AS SELECT * FROM users;,万一删错了还能恢复。这个习惯在面试里说出来,面试官会觉得你有生产意识。