← 中文资源

简体中文版本

PostgreSQL — 明明先查过仍报 duplicate key violates unique constraint

解释并发下"先查后插"仍报 duplicate key 的原因,用 ON CONFLICT 做原子 upsert,并正确处理序列化失败。

阅读英文原文

症状

  矛盾在看清交错那一刻消散:T1 与 T2 的存在性检查都在任一方插入提交之前执行,都读到 0 行,随后双双插入,唯一约束正确地拒绝了第二个提交。普通 SELECT 不获取任何能阻止其他写入者的锁——检查只是一次观察,不是一次预留。check-then-insert 模式本质上是对唯一约束的 TOCTOU 竞态,而约束存在的意义正是抓住它。重复率与“至少一次投递”(webhook 重投、队列重试、双击提交)同步上升是指纹:系统按投递语义正常工作,代码却在把设计内的重复当作损坏处理。

诊断思路

  用两个会话开事务再现交错:都查缺、都插入、先后提交,几秒内复现竞态;再审计所有“存在性检查 + 分离 INSERT”的调用点,每一处都是候选竞态;最后给重复意图分类——设计内重复(至少一次投递)的答案是幂等 upsert,不是更紧的锁。序列化失败后的重试若不重读当前状态,会亲手复刻刚提交的重复。

根因

  两个并发事务都能观察到“不存在”然后都执行插入;唯一约束随后正确地以 duplicate key violation 拒绝第二个提交。检查与插入分属两条语句且无协调时,竞态必然存在。

修复与验证

  用单条原子语句取代竞态:幂等插入用 ON CONFLICT (email) DO NOTHING 加空返回时的回读;覆盖是本意时用 DO UPDATE SET name = EXCLUDED.name(仅当更新真正幂等,否则 DO NOTHING 更贴合意图)。验证时并发测试同时提交 N 个相同逻辑实体,断言恰好一行且零未处理错误;webhook 重投测试确认幂等。

代码示例

T1: SELECT ... WHERE email = '[email protected]'  -> 0 rows
T2: SELECT ... WHERE email = '[email protected]'  -> 0 rows
T1: INSERT ...                           -> ok
T2: INSERT ...                           -> ERROR: duplicate key
-- idempotent insert:
INSERT INTO users (email, name)
VALUES ('[email protected]', 'Ada')
ON CONFLICT (email) DO NOTHING;

-- true upsert when overwrite is the intent:
INSERT INTO users (email, name)
VALUES ('[email protected]', 'Ada')
ON CONFLICT (email)
DO UPDATE SET name = EXCLUDED.name;
INSERT ... ON CONFLICT DO NOTHING RETURNING id;
-- empty return: SELECT id FROM users WHERE email = '[email protected]';