在一个电商系统中,库存只剩最后一件,两个用户几乎同时点击了购买按钮。如果没有并发控制,很可能出现超卖:两个请求都读到库存为1,各自扣减后写入0,实际却卖出了两件。解决这类问题的核心技术就是乐观锁和悲观锁。这两种思路在实现方式、性能表现和适用场景上差异很大,很多c#开发者只知道概念,却不知道在实际项目中该怎么选。本文把两者的原理、代码实现和选型思路一次讲透。

乐观锁和悲观锁的底层原理
首先要明确一点:乐观锁和悲观锁是一种并发控制的思想,而不是数据库中某个具体的锁对象。悲观锁指的是在读取数据时就认为冲突一定会发生,于是直接对数据加锁,其他事务在锁释放前无法修改这条数据。在SQL Server中通常通过SELECT ... WITH (UPDLOCK, ROWLOCK)实现,MySQL中则是SELECT ... FOR UPDATE。它的特点是先加锁、后操作,整个过程中数据被独占。
乐观锁则相反,它读取数据时不加任何锁,只在提交更新的一瞬间检查数据是否被别人改过。最经典的实现是版本号机制:表中加一个Version字段,每次更新时版本号加一,UPDATE语句的WHERE条件里带上读出来的旧版本号。如果受影响行数为0,说明数据已经被其他事务修改,本次更新失败,需要业务层决定是重试还是报错。除了版本号,也可以用时间戳或者直接比对整行字段值(类似EF Core的并发令牌)。
两者的本质区别在于对待冲突的态度:悲观锁假设冲突频繁,用加锁换取确定性;乐观锁假设冲突稀少,用重试机制换取更高的吞吐。这个假设决定了它们的适用边界,也是后面选型的核心依据。
c#中实现两种锁的完整代码
先看悲观锁的实现。假设有一个商品库存表,使用ADO.NET配合SQL Server的UPDLOCK提示,在事务内锁定目标行:
public bool DeductStockPessimistic(SqlConnection conn, int productId, int quantity)
{
conn.Open();
using var transaction = conn.BeginTransaction();
try
{
// UPDLOCK 让其他事务无法同时修改这一行
string sql = @"SELECT Stock FROM Products WITH (UPDLOCK, ROWLOCK)
WHERE ProductId = @ProductId";
int stock;
using (var cmd = new SqlCommand(sql, conn, transaction))
{
cmd.Parameters.AddWithValue("@ProductId", productId);
stock = Convert.ToInt32(cmd.ExecuteScalar());
}
if (stock < quantity)
{
transaction.Rollback();
return false; // 库存不足
}
string updateSql = @"UPDATE Products SET Stock = Stock - @Qty
WHERE ProductId = @ProductId";
using (var cmd = new SqlCommand(updateSql, conn, transaction))
{
cmd.Parameters.AddWithValue("@Qty", quantity);
cmd.Parameters.AddWithValue("@ProductId", productId);
cmd.ExecuteNonQuery();
}
transaction.Commit();
return true;
}
catch
{
transaction.Rollback();
throw;
}
}
这段代码的关键在于UPDLOCK:从SELECT开始该行就被锁定,直到事务提交,其他事务的同类查询会阻塞等待。也就是说,检查库存和扣减库存两步操作是原子的,不可能出现超卖。
再看乐观锁的版本号实现:
public bool DeductStockOptimistic(SqlConnection conn, int productId, int quantity)
{
conn.Open();
// 第一步:不加锁地读出库存和版本号
int stock, version;
string readSql = @"SELECT Stock, Version FROM Products
WHERE ProductId = @ProductId";
using (var cmd = new SqlCommand(readSql, conn))
{
cmd.Parameters.AddWithValue("@ProductId", productId);
using var reader = cmd.ExecuteReader();
reader.Read();
stock = reader.GetInt32(0);
version = reader.GetInt32(1);
}
if (stock < quantity) return false;
// 第二步:带上旧版本号更新,版本号不匹配则失败
string updateSql = @"UPDATE Products
SET Stock = Stock - @Qty, Version = Version + 1
WHERE ProductId = @ProductId AND Version = @Version";
using (var cmd = new SqlCommand(updateSql, conn))
{
cmd.Parameters.AddWithValue("@Qty", quantity);
cmd.Parameters.AddWithValue("@ProductId", productId);
cmd.Parameters.AddWithValue("@Version", version);
int affected = cmd.ExecuteNonQuery();
return affected > 0; // 0表示并发冲突,需要重试
}
}
如果使用EF Core,乐观锁的写法更简洁,只需把版本字段配置为并发令牌:
// 实体类
public class Product
{
public int ProductId { get; set; }
public int Stock { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
}
// 更新时捕获并发冲突
try
{
product.Stock -= quantity;
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
// 数据已被其他人修改,提示用户刷新重试
return Conflict("数据已被其他用户修改,请刷新后重试");
}
注意EF Core抛出的DbUpdateConcurrencyException就是乐观锁冲突的信号,此时数据库中的值已经和你内存中的实体不一致,直接覆盖保存会产生脏数据,正确做法是把冲突抛给上层处理。
性能对比与场景选择建议
从性能角度看,悲观锁在冲突频繁的场景下表现更好,因为它避免了大量失败重试带来的CPU和连接开销;而乐观锁在冲突稀少时几乎零开销,读取阶段完全不加锁,数据库并发吞吐量最高。但如果冲突率高,乐观锁会导致大量更新失败和重试,反而拖垮系统;悲观锁在高并发下则容易出现锁等待、连接池耗尽甚至死锁。
具体怎么选,可以参考以下几个判断维度。第一看冲突概率:热点数据(如秒杀商品、热门直播间)冲突极高,用悲观锁或者直接上分布式锁更稳妥;普通业务数据(如用户资料、订单备注)冲突极低,乐观锁足够。第二看业务能否接受重试:乐观锁失败后需要用户或程序重试,如果是后台任务可以自动重试,如果是前端表单提交则最好给出友好提示。第三看事务时长:悲观锁持有锁的时间等于事务时长,事务里千万别夹带RPC调用、发短信这类慢操作,否则锁会一直不释放。
实际项目中两者经常组合使用。比如订单审核流程用乐观锁版本号防止审核和修改互踩,而资金转账这类强一致性要求极高的操作用悲观锁串行化处理。还有一个常见的折中方案是条件更新,即UPDATE Products SET Stock = Stock - @Qty WHERE Stock >= @Qty,一行SQL原子完成检查和扣减,本质上是数据库层面的乐观控制,既没有版本号字段,也不需要显式加锁,在库存扣减场景中往往是首选。
总结一句:并发量小、冲突少、业务可重试,选乐观锁;并发集中、冲突频繁、一致性要求强,选悲观锁。拿不准的时候,先用条件更新这种简单方案兜底,等监控数据证明确实存在冲突热点,再升级到显式锁策略,这样既不过度设计,也留足了演进空间。