注册接口偶尔返回"用户已存在",但数据库里查不到这个手机号。因为流程是"先查是否存在,再插入":两个并发请求同时查到"不存在",然后都插入——一个成功,一个撞上唯一索引。而前端把这个约束错误直接展示成了"用户已存在"。
这个错位还有一层:唯一索引的报错是"技术结论",而用户需要的是"业务结论"。前者说"这个值重复了",后者要区分"你手抖了"与"这个号被别人占了"。
唯一索引是数据库的最终保障,但它保护的是数据,不是用户体验。把它的报错直接抛给用户,等于让用户替并发冲突买单。
1. 三种处理方式
| 方式 | 适用 |
|---|---|
| 先查后插 | 冲突率低、可接受竞态 |
| 直接插 + 捕获冲突 | 冲突常见、需要精确 |
| 插入或更新(upsert) | 幂等写入 |
这个窗口的宽度也不稳定:索引检查的耗时受并发与 IO 影响,高峰时窗口更宽。所以"低峰测不出来、高峰偶发"是这类问题的典型形态。
第一项的问题是查与插之间存在竞态窗口,而窗口大小取决于并发与索引检查耗时。冲突率低时窗口很少命中,于是它长期"看起来正确"。
第二项最可靠:让数据库做判断,应用只处理结果。代价是要能区分"约束冲突"与"其它错误",这需要看具体的错误码而不是错误消息。
-- 反例:先查后插,竞态窗口存在
SELECT 1 FROM users WHERE phone = ?; -- 不存在
INSERT INTO users(phone, ...) VALUES (?, ...); -- 并发下会撞唯一索引
-- 正例:直接插,交给索引判断
INSERT INTO users(phone, ...) VALUES (?, ...);
-- 冲突时返回 1062(MySQL)/ 23505(PostgreSQL),据此走"已存在"分支
2. 错误码与错误消息
| 数据库 | 唯一冲突标识 |
|---|---|
| MySQL | errno 1062 / SQLSTATE 23000 |
| PostgreSQL | SQLSTATE 23505 |
| Oracle | ORA-00001 |
| SQL Server | 错误 2601 / 2627 |
不要用错误消息匹配:消息文本随版本与语言变化,而错误码稳定。
第二项(直插+捕获)还有一个成本:异常的开销比正常返回高一个量级。所以在"冲突非常频繁"的场景(如导入去重),逐行插+捕获可能比"先批量查重再插"更慢。
用消息匹配的代码在升级或换字符集后可能突然失效——表现为"冲突变成了未处理异常"。
// 按错误码判断,而不是按消息文本
try {
userMapper.insert(u);
} catch (DuplicateKeyException e) {
var code = extractSqlState(e); // 23000 / 23505
if (isUniqueViolation(code)) {
return Result.exists("phone"); // 业务分支,不是异常
}
throw e; // 其它约束问题继续抛
}
3. "已存在"的语义要分清
| 情况 | 正确响应 |
|---|---|
| 用户重复提交(同一请求) | 幂等:返回成功 |
| 两个用户争同一标识 | 返回"已被占用" |
| 同一用户不同标识 | 允许(业务校验) |
| 唯一约束跨业务语义 | 需要复合键 |
第一项与第二项的差别很重要:重复提交应当返回成功(幂等),而争用应当返回冲突。两者都撞同一个索引,但用户看到的应该是完全不同的结果。
区分方式通常是"这次请求是否带幂等键"或"是否同一用户"。
第一项的正确实现要落到"返回什么":重复提交应返回首次的结果(而不是新建一个),这要求把首次结果存下来(幂等表或请求记录),否则只能返回"成功"而没有数据。
如果做不到区分,用户体验就会变成"手抖点了两次,第二次报错"。
4. upsert 的代价
| 写法 | 风险 |
|---|---|
INSERT ... ON DUPLICATE KEY UPDATE |
会更新不属于本次的数据 |
MERGE |
语法差异大、锁行为不同 |
| 先插后捕再更新 | 两次往返 |
第二项(MERGE)的另一个问题是锁行为因数据库而异,同一段逻辑在 MySQL 与 Oracle 上可能得到不同的并发表现,跨库迁移时容易出问题。
第一项最危险:它把"冲突"当成"应该更新",而冲突可能来自另一个用户。比如两个用户同时注册同一手机号,upsert 会让第二个覆盖第一个的手机/密码。
所以 upsert 只适用于"这个键本来就是我的"的场景,比如"更新我自己的配置"。
-- 危险:两个用户争同一手机号时,后者会覆盖前者
INSERT INTO users(phone, password_hash) VALUES (?, ?)
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash);
-- 安全:先插,冲突则走业务分支,不做覆盖
INSERT INTO users(phone, password_hash) VALUES (?, ?);
-- 冲突 → 返回"手机号已注册",由用户决定是否找回密码
5. 批量插入里的部分冲突
| 策略 | 效果 |
|---|---|
| 逐行插 + 收集冲突 | 精确,但慢 |
| 批量插 + 整体回滚 | 快,但一行坏全批失败 |
INSERT IGNORE |
静默丢弃,无法报告 |
| 批量 + 冲突行重查 | 折中 |
第二项的问题是"一行冲突导致整批失败",而业务上通常希望"成功的继续,失败的报出来"。
第四项(批量 + 冲突行重查)的实现要点:在插入前按唯一键分组,把可能冲突的行放到小批次里。这样"正常行"一次成功,"可疑行"单独处理,总体往返次数接近一次。
第三项最危险:INSERT IGNORE 会把所有错误都忽略,包括类型错误与超长,于是问题被静默吞掉。它适合"我完全不关心哪些失败"的场景,而这种场景其实很少。
// 批量的折中做法
1. 批量插入 1000 行
2. 若失败,二分定位冲突行(或按唯一键预先分组)
3. 冲突行单独处理,其余继续
// 代价:冲突少时接近一次往返;冲突多时退化
唯一索引还有一个"跨表"的边界:同一份业务标识存在两张表里(如 users 与 user_profile)时,唯一性只在各自表内成立。此时"查不到就插"需要在同一事务里写两张表,否则会出现"主表有、副表无"的半状态。
-- 两张表都要保证唯一:用同一事务,失败一起回滚
START TRANSACTION;
INSERT INTO users(phone) VALUES (?); -- 撞唯一 → 整体失败
INSERT INTO user_profile(user_id, ...) VALUES (LAST_INSERT_ID(), ...);
COMMIT;
-- 反例:两次独立提交 → 第一步成功第二步失败 → 用户存在但资料缺失
6. 排错:五种"唯一索引相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 偶发"已存在"但查不到 | 先查后插的竞态 | 改为直插+捕获 |
| 升级后冲突变未处理异常 | 按消息匹配错误 | 改按错误码 |
| 重复提交报错 | 未区分幂等与争用 | 加幂等键 |
| 用户数据被覆盖 | upsert 误用 | 改先插后分支 |
| 批量导入丢数据 | INSERT IGNORE |
改逐行或二分 |
第四项是最严重的一类:它是数据损坏,而不只是报错。
第二项(升级后变未处理异常)还有一个变体:换了连接池或 ORM 之后异常类型变了(如从 SQLIntegrityConstraintViolationException 变成 DuplicateKeyException),而代码只捕获了旧类型。
而它通常在上线一段时间后才被发现,因为需要两个用户同时争同一个标识。
7. 边界:什么情况先查后插可以接受
| 场景 | 为什么 | 做法 |
|---|---|---|
| 冲突率极低(万分之一) | 竞态几乎不发生 | 先查后插 + 兜底捕获 |
| 单写者(同一队列) | 无并发 | 先查后插 |
| 内部导入工具 | 有人盯着 | 先查后插 |
| 已有事务与锁保护 | 串行化 | 先查后插 |
最后一条与"索引设计"有关的边界:唯一索引的选择会决定冲突的语义。(tenant_id, phone) 与 phone 是两个不同的约束——前者允许不同租户用同一手机号,后者不允许。选错了不是性能问题,是业务规则错误。
判断口径一句话:"查不到就插"这条路径会不会被两个请求同时走到。会,就必须靠唯一索引兜底并处理冲突;不会,先查后插更简单也更易读。
8. 自测
- 先查后插的竞态窗口由什么决定?为什么冲突率低时它长期"看起来正确"?
- 为什么不能用错误消息匹配唯一冲突?升级后会发生什么?
- "重复提交"与"两个用户争同一标识"撞的是同一个索引,为什么响应必须不同?
ON DUPLICATE KEY UPDATE在什么场景下会造成数据覆盖?为什么它只适合"这个键本来就是我的"?INSERT IGNORE为什么比"整批失败"更危险?- 判断"先查后插能否接受"的那个问题是什么?