Azure Premium函数并发更新SQL Server同数据行异常,求替代UPDLOCK的方案
问题描述
我有一个Azure函数应用,当服务总线队列中有数据入队时触发。在该函数中实现了扣费逻辑:首先从数据库查询指定用户的账户积分数据,执行语句为:
SELECT * FROM UsersAccountPoints WHERE UserId = @UserId
随后更新积分数据,执行语句为:
UPDATE UsersAccountPoints SET FundsChange = @ChargeAmount, FundsAmount -= @ChargeAmount WHERE UserId = @UserId
本地运行时一切正常,但部署到Azure后,函数应用开始自动扩缩容,出现多进程并发更新UsersAccountPoints表同一数据行的情况,导致结果异常。我尝试使用UPDLOCK锁机制:
SELECT * FROM UsersAccountPoints WITH (UPDLOCK) WHERE UserId = @UserId
但问题仍未解决。当前使用Azure Premium函数、SQL Server数据库、Dapper作为ORM,请问有没有比UPDLOCK更优的解决方案?
1. 合并查询与更新为单原子语句(最优方案)
你的核心问题是先查后更的两步操作不具备原子性——哪怕加了UPDLOCK,如果SELECT和UPDATE不在同一个事务内,锁会在SELECT执行完成后立即释放,后续并发请求依然能读取到旧数据并执行更新,最终导致数据不一致。
直接将查询与更新合并为一条UPDATE语句,利用SQL Server的OUTPUT子句返回更新前后的数据,整个操作是原子性的,完全避免并发冲突:
UPDATE UsersAccountPoints SET FundsChange = @ChargeAmount, FundsAmount -= @ChargeAmount OUTPUT inserted.*, deleted.* WHERE UserId = @UserId
用Dapper执行这条语句时,可直接获取更新后的实体,无需单独执行SELECT。这种方式不需要额外锁提示,SQL Server会自动处理行级锁,既保证一致性又具备最优性能。
2. 确保查询与更新在同一事务内并增强锁提示
如果必须保留先查后更的逻辑,要确保整个操作在同一个数据库事务中,同时给SELECT语句加上UPDLOCK, HOLDLOCK组合提示(或直接使用SERIALIZABLE隔离级别):
using (var connection = new SqlConnection(connectionString)) { await connection.OpenAsync(); using (var transaction = connection.BeginTransaction(System.Data.IsolationLevel.Serializable)) { try { var userPoints = await connection.QueryFirstOrDefaultAsync<UsersAccountPoints>( "SELECT * FROM UsersAccountPoints WITH (UPDLOCK, HOLDLOCK) WHERE UserId = @UserId", new { UserId = userId }, transaction: transaction ); // 这里可添加业务校验,比如积分是否充足 if (userPoints.FundsAmount < chargeAmount) { throw new InvalidOperationException("积分不足"); } await connection.ExecuteAsync( "UPDATE UsersAccountPoints SET FundsChange = @ChargeAmount, FundsAmount -= @ChargeAmount WHERE UserId = @UserId", new { ChargeAmount = chargeAmount, UserId = userId }, transaction: transaction ); transaction.Commit(); } catch { transaction.Rollback(); throw; } } }
HOLDLOCK会将UPDLOCK的持有时间延长至事务结束,避免其他事务在你完成更新前修改数据。SERIALIZABLE隔离级别也能达到类似效果,但锁范围更大,可根据实际场景选择。
3. 乐观锁方案(适合高并发低冲突场景)
如果悲观锁(UPDLOCK等)带来性能瓶颈,可采用乐观锁方案:给UsersAccountPoints表新增Version列(比如int类型,每次更新自增),更新时校验版本号是否与查询时一致:
- 查询带版本号的数据:
SELECT *, Version FROM UsersAccountPoints WHERE UserId = @UserId
- 执行更新时带上版本号条件:
UPDATE UsersAccountPoints SET FundsChange = @ChargeAmount, FundsAmount -= @ChargeAmount, Version += 1 WHERE UserId = @UserId AND Version = @OriginalVersion
- 执行后检查受影响行数,若为0说明存在并发冲突,需触发重试逻辑(可借助Polly等库实现)。
这种方式无需加锁,通过版本号控制并发,适合高并发、冲突率低的场景,但需要处理更新失败后的重试逻辑。
内容的提问来源于stack exchange,提问作者Shehan V

