在 .NET 开发中,利用 RowVersion(SQL Server 中的 rowversion 类型)解决高并发下的库存扣减或状态更新冲突,是乐观锁(Optimistic Locking)最经典且高效的落地实践。
当两个请求同时修改同一条库存记录时,RowVersion 通过“版本号校验”机制,确保只有第一个提交的请求能成功,第二个请求会被数据库拒绝并抛出异常,从而避免数据覆盖(Lost Update)问题。
以下是基于 EF Core 和 SQL Server 的完整实战解析,涵盖原理、配置、代码实现及异常处理策略。
1. 核心原理:为什么 RowVersion 能救命?
RowVersion 是 SQL Server 提供的一种自动生成的二进制字段(8字节)。
自动递增:每次该行数据发生任何更新(Update),数据库引擎会自动生成一个新的、唯一的二进制值。
不可篡改:应用层无法手动赋值或跳过更新,由数据库强制维护。
CAS 机制:EF Core 在执行 UPDATE 时,会将读取时的旧 RowVersion 值放入 WHERE 子句。
sql
-- EF Core 生成的 SQL 示意
UPDATE [Products]
SET [Stock] = @p0, [RowVersion] = @p1
WHERE [Id] = @p2 AND [RowVersion] = @p3; -- @p3 是读取时的旧版本
-- 如果此时另一事务已修改该行,RowVersion 已变,WHERE 条件不匹配
-- 受影响行数为 0,EF Core 判定为并发冲突
相比手写 int Version 字段,RowVersion 更可靠,因为它由引擎保证原子性和唯一性,无需担心应用层逻辑漏写 Version++。
2. EF Core 配置步骤
要让 RowVersion 生效,必须严格满足以下三个条件,缺一不可:
第一步:实体类定义
属性类型必须是 byte[]。
csharp
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public int Stock { get; set; }
// 必须为 byte[] 类型
public byte[] RowVersion { get; set; }
}
第二步:模型配置
在 OnModelCreating 中使用 IsRowVersion() 标记该属性为并发令牌。
csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(p => p.Id);
// 关键配置:告诉 EF Core 此列用于乐观并发控制
entity.Property(p => p.RowVersion).IsRowVersion();
});
}
第三步:数据库迁移
执行 Add-Migration 和 Update-Database 后,检查生成的迁移文件,确保列类型为 rowversion 且非空。
csharp
migrationBuilder.AddColumn<byte[]>(
name: "RowVersion",
table: "Products",
type: "rowversion",
nullable: false);
3. 业务代码实现与异常处理
RowVersion 本身只负责检测冲突,不负责解决冲突。当冲突发生时,EF Core 会抛出 DbUpdateConcurrencyException。你需要根据业务场景决定如何处理。
场景一:库存扣减(失败即报错,提示用户重试)
适用于秒杀、抢购等强一致性场景,不允许静默覆盖。
csharp
public async Task<bool> DeductStockAsync(int productId, int quantity)
{
using var context = new AppDbContext();
try
{
// 1. 查询商品(跟踪状态,EF Core 会记录当前的 RowVersion)
var product = await context.Products.FindAsync(productId);
if (product == null || product.Stock < quantity)
return false;
// 2. 业务逻辑:扣减库存
product.Stock -= quantity;
// 3. 保存更改
// 此时 EF Core 生成的 SQL 会带上 WHERE RowVersion = @OriginalValue
await context.SaveChangesAsync();
return true;
}
catch (DbUpdateConcurrencyException ex)
{
// 4. 捕获并发冲突
// 日志记录:谁在什么时间发生了冲突
Console.WriteLine($"并发冲突 detected for product {productId}");
// 策略:直接返回失败,让前端提示“库存变化,请刷新后重试”
return false;
}
}
场景二:最后写入获胜(Last Write Wins)
适用于后台编辑商品信息等非敏感场景,允许后提交的覆盖先提交的。
csharp
public async Task UpdateProductAsync(Product updatedProduct)
{
using var context = new AppDbContext();
try
{
context.Products.Update(updatedProduct);
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
// 获取数据库中的最新值
var databaseEntry = ex.Entries.Single();
var databaseValues = await databaseEntry.GetDatabaseValuesAsync();
if (databaseValues == null)
{
throw new InvalidOperationException("记录已被删除");
}
// 策略:用数据库的最新值覆盖当前实体的原始值(Original Values)
// 这相当于告诉 EF:“我接受数据库现在的状态作为基准”
databaseEntry.OriginalValues.SetValues(databaseValues);
// 再次尝试保存(此时 RowVersion 已更新为最新,WHERE 条件匹配成功)
await context.SaveChangesAsync();
}
}
场景三:合并冲突(Merge Changes)
适用于协作编辑场景,需要保留双方的修改。
csharp
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync();
var clientValues = entry.CurrentValues.ToObject(); // 用户提交的值
// 自定义合并逻辑:例如,如果用户只改了名称,而数据库只改了价格,则合并
// 这里需要根据具体业务字段判断
entry.CurrentValues.SetValues(databaseValues); // 先同步数据库最新状态
entry.CurrentValues["Name"] = ((Product)clientValues).Name; // 再应用用户的修改
await context.SaveChangesAsync();
}
4. 常见踩坑点与排查
如果 RowVersion 没有生效(即并发修改未抛出异常,数据被覆盖),请检查以下几点:
属性类型错误:实体类中 RowVersion 必须是 byte[],如果是 long、DateTime 或 string,EF Core 不会将其识别为并发令牌。
忘记配置 IsRowVersion():如果没有在 Fluent API 中调用 .IsRowVersion(),EF Core 只会把它当作普通二进制列,不会在 UPDATE 的 WHERE 子句中包含它。
使用了 AsNoTracking():
csharp
// 错误示范
var product = await context.Products.AsNoTracking().FirstAsync(p => p.Id == id);
product.Stock -= 1;
context.Products.Update(product); // Attach 后 OriginalValues 为空或错误
await context.SaveChangesAsync(); // 不会检查 RowVersion
AsNoTracking() 查询出的实体不包含原始值快照。如果后续要更新,必须重新查询或使用 Attach 并手动设置原始值,否则并发检查失效。建议并发场景下不要对要更新的实体使用 AsNoTracking。
手动赋值 RowVersion:不要在代码中给 RowVersion 赋值(如 product.RowVersion = new byte[8]),这会破坏数据库的自动生成机制,导致校验逻辑混乱。
SQL Server 兼容级别:确保数据库兼容级别至少为 110(SQL Server 2012+),虽然 rowversion 历史悠久,但某些新特性依赖较高版本。
5. 悲观锁 vs 乐观锁:如何选择?
表格
特性 乐观锁 (RowVersion) 悲观锁 (SELECT ... WITH UPDLOCK)
适用场景 读多写少,冲突概率低 写多读少,冲突概率极高(如秒杀库存)
性能影响 无锁开销,吞吐量高 锁持有期间阻塞其他事务,吞吐量低
用户体验 冲突时需重试或提示 用户等待锁释放,响应慢但必成功
死锁风险 极低 高(尤其长事务中)
实现复杂度 简单(EF Core 原生支持) 复杂(需手动管理事务和锁提示)
结论:
对于大多数电商库存、订单状态流转场景,RowVersion 乐观锁是首选。它在保证数据一致性的同时,提供了最佳的性能和用户体验。只有在极端高并发写冲突(如剩余库存为1的秒杀)场景下,才考虑结合存储过程或悲观锁进行精细化控制。
发布评论