多线程数据库插入时如何避免重复值插入?
这个问题核心是竞态条件搞的鬼——两个线程同时检查同一主键的记录,都判定“不存在”后同时执行插入,直接触发主键重复错误。既然不想靠捕获异常来兜底,咱们可以从「数据库原子操作」「应用层同步锁」「事务隔离级别」这几个方向来解决,给你具体的方案:
1. 数据库层面:用原子化的插入语句(最推荐)
把“检查是否存在+插入”的逻辑合并成数据库端的原子操作,让数据库帮你处理并发冲突,这是最可靠且性能损耗最小的方案。
MySQL 示例:INSERT ... ON DUPLICATE KEY UPDATE
如果你的数据库是MySQL,可以用这个语法:
INSERT INTO team (id, name) VALUES (1249, '银河战队') ON DUPLICATE KEY UPDATE name = name; -- 重复时执行无意义的更新,或者按需调整逻辑
这条语句是数据库原子执行的,不管多少线程同时调用,只会有一条插入成功,其他的会执行UPDATE(部分MySQL版本支持DO NOTHING,可以直接跳过重复插入),完全不会抛出重复键异常。
SQL Server 示例:MERGE 语句
SQL Server可以用MERGE来实现同样的逻辑:
MERGE INTO team AS target USING (SELECT 1249 AS id, '银河战队' AS name) AS source ON target.id = source.id WHEN NOT MATCHED THEN INSERT (id, name) VALUES (source.id, source.name) WHEN MATCHED THEN UPDATE SET name = source.name; -- 或者什么都不做
同样是原子操作,彻底避免竞态条件。
2. 应用层面:加全局/细粒度锁
如果没法修改SQL语句,可以在应用层对需要操作的资源加锁,确保同一时间只有一个线程能执行“检查+插入”的逻辑。
全局锁(简单但性能一般)
定义一个静态的锁对象,在操作team记录时锁住:
// 全局锁对象,确保是静态且只读的 private static readonly object _teamLock = new object(); public void AddTeams() { lock(_teamLock) { // 检查team 1249是否存在 if (!IsTeamExist(1249)) { InsertTeam(1249); } // 插入关联记录 InsertTeamRelatedData(1249); } }
注意:这种全局锁会让所有操作team的线程排队,并发量高的时候会影响性能。
细粒度锁(推荐,兼顾并发和安全)
如果有多个不同的team id需要处理,可以用ConcurrentDictionary来为每个id创建独立的锁,避免互相阻塞:
// 存储每个team id对应的锁对象 private static readonly ConcurrentDictionary<int, object> _teamLocks = new ConcurrentDictionary<int, object>(); public void AddTeams() { // 获取或创建team 1249对应的锁对象 var lockObj = _teamLocks.GetOrAdd(1249, key => new object()); lock(lockObj) { if (!IsTeamExist(1249)) { InsertTeam(1249); } InsertTeamRelatedData(1249); } }
这样不同team id的操作可以并行执行,只有同一个id的操作会排队,性能友好很多。
3. 事务+高隔离级别(备选,注意性能)
把“检查+插入”放到一个事务里,并且设置较高的隔离级别(比如SERIALIZABLE),让数据库在事务期间锁住相关资源,防止其他线程修改。
以EF Core为例:
using(var transaction = dbContext.Database.BeginTransaction(System.Data.IsolationLevel.Serializable)) { try { // 检查team是否存在 if (!dbContext.Teams.Any(t => t.Id == 1249)) { dbContext.Teams.Add(new Team { Id = 1249, Name = "银河战队" }); dbContext.SaveChanges(); } // 插入关联记录 dbContext.TeamRecords.Add(new TeamRecord { TeamId = 1249 }); dbContext.SaveChanges(); transaction.Commit(); } catch { transaction.Rollback(); throw; // 这里抛出的异常不会是主键重复,而是锁超时之类的,按需处理 } }
⚠️ 注意:SERIALIZABLE隔离级别会让数据库加更多锁,可能导致并发性能下降,适合并发量不高的场景。
内容的提问来源于stack exchange,提问作者utop

