C# 数据库并发控制方案:从乐观锁到 Entity Framework 实践
并发问题的本质
在多用户同时访问系统的环境下,当多个进程或线程尝试同时修改同一份数据时,就会产生并发问题。如果缺乏有效的控制机制,可能会导致数据不一致,如"丢失更新"、"脏读"或"不可重复读"。
针对并发的处理策略通常分为两大类:悲观并发控制(Pessimistic Concurrency)和乐观并发控制(Optimistic Concurrency)。
1. 悲观并发控制与乐观并发控制
悲观锁假设冲突频繁发生。在数据被读取时即进行锁定,直到事务完成。在 C# 中,本地内存级别的悲观锁可以通过 lock、Monitor 或 Mutex 实现;在数据库层面,通常通过 SELECT ... WITH (UPDLOCK) 等语法实现。其缺点是锁定的资源时间较长,在高并发场景下会导致系统吞吐量大幅下降。
乐观锁则假设冲突较少发生。它在读取数据时不加锁,而是在提交更新时检查数据自读取以来是否被其他用户修改。这种方式通常依赖于"版本号"或"时间戳"机制,具有更好的扩展性和性能。
2. 典型并发异常场景模拟
假设我们有一个简单的库存管理系统,包含库存表 Stock 和操作日志表 StockLog。以下是一个没有并发控制的减少库存操作示例:
public void UpdateInventory(int skuId)
{
using (var db = new AppDbContext())
{
using (var trans = db.Database.BeginTransaction())
{
// 1. 获取当前库存
var stockItem = db.Stocks.FirstOrDefault(s => s.SkuId == skuId);
if (stockItem != null && stockItem.Count > 0)
{
// 2. 模拟耗时操作,增加并发冲突概率
Thread.Sleep(500);
// 3. 减少库存
stockItem.Count -= 1;
db.SaveChanges();
// 4. 记录日志
db.StockLogs.Add(new StockLog { Message = "库存扣减成功" });
db.SaveChanges();
trans.Commit();
}
}
}
}
当多个用户同时调用此方法时,由于多个线程可能读取到相同的初始库存值,最终会导致日志记录了多次,但库存仅减少了一次,产生了典型的"并发覆盖"错误。
3. 通用数据库级乐观锁方案
在数据库层面实现乐观锁,最常用的方法是引入一个 rowversion(SQL Server)或 timestamp 字段。每次行数据发生变化时,数据库会自动更新该字段的值。
我们可以通过存储过程来实现基于版本的安全更新:
CREATE PROCEDURE sp_SafeDecreaseStock
@SkuId INT,
@IsSuccess BIT OUTPUT
AS
BEGIN
DECLARE @OldVersion ROWVERSION;
DECLARE @CurrentCount INT;
BEGIN TRAN
-- 获取当前库存和版本号
SELECT @CurrentCount = [Count], @OldVersion = [Version]
FROM Stocks WHERE SkuId = @SkuId;
-- 仅当版本号匹配时才执行更新
UPDATE Stocks
SET [Count] = @CurrentCount - 1
WHERE SkuId = @SkuId AND [Version] = @OldVersion;
IF @@ROWCOUNT > 0
BEGIN
INSERT INTO StockLogs (Message) VALUES ('乐观锁更新成功');
SET @IsSuccess = 1;
END
ELSE
BEGIN
SET @IsSuccess = 0;
END
COMMIT TRAN
END
在 C# 调用端,如果执行失败(即 @IsSuccess = 0),可以采取重试机制:
public void ExecuteWithRetry(int skuId)
{
int maxRetry = 5;
bool success = false;
while (!success && maxRetry > 0)
{
success = CallStoredProcedure(skuId);
if (!success)
{
maxRetry--;
Thread.Sleep(100); // 短暂延迟后重试
}
}
}
4. Entity Framework 中的并发控制
Entity Framework (EF) 提供了内置的乐观锁支持。通过配置实体属性的 ConcurrencyMode,可以轻松处理冲突。
4.1 配置并发令牌
在 EF 的 .edmx 模型设计器中,或使用 Fluent API,可以将某个字段(如 RowVersion)的 ConcurrencyMode 设置为 Fixed。对于 SQL Server,建议使用 byte[] 类型并映射为 rowversion 字段。
// Fluent API 配置示例
modelBuilder.Entity<Stock>()
.Property(s => s.Version)
.IsRowVersion();
4.2 捕获并发异常
当执行 SaveChanges() 时,如果数据库中的版本号已改变,EF 会抛出 DbUpdateConcurrencyException。我们可以捕获该异常并决定是"数据库获胜"还是"当前客户端获胜"。
public bool SaveStockChanges(Stock updatedItem)
{
using (var db = new AppDbContext())
{
try
{
db.Entry(updatedItem).State = EntityState.Modified;
db.SaveChanges();
return true;
}
catch (DbUpdateConcurrencyException ex)
{
// 获取当前的数据库值
var entry = ex.Entries.Single();
var databaseValues = entry.GetDatabaseValues();
if (databaseValues == null)
{
// 记录已被删除
return false;
}
else
{
// 这里可以根据业务逻辑决定:重试、合并数据或放弃
return false;
}
}
}
}
5. 总结与建议
在处理高并发数据交互时:
- 若并发频率极高且业务逻辑复杂,考虑在数据库层面使用存储过程结合版本检查,以减少网络往返开销。
- 若使用 EF 等 ORM 工具,务必配置
rowversion字段作为并发令牌,并编写稳健的异常捕获与重试逻辑。 - 对于极度敏感的财务数据,在必要时仍需结合悲观锁(如事务隔离级别调整)确保绝对的数据安全性。