最稳妥的跨数据库幂等插入方案是insert … select + not exists,它不依赖特定方言、不触发唯一键异常;需确保子查询中显式关联待插字段,避免漏写关联条件导致逻辑错误。

EXISTS 子查询为什么不能直接保证幂等插入
因为 EXISTS 本身只是布尔判断,不阻塞并发写入;在高并发下,两个事务可能同时通过 EXISTS 检查(都看到“不存在”),然后都执行 INSERT,最终违反唯一约束或产生重复数据。单纯靠 SELECT ... EXISTS + INSERT 两步是**非原子操作**,不是安全的幂等方案。
真正安全的做法:用 INSERT ... SELECT + NOT EXISTS 组合
必须把检查和插入合并为一条原子语句,数据库才能加锁并保证一致性。核心是让 INSERT 的源数据来自一个带 NOT EXISTS 的子查询,且该子查询与目标表存在关联条件。
- 确保目标表有唯一索引(如
UNIQUE (user_id, event_type)),否则无法防止重复 INSERT INTO t1 (...) SELECT ... FROM (VALUES (...)) AS v(...) WHERE NOT EXISTS (SELECT 1 FROM t1 WHERE t1.user_id = v.user_id AND t1.event_type = v.event_type)- PostgreSQL 支持
INSERT ... ON CONFLICT DO NOTHING,更简洁;MySQL 则需依赖INSERT IGNORE或INSERT ... ON DUPLICATE KEY UPDATE ...,但后者会触发更新逻辑,不算纯幂等插入 - SQL Server 可用
MERGE,但要注意MERGE在某些版本中存在并发死锁风险,需加HOLDLOCK提示
容易被忽略的隔离级别陷阱
即使用了 INSERT ... SELECT + NOT EXISTS,在 READ COMMITTED 隔离级别下仍可能因“不可重复读”导致误判——另一个事务在检查后、插入前提交了同一条记录,当前事务却看不到它(因未提交时不可见,而自己又没加锁)。解决方法:
- PostgreSQL:子查询中显式加
FOR UPDATE锁住相关行(但需谨慎,避免锁范围过大) - MySQL(InnoDB):在
NOT EXISTS子查询里引用目标表时,会自动在匹配的唯一索引上加INSERT intention lock,但前提是 WHERE 条件能命中唯一索引;否则可能升级为间隙锁,影响并发 - 务必在事务中执行整条语句,并确认应用层没有意外的自动提交(例如 ORM 默认开启 autocommit)
别把 EXISTS 当 UPSERT 用
看到 “如果存在就跳过” 就下意识写 IF EXISTS(...) INSERT,这是典型误区——几乎所有 SQL 方言都不支持在普通 DML 中嵌套这种控制流(除 T-SQL 的存储过程体外)。即使语法允许,也逃不开竞态问题。
真正可落地的方案只有三类:INSERT ... ON CONFLICT(PG)、INSERT IGNORE(MySQL)、或标准 SQL 的 INSERT ... SELECT + NOT EXISTS。其余都是表面正确、实际脆弱的写法。
最常出问题的地方,其实是唯一约束没建对:比如只对 id 加了主键,但业务要求的是 (tenant_id, external_ref) 不可重复——这时候再怎么写 EXISTS 都没用,数据库根本不会拦截重复。










