PAYATHON 2026

EF Code First 继承导致 Primary Key Violation

支付阿杰

结论

这个问题由两个原因共同导致:

  1. DBCC CHECKIDENT 只适用于包含 IDENTITY 列的表。在 TPC 映射中,GiftCouponPayment 和 ClubCardPayment 的主键不能各自使用默认且种子相同的 identity。对没有 identity 列的表执行 DBCC CHECKIDENT,就会出现以下错误:

    'GiftCouponPayment' does not contain an identity column.
    
  2. 虽然 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 与模型保持一致。

备注:内容仅供参考。