EF Core乐观并发必须配置RowVersion字段并正确捕获DbUpdateConcurrencyException;仅加[Timestamp]属性不够,需确保数据库支持行版本语义,且异常处理中必须用SetValues()同步DatabaseValues到OriginalValues才能重试成功。

EF Core 乐观并发必须配 RowVersion 字段 + 正确捕获 DbUpdateConcurrencyException,否则 SaveChanges() 永远不会抛异常,你以为的“锁”根本没生效。
怎么加 RowVersion 字段才真正起作用
不是随便加个 byte[] 属性打上 [Timestamp] 就完事——EF Core 要能识别它是数据库自增的行版本字段,且 SQL Server/PostgreSQL 必须实际支持该语义。
-
[Timestamp]只在 SQL Server 上自动映射为rowversion类型;PostgreSQL 需配合bytea+ 手动触发更新(如用触发器或DEFAULT gen_random_bytes(8)) - 字段必须声明为
public byte[] RowVersion { get; set; } = Array.Empty<byte>();</byte>,不能为null,否则迁移会失败或运行时报错 - Fluent API 配置优先级高于特性,如果同时用了
[Timestamp]和IsRowVersion(),后者生效;但若只用 Fluent API,[Timestamp]会被忽略 - 别手动赋值
RowVersion:SQL Server 每次 INSERT/UPDATE 都生成新值,EF Core 读取后自动跟踪,你改它反而破坏校验逻辑
DbUpdateConcurrencyException 捕获后不刷新 OriginalValues 就重试 = 白忙活
很多人写了 try-catch,也调了 SaveChanges() 第二次,但还是失败——因为没把数据库当前值同步回实体的原始快照里。EF Core 下次比较的仍是旧的 OriginalValues,WHERE 条件永远不匹配。
- 必须对每个冲突条目调用
await entry.GetDatabaseValuesAsync()获取真实库值 - 再用
entry.OriginalValues.SetValues(databaseValues)覆盖原始快照,这样下次 SaveChanges() 的 WHERE 才会带最新版本号 - 如果实体有多个字段被修改,仅刷新
RowVersion不够:SetValues()会一并更新所有原始值(包括业务字段),避免部分字段“看起来没变但其实已被覆盖” - 不要在同一个
DbContext实例里无限循环重试,建议限制次数(比如最多 3 次),超时后让上层决定是提示用户还是丢弃变更
为什么不用 [ConcurrencyCheck] 而坚持用 RowVersion
[ConcurrencyCheck] 看似灵活,但实际埋坑多:它只校验你标记的字段,其他字段被改了也不会触发冲突,容易漏检;而 RowVersion 是整行级别的原子变更标识,只要行被更新过就一定变。
-
[ConcurrencyCheck]标记的字段(比如int Version)必须由你每次更新前主动entity.Version++,漏一次等于关掉锁 - MySQL、SQLite 不支持原生
rowversion,但可以用datetime2或BIGINT模拟,此时仍推荐 Fluent API 配置IsRowVersion(),EF Core 会按语义生成 WHERE 条件,而非依赖数据库类型 - 如果用
[ConcurrencyCheck]标记非版本字段(比如string Name),那“别人只改了 Price,Name 没动”也会通过校验——这不是并发控制,这是字段级校验
真正难的不是加字段或写 catch 块,而是理解 OriginalValues 和 DatabaseValues 的生命周期差异:前者是 EF Core 内存里的“出发快照”,后者是数据库此刻的“真实状态”,中间差的那一步 SetValues(),90% 的重试失败都栽在这儿。


















