EF Core并发处理咨询:FindOrCreate方法并发重复创建问题
处理并发场景下的FindOrCreate问题
你遇到的这个问题是并发场景里典型的检查-创建竞态条件:两个线程同时通过了“查找不存在”的检查,接着一个成功创建,另一个触发唯一索引异常。下面给你分析几种合适的解决方案,以及各自的适用场景:
方案1:捕获唯一索引异常并重试(推荐多实例场景)
既然已经在数据库层面加了唯一索引,那我们可以利用这个约束作为最终保障,捕获冲突异常后重试查找——这是分布式/多实例场景下最可靠的方案,因为应用层锁无法跨实例生效。
修改后的代码示例:
public async Task<Country> FindOrCreateAsync(string name) { // 先尝试查找,无锁 var country = await _context.Countries.FirstOrDefaultAsync(p => p.Name == name); if (country != null) return country; try { // 尝试创建 country = new Country { Name = name }; _context.Countries.Add(country); await _context.SaveChangesAsync(); return country; } catch (DbUpdateException ex) { // 注意:不同数据库的唯一键冲突错误码不同,SQL Server是2601/2627,MySQL是1062 if (ex.InnerException is SqlException sqlEx && (sqlEx.Number == 2601 || sqlEx.Number == 2627)) { // 冲突发生,此时第一个线程已经创建完成,再次查找即可 return await _context.Countries.FirstOrDefaultAsync(p => p.Name == name); } // 非唯一键异常,重新抛出 throw; } }
优点:跨实例有效,依赖数据库的天然约束,无需额外锁机制;缺点:会有少量预期内的异常,但重试逻辑可以完全处理,对业务无影响。
方案2:应用层内存锁(适合单实例场景)
如果你的服务是单实例部署的,那可以用应用层锁来确保同一时间只有一个线程执行创建逻辑。注意:必须在锁内再次执行查找,因为等待锁的过程中,可能已有其他线程完成了创建。
代码示例:
// 定义一个针对Country创建的专用锁 private readonly SemaphoreSlim _countryCreationLock = new SemaphoreSlim(1, 1); public async Task<Country> FindOrCreateAsync(string name) { // 先无锁查找 var country = await _context.Countries.FirstOrDefaultAsync(p => p.Name == name); if (country != null) return country; // 进入锁 await _countryCreationLock.WaitAsync(); try { // 锁内再次检查!避免等待期间其他线程已创建 country = await _context.Countries.FirstOrDefaultAsync(p => p.Name == name); if (country != null) return country; // 确认不存在,执行创建 country = new Country { Name = name }; _context.Countries.Add(country); await _context.SaveChangesAsync(); return country; } finally { // 务必释放锁,避免死锁 _countryCreationLock.Release(); } }
优点:避免了数据库异常,代码逻辑更“干净”;缺点:仅适合同一应用实例的并发,如果是多实例集群,不同实例的锁相互独立,无法阻止跨实例的竞态。
方案3:数据库事务+高隔离级别(慎用)
通过提升事务隔离级别到Serializable,或者使用UPDLOCK/HOLDLOCK查询提示,让数据库在查找时加排他锁,阻止其他线程插入符合条件的记录。
代码示例(EF Core + SQL Server):
public async Task<Country> FindOrCreateAsync(string name) { using (var transaction = await _context.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable)) { try { // 使用Serializable隔离级别,确保读取时锁定范围,阻止插入 var country = await _context.Countries .FirstOrDefaultAsync(p => p.Name == name); if (country != null) { await transaction.CommitAsync(); return country; } country = new Country { Name = name }; _context.Countries.Add(country); await _context.SaveChangesAsync(); await transaction.CommitAsync(); return country; } catch { await transaction.RollbackAsync(); throw; } } }
优点:从数据库层面彻底避免竞态;缺点:Serializable隔离级别会大幅增加数据库的锁竞争,降低并发性能,只适合并发量极低的场景,一般不推荐作为常规方案。
总结选择建议
- 如果你是多实例部署(比如微服务集群):优先选方案1,这是最通用且可靠的方式;
- 如果你是单实例部署:可以选方案2,避免异常;或者也可以用方案1,保持代码的通用性;
- 方案3尽量少用,除非你必须彻底避免数据库异常,且能接受并发性能的下降。
内容的提问来源于stack exchange,提问作者Vitor Durante
相关产品推荐
相关产品推荐

