從應用程式日誌看到下面這個 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));
以上針對「相同主索引鍵的實體無法被追蹤」這個錯誤,提供三種解法供大家參考。