You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.10 07:25:55