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

ASP.NET Core API礼品卡并发购买的数据库并发问题解决方案

礼品卡系统并发分配激活码问题解决方案

问题背景

开发的礼品卡系统包含GiftCards和ActivationCodes两个核心表,每张礼品卡关联多个预生成激活码,用户购买时分配一个可用(未使用、未过期、未删除)的激活码。当前实现中,并发购买时多个用户会被分配到同一激活码,触发DbUpdateConcurrencyException,现有乐观锁虽能处理但希望从根源避免冲突,且悲观锁不适用(无需等待特定激活码)。

现有核心代码片段:

// ActivationCode Entity
public class ActivationCode : BaseEntity
{
    public Guid Id { get; set; }
    public string Code { get; set; }
    public bool isUsed { get; set; } = false;
    public DateTime? ExpiresOn { get; set; }
    public bool IsExpired => DateTime.UtcNow >= ExpiresOn;

    public Guid GiftCardId { get; set; }
    public GiftCard GiftCard { get; set; }
    
    [Timestamp]
    public uint RowVersion { get; set; } // 乐观并发控制
}

// Purchase Gift Card 核心逻辑
public async Task<GeneralResult<GiftCardPurchaseResult>> PurchaseGiftCard(
    Guid giftCardId, string userId)
{
    // ... 省略礼品卡校验、余额校验逻辑
    
    // 问题点:无锁查询激活码,并发时会重复获取同一记录
    var getAvailableActivationCode = await _activationCodeRepository
        .GetTableNoTracking()
        .Where(gd => gd.GiftCardId == giftCardId && !gd.isUsed && !gd.IsDeleted && 
                     (DateTime.UtcNow < gd.ExpiresOn || gd.ExpiresOn == null))
        .FirstOrDefaultAsync();

    if (getAvailableActivationCode is null)
    {
        return GeneralResult<GiftCardPurchaseResult>
            .Failure("该礼品卡暂无可用激活码",
            HttpStatusCode.BadRequest,
            new List<string> { GiftCardTransactionsErrorCode.ACTIVATION_CODES_NOT_AVAILABLE });
    }

    using (var transaction = await _dataContext.Database.BeginTransactionAsync())
    {
        try
        {
            // ... 省略钱包扣款、购买记录创建逻辑
            
            getAvailableActivationCode.isUsed = true;
            await _activationCodeRepository.UpdateAsync(getAvailableActivationCode);

            await transaction.CommitAsync();
            return GeneralResult<GiftCardPurchaseResult>.Success(purchaseResultInfo, "礼品卡购买成功");
        }
        catch (DbUpdateConcurrencyException ex)
        {
            // 并发冲突处理
        }
    }
}

核心痛点

并发场景下,多个事务同时查询到同一可用激活码,后续更新时触发乐观锁冲突;系统有其他可用激活码,无需等待,需从根源避免重复分配。

解决方案

方案1:原子化分配激活码(数据库层面锁定并更新)

将「查询可用激活码」和「标记为已使用」合并为一个原子操作,避免中间间隙被其他事务抢占。通过EF Core的ExecuteUpdateAsync直接在数据库层面执行更新,同时返回被更新的激活码信息。

修改后的核心逻辑:

// 替换原查询+更新步骤,改为原子操作
var updateCount = await _activationCodeRepository
    .GetTable()
    .Where(gd => gd.GiftCardId == giftCardId && !gd.isUsed && !gd.IsDeleted && 
                 (gd.ExpiresOn == null || DateTime.UtcNow < gd.ExpiresOn))
    .OrderBy(gd => gd.Id) // 保证分配顺序一致性
    .Take(1)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(gd => gd.isUsed, true)
        .SetProperty(gd => gd.UsedAt, DateTime.UtcNow)); // 新增UsedAt字段记录使用时间

if (updateCount == 0)
{
    return GeneralResult<GiftCardPurchaseResult>
        .Failure("该礼品卡暂无可用激活码",
        HttpStatusCode.BadRequest,
        new List<string> { GiftCardTransactionsErrorCode.ACTIVATION_CODES_NOT_AVAILABLE });
}

// 查询刚更新的激活码
var assignedCode = await _activationCodeRepository
    .GetTable()
    .Where(gd => gd.GiftCardId == giftCardId && gd.isUsed && gd.UsedAt >= DateTime.UtcNow.AddSeconds(-1))
    .FirstOrDefaultAsync();

原理:ExecuteUpdateAsync会生成一条UPDATE ... WHERE ...语句,数据库执行时会锁定符合条件的行并完成更新,确保只有一个事务能成功修改某条激活码,其他事务会匹配到下一条可用记录。

方案2:使用数据库行级锁(针对查询加锁)

在查询激活码时使用READPAST(SQL Server)或SKIP LOCKED(PostgreSQL/MySQL 8.0+),跳过已被其他事务锁定的行,直接获取下一个可用的激活码。

EF Core中通过原生SQL实现:

// SQL Server语法,UPDLOCK加更新锁,READPAST跳过锁定行
var sql = @"
SELECT TOP 1 * FROM ActivationCodes 
WHERE GiftCardId = @giftCardId AND isUsed = 0 AND IsDeleted = 0 
AND (ExpiresOn IS NULL OR GETUTCDATE() < ExpiresOn)
WITH (UPDLOCK, READPAST)";

var availableCode = await _dataContext.ActivationCodes
    .FromSqlRaw(sql, new SqlParameter("@giftCardId", giftCardId))
    .FirstOrDefaultAsync();

if (availableCode is null)
{
    return GeneralResult<GiftCardPurchaseResult>.Failure("该礼品卡暂无可用激活码", ...);
}

// 后续标记为已使用的逻辑
availableCode.isUsed = true;
await _dataContext.SaveChangesAsync();

原理:READPAST/SKIP LOCKED让查询跳过被其他事务锁定的行,直接返回下一个可用的激活码,避免多个事务争抢同一条记录。

方案3:预分配激活码池(应用层缓存+原子操作)

提前将可用激活码加载到内存缓存(如Redis),使用Redis的SPOP或LPOP原子操作获取激活码,再同步到数据库标记为已使用。

步骤:

  1. 系统启动或定时任务将礼品卡的可用激活码同步到Redis集合(如giftcard:{giftCardId}:available_codes)
  2. 用户购买时,调用Redis的SPOP命令随机获取一个激活码(原子操作,保证唯一分配)
  3. 在数据库中验证该激活码是否未使用、未过期,若有效则标记为已使用;若无效则从Redis中再获取一个
  4. 若Redis中无可用码,再查询数据库补充到Redis

优势:减少数据库并发压力,Redis的原子操作天然避免重复分配,适合高并发场景。

方案对比

方案适用场景优点缺点
原子化更新中小并发、依赖EF Core原生能力无需额外组件,代码侵入性低数据库压力略高
行级锁+跳过锁定行中高并发、支持对应数据库语法直接在数据库层面解决,逻辑简单依赖特定数据库特性,跨库兼容性差
Redis预分配高并发场景性能最优,减少数据库竞争需要维护Redis缓存与数据库的一致性,增加系统复杂度

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 16:32:03