首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >.NET EF Core 搞懂乐观锁与悲观锁

.NET EF Core 搞懂乐观锁与悲观锁

原创
作者头像
步步为营DotNet
发布2026-08-26 16:27:36
发布2026-08-26 16:27:36
1070
举报

在开发业务系统时,经常会遇到一个问题:

两个人同时修改同一条数据,怎么办?

例如数据库里有一条商品:

代码语言:javascript
复制
商品:iPhone
库存:10

此时用户 A 和用户 B 几乎同时购买。

如果两个人都读取到:

代码语言:javascript
复制
库存 = 10

然后各自执行:

代码语言:javascript
复制
10 - 1 = 9

最后数据库可能变成:

代码语言:javascript
复制
库存 = 9

但实际上应该是:

代码语言:javascript
复制
库存 = 8

这就是典型的并发更新问题

解决这类问题,最常见的两种思路就是:

  • 乐观锁(Optimistic Concurrency)
  • 悲观锁(Pessimistic Locking)

一、先理解:为什么会出现并发问题?

假设数据库有一条数据:

代码语言:javascript
复制
Id = 1
Name = iPhone
Stock = 10

现在同时来了两个请求。

请求 A和请求 B同时

购买商品:

于是:

代码语言:javascript
复制
A:10 → 9

B:10 → 9

最后数据库:

代码语言:javascript
复制
Stock = 9

实际上两个用户都购买成功了,所以正确结果应该是:

代码语言:javascript
复制
Stock = 8

问题就在于:

A 和 B 都基于“旧数据”进行了修改。

这就是并发控制要解决的问题。


二、乐观锁和悲观锁到底有什么区别?

先不要记复杂定义。

一句话就可以理解:

乐观锁:我认为冲突不经常发生,先干,提交时发现别人改过了,再处理。

悲观锁:我认为冲突可能发生,修改之前先把数据锁住,不让别人同时修改。

可以把它理解成两个人编辑同一个 Word 文件。

乐观锁

两个人都可以打开文件。

A 修改:

代码语言:javascript
复制
价格:100 → 90

B 也修改:

代码语言:javascript
复制
价格:100 → 80

A 先保存成功。

B 保存时发现:

你打开文件以后,这条数据已经被别人修改过了。

于是 B 保存失败。

悲观锁

A 打开文件以后直接锁住。

B 想修改:

代码语言:javascript
复制
等着……

A 修改完成:

代码语言:javascript
复制
释放锁

B 才可以继续修改。

这就是两种锁最核心的区别。


三、EF Core 最推荐先掌握:乐观并发

在 EF Core 中,乐观并发是非常常见的。

最简单的实现方式之一,就是使用:

代码语言:javascript
复制
IsRowVersion()

四、IsRowVersion() 到底是什么?

先创建一个商品实体:

代码语言:javascript
复制
public class Product
{
    public int Id { get; set; }

    public string Name { get; set; } = "";

    public decimal Price { get; set; }

    public int Stock { get; set; }

    public byte[] RowVersion { get; set; } = [];
}

然后在 EF Core 中配置:

代码语言:javascript
复制
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .Property(x => x.RowVersion)
        .IsRowVersion();
}

这里的:

代码语言:javascript
复制
.IsRowVersion()

就是告诉 EF Core:

RowVersion 是这条数据的并发版本字段。

在 SQL Server 中,通常会映射成:

代码语言:javascript
复制
rowversion

五、RowVersion 到底有什么用?

这是理解 EF Core 乐观锁最重要的地方。

假设数据库:

代码语言:javascript
复制
Id        Name      Stock      RowVersion
1         iPhone    10         AAA

A 查询:

代码语言:javascript
复制
Stock = 10
RowVersion = AAA

B 查询:

代码语言:javascript
复制
Stock = 10
RowVersion = AAA

注意:

A 和 B 拿到的是相同版本 AAA


A 修改

A:

代码语言:javascript
复制
Stock: 10 → 9

然后执行:

代码语言:javascript
复制
await db.SaveChangesAsync();

EF Core 不只是简单执行:

代码语言:javascript
复制
UPDATE Products
SET Stock = 9
WHERE Id = 1

它会把原来的版本也作为条件。

逻辑上相当于:

代码语言:javascript
复制
UPDATE Products
SET Stock = 9
WHERE Id = 1
  AND RowVersion = @OldRowVersion

也就是说:

只有数据库中的 RowVersion 还是我刚才读取的版本,我才允许修改。


六、A 修改成功之后发生什么?

A 修改成功:

代码语言:javascript
复制
Stock = 9

数据库的 RowVersion 也会自动变化:

代码语言:javascript
复制
AAA → BBB

数据库变成:

代码语言:javascript
复制
Id        Name      Stock      RowVersion
1         iPhone    9          BBB

七、这时候 B 再修改会怎么样?

B 手里的数据还是:

代码语言:javascript
复制
Stock = 10
RowVersion = AAA

B 修改:

代码语言:javascript
复制
Stock: 10 → 9

然后:

代码语言:javascript
复制
await db.SaveChangesAsync();

EF Core 相当于执行:

代码语言:javascript
复制
UPDATE Products
SET Stock = 9
WHERE Id = 1
  AND RowVersion = AAA

但是数据库现在是:

代码语言:javascript
复制
RowVersion = BBB

所以:

代码语言:javascript
复制
WHERE Id = 1
AND RowVersion = AAA

匹配不到任何记录。

结果:

代码语言:javascript
复制
影响行数 = 0

EF Core 就知道:

这条数据在我读取以后已经被别人修改过了。

于是抛出:

代码语言:javascript
复制
DbUpdateConcurrencyException

这就是 EF Core 的乐观并发异常


八、最简单的完整代码

实体:

代码语言:javascript
复制
public class Product
{
    public int Id { get; set; }

    public string Name { get; set; } = "";

    public decimal Price { get; set; }

    public int Stock { get; set; }

    public byte[] RowVersion { get; set; } = [];
}

EF Core 配置:

代码语言:javascript
复制
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .Property(x => x.RowVersion)
        .IsRowVersion();
}

修改:

代码语言:javascript
复制
var product = await db.Products.FindAsync(1);

product!.Price = 99;

try
{
    await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
    Console.WriteLine("数据已经被其他人修改,请重新读取!");
}

就这么简单。


九、为什么 EF Core 能知道别人修改过?

核心其实不是 EF Core 有什么神奇能力。

本质上就是:

代码语言:javascript
复制
版本号

例如:

代码语言:javascript
复制
第一次读取:

Id = 1
Price = 100
RowVersion = A

修改时:

代码语言:javascript
复制
UPDATE Product
SET Price = 99
WHERE Id = 1
AND RowVersion = A

如果成功:

代码语言:javascript
复制
影响 1 行

说明:

代码语言:javascript
复制
数据没有被别人修改

如果:

代码语言:javascript
复制
影响 0 行

说明:

代码语言:javascript
复制
数据版本已经变了

EF Core 根据这个结果判断发生了并发冲突。

所以可以把乐观锁理解成:

利用版本号 + UPDATE 条件,检测数据在读取之后有没有被别人修改。


十、为什么叫“乐观锁”?

因为它并没有真正把数据库记录“锁住”。

A 读取:

代码语言:javascript
复制
Product 1

B 依然可以读取:

代码语言:javascript
复制
Product 1

甚至 B 也可以修改。

它只是:

到最终提交的时候,检查一下版本有没有变化。

所以它其实更准确地说是:

代码语言:javascript
复制
乐观并发控制
Optimistic Concurrency Control

而不是传统意义上:

代码语言:javascript
复制
把数据库记录锁住

十一、悲观锁又是什么?

悲观锁完全是另外一种思路。

它认为:

这条数据很可能马上发生冲突,所以我修改之前就把它锁住。

例如:

代码语言:javascript
复制
A:

开始事务
↓
查询商品并加排他锁
↓
修改库存
↓
提交事务
↓
释放锁

此时:

代码语言:javascript
复制
B:

开始事务
↓
查询同一商品
↓
等待……

直到 A 提交事务以后,B 才能继续。


十二、悲观锁最常见的应用场景:库存

例如:

代码语言:javascript
复制
库存 = 1

现在同时来了:

代码语言:javascript
复制
用户 A
用户 B

如果这是一个非常高并发的库存扣减场景,有时候就需要更加严格的数据库锁控制。

典型思路:

代码语言:javascript
复制
开启事务
↓
锁定商品
↓
读取库存
↓
判断库存
↓
库存 - 1
↓
提交事务

十三、EF Core 中的悲观锁要特别注意

这里有一个非常重要的概念:

EF Core 本身没有一个通用的 .UsePessimisticLock()

也就是说,你不能简单写:

代码语言:javascript
复制
db.Products
    .Where(x => x.Id == 1)
    .UsePessimisticLock();

不存在这种通用 API。

为什么?

因为:

悲观锁本质上是数据库能力。

不同数据库的锁机制、SQL 语法并不一样。

例如 SQL Server、MySQL、PostgreSQL 的实现方式都不同。

所以 EF Core 通常需要通过:

代码语言:javascript
复制
数据库事务
+
数据库特定 SQL

来实现悲观锁。


十四、SQL Server 中理解悲观锁

例如 SQL Server 可以使用:

代码语言:javascript
复制
SELECT *
FROM Products WITH (UPDLOCK, ROWLOCK)
WHERE Id = 1;

这里:

代码语言:javascript
复制
UPDLOCK

表示:

读取的时候获取更新锁。

这样其他事务想同时修改这条记录,就需要等待。

结合事务:

代码语言:javascript
复制
await using var transaction =
    await db.Database.BeginTransactionAsync();

var product = await db.Products
    .FromSqlInterpolated($"""
        SELECT *
        FROM Products WITH (UPDLOCK, ROWLOCK)
        WHERE Id = {id}
        """)
    .SingleAsync();

if (product.Stock <= 0)
{
    return;
}

product.Stock--;

await db.SaveChangesAsync();

await transaction.CommitAsync();

这里真正关键的是:

代码语言:javascript
复制
BeginTransactionAsync()

以及:

代码语言:javascript
复制
WITH (UPDLOCK, ROWLOCK)

十五、为什么悲观锁必须配合事务?

这是很多初学者最容易搞错的地方。

错误理解:

代码语言:javascript
复制
var product = await db.Products
    .FromSqlRaw("SELECT ... WITH (UPDLOCK)")
    .FirstAsync();

product.Stock--;

await db.SaveChangesAsync();

然后认为:

我已经加锁了。

实际上你必须考虑:

这个锁什么时候释放?

数据库锁通常与事务生命周期密切相关。

正确思路是:

代码语言:javascript
复制
BEGIN TRANSACTION
        ↓
SELECT ... FOR UPDATE / UPDLOCK
        ↓
修改数据
        ↓
SAVE
        ↓
COMMIT
        ↓
释放锁

所以悲观锁的核心不是某个 SQL 关键字,而是:

在事务中获取数据库锁,并在事务结束后释放。


十六、乐观锁和悲观锁的真正区别

可以用一个表直接理解:

对比

乐观锁

悲观锁

核心思想

先操作,提交时检查

操作之前先加锁

是否阻塞别人

通常不会

可能会

冲突处理

发生冲突后处理

尽量提前避免冲突

EF Core 支持

原生支持

通常依赖数据库 SQL

常见实现

RowVersion

DB Lock + Transaction

并发性能

通常较好

可能产生等待

编程复杂度

较低

较高

适合场景

一般业务更新

强一致、高冲突场景


十七、实际开发到底应该用哪个?

对于绝大多数普通业务系统:

优先考虑乐观并发控制。

例如:

代码语言:javascript
复制
用户资料
商品信息
订单信息
配置数据
文章
项目数据
后台管理数据

这些场景通常没有必要为了修改一条数据就把数据库记录锁住。

使用:

代码语言:javascript
复制
.IsRowVersion()

就已经非常实用。


十八、什么时候考虑悲观锁?

当你的业务具有明显的:

代码语言:javascript
复制
高竞争
+
不能允许同时修改
+
必须保证操作过程中的数据一致性

例如:

代码语言:javascript
复制
库存扣减
账户余额
资金账户
抢购
资源分配

这类场景才需要认真考虑数据库锁。

不过需要强调:

库存、余额等场景并不意味着一定要使用悲观锁。

很多情况下,通过:

代码语言:javascript
复制
UPDATE Products
SET Stock = Stock - 1
WHERE Id = @id
AND Stock > 0

这种原子更新,或者使用乐观并发控制,同样可以解决问题。

不要为了“用了锁所以更安全”而盲目使用悲观锁。


十九、还有一个容易混淆的概念:数据库事务 ≠ 乐观锁

很多刚接触 EF Core 的开发者会认为:

代码语言:javascript
复制
BeginTransactionAsync()

就是加锁。

不是。

事务主要解决的是:

一组数据库操作要么全部成功,要么全部失败。

例如:

代码语言:javascript
复制
扣库存
+
创建订单
+
记录支付信息

希望:

代码语言:javascript
复制
全部成功

或者:

代码语言:javascript
复制
全部回滚

这是事务解决的问题。

而:

代码语言:javascript
复制
A 和 B 同时修改同一条数据怎么办?

这是并发控制解决的问题。

两者不是一个概念。


二十、事务和锁又是什么关系?

三者最好分开理解:

代码语言:javascript
复制
事务
    ↓
保证一组操作的原子性、一致性等

锁
    ↓
控制并发访问

乐观并发
    ↓
通过版本检测冲突

悲观并发
    ↓
通过数据库锁提前阻止冲突

实际项目中它们经常组合使用。

例如悲观锁:

代码语言:javascript
复制
事务
  +
数据库锁

而乐观并发也可以运行在事务体系中。


二十一、IsRowVersion() 有一个重要限制

IsRowVersion() 并不是所有数据库都可以完全按照 SQL Server 的方式工作。

它最典型的场景是:

代码语言:javascript
复制
SQL Server

SQL Server 中:

代码语言:javascript
复制
.IsRowVersion()

会使用数据库的 rowversion 机制。

如果你使用的是:

代码语言:javascript
复制
MySQL
PostgreSQL
SQLite

就不能简单地认为:

代码语言:javascript
复制
.IsRowVersion()

会产生完全一样的数据库行为。

所以实际开发时一定要区分:

EF Core 的并发令牌机制

和:

SQL Server 的 rowversion 数据库类型

它们不是完全等价的概念。


二十二、EF Core 真正抽象的是“Concurrency Token”

实际上,EF Core 的乐观并发机制核心是:

代码语言:javascript
复制
Concurrency Token

RowVersion 只是最常见的一种实现。

例如:

代码语言:javascript
复制
modelBuilder.Entity<Product>()
    .Property(x => x.RowVersion)
    .IsConcurrencyToken();

或者使用 SQL Server 常见的:

代码语言:javascript
复制
.IsRowVersion();

可以简单理解成:

代码语言:javascript
复制
IsConcurrencyToken()
    ↓
告诉 EF:这个字段参与并发检查

IsRowVersion()
    ↓
告诉 EF:这是一个 RowVersion 字段
    +
在 SQL Server 中使用 rowversion 机制

二十三、如果不用 RowVersion,也可以做乐观锁吗?

当然可以。

例如实体:

代码语言:javascript
复制
public class Product
{
    public int Id { get; set; }

    public decimal Price { get; set; }

    public int Version { get; set; }
}

配置:

代码语言:javascript
复制
modelBuilder.Entity<Product>()
    .Property(x => x.Version)
    .IsConcurrencyToken();

数据库:

代码语言:javascript
复制
Id = 1
Price = 100
Version = 5

A 读取:

代码语言:javascript
复制
Version = 5

B 读取:

代码语言:javascript
复制
Version = 5

A 修改:

代码语言:javascript
复制
Version = 6

B 修改时,EF Core 会要求:

代码语言:javascript
复制
WHERE Version = 5

但是数据库已经:

代码语言:javascript
复制
Version = 6

于是更新失败。

所以:

乐观锁真正的核心是“并发令牌”,而不是必须叫 RowVersion。


二十四、用一句 SQL 彻底理解乐观锁

如果你只想记住一条 SQL,记住这个:

代码语言:javascript
复制
UPDATE Products
SET Price = 90
WHERE Id = 1
AND RowVersion = @OldRowVersion;

这就是乐观并发控制最核心的思想。

翻译成人话:

“我只允许修改我刚才看到的那一版数据。”

如果数据库已经不是那一版:

代码语言:javascript
复制
更新 0 行

EF Core:

代码语言:javascript
复制
DbUpdateConcurrencyException

二十五、用一句话彻底理解悲观锁

悲观锁可以理解成:

代码语言:javascript
复制
先锁住
↓
别人等着
↓
我修改
↓
提交
↓
释放锁

它解决的是:

“在我处理这条数据期间,不希望其他事务同时修改它。”


二十六、实际项目中的选择建议

如果你刚开始使用 EF Core,不要一上来就研究各种数据库锁。

建议按照下面的顺序掌握:

第一阶段:普通 EF Core

先学会:

代码语言:javascript
复制
FindAsync()
代码语言:javascript
复制
SaveChangesAsync()
代码语言:javascript
复制
BeginTransactionAsync()

理解:

代码语言:javascript
复制
实体
DbContext
事务

第二阶段:乐观并发

重点掌握:

代码语言:javascript
复制
.IsRowVersion()

以及:

代码语言:javascript
复制
DbUpdateConcurrencyException

理解:

代码语言:javascript
复制
版本号
    ↓
UPDATE ... WHERE Version = oldVersion
    ↓
影响 0 行
    ↓
并发冲突

这一套是最值得掌握的。


第三阶段:悲观锁

再学习:

代码语言:javascript
复制
Transaction
+
SELECT ... FOR UPDATE

或者 SQL Server:

代码语言:javascript
复制
WITH (UPDLOCK)

理解:

代码语言:javascript
复制
数据库锁
锁粒度
锁等待
死锁
事务隔离级别

到了这一步,你才真正开始进入数据库并发控制。


原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 在开发业务系统时,经常会遇到一个问题:
  • 一、先理解:为什么会出现并发问题?
    • 请求 A和请求 B同时
  • 二、乐观锁和悲观锁到底有什么区别?
    • 乐观锁
    • 悲观锁
  • 三、EF Core 最推荐先掌握:乐观并发
  • 四、IsRowVersion() 到底是什么?
  • 五、RowVersion 到底有什么用?
    • A 修改
  • 六、A 修改成功之后发生什么?
  • 七、这时候 B 再修改会怎么样?
  • 八、最简单的完整代码
  • 九、为什么 EF Core 能知道别人修改过?
  • 十、为什么叫“乐观锁”?
  • 十一、悲观锁又是什么?
  • 十二、悲观锁最常见的应用场景:库存
  • 十三、EF Core 中的悲观锁要特别注意
  • 十四、SQL Server 中理解悲观锁
  • 十五、为什么悲观锁必须配合事务?
  • 十六、乐观锁和悲观锁的真正区别
  • 十七、实际开发到底应该用哪个?
  • 十八、什么时候考虑悲观锁?
  • 十九、还有一个容易混淆的概念:数据库事务 ≠ 乐观锁
  • 二十、事务和锁又是什么关系?
  • 二十一、IsRowVersion() 有一个重要限制
  • 二十二、EF Core 真正抽象的是“Concurrency Token”
  • 二十三、如果不用 RowVersion,也可以做乐观锁吗?
  • 二十四、用一句 SQL 彻底理解乐观锁
  • 二十五、用一句话彻底理解悲观锁
  • 二十六、实际项目中的选择建议
    • 第一阶段:普通 EF Core
    • 第二阶段:乐观并发
    • 第三阶段:悲观锁
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档