当前位置:首页>排行榜>.NET并发控制怎么选?乐观锁、悲观锁与原子更新一次讲清

.NET并发控制怎么选?乐观锁、悲观锁与原子更新一次讲清

  • 更新时间 2026-10-08 00:51:50
.NET并发控制怎么选?乐观锁、悲观锁与原子更新一次讲清

生产环境里有一种 bug 很安静:接口返回 200,日志没有异常,用户也提示“保存成功”,但过了一会儿,刚刚修改的数据却神奇地恢复了。

这不是数据库“丢数据”,而是典型的 Lost Update(丢失更新):两个请求同时读取同一条记录,各自在内存中修改,然后依次写回数据库。后写入的请求并不知道前一个请求已经改过数据,于是直接把前面的修改覆盖掉。

对于 .NET + EF Core 应用来说,真正值得掌握的不是“加一个事务”这么简单,而是要理解:你究竟希望冲突被发现,还是希望冲突根本不要发生?

一、先看最容易踩坑的场景

假设仓库系统里有一条 Shipment:

  • 操作员 A 修改收货地址

  • 操作员 B 修改承运商

  • 两个人几乎同时打开同一条记录

如果没有并发控制,两个请求读取到的都是旧数据。A 修改地址后保存,B 随后保存承运商时,可能把 A 修改后的地址重新覆盖掉。

最麻烦的是,两次 SaveChangesAsync() 都可能成功。

所以,“接口返回成功”不代表业务层面的并发操作就是正确的。

很多人第一反应是给代码加事务,但在常见的 Read Committed 隔离级别下,这并不能自动解决这个问题。两个事务都可以读取同一份已提交数据,随后各自完成更新。数据库只是忠实执行了你的指令,而你没有告诉它:“这次更新依赖于我刚才读取到的那个版本。”

二、乐观并发:先让大家跑,提交时检查版本

大多数业务系统更适合 Optimistic Concurrency(乐观并发)。

核心思路很简单:

不提前锁住数据,大家正常读取和编辑;真正提交时,再检查“我读到的数据还是不是原来的版本”。

EF Core 可以通过并发令牌实现这一点。

例如 SQL Server 可以使用 rowversion,PostgreSQL 可以使用 xmin,也可以自己维护一个 Guid Version 字段。

最终生成的 SQL 类似:

UPDATE shipmentsSET address = @p0,    carrier = @p1,    version = @p2WHERE id = @p3  AND version = @p4;
真正关键的是最后这个 version。

如果期间没有其他人修改记录,UPDATE 正常完成;如果已经有人修改过,版本号发生变化,WHERE 条件匹配不到记录,EF Core 就会抛出 DbUpdateConcurrencyException。

这比“后写覆盖前写”要安全得多,因为冲突终于被系统明确识别出来了。

人工编辑场景,还有一个细节特别重要

假设用户打开页面后,过了两分钟才点击保存。

这两分钟里数据库可能已经发生变化。

因此,版本号不能只在服务器内部比较,而应该随着数据返回前端,再随着更新请求传回来。更新时把客户端拿到的版本设置为 EF Core 的 OriginalValue,这样检查的才是真正的“用户打开页面时的数据版本”。

否则你刚刚从数据库重新读取了一遍数据,再拿这个最新版本去比较,前端用户等待两分钟产生的并发冲突就被绕过去了。

三、发生冲突之后,不是简单 catch 一下就完事

DbUpdateConcurrencyException 本质上是在告诉你:

有人和你同时修改了同一份数据,现在到底听谁的?

通常有三种处理方式:

  1. 数据库优先:放弃当前修改,重新加载最新数据,让用户确认后再保存。这通常最适合人工编辑。

  2. 客户端优先:强制使用当前提交的数据覆盖数据库,适合明确拥有最终写入权的导入任务。

  3. 字段级合并:如果用户只修改了地址,而另一个用户修改了承运商,那么可以保留双方互不冲突的修改。

这里最容易犯的错误,是把所有并发异常都做成“自动重试”。

重试并不等于解决冲突。对于人工编辑来说,自动重试很可能只是把别人的修改再次覆盖掉。

四、悲观并发:既然抢得厉害,那就排队

另一条路线是 Pessimistic Concurrency(悲观并发)。

它的思路完全不同:

我认为冲突很可能发生,所以先把记录锁住,其他请求等我处理完再继续。

典型场景就是库存。

一个热门 SKU 可能每秒被大量请求扣减。如果所有请求都先读取库存,再依赖乐观并发不断失败、重试,那么高竞争情况下,大量计算其实都浪费了。

PostgreSQL 可以使用:

SELECT *FROM stock_itemsWHERE sku = @skuFOR UPDATE;
拿到行锁之后,其他事务需要等待当前事务提交或回滚。

PostgreSQL 还提供了两个很实用的变体:

  • FOR UPDATE NOWAIT:拿不到锁就立即失败,不等待。

  • FOR UPDATE SKIP LOCKED:跳过已经被锁定的记录,非常适合任务队列、Outbox 等多 Worker 消费场景。

但悲观锁也有一个非常现实的坑:锁住的时间越长,后面的请求等得越久。

所以千万不要在持锁事务里面调用外部 API。支付接口卡 3 秒,意味着后面等待这条记录的请求也可能被阻塞 3 秒。

五、其实还有第三种方案:根本不要先读

很多库存、计数器、状态切换问题,并不需要在“乐观”和“悲观”之间二选一。

如果业务规则可以直接写进 SQL,那么可以直接做一次原子更新。

例如扣库存:

var affected = await dbContext.StockItems    .Where(x => x.Sku == sku &&                x.Quantity >= request.Quantity)    .ExecuteUpdateAsync(        x => x.SetProperty(            item => item.Quantity,            item => item.Quantity - request.Quantity),        ct);

对应的核心 SQL 就是:

UPDATE stock_itemsSET quantity = quantity - @amountWHERE sku = @sku  AND quantity >= @amount;

更新成功,说明库存足够;影响行数为 0,就代表条件没有满足。

整个过程没有“先查询 → 修改内存对象 → 再保存”的时间窗口,数据库直接完成判断和更新。

这种方式特别适合:

  • 库存扣减

  • 数量累加/减少

  • 状态转换

  • 简单计数器

但它也不是万能的。ExecuteUpdateAsync 会绕过 EF Core Change Tracker,因此已经加载到内存里的实体可能变成旧数据,相关拦截器、领域事件等也不会像普通 SaveChanges 一样执行。

六、到底应该怎么选?

可以把实际项目里的判断简单化:

普通业务表、用户编辑、冲突不频繁:优先乐观并发。

例如订单信息、客户资料、Shipment、商品配置等。用户编辑时间可能持续几十秒甚至几分钟,这种情况下不可能从打开页面开始就锁住数据库记录。

高并发热点数据:考虑悲观锁。

例如多个请求同时操作同一个库存、余额或任务记录,而且业务要求操作必须在一个短事务里完成。

简单的计数和扣减:优先考虑原子 SQL。

能用一条带条件的 UPDATE 解决,就不要为了“规范”强行引入复杂的锁和重试机制。

最后记住几个明显的信号:

  • 并发异常大量出现:乐观并发可能已经扛不住当前竞争。

  • 大量请求等待锁:悲观事务可能太重。

  • 经常出现 Deadlock:检查不同业务路径的加锁顺序。

  • 用户反馈“我的修改怎么没了”:系统大概率根本没有正确处理并发更新。

个人观点

并发控制真正难的地方,从来不是记住 rowversion、FOR UPDATE 或 ExecuteUpdateAsync 的写法,而是判断业务到底属于哪一种竞争模型。普通人工编辑,我更倾向乐观并发,让冲突显式暴露;热点库存、队列消费,则应该考虑锁或原子更新。不要为了“加事务”而加事务,更不要把重试当成万能药。先找到读和写之间那个真正的竞争窗口,再决定怎么消灭它。

随机文章