如何为供应商计数器表实现无性能损耗的行锁,解决重复编号问题
这个问题我之前在高并发交易系统里碰到过,核心问题是你的当前代码存在竞态条件——多个请求同时查询到同一个计数器值,各自加1后保存,自然会出现重复编号。下面给你几个经过生产环境验证的解决方案,从EF Core原生优化到数据库原子操作都有,你可以根据自己的技术栈和性能需求选择:
方案一:EF Core原生行级锁优化
通过SELECT ... FOR UPDATE锁定目标行,确保同一时间只有一个请求能修改该供应商的计数器,避免并发冲突。同时给ProviderId字段加唯一约束,防止多个请求同时创建同一供应商的计数器记录。
public async Task<int> GetNumeration(int id) { // 锁定目标行,直到事务结束 var providerNumerationDb = await _dbContextEf.providerNumeration .FromSqlRaw("SELECT * FROM ProviderNumeration WHERE ProviderId = {0} FOR UPDATE", id) .SingleOrDefaultAsync(); if (providerNumerationDb == null) { // 处理并发创建的情况:如果唯一键冲突,说明已有请求创建了记录,重新查询 try { providerNumerationDb = new ProviderNumeration { ProviderId = id, Number = 1 }; await _dbContextEf.AddAsync(providerNumerationDb); await _dbContextEf.SaveChangesAsync(); } catch (DbUpdateException ex) { // SQL Server唯一键冲突错误码是2627,其他数据库请对应调整 if (ex.InnerException is SqlException sqlEx && sqlEx.Number == 2627) { providerNumerationDb = await _dbContextEf.providerNumeration .FromSqlRaw("SELECT * FROM ProviderNumeration WHERE ProviderId = {0} FOR UPDATE", id) .SingleAsync(); } else { throw; } } } else { // 原子更新计数器,避免先查后改的竞态 await _dbContextEf.providerNumeration .Where(x => x.ProviderId == id) .ExecuteUpdateAsync(s => s.SetProperty(x => x.Number, x => x.Number + 1)); providerNumerationDb.Number += 1; } _dbContextEf.Entry(providerNumerationDb).State = EntityState.Detached; return providerNumerationDb.Number; }
优势:基于EF Core改造,不需要大幅调整现有代码结构;行级锁粒度小,对其他供应商的计数器操作无影响。
注意:如果是MySQL数据库,FOR UPDATE需要在事务中生效,EF Core默认的SaveChangesAsync会自动开启事务,所以没问题。
方案二:数据库原子操作(推荐)
直接在数据库端完成「查询+更新/插入」的原子操作,不需要EF上下文的状态管理,性能最优,因为只需要一次数据库往返,且数据库原生保证原子性,不需要额外手动加锁。
你可以用存储过程,或者直接在EF中执行原生SQL:
方式1:使用MERGE语句(SQL Server适用)
public async Task<int> GetNumeration(int id) { var outputParam = new SqlParameter("@NewNumber", SqlDbType.Int) { Direction = ParameterDirection.Output }; await _dbContextEf.Database.ExecuteSqlRawAsync(@" MERGE ProviderNumeration AS target USING (SELECT @ProviderId AS ProviderId) AS source ON target.ProviderId = source.ProviderId WHEN MATCHED THEN UPDATE SET target.Number = target.Number + 1 WHEN NOT MATCHED THEN INSERT (ProviderId, Number) VALUES (source.ProviderId, 1) OUTPUT inserted.Number INTO @NewNumber; ", new SqlParameter("@ProviderId", id), outputParam); return (int)outputParam.Value; }
方式2:UPDATE+INSERT组合(兼容多数数据库)
public async Task<int> GetNumeration(int id) { var outputParam = new SqlParameter("@NewNumber", SqlDbType.Int) { Direction = ParameterDirection.Output }; await _dbContextEf.Database.ExecuteSqlRawAsync(@" DECLARE @NewNumber INT; -- 先尝试更新计数器 UPDATE ProviderNumeration SET @NewNumber = Number = Number + 1 WHERE ProviderId = @ProviderId; -- 如果没有找到记录,插入新的计数器 IF @@ROWCOUNT = 0 BEGIN INSERT INTO ProviderNumeration (ProviderId, Number) VALUES (@ProviderId, 1); SET @NewNumber = 1; END SELECT @NewNumber; ", new SqlParameter("@ProviderId", id), outputParam); return (int)outputParam.Value; }
优势:完全避免EF上下文的竞态问题,性能比方案一更优;数据库原生保证操作原子性,不需要处理并发创建的重试逻辑。
方案三:分布式锁(多实例部署场景)
如果你的应用是多实例部署,且数据库行级锁无法覆盖跨实例的并发(比如部分缓存场景),可以用分布式锁(如Redis锁、ZooKeeper锁)。但这个方案复杂度较高,只有在必须的情况下才推荐,因为会引入额外的依赖和故障点。
总结推荐
如果追求性能和可靠性,方案二是最优选择——数据库原生的原子操作既避免了竞态条件,又不需要额外的锁管理,性能损耗几乎为零。如果不想大幅修改现有EF代码结构,方案一也是可靠的选择,只要确保ProviderId的唯一约束生效。
内容的提问来源于stack exchange,提问作者Nicolás Sosa

