我为了防止插入重复数据,代码里先查一下存不存在、不存在才插入,单机测试一切正常,可一上并发就冒出了重复记录,因为先检查再插入这两步之间有个窗口、并发请求全挤了进来的深度复盘

我有个场景要防止插入重复(同一用户名只能注册一个、同一订单号只能下一单),写得很合理:先 SELECT 查这条记录在不在、不在才 INSERT。单机低并发测试一切正常、防重也生效。可一上线并发一高就冒出重复记录:数据库里出现两条一模一样的记录,先查再插形同虚设。复盘才想明白:先检查(SELECT)再行动(INSERT)这个 check-then-act 模式在并发下根本不是原子的——两步之间有时间窗口;并发下两个请求几乎同时执行 SELECT 都查到不存在(此刻谁都还没插),于是都执行 INSERT,插入两条重复。查的时候不存在和插的时候依然不存在是两回事,窗口期里别的请求已经插进来了,而我还拿着过期的不存在的判断在行动。真正能防重的不是应用层先查再插,而是数据库唯一索引(原子保证)+ 处理插入冲突。这篇复盘从故障现场讲到 check-then-act 竞态(TOCTOU)、为何应用层先查再插防不住、唯一约束原子保证,再到加唯一索引、直接插捕获冲突、upsert、防超扣用原子 UPDATE...WHERE 的完整正解,以及其他检查与行动之间状态变了的坑,和并发下检查与行动之间有窗口、观察结果到行动时可能已过期、要把检查与行动原子化或交给底层约束裁决的认知。

我为了防止插入重复数据,代码里先查一下"存不存在"、不存在才插入,单机测试一切正常,可一上并发就冒出了重复记录,因为"先检查再插入"这两步之间有个窗口、并发请求全挤了进来:一次 check-then-act 竞态、误以为"先查再插"能防重的深度复盘

那批"明明做了防重、却还是冒出重复数据"的脏数据,是我"先查再插"的写法在并发下漏出来的。我有个场景要防止插入重复(比如同一用户名只能注册一个、同一订单号只能下一单),写得"很合理":先 SELECT 查这条记录在不在 → 不在,才 INSERT。单机、低并发测试时,一切正常,防重也生效。可一上线、并发一高,就冒出了重复记录:数据库里出现了两条一模一样的记录(同一个用户名两个账号、同一个订单号两条订单),我的"先查再插"形同虚设。复盘这个 bug,我才彻底想明白,后背发凉:问题出在"先检查(SELECT)再行动(INSERT)"这个 check-then-act 模式,在并发下根本不是原子的我的逻辑分两步:第一步查"存不存在",第二步根据查的结果决定插不插;可这两步之间有一个时间窗口;并发下,两个请求几乎同时执行第一步 SELECT,都查到"不存在"(因为此刻谁都还没插入);于是它俩都认为"该插",都执行了第二步 INSERT——结果插入了两条重复记录;"查的时候不存在"和"插的时候依然不存在"是两回事:在这两步的窗口期里,别的请求已经插进来了,而我还拿着那个过期的"不存在"的判断在行动;真正能防重的,不是"应用层先查再插"(它防不住并发),而是在数据库层面加唯一索引(让数据库保证"这个值只能有一条"),并处理好"插入时撞上唯一约束"的冲突。根本原因是:"先 SELECT 查不存在、再 INSERT"是 check-then-act,检查和行动之间有窗口、不原子;并发请求同时检查都得到"不存在"、都执行插入,产生重复;防重应靠数据库唯一索引(原子保证)+ 处理冲突,而非应用层先查再插。问题的根,是 check-then-act 竞态——"先查再插"两步间有窗口,并发下都查到不存在都插入导致重复;防重要靠唯一索引而非应用层先查再插。这篇就把这次"唯一索引并发插入"的坑,从头到尾复盘一遍。

故障现场:先查再插,并发下查到的"不存在"过期了

问题在于 check-then-act 的检查与行动之间有窗口:

// 我的写法: 先查存不存在, 不存在才插(check-then-act)
public void register(String username) {
    User existing = userMapper.findByUsername(username);   // 第一步: 检查
    if (existing == null) {                                 // 检查结果: 不存在
        userMapper.insert(new User(username));              // 第二步: 行动(插入)
    }
}

/*
单机/低并发: 没问题, 查到不存在就插, 防重生效。
并发下重复是怎么发生的(check-then-act竞态):
  时刻T1: 请求A 执行 findByUsername("alice") → 不存在;
  时刻T2: 请求B 执行 findByUsername("alice") → 也不存在(此刻A还没插!);
  时刻T3: 请求A insert("alice")  → 成功;
  时刻T4: 请求B insert("alice")  → 又成功! → 两条 "alice"! 重复!

  根本问题:
  - "检查(SELECT)"和"行动(INSERT)"是【两步】, 中间有【时间窗口】;
  - 在这个窗口里, 其他请求可能已经改变了状态(插入了同样的数据);
  - 而你还拿着【检查那一刻的、可能已过期的结论】("不存在")去行动 → 出错;
  - 这就是经典的 check-then-act(检查后行动)竞态 / TOCTOU(检查时间≠使用时间)。

  为什么应用层"先查再插"防不住:
  - 它假设"查完到插完"这段时间状态不变, 但并发下这个假设不成立;
  - 加事务也不一定够(取决于隔离级别, 默认RR也可能因间隙锁/快照读有微妙问题);
  - 真正可靠的"唯一性", 必须由【数据库的唯一约束】在写入那一刻原子地保证。

★ 核心: "先检查再行动"两步之间有窗口、并发下不原子, 检查的结论会过期;
  防重不能靠应用层"先查再插"(防不住并发), 要靠【数据库唯一索引】原子保证唯一 + 处理插入冲突。

看着数据库里那两条一模一样的 "alice",我又懊恼又恍然:"我以为'先查一下、不存在才插'天经地义、滴水不漏……谁知道'查的时候不存在'根本不代表'插的时候还不存在',并发的两个请求都在那个空当里查到了'不存在',然后双双插了进去。"这个坑最隐蔽的地方在于:在单机、低并发、测试环境下完全正常(请求一个一个来,查完插完没人插队),给你"防重有效"的错觉;它只在"并发"——两个请求恰好挤在那个窗口期"时才暴露;而且重复数据不报错(两条 insert 都成功了),要等对账或下游出问题才发现。下面就来拆解,防重到底该怎么做。

第一件事:搞懂 check-then-act 竞态与唯一约束

我顺着这次事故,把并发防重的正确做法彻底理清了。

为什么"先查再插"防不住重复? 该怎么防?

【核心: "先检查再行动(check-then-act)"两步间有窗口、并发下不原子、检查结论会过期; 并发都查到不存在都插
   入→重复; 防重靠数据库【唯一索引】原子保证 + 处理插入冲突(ON DUPLICATE/捕获异常), 而非应用层先查再插】

1. check-then-act 竞态(问题的本质):
   - 模式: 先"检查一个条件"(查存不存在), 再"根据条件行动"(插入);
   - 问题: 检查和行动是两步, 中间有时间窗口; 并发下, 窗口期内别的请求可能已改变状态;
   - 结果: 你拿着"检查那一刻、已过期的结论"去行动 → 出错(都查到不存在→都插→重复);
   - 别名: TOCTOU(Time-Of-Check to Time-Of-Use, 检查时间≠使用时间)。

2. 为什么应用层"先查再插"防不住并发:
   - 它隐含假设"查完到插完, 状态不变"——并发下这个假设不成立;
   - 多个请求可以同时跑到"检查"那一步、都得到"不存在";
   - 加应用层锁? 单机锁挡不住多实例; 分布式锁可以但复杂且有自己的坑(同562)。

3. 真正可靠的防重: 让数据库的【唯一约束】原子保证
   - 给字段加唯一索引: ALTER TABLE users ADD UNIQUE KEY uk_username(username);
   - 这样: 谁先插成功, 后插的会因唯一约束冲突而失败(数据库在写入那一刻原子判定);
   - 不再依赖"先查"——直接插, 让数据库来保证唯一, 重复的自然被拦下。

4. 处理"插入冲突"的几种方式:
   ① 直接 INSERT, 捕获唯一约束冲突异常(DuplicateKeyException), 按"已存在"处理;
   ② INSERT ... ON DUPLICATE KEY UPDATE(MySQL): 冲突就更新(upsert);
   ③ INSERT IGNORE(MySQL): 冲突就忽略(慎用, 会吞其他错误);
   ④ INSERT ... ON CONFLICT(PostgreSQL): 灵活处理冲突;
   - 选哪种看业务: 要报错/要幂等更新/要忽略, 但底层都靠唯一索引兜底。

5. 一般化: check-then-act 的其他场景也一样
   - 扣库存"先查够不够再扣"、转账"先查余额够不够再扣"——并发下都有同样的窗口竞态;
   - 正解: 用原子操作(UPDATE ... WHERE qty>=n)、乐观锁(版本号)、或数据库约束, 而非"先查再改"。

一句话: "先检查再行动"两步间有窗口、并发下不原子、检查结论会过期, 导致并发都查到不存在都插入而重复;
   防重靠数据库唯一索引原子保证唯一 + 处理插入冲突, 别靠应用层"先查再插"; 一切check-then-act并发下都要警惕。

这套认知,是整个坑的根。check-then-act 竞态:先检查条件再据此行动,两步间有窗口,并发下窗口期内别的请求已改变状态,你拿着过期的结论行动就出错(TOCTOU)为什么先查再插防不住:它假设"查完到插完状态不变",并发下不成立;多个请求可同时查到"不存在";单机锁挡不住多实例真正的防重:给字段加唯一索引,让数据库在写入那一刻原子判定唯一,直接插、让重复的自然冲突失败处理冲突:捕获唯一约束异常按已存在处理、ON DUPLICATE KEY UPDATE、INSERT IGNORE、ON CONFLICT一般化:扣库存/转账"先查再改"也是同样的窗口竞态,正解用原子操作/乐观锁/约束一句话:"先检查再行动"两步间有窗口、并发下不原子、检查结论会过期,导致并发都查到不存在都插入而重复;防重靠数据库唯一索引原子保证唯一 + 处理插入冲突,别靠应用层"先查再插";一切 check-then-act 并发下都要警惕。

第二件事:正解——唯一索引兜底 + 处理插入冲突

知道了 check-then-act 防不住,正解就清楚了:靠数据库唯一索引原子保证,处理好冲突。

-- 正解1: 给字段加唯一索引(防重的根基, 本次缺的)
ALTER TABLE users ADD UNIQUE KEY uk_username (username);
-- 之后无论并发多少, 同一个 username 数据库只会保留一条, 重复插入直接冲突。
// 正解2: 直接插, 捕获唯一约束冲突异常(最通用)
public void register(String username) {
    try {
        userMapper.insert(new User(username));     // 直接插, 不先查
    } catch (DuplicateKeyException e) {             // 撞唯一索引 → 说明已存在
        // 按业务处理: 提示"用户名已被注册"、或当幂等成功、或忽略
        throw new BusinessException("用户名已存在");
    }
}
// 不再"先查再插"; 让数据库唯一索引在写入那一刻原子地判重, 重复的会冲突, 我们捕获处理。

// 正解3: upsert —— 冲突就更新(幂等场景, 同586)
// MySQL:
//   INSERT INTO t(k, v) VALUES(?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v);
// PostgreSQL:
//   INSERT INTO t(k, v) VALUES(?, ?) ON CONFLICT(k) DO UPDATE SET v = EXCLUDED.v;

// 正解4: 其他 check-then-act 场景也用"原子操作"而非"先查再改"
// ✗ 扣库存: 先查qty够不够, 再 update qty=qty-n (并发超扣, 同558)
// ✓ 原子操作: UPDATE stock SET qty=qty-n WHERE sku=? AND qty>=n;  (影响0行=库存不足)
//   把"检查(qty>=n)"和"行动(扣减)"合并成一条原子SQL, 没有中间窗口。

// 正解5: 配合幂等键(同586)——分布式/消息重复也用唯一索引去重
//   给"业务幂等键"加唯一索引, 重复请求/消息插入去重表时冲突, 天然防重。

// 反例(别这样):
// if (findByX() == null) { insert(); }     // ✗ check-then-act, 并发下重复
// if (stock >= n) { update(qty - n); }      // ✗ 同样的窗口竞态, 并发超扣

// 核心: 防重/防超扣靠"数据库原子保证"——唯一索引(防重复插入)、原子UPDATE...WHERE(防超扣),
//   并处理冲突(捕获异常/upsert); 别用应用层"先查再行动"这种有窗口的check-then-act。

这套正解的关键,是把"唯一性/条件"的保证,从"应用层分两步的检查+行动"下沉到"数据库的原子约束/原子操作"加唯一索引:防重的根基,让数据库在写入那一刻原子判重——这正是本次缺的。直接插 + 捕获冲突异常:不先查,直接插,撞唯一约束就按"已存在"处理,最通用。upsert:冲突就更新(幂等场景,同 586)。其他 check-then-act 用原子操作:扣库存用 UPDATE ... WHERE qty>=n 把检查和行动合并成一条原子 SQL、没有中间窗口(防超扣,同 558)。核心是:靠数据库的原子保证,别靠应用层有窗口的"先查再行动"。

第三件事:其他几个"检查与行动之间状态变了"的坑

顺着这次 check-then-act,我把"检查后行动、中间状态已变"的几类坑也一并理了:

几类"检查与行动之间状态变了"的坑(都是 check-then-act / TOCTOU):

坑1: 先查库存够再扣(超扣, 同558)——并发都查到够、都扣, 扣成负数;
   正解: 原子 UPDATE ... WHERE qty>=n, 看影响行数。

坑2: 先查余额够再转账——并发都查到够、都转, 透支;
   正解: 原子扣减 + 约束(余额>=0)或乐观锁。

坑3: 文件操作"先检查存在/权限再操作"(经典TOCTOU安全/正确性问题)——检查后文件被换/删;
   正解: 直接操作并处理异常(open再catch), 别先stat再open。

坑4: 分布式锁"先查没人持有再加锁"——非原子; 正解: 用原子的 SETNX(set if not exists, 同562)。

坑5: "先判断key不存在再put"到map/缓存——并发下两个都put;
   正解: 用原子的 putIfAbsent / SETNX。

坑6: 状态机"先查当前状态是X再改成Y"——并发下状态已被改;
   正解: 带条件更新 UPDATE ... WHERE status=X(CAS), 看影响行数(同586状态机)。

共同的根: "先检查一个条件, 再基于这个条件去行动"——检查和行动是两步, 中间隔着时间;
   在并发/共享环境下, 这段时间里条件可能已经被别人改变, 而你还拿着"过期的检查结论"在行动;
   正解是把"检查+行动"做成【一个原子操作】(原子SQL、CAS、唯一约束、SETNX、putIfAbsent),
   或靠底层的【原子约束】兜底, 消除那个危险的中间窗口。

这些坑看似不同,根却是同一个:"先检查一个条件,再基于这个条件去行动"——检查和行动是两步、中间隔着时间;并发/共享环境下,这段时间里条件可能已被别人改变,而你还拿着过期的检查结论在行动正解都是:把"检查+行动"做成一个原子操作(原子 SQL、CAS、唯一约束、SETNX),消除那个危险的中间窗口。认清这个根("并发下别 check-then-act,要原子化"),就能避开一大类并发竞态。

第四件事:防重做法对比 / 原子化方案——两张对照表

我把"先查再插"和"唯一索引"的对比、以及各种 check-then-act 的原子化方案,整理成对照表,贴在了团队的 DB 规范里:

维度 应用层先查再插 数据库唯一索引
单机/低并发 看似有效 有效
高并发 失效,产生重复 有效,原子保证
多实例部署 更挡不住 不受影响(库层统一)
是否原子 否(两步有窗口) 是(写入那刻判定)
重复时 静默插入两条 冲突,可捕获处理
可靠性 不可靠 可靠
check-then-act 场景 原子化正解
防重复插入 唯一索引 + 捕获冲突/upsert
防超扣库存 UPDATE...WHERE qty>=n,看影响行数
防透支转账 原子扣减 + 余额约束/乐观锁
状态流转 UPDATE...WHERE status=X(CAS)
并发占位/加锁 SETNX / putIfAbsent
消息/请求去重 幂等键唯一索引(同 586)

这两张表的核心,第一张是应用层"先查再插"只在低并发/单机碰巧有效,高并发、多实例下必然失效;唯一索引才是原子、可靠的防重;第二张是一切 check-then-act 的正解,都是"把检查和行动合并成一个原子操作"(原子 SQL / CAS / 唯一约束 / SETNX)。记住一条:并发下要保证唯一性/条件,靠"数据库原子约束/操作",别靠"应用层先查再做"。

第五件事:关于并发防重的几组容易想当然的认知

这次事故也让我厘清了几组关于并发防重的、容易想当然的概念:

直觉以为 实际上
先查不存在再插就能防重 并发下两个都查到不存在、都插,重复
查的时候不存在=插的时候不存在 两步之间有窗口,状态可能已变
测试没重复说明防重有效 测试低并发碰巧没撞上窗口
加个事务就解决了 不一定,要靠唯一约束才原子可靠
应用层加锁就够 单机锁挡不住多实例,分布式锁另有坑
唯一索引是可选优化 它是并发防重的根基,不是可选
先查够再扣库存能防超扣 同样 check-then-act,并发会超扣

这张表里,我栽的是第一行和第二行:把"先查不存在再插"当成了可靠的防重,以为"查的时候不存在"就等于"插的时候还不存在",没意识到这两步之间的窗口会被并发请求挤进来厘清这些,核心是一个意识:在并发/共享环境下,"先检查一个条件、再基于它行动"这种分两步的做法是不可靠的——因为检查得到的结论,到行动时可能已经过期;要保证唯一性、条件这类约束,必须靠"数据库唯一索引、原子操作"在那一刻原子地保证,而不是靠应用层把检查和行动分开来做。

第六件事:写并发防重 / 条件操作时,我现在的自检习惯

现在每当我要写"防重复、防超量、按条件操作"的逻辑,我都会先按这张图问自己:

这张图的精髓,是"先查再做+并发=危险,改成原子:防重用唯一索引、防超量用 UPDATE...WHERE、占位用 SETNX/CAS"先问是不是 check-then-act会不会并发、会就把检查和行动合并成原子操作这套习惯,让我从"先查再插防重"变成了"靠唯一索引和原子操作防重"——核心始终是:"先检查再行动"两步间有窗口、并发下不原子、检查结论会过期;防重靠数据库唯一索引 + 处理冲突,防超量靠原子 UPDATE...WHERE,别靠应用层先查再做。

我立下的几条规矩

这场"先查再插、并发下冒出重复"的事故,换来了我写并发逻辑时,刻进骨子里的几条铁律:

  1. "先 SELECT 查不存在、再 INSERT"是 check-then-act,检查和行动之间有窗口、并发下不原子。
  2. 并发下两个请求会同时查到"不存在"、都插入,产生重复——应用层先查再插防不住。
  3. 防重靠数据库唯一索引:让数据库在写入那一刻原子判重,重复的会冲突。
  4. 直接插 + 捕获唯一约束冲突异常(或 upsert / INSERT...ON DUPLICATE),按业务处理冲突。
  5. 防超扣用原子 UPDATE...WHERE qty>=n(看影响行数),别"先查够再扣"。
  6. 占位/状态流转用 SETNX/putIfAbsent/CAS(UPDATE...WHERE status=X),别"先查再改"。
  7. 测试没重复≠防重有效,可能只是没撞上并发窗口;一切 check-then-act 并发下都要原子化。

写在最后

回头看,这场由"先查再插、check-then-act 竞态"引发的、并发下冒出重复数据的事故,真正教给我的,远不止"防重要加唯一索引"这一个技巧。它让我对"'我先看一眼情况, 再根据看到的去做决定' 这个无比自然的两步动作, 在一个'别人也在同时行动、情况随时会变'的环境里, 暗藏着一个致命的假设:'我看的那一刻'和'我做的那一刻'之间, 情况没变; 而这个假设, 恰恰是并发世界里最不成立的",有了一次刻骨的体会。我栽跟头,是因为我把"我检查时看到的情况"当成了"我行动时依然成立的情况"——我查的时候"不存在", 我就笃定地以为"插的时候也还不存在";可在"我查完"到"我插完"这短短的一瞬里, 另一个和我一样的请求, 也查到了"不存在"、也插了进来——我的"检查"和"行动"之间, 被别人插了队;我基于一个"已经过期了的快照"做了行动, 而我浑然不觉这让我领悟到一个关于"观察与行动、瞬间与变化"的深刻认知:在任何"多方同时行动、状态共享且随时在变"的环境里, "检查(观察情况)"和"行动(基于情况做事)"如果是分开的两步, 它们之间就有一道缝隙; 而在这道缝隙里, 世界可能已经变了, 你赖以决策的'观察结果'可能已经作废;所以"看一眼再做"在静态、独占的环境里没问题, 在动态、共享的环境里却是不可靠的——你以为的"当前情况", 在你动手时早已不是当前;可靠的做法, 是让"检查和行动'合二为一、不可分割地'发生"(原子操作), 或者干脆不去'先看', 而是'直接做、让底层的规则在那一刻原子地裁决'(唯一约束)——把"判断"交给"行动发生的那一刻", 而非"行动之前的某个已过去的时刻"这给了我一种处理"共享、并发"时的清醒:每当我想"先查一下情况、再根据情况做事"时,要警觉地问"这件事会和别人同时发生吗?在我''和''之间, 情况会被别人改变吗?"——如果会, 就别相信"查到的那一刻的情况", 而要把"检查+行动"做成一个原子的整体, 或交给底层约束在行动那一刻裁决;"在并发共享环境里, 不依赖'已过去的观察', 而靠'原子的检查即行动'",是避免一切 check-then-act 竞态的关键认清并发下检查与行动之间有窗口、观察结果到行动时可能已过期、要把检查与行动原子化或交给底层约束裁决——这,是我用一次唯一索引并发插入的事故,换来的、关于数据库并发、也关于如何在变化的世界里观察与行动的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想"先查一下再插"防重之前,顿一下、给字段加上唯一索引、改成"直接插再处理冲突",那我对着那两条一模一样的 "alice" 排查的这段时间,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我在 Java 的 finally 块里写了个 return 做收尾,结果 try 块里本该抛出的异常凭空消失了、本该返回的值也被悄悄换掉了,一个本该报错的方法竟然正常返回:一次 finally 里 return 吞掉了 try 结果的深度复盘

2026-6-3 1:59:27

技术教程

我的服务用连接池复用长连接调下游,平时好好的,却总在低峰期之后偶发 connection reset,排查发现是连接被对端的空闲超时悄悄关了、我的连接池却还留着这条已死的连接照样拿来用的深度复盘

2026-6-3 2:10:24

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索