使用SumAsync()计算用户Token时,如何处理数据库并发冲突?
解决学生选课网站Token并发操作的一致性问题
问题背景
运营学生选课网站,采用Token系统作为注册支付方式(如注册需消耗5个Token)。每次用户完成操作时,会创建Payment和Token两条数据记录,流程为先调用.SumAsync()获取用户当前总Token数,再计算TokensBefore和TokensAfter并保存数据。
核心风险:若网站和后端Azure Function同时调用SumAsync(),会读取到相同的Token总和,导致两条数据的TokensBefore/TokensAfter计算错误,最终Token余额出现不一致。
疑问:如何避免这种情况?考虑过两种方向:
- 创建
TotalTokens表存储用户Token总额,做并发检查 - 在
Token表新增TotalTokens字段,针对UserId加锁
可行解决方案
方案1:乐观并发控制(推荐)
通过数据库的行版本校验实现无锁并发控制,当多个进程同时修改同一用户余额时,只有第一个提交的操作会生效,其余操作触发并发异常后可重试。
实现步骤
- 创建
UserTokenBalance表,结构包含:UserId(主键)TotalTokens(当前总Token数)RowVersion(数据库自动维护的行版本字段,如SQL Server的ROWVERSION)
- 每次操作时读取用户的余额记录(包含
RowVersion),计算新余额后,更新时携带版本值做校验,版本匹配才执行更新。
代码示例(EF Core)
// 定义余额实体 public class UserTokenBalance { public Guid UserId { get; set; } public int TotalTokens { get; set; } public byte[] RowVersion { get; set; } } // DbContext配置 protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<UserTokenBalance>() .HasKey(u => u.UserId); modelBuilder.Entity<UserTokenBalance>() .Property(u => u.RowVersion) .IsRowVersion(); // 自动绑定数据库行版本字段 } // 注册消耗Token的操作逻辑 public async Task<bool> ProcessRegistration(Guid userId) { using var dbContext = new YourDbContext(); int retryCount = 3; // 设置重试次数 while (retryCount > 0) { try { var balance = await dbContext.UserTokenBalances .FirstOrDefaultAsync(u => u.UserId == userId); if (balance == null || balance.TotalTokens < 5) return false; // 无余额记录或余额不足 // 创建支付和流水记录 var payment = new Payment { Type = Type.Registration, TokensBefore = balance.TotalTokens, TokensAfter = balance.TotalTokens - 5, Token = new Token { Tokens = -5, UserId = userId } }; dbContext.Payments.Add(payment); balance.TotalTokens -= 5; await dbContext.SaveChangesAsync(); return true; } catch (DbUpdateConcurrencyException) { retryCount--; if (retryCount == 0) return false; // 清理跟踪器,重新读取最新数据准备重试 dbContext.ChangeTracker.Clear(); } } return false; }
方案2:悲观锁(适合低并发场景)
在读取用户余额时直接对该行加写锁,确保同一时间只有一个进程能修改数据。此方法会增加数据库锁竞争,高并发场景下可能导致性能瓶颈。
代码示例(EF Core)
public async Task ProcessTokenPurchase(Guid userId) { using var dbContext = new YourDbContext(); // 加写锁,阻止其他进程读取该行 var balance = await dbContext.UserTokenBalances .Where(u => u.UserId == userId) .WithLock(LockMode.PessimisticWrite) .FirstOrDefaultAsync(); if (balance == null) { balance = new UserTokenBalance { UserId = userId, TotalTokens = 0 }; dbContext.UserTokenBalances.Add(balance); } var payment = new Payment { Type = Type.TokenPurchase, TokensBefore = balance.TotalTokens, TokensAfter = balance.TotalTokens + 50, Token = new Token { Tokens = 50, UserId = userId } }; dbContext.Payments.Add(payment); balance.TotalTokens += 50; await dbContext.SaveChangesAsync(); }
方案3:数据库事务+存储过程(高效原子操作)
将余额计算、流水插入、余额更新逻辑封装到数据库存储过程中,通过事务保证所有操作的原子性,避免应用层的并发问题。
存储过程示例(SQL Server)
CREATE PROCEDURE ProcessRegistration @UserId UNIQUEIDENTIFIER AS BEGIN SET NOCOUNT ON; BEGIN TRANSACTION; BEGIN TRY DECLARE @CurrentTokens INT; SELECT @CurrentTokens = TotalTokens FROM UserTokenBalances WHERE UserId = @UserId; IF @CurrentTokens IS NULL OR @CurrentTokens < 5 BEGIN ROLLBACK TRANSACTION; RETURN 0; -- 操作失败 END -- 插入Token流水记录 INSERT INTO Tokens (UserId, Tokens) VALUES (@UserId, -5); -- 插入Payment记录 INSERT INTO Payments (UserId, Type, TokensBefore, TokensAfter) VALUES (@UserId, 'Registration', @CurrentTokens, @CurrentTokens -5); -- 更新总余额 UPDATE UserTokenBalances SET TotalTokens = TotalTokens -5 WHERE UserId = @UserId; COMMIT TRANSACTION; RETURN 1; -- 操作成功 END TRY BEGIN CATCH ROLLBACK TRANSACTION; RETURN 0; END CATCH END
代码调用示例
public async Task<bool> CallRegistrationProc(Guid userId) { using var dbContext = new YourDbContext(); var result = await dbContext.Database .ExecuteSqlInterpolatedAsync($"EXEC ProcessRegistration {userId}"); return result == 1; }
对初始思路的点评
- 创建
TotalTokens表:可行,本质就是上述方案中的UserTokenBalance表,搭配并发控制即可解决问题,是推荐的做法。 - 在
Token表新增TotalTokens字段+锁:不推荐。Token表是流水表,每条记录存储总额会造成数据冗余,且每次更新需修改大量历史记录,效率极低。针对UserId加锁的思路可迁移到单独的余额表实现。
内容的提问来源于stack exchange,提问作者chuckd
相关产品推荐
相关产品推荐

