从应用程式日誌看到下面这个 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 教学 |