EF Code First 继承导致 Primary Key Violation
结论
这个问题由两个原因共同导致:
-
DBCC CHECKIDENT只适用于包含IDENTITY列的表。在 TPC 映射中,GiftCouponPayment和ClubCardPayment的主键不能各自使用默认且种子相同的 identity。对没有 identity 列的表执行DBCC CHECKIDENT,就会出现以下错误:'GiftCouponPayment' does not contain an identity column. -
虽然 TPC 会把每个具体类型存入不同的表,但 EF 仍将整个继承层次视为同一个实体集。因此,
PaymentComponentID必须在所有具体类型之间保持全局唯一。如果两个对象都使用默认值0,或者两个具体表分别生成相同的 identity,都会造成实体键冲突或主键冲突。
如果没有必须使用 TPC 的要求,最稳妥的做法是改用默认的 TPH 映射。整个继承层次共用一张表和一个 identity 列,同时可以完整保留现有领域模型。
如果必须使用 TPC,就需要关闭该继承层次的数据库自动主键生成,并由应用程序为所有派生类型分配全局唯一的主键。不要分别重置两个具体表的 identity。
为什么 TPC 不能直接使用两个独立的 identity
TPC 会生成类似下面的表结构:
GiftCouponPayment
PaymentComponentID
MyValue
...
ClubCardPayment
PaymentComponentID
MyValue
...
从数据库的角度看,两张表可以各自拥有主键 1。但在 EF 看来,这两个对象都属于 PaymentComponent 实体集:
PaymentComponent(1)
EF 无法只凭实体键区分它们。查询基类时,TPC 还需要组合各个具体表的数据,所以主键必须在整个继承层次中唯一,不能只保证在各自表内唯一。
为不同的表设置不同的 identity seed,只能暂时降低碰撞概率,无法真正保证全局唯一。一张表增长到另一张表的起始区间后,仍然会发生冲突。
方案一:保留 TPC,由应用程序生成全局唯一的整数主键
明确关闭 PaymentComponentID 的数据库生成功能,同时保留两个具体类型的 TPC 映射:
using System.ComponentModel.DataAnnotations.Schema;
using System.Data.Entity;
using System.Data.Entity.ModelConfiguration.Conventions;
public class NerdDinners : DbContext
{
public NerdDinners(string connectionString)
: base(connectionString)
{
}
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
modelBuilder.Entity<PaymentComponent>()
.HasKey(x => x.PaymentComponentID);
modelBuilder.Entity<PaymentComponent>()
.Property(x => x.PaymentComponentID)
.HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);
modelBuilder.Entity<GiftCouponPayment>()
.Map(mapping =>
{
mapping.MapInheritedProperties();
mapping.ToTable("GiftCouponPayment");
});
modelBuilder.Entity<ClubCardPayment>()
.Map(mapping =>
{
mapping.MapInheritedProperties();
mapping.ToTable("ClubCardPayment");
});
base.OnModelCreating(modelBuilder);
}
public DbSet<GiftCouponPayment> GiftCouponPayments { get; set; }
public DbSet<ClubCardPayment> ClubCardPayments { get; set; }
public DbSet<PaymentComponent> PaymentComponents { get; set; }
public DbSet<Payment> Payments { get; set; }
}
保存之前,必须为两个对象设置不同且全局唯一的 ID:
using (var db = new NerdDinners(connectionString))
{
var giftCouponPayment = new GiftCouponPayment
{
PaymentComponentID = 10001,
MyValue = 250
};
var clubCardPayment = new ClubCardPayment
{
PaymentComponentID = 10002,
MyValue = 5000
};
var payment = new Payment
{
PaymentComponents = new List<PaymentComponent>
{
giftCouponPayment,
clubCardPayment
},
PayedTime = DateTime.Now
};
db.Payments.Add(payment);
db.SaveChanges();
}
这些 ID 不能由每个具体类型分别从 1 开始编号。生产环境应采用统一的发号机制,例如:
- 独立的键分配表;
- SQL Server
SEQUENCE; - 集中式 ID 服务;
- 可以保证全局唯一的应用层整数 ID 生成器。
并发环境中不要使用 SELECT MAX(ID) + 1,因为两个事务可能同时获得相同的值。
使用 GUID 更简单
如果可以把主键类型从 int 改为 Guid,TPC 的主键管理会简单很多:
public abstract class PaymentComponent
{
public Guid PaymentComponentID { get; set; } = Guid.NewGuid();
public int MyValue { get; set; }
public abstract int GetEffectiveValue();
}
将其配置为由客户端生成:
modelBuilder.Entity<PaymentComponent>()
.Property(x => x.PaymentComponentID)
.HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);
这样就不需要在不同的具体表之间协调整数 identity。
方案二:使用 TPH,共享一个 identity
如果表结构没有限制,建议删除两个 MapInheritedProperties() 映射,改用 EF 默认的 Table per Hierarchy。所有 PaymentComponent 派生类型都存放在同一张表中,再通过 discriminator 区分具体类型。
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
modelBuilder.Entity<PaymentComponent>()
.HasKey(x => x.PaymentComponentID);
modelBuilder.Entity<PaymentComponent>()
.Property(x => x.PaymentComponentID)
.HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
modelBuilder.Entity<PaymentComponent>()
.ToTable("PaymentComponent");
base.OnModelCreating(modelBuilder);
}
不要再配置:
mapping.MapInheritedProperties();
生成的表结构大致如下:
PaymentComponent
PaymentComponentID IDENTITY
MyValue
Discriminator
派生类型的其他列
整个继承层次只使用一个 identity 生成器,因此不同具体类型不会生成相同的主键。如果必须保留普通的 int identity,但对表结构没有特殊要求,TPH 更合适。
删除错误的 DBCC CHECKIDENT
使用 TPC 并由应用程序生成主键时,应从初始化器中删除以下代码:
context.Database.ExecuteSqlCommand(
"DBCC CHECKIDENT ('GiftCouponPayment', RESEED, 2)");
context.Database.ExecuteSqlCommand(
"DBCC CHECKIDENT ('ClubCardPayment', RESEED, 3)");
这两条命令既不能为普通整数列添加 identity 属性,也无法可靠解决 TPC 的全局主键问题。
如果 Payment 是独立实体,它仍然可以使用自己的 identity。由于它不属于 PaymentComponent 继承层次,其主键只需在 Payment 表内唯一。通常也不必在 Seed 中手动执行 DBCC CHECKIDENT。
必须重新创建或迁移现有数据库
CreateDatabaseIfNotExists 只会在数据库不存在时创建数据库。修改继承映射或主键生成策略后,它不会更新已经存在的表结构。
因此,如果异常中仍然出现:
PK_dbo.PaymentComponent
通常意味着当前数据库还保留着旧映射生成的 PaymentComponent 表,或者实际数据库结构已经与当前模型不一致。正确的 TPC 模型通常不会使用基类表来存放基类行。
开发阶段可以使用:
Database.SetInitializer(
new DropCreateDatabaseIfModelChanges<NerdDinners>());
如果模型元数据无法识别手工修改过的数据库,并且确认开发环境中的数据可以丢弃,也可以重新创建数据库。生产环境应使用 Code First Migrations,将旧的 TPH/TPT 结构明确迁移到目标结构,不要依赖初始化器自动修改已有表。
关于“未公开外键属性”的异常
下面的信息通常只是 DbUpdateException 的外层说明:
An error occurred while saving entities that do not expose foreign key
properties for their relationships...
它不是这次失败的根本原因。真正的原因位于 InnerException 中:
Violation of PRIMARY KEY constraint ...
在实体中公开外键属性有助于定位异常,例如:
public abstract class PaymentComponent
{
public int PaymentComponentID { get; set; }
public int PaymentID { get; set; }
public virtual Payment Payment { get; set; }
public int MyValue { get; set; }
public abstract int GetEffectiveValue();
}
不过,公开外键并不能解决 TPC 的主键碰撞。需要先修正主键生成方式,并确保数据库结构与当前映射一致。
可以按实际需求选择:
- 保留 TPC:将
PaymentComponentID配置为DatabaseGeneratedOption.None,并为所有派生类型使用统一的发号机制。 - 继续使用普通的
int identity:改用 TPH,让整个继承层次共享一张表和一个 identity。 - 无论采用哪种方案,都应删除不适用的
DBCC CHECKIDENT,再通过重新创建数据库或执行迁移,让现有 schema 与模型保持一致。
备注:内容仅供参考。
