DDD+CQRS+EF Core下Entity重复键跟踪冲突问题求助
解决方案:EF Core 跟踪冲突问题处理
核心原因
问题源于两次通过Dapper手动创建的同ID MainAccount 实例被同一个Scoped DbContext跟踪:第一次从GetBillClassification返回的实例,第二次从GetBillingCode返回的实例,虽数据一致但为内存中不同对象,EF尝试跟踪时检测到主键重复,触发报错。此前的Detach方法无效,是因为实例关联到Transaction并添加到上下文时,EF会自动重新开始跟踪这些对象。
具体解决方法
方法1:让查询返回的实例始终处于无跟踪状态(推荐)
确保手动创建的MainAccount和BillClassification永远不被EF跟踪,可通过两种方式实现:
方式A:创建实例时直接标记为无跟踪
在仓储中添加方法,生成并返回已脱离跟踪的实体:
// 在TransactionRepository中新增方法 public T CreateDetachedEntity<T>(Func<T> factory) where T : Entity { var entity = factory(); _context.Entry(entity).State = EntityState.Detached; return entity; }
修改BillClassificationQueries中的实例创建逻辑:
// GetBillClassification内的实例创建部分 var mainAccountForBillClass = _transactionRepository.CreateDetachedEntity(() => new MainAccount(mainAccount.MainAccountNo, mainAccount.MainAccountName, Enumeration.FromValue<MainAccountType>(mainAccount.MainAccountTypeId), Enumeration.FromValue<MainAccountSubType>(mainAccount.MainAccountSubTypeId), mainAccount.WriteOffAccountNo)); var billClass = _transactionRepository.CreateDetachedEntity(() => new BillClassification( billClassification.BillClassificationId, billClassification.Name, Enumeration.FromValue<ChargeType>(billClassification.ChargeType), mainAccountForBillClass ));
方式B:用EF查询替代Dapper(直接返回无跟踪实例)
若允许替换Dapper查询,使用EF的AsNoTracking获取无跟踪实体:
public async Task<BillClassification> GetBillClassification(int billingClassificationId) { return await _context.BillClassifications .Include(bc => bc.MainAccount) .AsNoTracking() // 关键:返回无跟踪实例 .FirstOrDefaultAsync(bc => bc.BillClassificationId == billingClassificationId); }
方法2:在Scoped生命周期内缓存MainAccount实例
利用服务的Scoped特性,在BillClassificationQueries中维护本地缓存,避免重复创建同ID实例:
public class BillingClassificationQueries : IBillingClassificationQueries { private readonly string _connectionString; private readonly ITransactionRepository _transactionRepository; private readonly Dictionary<string, MainAccount> _mainAccountCache = new(); // 按MainAccountNo缓存 public BillingClassificationQueries(string connectionString, ITransactionRepository transactionRepository) { _connectionString = connectionString; _transactionRepository = transactionRepository; } public async Task<BillClassification> GetBillClassification(int billingClassificationId) { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); var query = @"SELECT BillClassificationId , BC.Name , BC.ChargeType , MA.MainAccountNo , MA.Name as MainAccountName , MA.MainAccountTypeId , MA.MainAccountSubTypeId , MA.WriteOffAccountNo FROM [RATES].[BillClassification] BC JOIN [ACCOUNTING].[MainAccount] MA ON MA.MainAccountNo = BC.MainAccount WHERE BillClassificationId = @billingClassificationId "; var result = await connection.QueryAsync<dynamic, dynamic, dynamic>( query , (billClassification, mainAccount) => { var mainAccountNo = mainAccount.MainAccountNo; // 优先从缓存获取实例 if (!_mainAccountCache.TryGetValue(mainAccountNo, out var cachedMainAccount)) { cachedMainAccount = new MainAccount(mainAccount.MainAccountNo, mainAccount.MainAccountName, Enumeration.FromValue<MainAccountType>(mainAccount.MainAccountTypeId), Enumeration.FromValue<MainAccountSubType>(mainAccount.MainAccountSubTypeId), mainAccount.WriteOffAccountNo); _transactionRepository.Detach(cachedMainAccount); _mainAccountCache[mainAccountNo] = cachedMainAccount; } var billClass = new BillClassification( billClassification.BillClassificationId, billClassification.Name, Enumeration.FromValue<ChargeType>(billClassification.ChargeType), cachedMainAccount ); _transactionRepository.Detach(billClass); return billClass; } , new { billingClassificationId }, splitOn: "MainAccountNo"); return result.FirstOrDefault(); } } // 对GetBillingCode方法执行同样的缓存逻辑修改 }
方法3:添加Transaction前手动合并跟踪实例
在调用_context.Add(transaction)前,检查并替换重复的MainAccount实例:
// 在PostTransactionService中添加预处理逻辑 var existingMainAccount = _context.MainAccounts.Local.FirstOrDefault(ma => ma.MainAccountNo == transaction.BillClassification.MainAccount.MainAccountNo); if (existingMainAccount != null) { // 替换Transaction中的MainAccount为已跟踪实例 transaction.BillClassification.MainAccount = existingMainAccount; // 对所有LineItem中的MainAccount执行同样替换 foreach (var lineItem in transaction.LineItems) { var lineItemMainAccount = _context.MainAccounts.Local.FirstOrDefault(ma => ma.MainAccountNo == lineItem.BillingCode.MainAccount.MainAccountNo); if (lineItemMainAccount != null) { lineItem.BillingCode.MainAccount = lineItemMainAccount; } } } _context.Add(transaction);
额外建议
- 遵循CQRS原则:查询侧(BillClassificationQueries)应返回只读DTO而非领域实体,从根源上避免查询侧实体进入命令侧的EF跟踪逻辑。例如创建
BillClassificationDto和MainAccountDto,查询返回DTO后,命令侧再用DTO构建领域实体。 - 验证领域实体配置:确保
MainAccount的主键MainAccountNo被EF正确识别。
内容的提问来源于stack exchange,提问作者Serge
相关产品推荐
相关产品推荐

