[廚餘回收] Entity Framework Core 錯誤:「...cannot be tracked because another instance...」的原因與解法

從應用程式日誌看到下面這個 Entity Framework Core(以下簡稱 EF Core)發出的例外錯誤:

The instance of entity type 'MyTable' cannot be tracked because another instance with the same key value for {'Key'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached.

它的意思是,相同主索引鍵的實體無法被追蹤,因為已經有另一個實體正在被追蹤,我們來看看怎麼回事。

我的應用程式是主控台應用程式,使用 EF Core 操作資料庫,並且加入了 Microsoft.Extensions.DependencyInjection 套件實作 DI,因此在服務註冊的階段,很自然地使用 ServiceCollection.AddDbContext<T>() 來註冊 DbContext。

serviceCollection.AddDbContext<TestContext>(
    options =>
        {
            // 預設使用 No-tracking queries
            options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
            options.UseSqlServer(testConnStr);
        });

我有一個服務是新增或更新資料,下面是模擬的程式碼:

public class MyService(IServiceProvider serviceProvider)
{
    public void Upsert(string key, string value)
    {
        var testCtx = serviceProvider.GetService<TestContext>();

        var myTable = testCtx.MyTables.SingleOrDefault(x => x.Key == key);

        if (myTable == null)
        {
            myTable = new MyTable { Key = key };

            testCtx.Entry(myTable).State = EntityState.Added;
        }
        else
        {
            testCtx.Entry(myTable).State = EntityState.Modified;
        }

        myTable.Value = value;

        testCtx.SaveChanges();
    }
}

相同主索引鍵的資料會被至少更新 2 次以上,下面模擬多次更新:

var serviceProvider = serviceCollection.BuildServiceProvider();

// 第一次更新
serviceProvider.GetService<MyService>().Upsert("test", "test1");

// 第二次更新
serviceProvider.GetService<MyService>().Upsert("test", "test2");

然後就在第二次更新的時候,爆出了開頭所說的例外錯誤。

問題原因

眾所周知,服務是有所謂的生命週期,ServiceCollection.AddDbContext<T>() 在註冊 DbContext 的時候,預設的 Lifetime 是 ServiceLifetime.Scoped,如果是 ASP.NET Core 應用程式,DbContext 會跟隨每一個 Request 的生命週期而生滅。

但是主控台應用程式沒有 Request 的概念,所以如果我們的服務註冊為 Scoped,其生命週期會跟隨 DI 容器的 Root Scope,這幾乎等同於 Singleton,我們的 DbContext 一旦建立,就不會被釋放,當第二次更新的時候,相同主索引鍵的實體因無法被追蹤而爆出例外錯誤。

解決錯誤的方法有三種:

解法一:EntityState.Detached

將資料實體的 State 設為 EntityState.Detached,變成無追蹤狀態。

public class MyService(IServiceProvider serviceProvider)
{
    public void Upsert(string key, string value)
    {
        ...

        EntityEntry<MyTable> myTableEntry;

        if (myTable == null)
        {
            ...

            myTableEntry = testCtx.Entry(myTable);
            myTableEntry.State = EntityState.Added;
        }
        else
        {
            myTableEntry = testCtx.Entry(myTable);
            myTableEntry.State = EntityState.Modified;
        }

        ...

        myTableEntry.State = EntityState.Detached;
    }
}

解法二:ServiceLifetime.Transient

在註冊 DbContext 時,將其生命週期改為 ServiceLifetime.Transient,意即每解析服務一次就重新建立實例。

serviceCollection.AddDbContext<TestContext>(
    options =>
        {
            ...
        },
    ServiceLifetime.Transient);

解法三:手動建立 Scope

解法三有兩種寫法,一種是在服務內部建立 Scope:

public class MyService(IServiceProvider serviceProvider)
{
    public void Upsert(string key, string value)
    {
        using var scope = serviceProvider.CreateScope();

        var testCtx = scope.ServiceProvider.GetService<TestContext>();

        ...
    }
}

另一種則是在註冊服務時就建立 Scope:

serviceCollection.AddTransient(provider => new MyService(provider.CreateScope().ServiceProvider));

以上針對「相同主索引鍵的實體無法被追蹤」這個錯誤,提供三種解法供大家參考。

相關資源

C# 指南
ASP.NET 教學
ASP.NET MVC 指引
Azure SQL Database 教學
SQL Server 教學
Xamarin.Forms 教學