You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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:乐观并发控制(推荐)

通过数据库的行版本校验实现无锁并发控制,当多个进程同时修改同一用户余额时,只有第一个提交的操作会生效,其余操作触发并发异常后可重试。

实现步骤

  1. 创建UserTokenBalance表,结构包含:
    • UserId(主键)
    • TotalTokens(当前总Token数)
    • RowVersion(数据库自动维护的行版本字段,如SQL Server的ROWVERSION)
  2. 每次操作时读取用户的余额记录(包含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;
}

对初始思路的点评

  1. 创建TotalTokens表:可行,本质就是上述方案中的UserTokenBalance表,搭配并发控制即可解决问题,是推荐的做法。
  2. 在Token表新增TotalTokens字段+锁:不推荐。Token表是流水表,每条记录存储总额会造成数据冗余,且每次更新需修改大量历史记录,效率极低。针对UserId加锁的思路可迁移到单独的余额表实现。

内容的提问来源于stack exchange,提问作者chuckd

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 14:35:18