
两个人同时修改同一条数据,怎么办?
例如数据库里有一条商品:
商品:iPhone
库存:10此时用户 A 和用户 B 几乎同时购买。
如果两个人都读取到:
库存 = 10然后各自执行:
10 - 1 = 9最后数据库可能变成:
库存 = 9但实际上应该是:
库存 = 8这就是典型的并发更新问题。
解决这类问题,最常见的两种思路就是:
假设数据库有一条数据:
Id = 1
Name = iPhone
Stock = 10现在同时来了两个请求。
购买商品:
于是:
A:10 → 9
B:10 → 9最后数据库:
Stock = 9实际上两个用户都购买成功了,所以正确结果应该是:
Stock = 8问题就在于:
A 和 B 都基于“旧数据”进行了修改。
这就是并发控制要解决的问题。
先不要记复杂定义。
一句话就可以理解:
乐观锁:我认为冲突不经常发生,先干,提交时发现别人改过了,再处理。
悲观锁:我认为冲突可能发生,修改之前先把数据锁住,不让别人同时修改。
可以把它理解成两个人编辑同一个 Word 文件。
两个人都可以打开文件。
A 修改:
价格:100 → 90B 也修改:
价格:100 → 80A 先保存成功。
B 保存时发现:
你打开文件以后,这条数据已经被别人修改过了。
于是 B 保存失败。
A 打开文件以后直接锁住。
B 想修改:
等着……A 修改完成:
释放锁B 才可以继续修改。
这就是两种锁最核心的区别。
在 EF Core 中,乐观并发是非常常见的。
最简单的实现方式之一,就是使用:
IsRowVersion()先创建一个商品实体:
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 中配置:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.Property(x => x.RowVersion)
.IsRowVersion();
}这里的:
.IsRowVersion()就是告诉 EF Core:
RowVersion是这条数据的并发版本字段。
在 SQL Server 中,通常会映射成:
rowversion这是理解 EF Core 乐观锁最重要的地方。
假设数据库:
Id Name Stock RowVersion
1 iPhone 10 AAAA 查询:
Stock = 10
RowVersion = AAAB 查询:
Stock = 10
RowVersion = AAA注意:
A 和 B 拿到的是相同版本
AAA。
A:
Stock: 10 → 9然后执行:
await db.SaveChangesAsync();EF Core 不只是简单执行:
UPDATE Products
SET Stock = 9
WHERE Id = 1它会把原来的版本也作为条件。
逻辑上相当于:
UPDATE Products
SET Stock = 9
WHERE Id = 1
AND RowVersion = @OldRowVersion也就是说:
只有数据库中的 RowVersion 还是我刚才读取的版本,我才允许修改。
A 修改成功:
Stock = 9数据库的 RowVersion 也会自动变化:
AAA → BBB数据库变成:
Id Name Stock RowVersion
1 iPhone 9 BBBB 手里的数据还是:
Stock = 10
RowVersion = AAAB 修改:
Stock: 10 → 9然后:
await db.SaveChangesAsync();EF Core 相当于执行:
UPDATE Products
SET Stock = 9
WHERE Id = 1
AND RowVersion = AAA但是数据库现在是:
RowVersion = BBB所以:
WHERE Id = 1
AND RowVersion = AAA匹配不到任何记录。
结果:
影响行数 = 0EF Core 就知道:
这条数据在我读取以后已经被别人修改过了。
于是抛出:
DbUpdateConcurrencyException这就是 EF Core 的乐观并发异常。
实体:
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 配置:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.Property(x => x.RowVersion)
.IsRowVersion();
}修改:
var product = await db.Products.FindAsync(1);
product!.Price = 99;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
Console.WriteLine("数据已经被其他人修改,请重新读取!");
}就这么简单。
核心其实不是 EF Core 有什么神奇能力。
本质上就是:
版本号例如:
第一次读取:
Id = 1
Price = 100
RowVersion = A修改时:
UPDATE Product
SET Price = 99
WHERE Id = 1
AND RowVersion = A如果成功:
影响 1 行说明:
数据没有被别人修改如果:
影响 0 行说明:
数据版本已经变了EF Core 根据这个结果判断发生了并发冲突。
所以可以把乐观锁理解成:
利用版本号 + UPDATE 条件,检测数据在读取之后有没有被别人修改。
因为它并没有真正把数据库记录“锁住”。
A 读取:
Product 1B 依然可以读取:
Product 1甚至 B 也可以修改。
它只是:
到最终提交的时候,检查一下版本有没有变化。
所以它其实更准确地说是:
乐观并发控制
Optimistic Concurrency Control而不是传统意义上:
把数据库记录锁住悲观锁完全是另外一种思路。
它认为:
这条数据很可能马上发生冲突,所以我修改之前就把它锁住。
例如:
A:
开始事务
↓
查询商品并加排他锁
↓
修改库存
↓
提交事务
↓
释放锁此时:
B:
开始事务
↓
查询同一商品
↓
等待……直到 A 提交事务以后,B 才能继续。
例如:
库存 = 1现在同时来了:
用户 A
用户 B如果这是一个非常高并发的库存扣减场景,有时候就需要更加严格的数据库锁控制。
典型思路:
开启事务
↓
锁定商品
↓
读取库存
↓
判断库存
↓
库存 - 1
↓
提交事务这里有一个非常重要的概念:
EF Core 本身没有一个通用的
.UsePessimisticLock()。
也就是说,你不能简单写:
db.Products
.Where(x => x.Id == 1)
.UsePessimisticLock();不存在这种通用 API。
为什么?
因为:
悲观锁本质上是数据库能力。
不同数据库的锁机制、SQL 语法并不一样。
例如 SQL Server、MySQL、PostgreSQL 的实现方式都不同。
所以 EF Core 通常需要通过:
数据库事务
+
数据库特定 SQL来实现悲观锁。
例如 SQL Server 可以使用:
SELECT *
FROM Products WITH (UPDLOCK, ROWLOCK)
WHERE Id = 1;这里:
UPDLOCK表示:
读取的时候获取更新锁。
这样其他事务想同时修改这条记录,就需要等待。
结合事务:
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();这里真正关键的是:
BeginTransactionAsync()以及:
WITH (UPDLOCK, ROWLOCK)这是很多初学者最容易搞错的地方。
错误理解:
var product = await db.Products
.FromSqlRaw("SELECT ... WITH (UPDLOCK)")
.FirstAsync();
product.Stock--;
await db.SaveChangesAsync();然后认为:
我已经加锁了。
实际上你必须考虑:
这个锁什么时候释放?
数据库锁通常与事务生命周期密切相关。
正确思路是:
BEGIN TRANSACTION
↓
SELECT ... FOR UPDATE / UPDLOCK
↓
修改数据
↓
SAVE
↓
COMMIT
↓
释放锁所以悲观锁的核心不是某个 SQL 关键字,而是:
在事务中获取数据库锁,并在事务结束后释放。
可以用一个表直接理解:
对比 | 乐观锁 | 悲观锁 |
|---|---|---|
核心思想 | 先操作,提交时检查 | 操作之前先加锁 |
是否阻塞别人 | 通常不会 | 可能会 |
冲突处理 | 发生冲突后处理 | 尽量提前避免冲突 |
EF Core 支持 | 原生支持 | 通常依赖数据库 SQL |
常见实现 | RowVersion | DB Lock + Transaction |
并发性能 | 通常较好 | 可能产生等待 |
编程复杂度 | 较低 | 较高 |
适合场景 | 一般业务更新 | 强一致、高冲突场景 |
对于绝大多数普通业务系统:
优先考虑乐观并发控制。
例如:
用户资料
商品信息
订单信息
配置数据
文章
项目数据
后台管理数据这些场景通常没有必要为了修改一条数据就把数据库记录锁住。
使用:
.IsRowVersion()就已经非常实用。
当你的业务具有明显的:
高竞争
+
不能允许同时修改
+
必须保证操作过程中的数据一致性例如:
库存扣减
账户余额
资金账户
抢购
资源分配这类场景才需要认真考虑数据库锁。
不过需要强调:
库存、余额等场景并不意味着一定要使用悲观锁。
很多情况下,通过:
UPDATE Products
SET Stock = Stock - 1
WHERE Id = @id
AND Stock > 0这种原子更新,或者使用乐观并发控制,同样可以解决问题。
不要为了“用了锁所以更安全”而盲目使用悲观锁。
很多刚接触 EF Core 的开发者会认为:
BeginTransactionAsync()就是加锁。
不是。
事务主要解决的是:
一组数据库操作要么全部成功,要么全部失败。
例如:
扣库存
+
创建订单
+
记录支付信息希望:
全部成功或者:
全部回滚这是事务解决的问题。
而:
A 和 B 同时修改同一条数据怎么办?这是并发控制解决的问题。
两者不是一个概念。
三者最好分开理解:
事务
↓
保证一组操作的原子性、一致性等
锁
↓
控制并发访问
乐观并发
↓
通过版本检测冲突
悲观并发
↓
通过数据库锁提前阻止冲突实际项目中它们经常组合使用。
例如悲观锁:
事务
+
数据库锁而乐观并发也可以运行在事务体系中。
IsRowVersion() 并不是所有数据库都可以完全按照 SQL Server 的方式工作。
它最典型的场景是:
SQL ServerSQL Server 中:
.IsRowVersion()会使用数据库的 rowversion 机制。
如果你使用的是:
MySQL
PostgreSQL
SQLite就不能简单地认为:
.IsRowVersion()会产生完全一样的数据库行为。
所以实际开发时一定要区分:
EF Core 的并发令牌机制
和:
SQL Server 的 rowversion 数据库类型
它们不是完全等价的概念。
实际上,EF Core 的乐观并发机制核心是:
Concurrency TokenRowVersion 只是最常见的一种实现。
例如:
modelBuilder.Entity<Product>()
.Property(x => x.RowVersion)
.IsConcurrencyToken();或者使用 SQL Server 常见的:
.IsRowVersion();可以简单理解成:
IsConcurrencyToken()
↓
告诉 EF:这个字段参与并发检查
IsRowVersion()
↓
告诉 EF:这是一个 RowVersion 字段
+
在 SQL Server 中使用 rowversion 机制当然可以。
例如实体:
public class Product
{
public int Id { get; set; }
public decimal Price { get; set; }
public int Version { get; set; }
}配置:
modelBuilder.Entity<Product>()
.Property(x => x.Version)
.IsConcurrencyToken();数据库:
Id = 1
Price = 100
Version = 5A 读取:
Version = 5B 读取:
Version = 5A 修改:
Version = 6B 修改时,EF Core 会要求:
WHERE Version = 5但是数据库已经:
Version = 6于是更新失败。
所以:
乐观锁真正的核心是“并发令牌”,而不是必须叫 RowVersion。
如果你只想记住一条 SQL,记住这个:
UPDATE Products
SET Price = 90
WHERE Id = 1
AND RowVersion = @OldRowVersion;这就是乐观并发控制最核心的思想。
翻译成人话:
“我只允许修改我刚才看到的那一版数据。”
如果数据库已经不是那一版:
更新 0 行EF Core:
DbUpdateConcurrencyException悲观锁可以理解成:
先锁住
↓
别人等着
↓
我修改
↓
提交
↓
释放锁它解决的是:
“在我处理这条数据期间,不希望其他事务同时修改它。”
如果你刚开始使用 EF Core,不要一上来就研究各种数据库锁。
建议按照下面的顺序掌握:
先学会:
FindAsync()SaveChangesAsync()BeginTransactionAsync()理解:
实体
DbContext
事务重点掌握:
.IsRowVersion()以及:
DbUpdateConcurrencyException理解:
版本号
↓
UPDATE ... WHERE Version = oldVersion
↓
影响 0 行
↓
并发冲突这一套是最值得掌握的。
再学习:
Transaction
+
SELECT ... FOR UPDATE或者 SQL Server:
WITH (UPDLOCK)理解:
数据库锁
锁粒度
锁等待
死锁
事务隔离级别到了这一步,你才真正开始进入数据库并发控制。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。