2472 字
约 8 分钟
18
S11-A24 · 唯一索引冲突:报错、重试还是先查
2024-12-31
2026-11-10

注册接口偶尔返回"用户已存在",但数据库里查不到这个手机号。因为流程是"先查是否存在,再插入":两个并发请求同时查到"不存在",然后都插入——一个成功,一个撞上唯一索引。而前端把这个约束错误直接展示成了"用户已存在"。

这个错位还有一层:唯一索引的报错是"技术结论",而用户需要的是"业务结论"。前者说"这个值重复了",后者要区分"你手抖了"与"这个号被别人占了"。

唯一索引是数据库的最终保障,但它保护的是数据,不是用户体验。把它的报错直接抛给用户,等于让用户替并发冲突买单。


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. 自测

  1. 先查后插的竞态窗口由什么决定?为什么冲突率低时它长期"看起来正确"?
  2. 为什么不能用错误消息匹配唯一冲突?升级后会发生什么?
  3. "重复提交"与"两个用户争同一标识"撞的是同一个索引,为什么响应必须不同?
  4. ON DUPLICATE KEY UPDATE 在什么场景下会造成数据覆盖?为什么它只适合"这个键本来就是我的"?
  5. INSERT IGNORE 为什么比"整批失败"更危险?
  6. 判断"先查后插能否接受"的那个问题是什么?
S11-A24 · 唯一索引冲突:报错、重试还是先查
https://www.clxhxhhr.top/posts/6485/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。