.NET Core WebAPI重复提交致重复数据的服务端解决方案问询
解决.NET Core WebAPI并行请求导致重复数据的服务端方案
这个并发重复提交的问题确实很头疼——前端禁用按钮只能防君子,遇到快速点击或者恶意请求就失效了。除了数据库唯一约束,我给你几个实用的服务端解决方案,覆盖不同部署场景和业务需求:
1. 针对业务粒度的本地/分布式锁
如果你的服务是单实例部署,用SemaphoreSlim做细粒度锁是最快的方案——既不会锁住整个接口,又能保证同一类请求(比如相同MainGroup的创建请求)串行处理。
比如可以在服务类里维护一个按MainGroup分组的信号量字典:
// 用ConcurrentDictionary保证线程安全 private static readonly ConcurrentDictionary<string, SemaphoreSlim> _groupLocks = new ConcurrentDictionary<string, SemaphoreSlim>(); public async Task<ReturnMessageMain> Create(CreateOrEditMainCategoryDto input) { var groupKey = input.MainGroup.Trim(); // 为当前MainGroup获取或创建信号量,初始许可数设为1 var semaphore = _groupLocks.GetOrAdd(groupKey, _ => new SemaphoreSlim(1, 1)); try { await semaphore.WaitAsync(); // 等待获取锁 // 原有的校验逻辑 if (_mainCategoryRepository.GetAll().Any(x => x.MainGroup == groupKey)) { throw new UserFriendlyException("Main Category already exists"); } // 插入数据库逻辑 var mainCategory = ObjectMapper.Map<MainCategory>(input); int id = await _mainCategoryRepository.InsertAndGetIdAsync(mainCategory); return new ReturnMessageMain() { Id = id, Code = input.MainGroupCode, Message = "Saved Successfully" }; } finally { semaphore.Release(); // 无论成功失败都释放锁 // 可选:如果信号量回到初始状态,从字典移除避免内存占用 if (semaphore.CurrentCount == 1) { _groupLocks.TryRemove(groupKey, out _); } } }
如果是多实例部署,本地锁就失效了,这时候得用分布式锁——比如基于Redis的RedLock,或者数据库行级锁。核心思路是:用一个全局唯一的锁标识(比如MainGroup的值),让所有实例在处理前先竞争锁,拿到锁的才能执行后续逻辑。
2. 实现请求幂等性
幂等性的核心是:同一个请求无论调用多少次,结果都是一样的。适合需要重复请求返回相同响应的场景,比如表单提交。
具体步骤:
- 前端每次提交时生成一个唯一的幂等键(比如UUID),放在请求头(比如
Idempotency-Key)里 - 后端先校验这个幂等键是否已经处理过,处理过就直接返回之前的结果;没处理过就执行逻辑,并把结果缓存起来
示例代码:
public async Task<ReturnMessageMain> Create(CreateOrEditMainCategoryDto input, [FromHeader(Name = "Idempotency-Key")] string idempotencyKey) { if (string.IsNullOrEmpty(idempotencyKey)) { throw new UserFriendlyException("请提供幂等标识"); } var cacheKey = $"Idempotency:MainCategory:{idempotencyKey}"; // 从缓存(比如Redis)读取已有的处理结果 var cachedResult = await _cacheService.GetAsync<ReturnMessageMain>(cacheKey); if (cachedResult != null) { return cachedResult; // 直接返回之前的结果,不重复执行逻辑 } try { // 原有的校验和插入逻辑 var groupKey = input.MainGroup.Trim(); if (_mainCategoryRepository.GetAll().Any(x => x.MainGroup == groupKey)) { throw new UserFriendlyException("Main Category already exists"); } var mainCategory = ObjectMapper.Map<MainCategory>(input); int id = await _mainCategoryRepository.InsertAndGetIdAsync(mainCategory); var successResult = new ReturnMessageMain() { Id = id, Code = input.MainGroupCode, Message = "Saved Successfully" }; // 缓存结果,比如保留5分钟 await _cacheService.SetAsync(cacheKey, successResult, TimeSpan.FromMinutes(5)); return successResult; } catch (UserFriendlyException ex) { // 异常结果也缓存,避免重复抛出相同错误 var errorResult = new ReturnMessageMain() { Id = 0, Message = ex.Message }; await _cacheService.SetAsync(cacheKey, errorResult, TimeSpan.FromMinutes(5)); throw; } }
3. 数据库悲观锁+事务
把校验和插入操作放到同一个数据库事务里,并用悲观锁锁住查询范围,避免并行请求的间隙问题。
示例代码(以EF Core为例):
public async Task<ReturnMessageMain> Create(CreateOrEditMainCategoryDto input) { var dbContext = _mainCategoryRepository.GetDbContext(); using (var transaction = await dbContext.Database.BeginTransactionAsync()) { try { var groupKey = input.MainGroup.Trim(); // 查询时加UPDLOCK和HOLDLOCK,锁住符合条件的行范围 var exists = await dbContext.MainCategories .FromSqlRaw(@"SELECT * FROM MainCategories WITH (UPDLOCK, HOLDLOCK) WHERE MainGroup = {0}", groupKey) .AnyAsync(); if (exists) { throw new UserFriendlyException("Main Category already exists"); } var mainCategory = ObjectMapper.Map<MainCategory>(input); int id = await _mainCategoryRepository.InsertAndGetIdAsync(mainCategory); await transaction.CommitAsync(); return new ReturnMessageMain() { Id = id, Code = input.MainGroupCode, Message = "Saved Successfully" }; } catch { await transaction.RollbackAsync(); throw; } } }
这种方式依赖数据库的锁机制,会阻塞并行请求,适合对性能要求不是极致苛刻的场景。
最后提醒
无论用哪种方案,数据库唯一约束一定要保留——这是最后一道防线,避免因为锁失效、缓存异常等极端情况导致的数据重复。
内容的提问来源于stack exchange,提问作者Nighil
相关产品推荐
相关产品推荐

