今天聊 EF Core 的并发控制。两个请求同时改同一条记录,没有并发令牌的话,后写的会直接覆盖前写的,数据就错了。
先说事情经过
秦巴牧云有个饲料库存模块。场长在小程序上领料,每次领料扣减库存。这个逻辑写了快一年,一直没事。
上个月出事了。
两个饲养员同时领了同一种饲料。一个领 200 公斤,一个领 150 公斤。当时库存还剩 300 公斤。按道理,第一个人领完剩 100,第二个人应该被告知库存不足。
但实际上两个请求都成功了。库存变成了 150 公斤。
不是 100,不是 50,是 150。这意味着第一个人领的 200 公斤被直接覆盖掉了。
多发了 200 公斤饲料。场长找我的时候脸色不太好看。
为什么会这样
先看没有并发控制的代码长什么样:
问题代码:
// 领料扣减库存
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。
改造后:
// 实体类:加 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 变成了这样:
-- 没有 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,重新读、重新算、重新写 | 并发量不高,冲突偶发 |
| 报错 | 直接返回「数据已被修改,请刷新后重试」 | 表单提交、编辑冲突 |
| 合并 | 读最新值,和你的修改做字段级合并 | 多字段编辑,不同字段改动不冲突 |
秦巴牧云的库存扣减用的是重试。因为领料操作本身是幂等的——重新读一次库存,重新判断够不够,重新扣减,结果一样。但要加重试次数限制,不然两个请求互相重试就死循环了:
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精选营
暂无评论,来抢沙发吧~