生产环境里有一种 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 本质上是在告诉你:
有人和你同时修改了同一份数据,现在到底听谁的?
通常有三种处理方式:
数据库优先:放弃当前修改,重新加载最新数据,让用户确认后再保存。这通常最适合人工编辑。
客户端优先:强制使用当前提交的数据覆盖数据库,适合明确拥有最终写入权的导入任务。
字段级合并:如果用户只修改了地址,而另一个用户修改了承运商,那么可以保留双方互不冲突的修改。
这里最容易犯的错误,是把所有并发异常都做成“自动重试”。
重试并不等于解决冲突。对于人工编辑来说,自动重试很可能只是把别人的修改再次覆盖掉。
四、悲观并发:既然抢得厉害,那就排队
另一条路线是 Pessimistic Concurrency(悲观并发)。
它的思路完全不同:
我认为冲突很可能发生,所以先把记录锁住,其他请求等我处理完再继续。
典型场景就是库存。
一个热门 SKU 可能每秒被大量请求扣减。如果所有请求都先读取库存,再依赖乐观并发不断失败、重试,那么高竞争情况下,大量计算其实都浪费了。
PostgreSQL 可以使用:
SELECT *FROM stock_itemsWHERE sku = @skuFOR UPDATE;
拿到行锁之后,其他事务需要等待当前事务提交或回滚。
PostgreSQL 还提供了两个很实用的变体:
但悲观锁也有一个非常现实的坑:锁住的时间越长,后面的请求等得越久。
所以千万不要在持锁事务里面调用外部 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 的写法,而是判断业务到底属于哪一种竞争模型。普通人工编辑,我更倾向乐观并发,让冲突显式暴露;热点库存、队列消费,则应该考虑锁或原子更新。不要为了“加事务”而加事务,更不要把重试当成万能药。先找到读和写之间那个真正的竞争窗口,再决定怎么消灭它。