EF Core行/表锁与隔离级别:展位预订并发控制方案咨询
问题解决方案
你不需要使用Serializable隔离级别,这个方案并发性能差,且无法完全解决重复预订问题。最优实现是搭配UPDLOCK+ROWLOCK行级提示,配合默认的Read Committed隔离级别即可完全满足需求。
为什么原方案不可行
Serializable作为最高隔离级别,会对查询覆盖的键范围加范围锁,不只会锁定你要操作的单条展位记录,还会阻塞同表其他无关展位的写入操作,并发性能极差。
更关键的是,你现有代码中默认的EF Core查询在Serializable级别下加的是共享读锁,共享锁在读取完成后就会释放,不会持续到事务结束,两个并发请求完全可能同时读到同一条未预订的展位记录,后续都执行Purchase()逻辑更新状态,依旧会出现重复预订问题。
UPDLOCK+ROWLOCK的适配性说明
这两个查询提示的特性完全匹配你的核心诉求:
UPDLOCK为更新锁:查询时直接对目标行加更新锁,同一时间仅允许一个事务持有同一行的更新锁,其他同样执行购买逻辑、加更新锁查询同一条展位的请求会被直接阻塞,直到当前事务提交/回滚,从根源上避免两个请求同时读到未预订状态的问题。- 更新锁和普通读操作的共享锁完全兼容:同表其他展位的操作、不加更新锁的普通查询(比如展位列表展示、展位信息公开查询)不会被阻塞,不会影响其他业务场景。
ROWLOCK明确指定锁粒度为行级,避免数据库查询优化器自动将锁升级为页锁、表锁,进一步降低对无关操作的影响。- 所有锁会持续持有到事务结束才释放,完整覆盖你读取记录、校验预订状态、更新预订标记、保存数据的全流程,不存在锁提前释放的漏洞。
具体代码实现
事务不需要指定Serializable级别,使用数据库默认的Read Committed即可。
SQL Server 版本
SQL Server需要显式指定锁提示,通过原生SQL拼接查询即可:
using var tx = await context.Database.BeginTransactionAsync(); // 查询时加行级更新锁 var product = await context.SponsorshipProducts .FromSqlRaw("SELECT * FROM SponsorshipProducts WITH (UPDLOCK, ROWLOCK) WHERE Id = {0}", productId) .FirstOrDefaultAsync(); if (product == null) { await tx.RollbackAsync(); // 处理展位不存在的业务逻辑 return; } product.Purchase(); // 内部校验预订状态,未预订则设置ReservedById等标记 await context.SaveChangesAsync(); await tx.CommitAsync();
PostgreSQL/MySQL 版本
这类数据库使用SELECT ... FOR UPDATE语法实现行级排他读,EF Core 5.0+内置了对应支持,不需要写原生SQL:
using var tx = await context.Database.BeginTransactionAsync(); var product = await context.SponsorshipProducts .Where(x => x.Id == productId) .ForUpdate() // 等价于加行级更新锁 .FirstOrDefaultAsync(); if (product == null) { await tx.RollbackAsync(); // 处理展位不存在的业务逻辑 return; } product.Purchase(); await context.SaveChangesAsync(); await tx.CommitAsync();
额外兜底方案
为了彻底避免极端场景(比如漏加锁的业务代码、直接操作数据库的修改请求)导致的重复预订,可以在数据库层面加过滤唯一索引,从存储层做最后一层校验。以SQL Server为例:
CREATE UNIQUE INDEX IX_SponsorshipProducts_Reserved_Status ON SponsorshipProducts(Id) WHERE ReservedById IS NOT NULL;
该索引仅对已预订的展位记录生效,未预订的记录不受影响,性能开销极小,可以保证同一条展位永远只会存在一个有效的预订记录。
内容的提问来源于stack exchange,提问作者Chris Kooken
相关产品推荐
相关产品推荐

