先问一个问题:你们在生产环境里有没有遇到过两个人同时改同一条订单记录,后提交的人把先提交的人的数据整个覆盖掉的场景?我见过不止一次,而且每一次都是线上事故级别的。订单状态从“已支付”被改回“待支付”,用户收货地址被另外一个人误改,库存扣了两遍。这些问题几乎都指向同一个根源:并发写冲突没有被处理。
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 匹配不到任何行,数据库返回受影响行数为 0,EF 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.Entity<Order>() .Property(x => x.RowVersion) .IsRowVersion(); }方法三:通用并发令牌配置IsConcurrencyToken。这个方法不要求类型是 byte[],int、datetime、guid 都可以,EF Core 会把该属性作为并发校验条件拼进 UPDATE 的 WHERE 子句:
modelBuilder.Entity<Order>() .Property(x => x.RowVersion) .IsConcurrencyToken();方法四:完全自定义检验属性。很多人喜欢用 int 自增版本号而不是 byte[],因为调试方便、迁移文件里也看得懂:
modelBuilder.Entity<Order>() .Property(x => x.Version) .IsConcurrencyToken();我个人强烈推荐方法一或方法二,原因后面在“避坑指南”里细说。IsConcurrencyToken虽然通用,但它要求你自己保证该字段在每次 UPDATE 时一定会变,否则校验条件恒成立,乐观锁形同虚设。
2.3 迁移文件里到底发生了什么
配置好之后,执行一次迁移,生成的 SQL 里最关键的部分是:
migrationBuilder.AddColumn<byte[]>( 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;执行完这条 SQL,EF 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 时生成类似这样的 SQL:
UPDATE Products SET Stock = 0, RowVersion = @newVersion WHERE Id = @id AND RowVersion = @oldVersion;两个并发请求的@oldVersion都是数据库里改之前的版本号。第一个请求成功了,数据库里的 RowVersion 变了;第二个请求的 WHERE 匹配不到行,受影星数为 0,EF 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()?.GetValue<byte[]>("RowVersion") ?? Array.Empty<byte>(), entry.OriginalValues.GetValue<byte[]>("RowVersion") ?? Array.Empty<byte>()); } } }日志里带上这些信息,排查线上问题会顺畅很多。否则只知道“有并发冲突”,但不知道是哪个实体、哪个操作、冲突双方都是什么值,问题只能靠猜。
写在最后的一些经验
说实话,并发冲突这种东西,大部分情况下是“不发生则已,一发生就是事故”。所以配置乐观锁一定要趁早,不要等项目上线了、超卖了、覆盖了,才想起来补。
我个人在项目里的习惯是:所有核心业务表一律建 RowVersion 列,就算是读多写少的配置表也建。数据量大了以后,接口报一次并发冲突,就知道是哪儿撞车了,日志一查全都清楚。宁可多一个字段的开销,也不愿意线上数据悄悄被覆盖。
还有一个小技巧:如果你用的是 SQL Server,RowVersion 类型本身是 8 字节定长二进制,不用考虑索引碎片,EF Core 把它当普通属性就好。但要记住,行版本号是数据库全局递增的,不要把它展示给前端,也不要拿它做排序或分页,它只作为一个令牌存在,没有业务含义。
最后再分享一个真实感受:乐观锁真正难的不是配置,而是异常处理里的“策略选择”。技术方案本身是死的,但业务语义是活的。一定要把数据库当前值、用户当前值、谁先谁后的关系搞清楚,再决定是丢弃、覆盖还是重试。把这一层想明白了,不管是 EF Core 还是其他 ORM,并发冲突处理你都拿得下。