症状
矛盾在看清交错那一刻消散: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]';