☰
EF Core并发冲突处理:乐观锁、RowVersion与DbUpdateConcurrencyException实战
2026/9/30 10:50:07 网站建设 项目流程

开篇先聊个现象:做后端开发这些年,凡是涉及数据更新的项目,几乎都会遇到一个问题——两个用户同时修改同一条记录,各改各的,最后谁的值以谁为准?如果无脑覆盖,轻则数据错乱,重则对账出问题、生产事故。EF Core很早就内置了一套并发冲突检测机制,核心就是乐观锁、RowVersion和DbUpdateConcurrencyException。但很多人要么完全没启用,要么出了异常不知道怎么接,还有的干脆把DbContext整成单例来"绕过"并发。这些路子都不对。这篇文章我从底层原理讲到实际项目里的处理套路,把这块讲透,看完你就能直接在项目里落地。

1. 并发冲突到底是怎么产生的,为什么EF Core要管这事

1.1 丢失更新问题,比你想象的更常见

假设有一个库存表,里面某个商品还有10件库存。用户A下单买2件,用户B下单买3件。两个请求几乎同时到达服务器,分别读到了库存10。

  • 用户A的代码执行:库存 = 10 - 2 = 8,写回数据库。
  • 用户B的代码执行:库存 = 10 - 3 = 7,写回数据库。

最后数据库里库存变成了7,然而实际卖出去5件,剩下的应该是5。这就是经典的丢失更新问题——用户A的更新被用户B的更新覆盖掉了。

在小型项目里,这个问题可能很长时间都不暴露,因为并发量低,撞车概率小。但只要用户量上来,或者运营后台多人同时操作订单、商品数据,这个问题就是一定会来的。更麻烦的是,这类bug很难复现,测试环境基本不会触发,上线没多久就出现数据对不上。

1.2 为什么不能用"锁整个表"这样的简单方案

有人会说,那我更新库存的时候把整张表锁住不就行了?听起来粗暴有效,但代价极大。锁表意味着所有针对这张表的读写全部串行化,一个慢事务堵住,后续请求全在那干等。对于写多读少的系统,这就是自寻死路。事务里加共享锁、排他锁,如果锁的粒度设计不合理,还会引入死锁风险,DBA看到死锁日志就头大。

真正合理的思路是:把冲突检测从"锁资源"变成"校验版本",也就是乐观锁的思路。先让事务正常执行,提交的时候检查一下数据在读取之后有没有被别人改过。改过了,就告诉你产生了冲突,由业务层决定怎么处理。这样绝大多数情况下没有任何锁开销,只有真正撞车的时候才需要额外处理。

1.3 EF Core的默认行为和"假并发安全"

默认情况下,EF Core在调用SaveChanges时,会把所有状态为Modified、Added、Deleted的实体生成对应的INSERT、UPDATE、DELETE语句。对于UPDATE语句,EF Core只会用主键作为WHERE条件,其他字段统统不加条件。也就是说,两条并发请求把同一条记录从A改成B和从C改成D,后提交的那个会直接覆盖前一个,完全没有检测。

这样做的后果就是:你跟本不知道数据被别人改过。所以EF Core必须提供一种机制,让你能在UPDATE语句的WHERE条件里多带一些校验条件,比如"版本号等于我当初读到的值",如果版本号已经变了,影响行数是0,EF Core就知道并发冲突发生了,抛DbUpdateConcurrencyException。

这块必须理解清楚:并发控制不是EF Core默认帮你开的,需要你自己配置。配置的本质就是给实体加一个并发令牌属性,然后把这个属性告诉EF Core。

2. 乐观锁的落地:RowVersion的配置和原理

2.1 RowVersion到底是什么,为什么它适合做并发令牌

RowVersion是SQL Server里的一种特殊数据类型,每次任何字段更新时,数据库会自动给这行的RowVersion字段生成一个新的递增值,跟业务数据完全无关,纯粹用来表示"这行数据的版本"。

用RowVersion做并发令牌有几个天然优势:

  • 自动维护,程序里不需要手动赋值,数据库帮你改。
  • 全局唯一且单调递增,每个数据库里只有一个全局的当前版本号,每次任何表任何行更新都会推进它。
  • 占用空间小,8字节,完全不影响性能。

有些团队用DateTime做版本号,不是不行,但有个致命问题:在高并发下,两个事务在同一毫秒内更新同一行,DateTime的精度不够导致版本号没变化,冲突就检测不到了。RowVersion不会出现这种问题,它是数据库内部维护的递增序列,不会因为应用层的时间精度而失效。

2.2 一口气配置好RowVersion的完整步骤

用EF Core配置RowVersion,比较主流的方式是Fluent API。假设我有一个Product实体:

public class Product { public int Id { get; set; } public string Name { get; set; } public int Stock { get; set; } public decimal Price { get; set; } public byte[] RowVersion { get; set; } }

然后在DbContext的OnModelCreating里配置:

protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>() .Property(p => p.RowVersion) .IsRowVersion(); }

就这一行,EF Core会做三件事:

  • 在数据库表里创建rowversion类型的列。
  • 把这个属性标记为并发令牌。生成UPDATE语句时,WHERE条件里除了主键,还会带上ROWVERSION字段。
  • SaveChanges时自动检查受影响行数,行数为0就抛DbUpdateConcurrencyException。

如果你用的是数据迁移,记得在迁移文件里确认生成的列类型是rowversion。如果你用EnsureCreated或者直接由EF Core建表,都不用操心。

还有一种方式是用数据注解:

public class Product { [Timestamp] public byte[] RowVersion { get; set; } }

效果一样,但数据注解会把实体类跟数据库特性耦合在一起,我个人更倾向Fluent API,集中在DbContext里管理,看起来清爽。

需要注意,SQLite和PostgreSQL对RowVersion的支持方式不太一样。SQLite需要你自己管理一个类似版本的字段,每次更新时手动给并发令牌赋值,没有SQL Server那种自动递增值。PostgreSQL里可以用xmin系统列来模拟,EF Core的NpgsqlProvider对xmin有内置支持,配置方式略有不同。如果你的项目将来要跨数据库,建议把并发令牌的设计抽一层,不要直接依赖数据库特性。

2.3 并发令牌的粒度选择:行级是标配,字段级是进阶

EF Core默认的并发令牌是行级别的。只要这行的任何一个字段被改了,RowVersion就变,并发检查就失败。这对于大多数业务场景是合适的。

但也有更精细的场景。比如一个文章表,有标题、正文、阅读量、状态。用户A在编辑标题,用户B在修改状态,这两个操作本身并不冲突,但因为它们在同一个行上,行级并发令牌会把它们当成冲突。对这种情况,如果业务上确实希望互不干扰,可以把并发令牌配置到字段级别——只对需要严格控制的字段设置并发检查。

字段级配置的方式是这样的,例如只对"状态"字段做并发控制:

modelBuilder.Entity<Article>() .Property(a => a.Status) .IsConcurrencyToken();

这样生成的UPDATE语句的WHERE条件里会多一个Status = 原值条件,只有状态被别人改了才会冲突。但要注意,字段级并发令牌在并发更新同一行不同字段时能共存,可一旦两个请求改了同一个字段,还是会冲突。所以粒度选择取决于业务上到底要防什么。

我的经验是:默认情况下用行级RowVersion就够了,字段级适合特定场景,不要一上来就精细化,容易把自己绕晕。

3. DbUpdateConcurrencyException处理的完整套路

3.1 异常发生后,EF Core给你留了什么线索

当并发冲突发生时,抛出的DbUpdateConcurrencyException实例里带着Entries(),每个实体每一个你正在保存的、发生冲突的实体。通过它,你可以拿到三类数据:

  • 实体当前的值:你这次提交之前,程序内存里已有的值。
  • 数据库当前的值:别人已经改完提交的值。
  • 数据库原始的值:你当初从数据库读出来时的值。

这些值都是通过Property的CurrentValue、OriginalValue和GetDatabaseValues获取的。处理策略的本质,就是决定三个值里到底以哪个为准。

这里有一个很重要的细节:当你调用SaveChanges并捕获到DbUpdateConcurrencyException时,如果你什么都不做,问题实体的状态还停留在Detached或Unchanged吗?不是。实际上它的状态是Modified,但EF Core的内部跟踪和数据库状态已经不一致了。如果想重新尝试或者继续操作,需要先把实体的状态重置到Unchanged或者重新加载数据。

3.2 三种主流处理策略:客户端优先、数据库优先、合并值

策略一:客户端值覆盖数据库值,也就是"以我为准"。

这种策略适合"当前用户正在编辑,其他任何人的修改都让位"的场景。处理方式就是拿到数据库值,把当前实体的OriginalValue更新成数据库的OriginalValue,然后重试SaveChanges。

catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var databaseValues = await entry.GetDatabaseValuesAsync(); // 把数据库当前值设置成原始值,这样并发检查就能通过 entry.OriginalValues.SetValues(databaseValues); } await context.SaveChangesAsync(); }

这里的关键就是OriginalValues.SetValues(databaseValues),把"当初读到的是什么"改成"数据库现在是什么",相当于告诉EF Core:我已知晓别人的修改,现在强制用我的值覆盖上去。重试后,这个用户的修改就会生效,别人的修改被覆盖。

策略二:保留数据库值,放弃当前用户的修改。也就是"以数据库为准"。

catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { entry.Reload(); } }

Reload()会重新从数据库加载当前值,实体的值变成数据库的最新值。当前用户修改的字段全部丢弃,界面应该刷新并告诉用户"数据已被别人更新,你看到的是最新值"。

策略三:合并值。

不直接覆盖,也不全丢。比如用户A改了标题,用户B改了正文,两边都成功。合并逻辑通常是:在冲突发生后,取数据库当前值为基础,然后把"数据库里没变、但当前用户改了"的字段合并进去。

具体实现上,可以这样处理:

catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var databaseValues = await entry.GetDatabaseValuesAsync(); foreach (var property in entry.Metadata.GetProperties()) { var originalValue = entry.OriginalValues[property.Name]; var currentValue = entry.CurrentValues[property.Name]; var databaseValue = databaseValues[property.Name]; // 当前用户改了,但数据库里这个字段没变——合并 if (!Equals(originalValue, currentValue) && Equals(originalValue, databaseValue)) { continue; } // 其他情况都以数据库值为准 entry.CurrentValues[property.Name] = databaseValue; } entry.OriginalValues.SetValues(databaseValues); } await context.SaveChangesAsync(); }

这个逻辑的缺点是:如果两个用户改了同一个字段,数据库为准,另一个用户的修改丢失。如果你希望字段级合并,逻辑会更复杂,比如要记录每个字段被谁改的,这通常需要额外字段存修改人、修改时间。一般业务用不太上,但如果做协作文档类的产品,就需要走这个路线。

3.3 重试机制到底怎么写才靠谱

最简单的重试方式就是上面代码所示,捕获异常后处理完原始值再SaveChanges。但如果并发特别高,处理完第一次重试后可能又发现冲突,因为在你处理的时间窗口里别人又改了。所以严谨的写法是套一个for循环,控制重试次数。

var maxRetry = 3; for (int i = 0; i < maxRetry; i++) { try { await context.SaveChangesAsync(); break; } catch (DbUpdateConcurrencyException ex) when (i < maxRetry - 1) { foreach (var entry in ex.Entries) { var databaseValues = await entry.GetDatabaseValuesAsync(); entry.OriginalValues.SetValues(databaseValues); } } }

这里有个经验:重试次数不要设太大,2到3次就够了。两次重试都失败,说明当前记录是"热点行",再重试大概率还是失败,应该直接返回给前端"系统繁忙,请稍后重试"。

另外要注意,重试执行的代码块里不要包含业务Service层的其他操作,比如发消息、写日志等等,否则可能造成重复执行。最好把SaveChanges单独抽出来做重试,业务逻辑在前面已经执行完了。EF Core本身没有内置的事务性重试机制,所以这里只能自己写。.NET框架层面如果有Polly,可以结合它的重试策略做更灵活的控制,但核心还是"重置原始值+重新保存"。

4. 乐观锁与悲观锁,到底选哪个

4.1 悲观锁的实现方式和适用场景

悲观锁的思路是,我一开始就锁定资源,不让别人动。在关系数据库里最典型的实现就是SELECT ... FOR UPDATE,事务开启后,查询并锁定这些行,事务提交或回滚前,其他事务的更新会被阻塞。

EF Core里使用悲观锁需要手动执行SQL或者使用Transaction:

await using var transaction = await context.Database.BeginTransactionAsync(); var product = await context.Products .FromSqlRaw("SELECT * FROM Products WITH (UPDLOCK) WHERE Id = {0}", id) .FirstOrDefaultAsync(); // 同一个事务内,这条记录被锁住,别人更新不了 product.Stock -= 2; await context.SaveChangesAsync(); await transaction.CommitAsync();

悲观锁适合:并发冲突概率极高、冲突代价极高的场景。比如库存扣减,金额转账,特价秒杀。你愿意为了数据一致性牺牲一些并发性能,换来非常强的确定性。

但悲观锁的代价是:锁等待、死锁、数据库连接占用时间变长。在高并发秒杀场景下,如果每个请求都锁行然后处理业务逻辑,数据库连接池很快被占满,性能直线下降。所以很多秒杀系统实际上会用Redis做前置扣减,再用数据库做最终落账,而不是直接锁数据库。

4.2 乐观锁为什么更适合大多数业务

乐观锁坚信大多数情况下不会有人跟你抢,只在提交时检查一次。开销小得多,没有长时间持锁的问题,也没有死锁。电商场景下的商品资料编辑、订单备注修改、CMS内容管理,并发冲突概率低,用乐观锁完全够。

乐观锁和悲观锁并不是二选一,它们可以在同一系统里共存。比如下单扣库存用悲观锁,保证不超卖;商品资料编辑用乐观锁,减少锁冲突。关键是想清楚每个业务场景的冲突概率和容忍度。

4.3 什么时候乐观锁会拖垮你

有一个信号:如果你在日志里频繁看到DbUpdateConcurrencyException,说明这个业务场景的并发冲突率远高于预期。这时候坚持用乐观锁会让用户体验很差——用户辛苦填了半天的表单,一提交提示"数据已被修改",要重新填。遇到这种情况,就要评估是否要切悲观锁,或者改变产品交互,比如给数据加"编辑中"的状态,别人看到就只读。

还有一点,乐观锁只能防丢失更新,不能防脏读和不可重复读。它保护的是"更新"操作的一致性,读取方面还是需要配合数据库的事务隔离级别。这点很多刚接触并发控制的人会混淆,以为加了RowVersion所有并发问题都解决了,其实不是。

5. 实战排查:那些年用过才懂的坑

5.1 版本列没映射导致并发检查失效

这个问题特别容易踩。实体里有RowVersion属性,但OnModelCreating里面忘了写IsRowVersion()。此时EF Core会把它当成普通的byte[]字段处理,SaveChanges的UPDATE语句里根本不会带上这个字段作为条件。更隐蔽的是,如果数据库表里已经有rowversion列但实体里没有这个属性,EF Core查询时也不会报错,你完全感知不到并发检查在静默失效。

排查方法很直接:引入一个日志拦截器,把EF Core生成的SQL打出来,看UPDATE语句的WHERE条件里有没有RowVersion字段。没有,说明配置没生效。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information); }

这个日志在开发环境非常有用,也能顺便观察SQL执行情况。

5.2 OriginalValue被意外清空

有一种情况是,实体在DbContext里被Detach,然后又Attach到同一个或另一个DbContext实例,导致OriginalValue丢失。EF Core的并发检查依据的是实体状态里记录的OriginalValue,如果丢了这个值,它就会用当前值去匹配WHERE条件,通常不会生效。

解决方案有两类:要么确保实体在同一个DbContext生命周期内完成从查询到保存的全部操作,要么在Attach后显式设置OriginalValue。

用短生命周期DbContext是目前最佳实践。把DbContext注册成Scoped,每个请求在自己的作用域内创建和释放,避免跨请求复用。用Singleton或者长生命周期DbContext,往往就是为了解决性能问题而引入更大的并发隐患,不推荐。

5.3 数据库值读取异常导致处理失败

在DbUpdateConcurrencyException的处理代码里,调用GetDatabaseValuesAsync()有时会返回null。这种情况常见于:当前实体对应的行已经被别人删除了。数据库里已经没有这行了,自然拿不到数据库值。

处理逻辑里必须判断null:

var databaseValues = await entry.GetDatabaseValuesAsync(); if (databaseValues == null) { // 行已被删除,当前更新应当终止,或者按业务逻辑决定是否重新插入 entry.State = EntityState.Detached; return; }

有些业务要求"行被删了我还要更新",那可以考虑upsert逻辑,走Insert。但更多业务下,应该明确告知用户"这条记录已被删除"。

5.4 处理完DbUpdateConcurrencyException后又抛DbUpdateException

这个坑异常恶心。你按"客户端值覆盖"策略处理了并发冲突,重试SaveChanges,结果又抛异常。这次不是并发冲突,而是数据库约束错误,比如某个唯一索引冲突、非空约束违反。原因是你的"覆盖策略"把某个字段设成了非法值,或者是并发重试过程中另一个请求已经插入了一条唯一键相同的记录。

遇到这种情况,不要只catch DbUpdateConcurrencyException,要在外层再包一个DbUpdateException,看InnerException里是不是SQL Server的错误码2627(唯一键冲突)或者547(外键约束冲突),然后返回对应的业务错误码。

5.5 和高级查询结合时容易忽略AsNoTracking

先说一个EF Core高级查询相关的注意事项:使用AsNoTracking()查询出来的实体没有快照,不含OriginalValue。如果后续对它做修改并SaveChanges,EF Core不知道原始值,也就不可能正确做并发检查。

有几种做法可以正确处理:

  • 查询时必须带Tracking,默认就是Tracking,不要乱加AsNoTracking。
  • 如果一定要用AsNoTracking,保存前显式Attach实体,并设置OriginalValue为查询时的值。
  • 用ExecuteUpdate这类无跟踪批量更新时,并发检查天然不生效,需要自己在SQL里带上版本条件。

实际上ExecuteUpdate是EF Core 7以后推出的一把双刃剑,性能很好,但因为它直接执行UPDATE语句,不会走实体的变化跟踪,所以RowVersion的并发检查默认不生效。如果你想用ExecuteUpdate又保持并发安全,需要手动在Set和Where里加条件。为了省事,我自己做批量更新的时候,还是会优先选择循环内实体更新加SaveChanges,量大的才会评估走原生SQL。

5.6 不同数据库提供商的差异

RowVersion的用法因数据库而异,这是跨平台项目最容易翻车的地方。有一个表格可以参考:

数据库实现方式EF Core配置
SQL Serverrowversion类型,自动递增IsRowVersion()
PostgreSQLxmin系统列NpgsqlProvider配置UseXminAsConcurrencyToken()
SQLite无内置自动版本,需要手动维护手动管理版本号字段,每次更新时手动赋新值
MySQL无内置RowVersion,可以用TIMESTAMP列模拟IsConcurrencyToken()配合每次更新自动修改的timestamp

跨数据库的项目,建议把并发令牌的赋值逻辑封装成仓储层方法,屏蔽各数据库差异。不过大多数公司都是钉在一个数据库上的,倒也不用过度设计。

这里还需要一个经验之谈:SQL Server的rowversion列在表里创建之后,如果表的数据量特别大、更新频繁,rowversion本身会有一些页分裂和日志增长开销,但影响很小。真正有大影响的是你给并发令牌列建了索引,那是完全没必要的。RowVersion列做WHERE条件的更新,在没有并发冲突的情况下,靠主键就可以定位行,加索引反而增加写放大。

6. 从设计层面减少并发冲突的几个思路

6.1 用DTO隔离并发令牌字段

很多团队直接把实体类暴露给前端,用户提交整个对象回来,RowVersion也在其中。如果前端传回来的RowVersion比数据库新(你可以自己模拟一个新版本),那么在并发检查时就会误判。所以最佳实践是:入参的DTO里显式包含RowVersion,但由后端赋值,不信任前端传值;或者干脆在DTO里放一个"版本号"字段,后端再映射到实体的RowVersion上。

还有一种思路是:直接把RowVersion转成ulong或string传给前端,比如Convert.ToBase64String(RowVersion),前端原样带回,后端再转回byte[]。这样用户看不到原始字节,也改不了,但只要前端拿到的是旧值,后端就能正确检测到并发冲突。

6.2 把更新场景拆分:全字段更新不是唯一解

用EF Core默认的SetValues或者把整个实体标记Modified,会造成全字段更新,也就是UPDATE语句里所有非主键列都出现。这会带来两个问题:一是生成的SQL很宽,性能略差;二是并发冲突概率变大,因为任何一个字段变化都会让RowVersion变更。

更好的做法是:在业务层只更新需要变化的字段,比如用DTO里的属性逐个赋值,而不是整体SetValues。这样既减小SQL宽度,又能在部分场景下降低无谓的并发冲突。当然,如果业务上就是表单整体编辑,全字段更新也合理,不要为了优化而优化。

6.3 合理的事务边界比锁策略更重要

很多并发问题不是锁的问题,而是事务的问题。一个事务应该尽可能短,只包含必要的读写操作。比如扣库存时,不要在同一个事务里发短信、调用外部API、生成PDF。事务时间越长,锁持有的时间越长,并发冲突和死锁概率都上升。

EF Core的事务边界一般就是BeginTransaction到Commit之间。SaveChanges本身就是一个隐式事务,如果多个SaveChanges要合并成逻辑上的一个事务,才需要显式开启。这里有个常见的坑:在repository模式中,每个SaveChanges是独立事务,跨多次SaveChanges的业务不是一个原子操作,中途失败会出现数据不一致。要处理这种场景,必须把DbContext的Transaction拿出来包整个业务逻辑。

6.4 高并发场景下的兜底策略

除了乐观锁、悲观锁,还要考虑系统级的兜底。比如数据库层面对热点行做排队,或者用Redis做前置校验。典型场景是秒杀库存,很多人用数据库行锁加库存判断,但到了极大流量下还是扛不住。这时会采用"预热库存到Redis,减库存走Lua脚本,异步落库"的方式。这种方案里,数据库的并发控制只是最终一致性校验,不承担性能核心。

普通业务系统,并发量没那么大,乐观锁加RowVersion基本能覆盖99%的场景。剩下的1%,要么是热点账户、热点库存,要么是不合理的事务设计导致的冲突。先排查事务,再考虑切悲观锁,最后才上升到改架构,这是我认为比较稳健的顺序。

7. 内部源码级理解:为什么Update语句会带条件

7.1 EF Core如何决定UPDATE语句的影响行数检查

看EF Core源码(Microsoft.EntityFrameworkCore/Update/Internal/CommandBatchPreparer.cs)会发现,对于每个修改的实体,它会生成一个ModificationCommand。这个命令包含一个ColumnModification的集合,每个字段都有IsRead、IsWrite、IsCondition、IsKey等标志位。

具体规则大致是:

  • 主键字段:IsKey=true,同时IsCondition=true,作为WHERE条件。
  • 并发令牌:IsConcurrencyToken=true,同时IsCondition=true,也进入WHERE条件。
  • 其他修改字段:IsWrite=true,进SET子句。

生成SQL时,EF Core只把IsCondition=true的字段拼进WHERE条件。如果你配置了并发令牌,但生成SQL时发现WHERE里只有主键没有RowVersion,唯一的可能就是实体状态里的OriginalValue没有正确保存,或者配置没有同步到模型快照。

7.2 受影响行数为0时,EF Core是怎么判断的

当UPDATE语句执行后,EF Core拿到受影响的行数。如果实体修改涉及的记录数预期为1,但实际受影响行数为0,它就判定发生了并发冲突。前提是WHERE条件里包含并发令牌列。如果只有主键,那么只要记录还在,即使其他用户已经更新过,影响行数依然会是1,不会触发异常。

这就是为什么很多人配置了RowVersion却没生效时,并发冲突静默消失。不是EF Core没检测,而是它根本没有机会检测——条件不够。

7.3 与ExecuteUpdate、ExecuteDelete的边界

EF Core 7之后提供的ExecuteUpdate和ExecuteDelete直接生成并执行更新、删除SQL,天然不走变更跟踪器,所以并发令牌列也不会自动加到WHERE条件里。如果你在这一类方法里业务涉及并发保护,必须自己把RowVersion的校验条件写在Where里。有点麻烦,但这是EF Core 7后容易踩坑的地方。

比如:

await context.Products .Where(p => p.Id == id && p.RowVersion == version) .ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - 2));

这样执行完后,需要检查返回的影响行数,为0就说明有并发冲突。

8. 实操选型与团队落地的建议

8.1 老项目改造怎么下手

很多老项目已经在用EF6或者EF Core 2.x,代码里满是全字段更新、大DbContext。改造时不要一上来把所有表都加RowVersion,风险太大。建议按优先级挑业务敏感的表,比如订单、库存、账户、配置表。

具体流程:

  1. 选出5~8张最核心的业务表。
  2. 给这些表添加RowVersion列。
  3. 实体类加byte[]属性,配置IsRowVersion()。
  4. 全局捕获DbUpdateConcurrencyException,先统一走"数据库优先 + Reload"策略。
  5. 日志里观察哪些表频繁抛异常,再针对性做"客户端覆盖"或"合并值"。

这样渐进式改造风险最小,不会出现一次大重构崩一片的情况。

8.2 需要一个全局的并发异常处理中间件

不管你的策略是什么,给前端返回的HTTP状态码和错误信息结构要统一。我的做法是一个中间件捕获DbUpdateConcurrencyException,统一返回409 Conflict,body里携带冲突信息,包括涉及的表名、主键ID,以及可选的数据库当前值。

结构大致是:

{ "code": 409, "message": "数据已被其他用户修改,请刷新后重试", "conflicts": [ { "entityName": "Product", "id": 42 } ] }

前端拿到409后,可以引导用户刷新或者弹窗提示。如果业务允许,可以在响应里带上最新的数据库值,前端直接更新表单。

8.3 单元测试和集成测试怎么写

并发相关的测试必须写,而且不能只测试"没冲突时正常更新""有冲突时抛异常"这两个主路径。我认为至少要覆盖:

  • 两个不同实体同时更新同一条记录,后提交者抛异常。
  • 处理策略为"客户端覆盖"时,最终值是客户端提交的值。
  • 处理策略为"数据库优先"时,最终值是数据库当前值。
  • 数据库行被删除时,GetDatabaseValues返回null,处理逻辑不崩溃。
  • 并发令牌配置缺失时,同一条记录连续更新不抛异常(验证测试本身有效性)。

测试的实现方式用真实数据库最好,SQLite内存模式跟SQL Server在RowVersion行为上有差异,容易踩坑。建议集成测试用Testcontainers跑一个SQL Server容器,代码里通过环境变量切换连接串。虽然慢,但至少测试结果可靠。

8.4 给团队的约定和规范

并发控制这件事,光靠一个中间件和几个实体配置是不够的,团队得形成约定:

  • 新增实体时,业务表必须配置RowVersion。
  • Controller或Service层不允许直接接收整个实体类作为参数,必须DTO传入,后端再映射。
  • 不在事务里夹带外部请求、消息发送。
  • 所有批量更新操作优先级:ExecuteUpdate + 显式版本条件,否则不用。
  • SaveChanges重试逻辑统一封装到一个ConcurrencyHandlingHelper,不散落各业务代码里。

这些约定写进团队的ADR或者编码规范文档里,比靠口头传承要稳得多。

9. 最后分享几个个人常用的调试小方法

调试并发问题最烦的一点是难以稳定复现。我自己常用的一个办法是写一个简单的并发压测脚本,用HttpClient并行发请求命令到同一个接口。这个脚本不用很复杂,.NET里用Parallel.ForEachAsync循环发100个请求即可,命中率非常高:

await Parallel.ForEachAsync(Enumerable.Range(0, 100), async (i, ct) => { var client = new HttpClient(); var response = await client.PostAsJsonAsync("https://localhost:5001/api/products/update", new { Id = 1, Stock = new Random().Next(1, 5) }); var content = await response.Content.ReadAsStringAsync(); Console.WriteLine($"{i}: {response.StatusCode} {content}"); });

通过观察返回的409数量,可以大概评估并发冲突比例,也能验证你的处理策略是否符合预期。

另一个小技巧是启用EF Core的敏感数据日志。你在连接字符串或者OnConfiguring里加上EnableSensitiveDataLogging(),日志里会输出SQL参数的原始值,一看就知道WHERE条件里的RowVersion跟数据库里差多少。不过这个只能开发环境开,生产环境绝不能开,否则SQL参数里的用户数据会全被记录到日志文件里,有数据泄露风险。

日志查看还可以配合SQL Server Profiler,不过2022版本后推荐用Azure Data Studio的SQL Server Profiler扩展或者自定义扩展事件会话,抓一下RPC:Completed事件。一般用EF Core日志就够了,不用每次都上Profile。

还有,排查并发问题时要警惕一个假象:如果日志里看不到任何DbUpdateConcurrencyException,不代表没有并发冲突,可能是因为你根本没启用并发令牌。建议在项目的启动配置里加一个检查项,扫描所有实体,找出那种"主键不是唯一的更新条件而且没有并发令牌"的实体,记录下来输出警告。这个检查项其实就是遍历Model.GetEntityTypes(),看看每个实体类型的属性里有没有IsConcurrencyToken,如果某个实体状态是Modified时生成的Where条件里除了主键没有其他条件,就打印告警。这个小工具可以极大减少"以为配置了其实没配置"的情况。

关于并发控制,这些年我个人的体会是:不要一上来就追求极致的性能和复杂的冲突合并方案,先保证并发冲突能被正确发现,再考虑怎么处理。RowVersion加DbUpdateConcurrencyException这套组合,就是在"能发现"和"好处理"之间最平衡的方案。先把基础做扎实,高并发场景再逐步上更重的武器,就不会在数据一致性上栽大跟头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询