使用EF AsSplitQuery读取SQL Server数据时并发写入致数据丢失
问题分析与解决方向
核心问题定位
你的场景是:基于.NET 7 + EF Core 7 + SQL Server的应用,通过带20个Include的查询结合.UseSplitQuery()加载大量数据到内存缓存并定期刷新;近期出现仅在Item更新/插入时,读取操作返回的一对多关系多方数据丢失的问题,无异常抛出,本地和Azure SQL环境均存在,具体表现为:
- 被更新的Item丢失Multimedia等关联行,严重时所有Item的Multimedia行丢失
- 若读取ItemContact中途出问题,后续Subtypes、EnvironmentalCertificates等所有一对多关系数据全为空
- 写入操作通过单个
SaveChanges()执行,理论上处于单事务中
已尝试更换SQL Server、升降.NET/EF版本、添加额外事务、减少Include等方案,目前仅靠异常检测器临时处理。
可能的原因及对应解决方向
1. SplitQuery并发读取与事务隔离级别冲突
.UseSplitQuery()会将多Include的查询拆分为多个独立SQL语句执行,这些语句之间没有原子性保证。当写入事务(SaveChanges())使用默认的READ COMMITTED隔离级别时,可能出现:
- 第一个Split查询读取了Item主表数据(此时写入事务未提交)
- 写入事务提交后,后续Split查询读取关联表数据时,可能读到部分更新后的状态,或因锁等待跳过部分数据
- 极端情况下,若关联表查询遇到写入事务的锁,EF Core可能静默跳过结果(默认未开启严格的查询超时检测)
解决方向:
- 提升读取缓存的事务隔离级别至
REPEATABLE READ或SERIALIZABLE,确保SplitQuery的多个语句读取同一快照:using var transaction = _dbContext.Database.BeginTransaction(System.Data.IsolationLevel.RepeatableRead); var items = await _dbContext.Items.Include(...).UseSplitQuery().ToListAsync(); transaction.Commit(); - 开启EF Core查询超时设置,避免锁等待导致的静默失败:
_dbContext.Database.SetCommandTimeout(60); // 设置60秒超时,超时会抛出异常而非静默丢弃结果
2. EF Core SplitQuery结果合并逻辑缺陷
EF Core 7的SplitQuery在处理大量关联Include时,可能存在结果合并bug:当主表与关联表的查询排序不一致,或关联表数据被写入事务修改导致行数不匹配时,EF Core无法正确关联主从数据,进而导致关联数据丢失。
解决方向:
- 强制所有SplitQuery使用相同排序规则,保证主表与关联表的行顺序一致:
var items = await _dbContext.Items .Include(i => i.Multimedia) .Include(i => i.Subtypes) .OrderBy(i => i.Id) // 统一按主键排序,确保SplitQuery结果顺序匹配 .UseSplitQuery() .ToListAsync(); - 升级至EF Core 8及以上版本,官方在后续版本中修复了多个SplitQuery合并逻辑的并发场景bug。
3. 缓存刷新与写入操作的并发冲突
缓存刷新的读取操作与写入操作无互斥机制,导致:
- 写入操作修改Item及其关联数据时,缓存刷新的SplitQuery刚好读到部分修改后的状态,无法正确加载关联数据
- 若写入操作在SplitQuery执行过程中修改关联表的外键或数据,EF Core的跟踪机制无法正确识别关联关系
解决方向:
- 给缓存刷新和写入操作加分布式锁(如使用SQL Server的
sp_getapplock),确保同一时间仅一个操作处理Item数据:// 写入操作加排他锁 using var lockHandle = await _dbContext.Database.ExecuteSqlRawAsync("EXEC sp_getapplock @Resource = 'ItemCacheRefresh', @LockMode = 'Exclusive', @LockTimeout = 5000"); try { await _dbContext.SaveChangesAsync(); } finally { await _dbContext.Database.ExecuteSqlRawAsync("EXEC sp_releaseapplock @Resource = 'ItemCacheRefresh'"); } // 缓存刷新读取操作加共享锁 using var readLockHandle = await _dbContext.Database.ExecuteSqlRawAsync("EXEC sp_getapplock @Resource = 'ItemCacheRefresh', @LockMode = 'Shared', @LockTimeout = 5000"); try { var items = await _dbContext.Items.Include(...).UseSplitQuery().ToListAsync(); // 更新缓存逻辑 } finally { await _dbContext.Database.ExecuteSqlRawAsync("EXEC sp_releaseapplock @Resource = 'ItemCacheRefresh'"); } - 调整缓存刷新时机,避开写入高峰,或在写入操作完成后主动触发缓存刷新,替代定期刷新。
4. 关联表索引缺失或锁竞争导致查询异常
当写入操作频繁修改关联表(如Multimedia)时,若关联表缺少外键索引(如ItemId的非聚集索引),会导致查询时锁等待时间变长,EF Core可能静默丢弃部分结果。
解决方向:
- 检查所有一对多关联的多方表,确保外键字段(如Multimedia.ItemId)存在非聚集索引
- 监控SQL Server锁等待情况,通过
sys.dm_tran_locks查看是否有长期等待的锁,针对性优化索引或查询逻辑
临时修复优化(替代异常检测器)
- 在缓存刷新后添加校验逻辑:检查每个Item的关键关联数据是否存在,若缺失则立即重试查询(最多2-3次)
- 给读取操作添加详细日志,记录每次SplitQuery的执行时间、返回行数,当关联数据缺失时输出对应SQL语句和执行上下文,便于排查根因
内容的提问来源于stack exchange,提问作者Henric Rosvall
相关产品推荐
相关产品推荐

