两个请求同时改库存,RowVersion 救了一命

大家好,我是码农刚子。今天聊 EF Core 的并发控制。两个请求同时改同一条记录,没有并发令牌的话,后写的会直接覆盖前写的,数据就错了。


先说事情经过

秦巴牧云有个饲料库存模块。场长在小程序上领料,每次领料扣减库存。这个逻辑写了快一年,一直没事。

上个月出事了。

两个饲养员同时领了同一种饲料。一个领 200 公斤,一个领 150 公斤。当时库存还剩 300 公斤。按道理,第一个人领完剩 100,第二个人应该被告知库存不足。

但实际上两个请求都成功了。库存变成了 150 公斤。

不是 100,不是 50,是 150。这意味着第一个人领的 200 公斤被直接覆盖掉了。

多发了 200 公斤饲料。场长找我的时候脸色不太好看。

为什么会这样

先看没有并发控制的代码长什么样:

问题代码:

csharp 复制代码
// 领料扣减库存
public async Task DeductStock(int feedId, int quantity)
{
    var feed = await db.FeedStocks
        .FirstAsync(f => f.Id == feedId);

    if (feed.Quantity < quantity)
        throw new InvalidOperationException("库存不足");

    feed.Quantity -= quantity;  // 在内存里改
    await db.SaveChangesAsync(); // 写回数据库
}

这段代码在单用户场景下没问题。但两个请求同时进来,就会出事。

两个请求都读到库存 300,各自在内存里算出结果,然后先后写回。第二个写回的时候,数据库里已经是 100 了,但它不知道,直接用自己算的 150 覆盖掉了。

这就是经典的**「丢失更新」**问题。EF Core 默认的 SaveChanges 不检测这种情况------它只管把你改的值写进去,不管别人有没有中间改过。

复制代码
请求 A(领 200)          请求 B(领 150)
     │                         │
  读库存 = 300              读库存 = 300
     │                         │
 300 - 200 = 100           300 - 150 = 150
     │                         │
  写回 100 ──────→ DB        │
     │                   写回 150 ──→ DB 覆盖!
     │                         │
     └─────────────────────────┘
              最终库存 = 150
     应该剩 50,实际剩 150 → 多发了 200

问题:两个请求都读到了 300,各自减各自的,后写的覆盖前写的
EF Core 默认是「最后一个赢」(Last-Write-Wins) 策略
解法:加 RowVersion 并发令牌

RowVersion 怎么救的

EF Core 的并发令牌机制是这样的:你给实体加一个 RowVersion 字段,类型是 byte[]。每次 UPDATE 时,EF Core 会自动把这个字段加到 WHERE 条件里,同时把它更新成新值。

如果在你读和写之间,别人已经改过这条记录,那 RowVersion 就变了,你的 WHERE 条件匹配不到行,UPDATE 返回 0 行。EF Core 发现 affected rows = 0,就抛 DbUpdateConcurrencyException。

改造后:

csharp 复制代码
// 实体类:加 RowVersion 并发令牌
public class FeedStock
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Quantity { get; set; }

    // 并发令牌:数据库自动维护的版本号
    public byte[] RowVersion { get; set; }
}

// EF Core 配置
modelBuilder.Entity<FeedStock>()
    .Property(f => f.RowVersion)
    .IsRowVersion();  // 标记为并发令牌

// 领料逻辑(带并发冲突处理)
public async Task<bool> DeductStock(int feedId, int quantity)
{
    try
    {
        var feed = await db.FeedStocks
            .FirstAsync(f => f.Id == feedId);

        if (feed.Quantity < quantity)
            throw new InvalidOperationException("库存不足");

        feed.Quantity -= quantity;
        await db.SaveChangesAsync();
        return true;
    }
    catch (DbUpdateConcurrencyException)
    {
        // 有人抢先改了这条记录
        // 重新读取,重试
        db.ChangeTracker.Clear();
        return await DeductStock(feedId, quantity);
    }
}

加了一个字段、一行配置、一个 catch。三个改动,丢失更新的问题就解决了。

SQL 层面,EF Core 生成的 UPDATE 变成了这样:

sql 复制代码
-- 没有 RowVersion 的 UPDATE
UPDATE FeedStocks SET Quantity = 150 WHERE Id = 1;
-- 不管别人改没改过,直接覆盖

-- 有 RowVersion 的 UPDATE
UPDATE FeedStocks
SET Quantity = 150,
    RowVersion = @newVersion        -- 更新版本号
WHERE Id = 1
  AND RowVersion = @oldVersion;     -- 检查版本号有没有变
-- 如果别人改过,RowVersion 对不上,0 行受影响 → 抛异常

关键在于 WHERE 条件多了一个 RowVersion 检查。 你读出来时 RowVersion 是 A,写回去时检查还是不是 A。如果中间有人改过,版本号变成 B 了,你的 A 匹配不上,更新就失败了。

数据库层面怎么配

EF Core 的 IsRowVersion() 会映射为数据库的 rowversion(SQL Server)或 xmin(PostgreSQL)类型。数据库引擎自己维护这个字段,每次 INSERT/UPDATE 自动更新。你不用管它。

秦巴牧云用的是 PostgreSQL。PostgreSQL 没有原生的 rowversion 类型,但有 xmin 系统列,每行都有一个隐藏的事务 ID。EF Core PostgreSQL 驱动(Npgsql)会用 xmin 作为并发令牌。

SQL Server 就更直接了,rowversion(旧名 timestamp)是内置类型,数据库自动维护,零配置。

捕获异常之后怎么处理

抛了 DbUpdateConcurrencyException 之后怎么办?有三种策略:

策略 做法 适用场景
重试 清空 ChangeTracker,重新读、重新算、重新写 并发量不高,冲突偶发
报错 直接返回「数据已被修改,请刷新后重试」 表单提交、编辑冲突
合并 读最新值,和你的修改做字段级合并 多字段编辑,不同字段改动不冲突

秦巴牧云的库存扣减用的是重试。因为领料操作本身是幂等的------重新读一次库存,重新判断够不够,重新扣减,结果一样。但要加重试次数限制,不然两个请求互相重试就死循环了:

csharp 复制代码
public async Task<bool> DeductStock(
    int feedId, int quantity, int retry = 0)
{
    if (retry >= 3)
        throw new InvalidOperationException(
            "库存变动频繁,请稍后重试");

    try
    {
        var feed = await db.FeedStocks
            .FirstAsync(f => f.Id == feedId);

        if (feed.Quantity < quantity)
            throw new InvalidOperationException("库存不足");

        feed.Quantity -= quantity;
        await db.SaveChangesAsync();
        return true;
    }
    catch (DbUpdateConcurrencyException)
    {
        db.ChangeTracker.Clear();
        return await DeductStock(feedId, quantity, retry + 1);
    }
}

三次重试还冲突,就告诉用户「库存变动频繁」。这比无限重试靠谱,也比直接报错友好。

几个容易踩的坑

坑一:别忘了 ChangeTracker.Clear()。 重试时如果不清空 ChangeTracker,旧的实体还在追踪里。你重新查出来的实体和旧的那个冲突,EF Core 会报「同一实体被两个实例追踪」的错。

坑二:RowVersion 字段不要手动赋值。 它是数据库维护的,你赋了反而会导致并发检查失效。EF Core 在 SaveChanges 时会自动把读出来的原值放进 WHERE 条件,你不用管。

坑三:重试不是万能的。 如果你的操作不是幂等的(比如「加 100」而不是「设为 X」),重试时必须重新读取最新值再算,不能拿着旧的中间结果直接重试。否则你加的就是两次 100。

坑四:大数据量表要加索引。 RowVersion 检查走的是 WHERE Id = 1 AND RowVersion = @x。Id 是主键有索引,没问题。但如果你用的是非主键的并发令牌(比如用 Name 做令牌),就得确保 Name 上有索引。

这件事教给我的

并发问题不是「会不会发生」的问题,是「什么时候发生」的问题。 一年的代码不出事,不代表逻辑是对的。只是并发量没到那个临界点。两个饲养员同时领料,在业务上完全合理------它迟早会发生。

EF Core 的 SaveChanges 不是原子的。 它保证了「你改的这些字段会被一起写进去」,但不保证「你读到的数据在写的时候还是那个值」。并发控制得你自己加------EF Core 给了工具(RowVersion、ConcurrencyToken),但不会自动帮你用。

乐观锁比悲观锁更适合 Web 场景。 悲观锁(SELECT ... FOR UPDATE)在数据库层面锁行,连接占用时间长,Web 高并发下容易连接池耗尽。乐观锁(RowVersion)不锁行,只在写的时候检测一下版本,冲突了重试就行。


最后

多发了 200 公斤饲料之后,我花了半天加上 RowVersion 和重试逻辑。代码改动不大,一个字段、一行配置、一个 catch。但如果不加,下一次出事就不是 200 公斤饲料的问题了。

一句话总结 :EF Core 默认 Last-Write-Wins,并发写同一条记录会丢更新。加 RowVersion 并发令牌,WHERE 条件多一个版本检查,冲突抛 DbUpdateConcurrencyException,catch 住重试。

这条你之前用过吗?评论区说说你的场景。

转发给还在用 EF Core 裸 SaveChanges 的同事,少走我们趟过的弯路。

关注 CSharp精选营,每周二四 get 能直接抄的 C# 实战。

下一篇聊:新项目选 Blazor 还是 Razor Pages?我交了学费。


关于作者

码农刚子,六年 ERP / 制造业系统开发,专注 C# / .NET / Blazor。

博客:https://www.coderlog.net/ 公众号:CSharp精选营

相关推荐
写后端的胖头鱼2 天前
一文详解CAS
java·jvm·spring·cas·乐观锁·自旋
红红谈说4 天前
图库描述怎么批量生成?正文配图匹配的一次工程复盘
人工智能·相似度匹配·并发控制·图片描述·图库管理
CSharp精选营2 个月前
用 .NET 8 + uni-app 做一套多租户畜牧 SaaS:我们在秦巴牧云踩过的 6 个坑
uni-app·efcore·.net8·多租户·saas踩坑
零域码客2 个月前
Redis 双刃剑:缓存与分布式锁的本质区别
redis·分布式·缓存·并发控制·后端架构
AI人工智能+电脑小能手2 个月前
【大白话说Java面试题 第205题】【09_Zookeeper篇】第6题:ZooKeeper 实现分布式锁的原理
java·zookeeper·分布式锁·并发控制·分布式协调
2301_768103493 个月前
HarmonyOS趣味相机实战第16篇:实时识别采样、Busy锁与Generation并发治理
harmonyos·arkts·并发控制·camerakit·corevisionkit
递归尽头是星辰4 个月前
不同架构层级下的多版本设计:从业务设计到微服务与大数据层
数据湖·乐观锁·mvcc·分布式架构·多版本控制
.NET修仙日记5 个月前
.NET EFCore批量插入性能优化实战:30秒 → 0.5秒
性能优化·c#·.net·.netcore·微软技术·efcore·踩坑实录
月落归舟5 个月前
深入剖析乐观锁背后的原理
java·乐观锁