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原子操作获取激活码,再同步到数据库标记为已使用。
步骤:
- 系统启动或定时任务将礼品卡的可用激活码同步到Redis集合(如
giftcard:{giftCardId}:available_codes) - 用户购买时,调用Redis的
SPOP命令随机获取一个激活码(原子操作,保证唯一分配) - 在数据库中验证该激活码是否未使用、未过期,若有效则标记为已使用;若无效则从Redis中再获取一个
- 若Redis中无可用码,再查询数据库补充到Redis
优势:减少数据库并发压力,Redis的原子操作天然避免重复分配,适合高并发场景。
方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 原子化更新 | 中小并发、依赖EF Core原生能力 | 无需额外组件,代码侵入性低 | 数据库压力略高 |
| 行级锁+跳过锁定行 | 中高并发、支持对应数据库语法 | 直接在数据库层面解决,逻辑简单 | 依赖特定数据库特性,跨库兼容性差 |
| Redis预分配 | 高并发场景 | 性能最优,减少数据库竞争 | 需要维护Redis缓存与数据库的一致性,增加系统复杂度 |
内容的提问来源于stack exchange,提问作者OLIVER

