ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

EF Core并发冲突实战:乐观锁、RowVersion与异常处理深度解析

EF Core并发冲突实战:乐观锁、RowVersion与异常处理深度解析 先问一个问题你们在生产环境里有没有遇到过两个人同时改同一条订单记录后提交的人把先提交的人的数据整个覆盖掉的场景我见过不止一次而且每一次都是线上事故级别的。订单状态从“已支付”被改回“待支付”用户收货地址被另外一个人误改库存扣了两遍。这些问题几乎都指向同一个根源并发写冲突没有被处理。EF Core 并发冲突这个话题网上讲原理的不少但讲“异常抛出来之后到底该怎么办”的太少。很多文章写到 DbUpdateConcurrencyException 就结束了留下一句“请自行处理”然后人就没了。这篇我打算把乐观锁、RowVersion、DbUpdateConcurrencyException 这三件事掰开揉碎从配置手法、数据库底层机制、异常处理策略到真实抢购场景的完整落地一条线讲透。适合正在做 .NET 后端、被并发问题坑过或者准备提前预防的开发者看。1. 先搞清楚并发冲突到底在争什么——乐观锁与悲观锁的源码级对比很多初学者一上来就问 EF Core 怎么配并发其实根本没搞明白要解决什么问题。并发控制这件事数据库层面就两条路悲观锁和乐观锁。名字听着抽象但本质上是两种完全不同的做事逻辑。1.1 悲观锁先锁死再做事的思路悲观锁的逻辑是我不信任任何人在我改这条数据之前先把这条记录锁住别人谁都不能动等我改完了再放行。在 SQL Server 里典型的悲观锁长这样BEGIN TRANSACTION; SELECT * FROM Orders WITH (UPDLOCK, ROWLOCK) WHERE Id 1024; -- 在这里做业务逻辑处理比如计算金额、修改状态 UPDATE Orders SET Status 2 WHERE Id 1024; COMMIT;WITH (UPDLOCK)的意思是告诉数据库我要更新这条记录请你把行锁分配给我。在这个事务提交或回滚之前任何其他事务想 UPDATE 这一行都会阻塞等待。EF Core 里对应地需要手动开事务、指定隔离级别await using var transaction await context.Database.BeginTransactionAsync( IsolationLevel.Serializable); var order await context.Orders .FromSqlRaw(SELECT * FROM Orders WITH (UPDLOCK, ROWLOCK) WHERE Id 1024) .FirstAsync(); // 在这里做业务处理 order.Status OrderStatus.Paid; await context.SaveChangesAsync(); await transaction.CommitAsync();悲观锁的优点是冲突发生时不会产生异常写操作被串行化了缺点是锁持有期间所有读这条记录的写操作全部排队高并发场景下吞吐量肉眼可见地往下掉。而且一旦事务里还有外部 API 调用、消息发送这种慢操作锁的持有时间会被拉长死锁的概率激增。1.2 乐观锁边用边校验的 CAS 思路乐观锁正好反过来它认为并发冲突是小概率事件。不锁数据库正常读、正常改只是在提交更新的时候额外带上一个版本校验条件让数据库帮忙判断这条数据在我读取之后有没有被其他人动过。这个思路用生活类比解释就是你在一家共享文档平台上编辑一份方案不会把文档锁起来而是每保存一次系统就更新一次版本号。如果别人在你编辑期间也保存过系统会提示“版本冲突请手动合并”。在 EF Core 中乐观锁的实现不依赖 SQL Server 的锁机制而是靠 UPDATE 语句的 WHERE 条件来保证原子性。EF Core 生成的 SQL 大致长这样UPDATE [Orders] SET [Status] 2, [Version] [Version] 1 WHERE [Id] 1024 AND [Version] 7;WHERE里那个[Version] 7就是校验条件。7 是你读取这条记录时的版本号。如果在你读取之后、提交之前有其他事务把版本号改成了 8那这条 UPDATE 匹配不到任何行数据库返回受影响行数为 0EF Core 检测到 0 就知道发生了并发冲突抛 DbUpdateConcurrencyException。1.3 业务场景怎么选从扣库存到改昵称这两类锁没有孰优孰劣只有适不适合。悲观锁适合写冲突概率极高的场景典型的是金融级转账、库存极少的秒杀扣减、需要用 SELECT ... FOR UPDATE 先锁定父订单再处理明细的场景。这类业务的特点是不允许重试冲突了就是实打实的业务失败系统要给出明确反馈。乐观锁适合大多数普通业务系统。比如用户改自己资料、后台编辑文章、订单状态流转这些场景在正常业务时段几乎不会出现两个人同时改同一条记录的情况。乐观锁没有锁的开销读多写少的系统里性能几乎无损冲突了还可以通过重试来缓解。一个经验原则如果你的业务有重试容忍度优先乐观锁如果必须一次成功且冲突后果严重考虑悲观锁。EF Core 官方也更推荐乐观锁因为它不会阻塞读操作极大减少死锁风险。2. RowVersion 到底是怎么工作的——配置、迁移与数据库层面的真相乐观锁的关键是版本信息从哪来。EF Core 官方文档里推荐用rowversion但很多同学对这一列的理解停留在“有一个 Version 字段就行”的层面。实际上 RowVersion 是 SQL Server 里的一个特殊数据类型它和时间戳没有半点关系。2.1 SQL Server 的 rowversion 不是“时间戳”而是“行版本计数器”先纠正一个高频误解SQL Server 早期版本里这个类型叫timestamp但后来微软自己都觉得这个名字有误导性于是改名成rowversion。它存的不是时间而是一个数据库级别的单调递增计数器。每次数据库里任何一行数据被修改SQL Server 就会给这行生成一个新的版本号这个版本号是全局唯一递增的二进制值和行内容本身绑在一起。你不需要手动维护它数据库自动帮你更新。EF Core 配合这个机制只需要把字段映射到实体并在变更检测时把它当成并发令牌剩下的 SQL 生成交给 EF Core 即可。理解这一点很重要rowversion 列自己会变且只会在有 UPDATE 操作时变。INSERT 时生成初始值UPDATE 时自动递增DELETE 就不谈了。而且一个表最多只能有一个 rowversion 列这意味着一个实体只能有一个并发令牌。2.2 EF Core 配置并发令牌的四种写法配置 RowVersion 的方法很多我实际项目里用到的主要是 Data Annotation 和 Fluent API 两种。以订单实体为例public class Order { public int Id { get; set; } public string Status { get; set; } public decimal Amount { get; set; } public byte[] RowVersion { get; set; } }方法一Data Annotation最简单直接用特性标注public class Order { public int Id { get; set; } [Timestamp] public byte[] RowVersion { get; set; } }方法二Fluent API 的IsRowVersion在 DbContext 里配protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityOrder() .Property(x x.RowVersion) .IsRowVersion(); }方法三通用并发令牌配置IsConcurrencyToken。这个方法不要求类型是 byte[]int、datetime、guid 都可以EF Core 会把该属性作为并发校验条件拼进 UPDATE 的 WHERE 子句modelBuilder.EntityOrder() .Property(x x.RowVersion) .IsConcurrencyToken();方法四完全自定义检验属性。很多人喜欢用 int 自增版本号而不是 byte[]因为调试方便、迁移文件里也看得懂modelBuilder.EntityOrder() .Property(x x.Version) .IsConcurrencyToken();我个人强烈推荐方法一或方法二原因后面在“避坑指南”里细说。IsConcurrencyToken虽然通用但它要求你自己保证该字段在每次 UPDATE 时一定会变否则校验条件恒成立乐观锁形同虚设。2.3 迁移文件里到底发生了什么配置好之后执行一次迁移生成的 SQL 里最关键的部分是migrationBuilder.AddColumnbyte[]( name: RowVersion, table: Orders, type: rowversion, rowVersion: true, nullable: false, defaultValue: new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 });type: rowversion告诉 SQL Server 这是数据库维护的版本列rowVersion: true告诉 EF Core 该列需要在每次 INSERT 之后从数据库回读这样内存里的实体才能跟上数据库的最新版本。这里有个容易忽略的坑EF Core 在查询出来之后实体的 RowVersion 属性会保存一个值这个值会作为 UPDATE 时的 WHERE 校验条件。如果你在代码里手动给 RowVersion 赋值比如从 Redis 缓存里读出来设置回去EF Core 会把它当成原始值用一旦这个值和数据库里的实际值不一致就会误报并发冲突。3. DbUpdateConcurrencyException 的处理——从抛出异常到收尾的完整链路配置完成只是第一步。并发冲突真正发生的时候EF Core 不会自动帮你解决它只会抛出一个DbUpdateConcurrencyException。你需要的是一套完整的“发现冲突 → 读取现状 → 决定策略 → 再次提交”的处理链路。3.1 异常到底怎么抛出来的为什么是 DbUpdateConcurrencyException要理解为什么是这个异常得回头看 EF Core 保存数据时发生了什么。SaveChanges 的时候EF Core 会为每个状态为 Modified 的实体生成 UPDATE 语句并且把设置了并发令牌的属性的原始值OriginalValue拼进 WHERE 子句UPDATE [Orders] SET [Status] 2, [Amount] 99.00, [RowVersion] newRowVersion WHERE [Id] 1024 AND [RowVersion] oldRowVersion;执行完这条 SQLEF Core 会检查返回的受影响行数。如果行数是 0说明 WHERE 条件不满足。不满足的原因只有一个并发令牌的原始值已经不等于数据库里的当前值了即这条记录被别人改过了。此时 EF Core 不重试、不忽略直接抛DbUpdateConcurrencyException并在异常对象的Entries属性里带上所有发生冲突的实体条目。注意这个异常并不是数据库抛出来的是 EF Core 自己根据受影响行数推断并抛出的。所以它携带的信息不是数据库错误码而是 EF Core 跟踪上下文中的实体状态。3.2 首选方案数据库优先 存档重放网上关于 DbUpdateConcurrencyException 的处理方案五花八门我从实际项目里总结出来的首选方案可以叫“数据库优先 合并重试”。核心思路是发生冲突时不要盲目用内存里的数据覆盖数据库也不要无条件丢弃用户输入。先读数据库当前值然后根据业务规则决定怎么合并。举个例子用户改昵称和积分是两个独立接口理论上不该互相覆盖。假设同一个用户在这两个接口里分别被修改那么用户昵称的更新不应该导致积分被覆盖反之亦然。这种情况下如果用“用户内存值整体覆盖数据库值”就会把另一个接口写进去的积分清掉——这就是最典型的覆盖事故。正确做法是进入异常处理分支后读取数据库当前所有字段值把用户本次操作涉及的字段从内存值覆盖到数据库值上其余字段保留数据库值不动。这样既保留了用户的修改意图又不会误伤其他字段。3.3 完整示例一个可复用的并发冲突处理模板我先给一个能直接抄的模板然后再解释每行代码在干什么。try { await context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { // 1. 获取实体当前内存中的值用户想改成什么样子 var proposedValues entry.CurrentValues; // 2. 从数据库读取最新值别人已经改成了什么样子 var databaseValues entry.GetDatabaseValues(); if (databaseValues null) { // 当前实体在数据库里已被删除走删除分支 entry.State EntityState.Detached; throw new InvalidOperationException(该记录已被删除); } // 3. 把并发令牌的原始值更新为数据库当前值 // 这样下一次 UPDATE 的 WHERE 条件就能匹配到最后一条数据 foreach (var property in entry.Metadata.GetProperties()) { if (property.IsConcurrencyToken) { entry.OriginalValues[property.Name] databaseValues[property.Name]; } } // 4. 根据业务规则合并字段 // 这里以“当前内存值优先但保留数据库值里用户未修改的字段”为例 foreach (var property in entry.Metadata.GetProperties()) { if (property.IsConcurrencyToken) { continue; } var databaseValue databaseValues[property.Name]; var currentValue proposedValues[property.Name]; // 如果内存中没有这个属性例如当前操作根本不该修改它用数据库值 // 如果内存中有值用当前值覆盖 —— 具体按业务约定调整 if (currentValue null || currentValue.Equals(databaseValue)) { continue; } entry.CurrentValues[property.Name] currentValue; } // 5. 重新保存 await context.SaveChangesAsync(); } }这段代码里有几个关键点第一entry.GetDatabaseValues()这个方法是 EF Core 里特别好用但容易被忽略的 API。它发出的 SQL 是SELECT * FROM Orders WHERE Id id不受并发令牌影响永远能拿到数据库当前的真实值。第二第 3 步更新并发令牌的 OriginalValue 是重试能否成功的核心。因为第一次失败就是因为 WHERE 里的旧版本号匹配不上你不把原始值刷新成数据库的当前版本重试一万次也一样失败。第三字段合并不是无脑覆盖。不同业务处理方式不同有的要“用户输入优先”有的要“数据库值优先”有的要“特定字段取合并结果”。你要是业务简单可以简化成下面这样。简化版一丢弃内存值采用数据库值适合“谁先提交谁生效”的逻辑catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var databaseValues entry.GetDatabaseValues(); entry.CurrentValues.SetValues(databaseValues); } await context.SaveChangesAsync(); }简化版二无条件用内存值覆盖数据库值适合“最后提交者赢”的逻辑catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var databaseValues entry.GetDatabaseValues(); entry.OriginalValues.SetValues(databaseValues); } await context.SaveChangesAsync(); }注意上面这个简化的“最后提交者赢”可不是简单 SetValues 就完事的它要求你先调用entry.OriginalValues.SetValues(databaseValues)把 WHERE 条件更新为数据库当前版本号。这样 EF Core 在重试时就不会再被版本冲突拦住了。3.4 冲突处理三选一的口诀我在团队里给新人讲并发冲突处理时总结了一个三选一口诀删则脱管、重试改原值、策略定胜负。删则脱管GetDatabaseValues()返回 null 说明记录已经被删了。此时不要再保存把实体状态设为Detached直接向业务层抛出“记录不存在”之类的业务错误让上层决定是提示刷新还是重新创建。重试改原值不管用哪种覆盖策略重试之前一定先把并发令牌的OriginalValue改成数据库当前值。这一步漏掉后面全白搭。策略定胜负确定“当前值优先”还是“数据库值优先”要根据业务语义来。数据库优先适合审计、状态机场景当前值优先适合用户主动提交表单的场景。4. 实战案例库存扣减场景下的乐观锁落地前面讲了原理和异常处理逻辑这一节用一个完整的库存扣减场景把整条链路串起来。这是 .NET 面试和实际项目里出现频率几乎最高的并发场景。4.1 复现抢购场景假设有一个秒杀系统商品表里有个 Stock 字段。用户下单时要扣减库存。最危险的实现是这种var product await context.Products.FindAsync(productId); if (product.Stock 0) { product.Stock--; } await context.SaveChangesAsync();这段代码在并发量上来时必挂。两个请求同时读到 Stock 1都通过了 if 判断都执行了Stock--最终数据库里 Stock 变成了 0但实际有两个人扣减成功了——这就是超卖。给商品表加上 RowVersion 之后EF Core 会在 UPDATE 时生成类似这样的 SQLUPDATE Products SET Stock 0, RowVersion newVersion WHERE Id id AND RowVersion oldVersion;两个并发请求的oldVersion都是数据库里改之前的版本号。第一个请求成功了数据库里的 RowVersion 变了第二个请求的 WHERE 匹配不到行受影星数为 0EF Core 抛出 DbUpdateConcurrencyException。在异常处理里对于扣库存这种场景不能采用“丢内存值、采用数据库值”的策略因为那样等于这次扣减直接失效用户下单却扣不到库存。合理策略是自动重试读最新库存如果还够就再扣一次不够则报库存不足。完整代码如下var maxRetryCount 5; for (var retry 0; retry maxRetryCount; retry) { try { var product await context.Products.FindAsync(productId); if (product.Stock quantity) { throw new InsufficientStockException(库存不足); } product.Stock - quantity; product.SoldCount quantity; await context.SaveChangesAsync(); return; // 保存成功 } catch (DbUpdateConcurrencyException) { // 并发冲突数据在读取后被其他请求修改过刷新并重试 await context.Entry(product).ReloadAsync(); } } throw new ConcurrencyRetryExceededException(系统繁忙请稍后再试);ReloadAsync()会重新从数据库加载实体把RowVersion和Stock都刷新为最新值。这个方案不需要处理OriginalValues因为ReloadAsync会连实体的原始值和当前值一起刷新。这个重试方案有几个细节要注意重试上限不能太高否则极端并发下请求会长时间挂起占用数据库连接每次重试之间建议加一个短暂的随机延时错峰重试减少系统性“抖动”如果最终仍冲突必须向用户返回明确错误而不是静默吞掉。4.2 冲突率高的场景怎么优化上面这个方案在冲突不严重时还能用但真正的秒杀场景下大量请求同时抢同一个商品乐观锁重试会变成“大家一起撞墙撞完排队再撞”效率极低。这种情况下第一选择是给扣减库存的 UPDATE 语句加一个“条件扣减”逻辑。EF Core 7 之后可以用ExecuteUpdate配合条件表达式var rowsAffected await context.Products .Where(p p.Id productId p.Stock quantity) .ExecuteUpdateAsync(setters setters .SetProperty(p p.Stock, p p.Stock - quantity) .SetProperty(p p.SoldCount, p p.SoldCount quantity));这段代码翻译成 SQL 是UPDATE TOP(count) [Products] SET [Stock] [Stock] - quantity, [SoldCount] [SoldCount] quantity WHERE [Id] id AND [Stock] quantity;如果rowsAffected 0说明库存不足业务直接返回失败如果rowsAffected 1扣减成功。这种做法的好处是并发冲突时数据库自动重试 UPDATE 语句你不需要在上层循环重试也没有实体跟踪的额外开销。但请注意ExecuteUpdate是直接执行 SQL不会经过 EF Core 的变更追踪器如果你对同一个实体后续还要做业务操作需要手动重新查询实体或者关闭跟踪。这一点后面避坑指南里还会展开。5. 避坑指南并发令牌的常见误区和经验讲到这里配置、原理、异常处理、实战案例都覆盖了。最后这部分我集中写一写实际项目中踩过的坑很多问题是光看文档看不出来的。5.1 并发令牌只对单个 SaveChanges 生效EF Core 的并发检测是在单个 SaveChanges 调用内统一生效的。如果你在一个方法里先调用 SaveChanges 保存了实体 A然后又修改实体 B 再次调用 SaveChanges这两次调用之间完全没有并发保护协同。举例你在同一个请求里先扣库存再更新订单状态。扣库存时 RowVersion 是 v1更新订单状态之前另一个请求把订单状态改了RowVersion 变成了 v2。如果订单状态更新时你没手动校验版本号后者就会直接覆盖。所以一个业务操作需要更新多个实体时尽量在同一个 SaveChanges 里完成。如果必须分多次就要对每个实体都做并发校验或者接受这种“部分校验”的格局。5.2 ExecuteUpdate 和 ExecuteDelete 会绕过并发令牌EF Core 7 引入的ExecuteUpdate/ExecuteDelete很香性能比 SaveChanges 好几个量级但有个致命特性它们不会自动带上并发令牌校验。上面库存扣减例子之所以能用ExecuteUpdate是因为我在 Where 子句里手动写了p.Stock quantity作为条件。如果你写的是await context.Products .Where(p p.Id productId) .ExecuteUpdateAsync(setters setters.SetProperty(p p.Stock, 100));那它生成的 SQL 是UPDATE Products SET Stock 100 WHERE Id id没有任何版本校验任何并发修改都会无条件覆盖。而且在 ExecuteUpdate 执行后EF Core 跟踪中的实体状态不会自动同步如果上下文缓存里还留着旧实体后续 SaveChanges 会拿这个实体再去 UPDATE就可能出现“看起来没改、但就是报冲突”的问题。规避方法用 ExecuteUpdate 做批量或条件更新时把并发条件手动写进 Where执行后立即使用context.ChangeTracker.Clear()清空跟踪防止旧实体污染后续操作。5.3 UPDATE 匹配不到行不一定是并发冲突这一点容易让人掉坑。如果你的 UPDATE 语句的 WHERE 条件里包含了其他业务过滤条件比如WHERE Id id AND Status 1那受影响行数为 0 也可能是因为状态不对而不是并发冲突。EF Core 只会在设置了并发令牌的实体的 UPDATE 语句返回 0 行时抛DbUpdateConcurrencyException如果只是业务条件不满足EF Core 不会抛异常它只会静默返回而你的实体状态可能还停留在 Modified。这种情况下你会看到 SaveChanges 没抛异常但数据没更新。遇到这种场景建议把业务条件单独在代码里判断或者用存储过程不要依赖 EF Core 的判断结果。5.4 原生 SQL 也要手动带上 RowVersion 条件如果你项目中混用了原生 SQL、Dapper或者 SQL 字符串拼接需要自己写并发校验。EF Core 的并发保护不会自动延伸到原生 SQL 里。举例UPDATE Products SET Stock Stock - 1 WHERE Id id这句话不会带 RowVersion 条件。你要自己写成UPDATE Products SET Stock Stock - 1 WHERE Id id AND RowVersion oldRowVersion然后判断受影响行数。这种场景下我更推荐用ExecuteUpdate加.Where(p p.RowVersion expectedVersion)的方式至少语句是 EF Core 生成的不容易漏掉并发条件。5.5 缓存中的旧值刷新问题很多项目会把实体或实体的部分字段缓存到 Redis、MemoryCache 里。因为这些缓存值可能不是最新值所以千万不要从缓存里取 RowVersion 作为并发校验的原始值。RowVersion 应该在每次从数据库查询实体时获取。如果你需要缓存实体数据缓存行版本号不如缓存业务字段数据库读取实体时再取 RowVersion永远用 EF Core 跟踪实体中的OriginalValue。这是并发控制里非常重要的一条原则。5.6 多实体一起 SaveChanges 时冲突定位一次 SaveChanges 里更新了多个实体只有一个实体发生并发冲突时DbUpdateConcurrencyException.Entries里只会包含这一个实体的 entry而不是全部。你可以遍历Entries检查entry.Entity的类型和主键把冲突精准定位到具体的业务字段。定位代码参考catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { if (entry.Entity is Order order) { _logger.LogWarning(订单 {OrderId} 发生并发冲突数据库版本 {DbVer}当前版本 {CurVer}, order.Id, entry.GetDatabaseValues()?.GetValuebyte[](RowVersion) ?? Array.Emptybyte(), entry.OriginalValues.GetValuebyte[](RowVersion) ?? Array.Emptybyte()); } } }日志里带上这些信息排查线上问题会顺畅很多。否则只知道“有并发冲突”但不知道是哪个实体、哪个操作、冲突双方都是什么值问题只能靠猜。写在最后的一些经验说实话并发冲突这种东西大部分情况下是“不发生则已一发生就是事故”。所以配置乐观锁一定要趁早不要等项目上线了、超卖了、覆盖了才想起来补。我个人在项目里的习惯是所有核心业务表一律建 RowVersion 列就算是读多写少的配置表也建。数据量大了以后接口报一次并发冲突就知道是哪儿撞车了日志一查全都清楚。宁可多一个字段的开销也不愿意线上数据悄悄被覆盖。还有一个小技巧如果你用的是 SQL ServerRowVersion 类型本身是 8 字节定长二进制不用考虑索引碎片EF Core 把它当普通属性就好。但要记住行版本号是数据库全局递增的不要把它展示给前端也不要拿它做排序或分页它只作为一个令牌存在没有业务含义。最后再分享一个真实感受乐观锁真正难的不是配置而是异常处理里的“策略选择”。技术方案本身是死的但业务语义是活的。一定要把数据库当前值、用户当前值、谁先谁后的关系搞清楚再决定是丢弃、覆盖还是重试。把这一层想明白了不管是 EF Core 还是其他 ORM并发冲突处理你都拿得下。
返回列表