多用户并发下ASP.NET(C#)中SQL Server余额读写更新的最优方案
并发场景下SQL Server余额更新的一致性保障方案
在多用户并发操作余额时,核心要解决的是丢失更新问题——即两个用户同时读取同一余额,各自扣除后写回,导致其中一次扣除操作被覆盖。以下是几种实用的解决方法:
1. 直接使用数据库原子更新(优先推荐)
不要在应用层先读取余额再计算更新,而是把扣除逻辑直接嵌入SQL语句,让数据库引擎保证操作的原子性。数据库会自动对目标行加排他锁,确保同一时间只有一个事务能修改该行。
示例SQL:
UPDATE BalanceTable SET CurrentBalance = CurrentBalance - @DeductAmount WHERE AccountId = @AccountId -- 可选:添加余额校验,防止扣成负数 AND CurrentBalance >= @DeductAmount
C#代码示例(ADO.NET实现):
using (var conn = new SqlConnection("YourConnectionString")) { conn.Open(); var cmd = new SqlCommand(@" UPDATE BalanceTable SET CurrentBalance = CurrentBalance - @DeductAmount WHERE AccountId = @AccountId AND CurrentBalance >= @DeductAmount", conn); cmd.Parameters.AddWithValue("@AccountId", accountId); cmd.Parameters.AddWithValue("@DeductAmount", deductAmount); int affectedRows = cmd.ExecuteNonQuery(); if (affectedRows == 0) { // 处理余额不足或账户不存在的情况 throw new InvalidOperationException("余额不足或账户不存在"); } }
2. 事务+更新锁(需先读取余额的场景)
如果业务必须先读取余额做判断(比如要返回扣除前的余额给用户),可以在读取时加上UPDLOCK(更新锁)和HOLDLOCK(持有锁直到事务结束),确保读取后其他事务无法修改该行,直到当前事务完成更新。
示例代码:
using (var conn = new SqlConnection("YourConnectionString")) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 读取余额时加更新锁,锁定该行直到事务结束 var readCmd = new SqlCommand(@" SELECT CurrentBalance FROM BalanceTable WITH (UPDLOCK, HOLDLOCK) WHERE AccountId = @AccountId", conn, tran); readCmd.Parameters.AddWithValue("@AccountId", accountId); decimal currentBalance = (decimal)readCmd.ExecuteScalar(); if (currentBalance < deductAmount) { tran.Rollback(); throw new InvalidOperationException("余额不足"); } // 执行余额扣除 var updateCmd = new SqlCommand(@" UPDATE BalanceTable SET CurrentBalance = CurrentBalance - @DeductAmount WHERE AccountId = @AccountId", conn, tran); updateCmd.Parameters.AddWithValue("@AccountId", accountId); updateCmd.Parameters.AddWithValue("@DeductAmount", deductAmount); updateCmd.ExecuteNonQuery(); tran.Commit(); } catch { tran.Rollback(); throw; } } }
3. 乐观并发控制(低冲突场景适用)
如果系统并发冲突率不高,可以用乐观锁:给表加一个版本列(比如RowVersion类型,SQL Server会自动维护),读取时同时获取版本号,更新时校验版本号是否和读取时一致——不一致说明有其他事务修改过,需要重试。
首先给表添加版本列:
ALTER TABLE BalanceTable ADD RowVersion ROWVERSION;
C#代码示例:
public bool DeductBalance(int accountId, decimal deductAmount, int retryCount = 3) { using (var conn = new SqlConnection("YourConnectionString")) { conn.Open(); for (int i = 0; i < retryCount; i++) { // 读取余额和版本号 var readCmd = new SqlCommand(@" SELECT CurrentBalance, RowVersion FROM BalanceTable WHERE AccountId = @AccountId", conn); readCmd.Parameters.AddWithValue("@AccountId", accountId); using (var reader = readCmd.ExecuteReader()) { if (!reader.Read()) return false; decimal currentBalance = (decimal)reader["CurrentBalance"]; byte[] originalVersion = (byte[])reader["RowVersion"]; if (currentBalance < deductAmount) return false; reader.Close(); // 尝试更新,校验版本号 var updateCmd = new SqlCommand(@" UPDATE BalanceTable SET CurrentBalance = CurrentBalance - @DeductAmount WHERE AccountId = @AccountId AND RowVersion = @OriginalVersion", conn); updateCmd.Parameters.AddWithValue("@AccountId", accountId); updateCmd.Parameters.AddWithValue("@DeductAmount", deductAmount); updateCmd.Parameters.AddWithValue("@OriginalVersion", originalVersion); int affectedRows = updateCmd.ExecuteNonQuery(); if (affectedRows > 0) return true; // 更新成功 // 版本不一致,进入下一次重试 } } return false; // 多次重试失败 } }
关键注意事项
- 避免使用
READ UNCOMMITTED隔离级别,会导致脏读;优先用REPEATABLE READ或更高隔离级别,配合锁提示。 - 不要在事务中执行无关操作,尽量缩短事务时长,减少锁持有时间,降低并发冲突概率。
- 高并发场景下,优先选择数据库原子更新方案,性能和可靠性最优。
内容的提问来源于stack exchange,提问作者Frank Martin
相关产品推荐
相关产品推荐

