多实例部署场景下EF Core并发访问重复拉取数据问题如何解决?
问题结论
首先明确:这段代码运行在多实例场景下一定会出现并发问题,多个实例会拉取到重复的记录。
问题原因
你的逻辑分为「查询未处理记录」和「标记为处理中」两个独立步骤,两个实例可以同时执行完第一步查询,拿到完全相同的100条记录,之后再分别执行标记更新,最终双方都会认为自己拿到了这批记录的处理权,就会出现重复处理的情况。
另外你给出的示例代码有语法错误:records是List集合类型,不能直接给集合赋值IsProcessing属性,需要遍历每个元素单独赋值,或者使用批量更新语法。
解决方案
下面是几种常用的防重复方案,可根据你的业务场景选择:
方案1:使用数据库原子操作(优先推荐)
将「查询+标记」两个步骤合并为一个原子SQL语句,靠数据库本身的事务原子性保证不会有重复拉取,性能最高,实现也最简单。
不同数据库的语法略有差异,以SQL Server为例,可以使用UPDATE + OUTPUT语法一步完成拉取和标记:
// 原子操作:查询并标记100条未处理记录,不会重复 var records = await db.Records .FromSqlRaw(@" UPDATE TOP(100) Records SET IsProcessing = 1 OUTPUT INSERTED.* WHERE IsProcessing = 0 ") .ToListAsync(); // 后续直接处理records即可,不需要再单独执行SaveChanges更新IsProcessing字段
MySQL的等效语法可以用SELECT ... FOR UPDATE加事务,或者用UPDATE加ORDER BY和LIMIT之后再查询,只要保证整个逻辑在同一个事务内即可。
方案2:悲观锁方案
如果不想写原生SQL,可以在查询时加数据库排他锁,把查询和更新逻辑包在同一个显式事务中,保证查询时其他事务不能读取这批记录:
using var transaction = await db.Database.BeginTransactionAsync(); try { // 查询时加更新锁,阻止其他事务同时读取这批记录 var records = await db.Records .FromSqlRaw("SELECT * FROM Records WITH (UPDLOCK, HOLDLOCK) WHERE IsProcessing = 0") .Take(100) .ToListAsync(); foreach (var record in records) { record.IsProcessing = true; } await db.SaveChangesAsync(); await transaction.CommitAsync(); // 后续处理records } catch { await transaction.RollbackAsync(); throw; }
该方案实现简单,适合并发量不高的场景,缺点是锁粒度较大,高并发下可能出现较多锁等待。
方案3:分布式锁方案
如果你的业务不允许直接操作数据库锁,或者需要跨数据库、跨服务协调,可以引入分布式锁(基于Redis、ZooKeeper等中间件实现):
- 执行拉取记录逻辑前,先尝试获取全局分布式锁
- 只有抢到锁的实例才能执行查询和标记逻辑,执行完成后释放锁
- 锁需要设置合理的超时时间,避免实例崩溃导致锁永久占用
该方案通用性强,不依赖数据库特性,缺点是需要额外引入中间件,需要处理锁超时、锁误释放等边界问题。
内容的提问来源于stack exchange,提问作者Anarion

