开群机器人|QQ群机器人|微信群机器人|三公机器人

开群机器人 利用 RowVersion(SQL Server 中的 rowversion 类型)解决高并发下的库存扣减或状态更新冲突

在 .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的秒杀)场景下,才考虑结合存储过程或悲观锁进行精细化控制。


admin
admin
这个人很神秘

发布评论