惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

The Cloudflare Blog
U
Unit 42
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
腾讯CDC
罗磊的独立博客
博客园 - 聂微东
博客园_首页
雷峰网
雷峰网
云风的 BLOG
云风的 BLOG
Jina AI
Jina AI
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
D
DataBreaches.Net
The GitHub Blog
The GitHub Blog
人人都是产品经理
人人都是产品经理
Y
Y Combinator Blog
量子位
Microsoft Azure Blog
Microsoft Azure Blog
阮一峰的网络日志
阮一峰的网络日志
小众软件
小众软件
月光博客
月光博客
T
The Exploit Database - CXSecurity.com
Google DeepMind News
Google DeepMind News
H
Help Net Security
O
OpenAI News
Blog — PlanetScale
Blog — PlanetScale
S
Security Affairs
S
Security @ Cisco Blogs
Microsoft Security Blog
Microsoft Security Blog
T
The Blog of Author Tim Ferriss
AI
AI
MongoDB | Blog
MongoDB | Blog
G
Google Developers Blog
MyScale Blog
MyScale Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
D
Docker
Hugging Face - Blog
Hugging Face - Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
S
Schneier on Security
Cloudbric
Cloudbric
H
Heimdal Security Blog
J
Java Code Geeks
N
News and Events Feed by Topic
Hacker News - Newest:
Hacker News - Newest: "LLM"
宝玉的分享
宝玉的分享
有赞技术团队
有赞技术团队
S
SegmentFault 最新的问题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
爱范儿
爱范儿
I
Intezer
GbyAI
GbyAI

博客园 - 东邪独孤

【ASP.NET Core】在 Windows Forms 中运行 Web 服务器 【EF Core】级联删除行为 【EF Core】继承策略——TPC 【EF Core】继承策略——TPH 【EF Core】使用自定义的值比较器 【EF Core】值转换器 【EF Core】直接更新数据 【EF Core】实体追踪——Entry中记录的数据 【EF Core】实体状态与变更追踪 【EF Core】将一个实体映射到多个表的正确方法 【EF Core】“Code First”方案下以编程方式生成迁移 【EF Core】“DB First”方案下用编程方式生成数据库模型代码 【EF Core】三种方法记录生成的 SQL 语句 【EF Core】未定义实体类的数据库模型 【EF Core】“多对多”关系与跳跃导航 【EF Core】FromExpression 方法有什么用? 【EF Core】通过 DbContext 选项扩展框架 【EF Core】框架底层的数据库连接管理 【EF Core】再谈普通实体关系与 Owned 关系的区别 【EF Core】实体类的依赖注入 【EF Core】优化后的模型 【EF Core】使用外部 Model 【EF Core】聊聊“复合”属性 【EF Core】为 DatabaseFacade 扩展“创建”与“删除”数据表功能 【EF Core】带主键实体与无主键实体 【EF Core】框架是如何识别实体类的属性和主键的
【EF Core】继承策略——TPT
东邪独孤 · 2026-05-31 · via 博客园 - 东邪独孤

先补充一下前一篇中的 TPH 策略的内容——非完整性类型鉴别器。这个东西官方文档写了等于没写,许多大伙伴可能不知道是啥玩意儿。不用慌,老周给你整个示例,你就懂了。

这种特例多见于先有数据库(DB First)的方案。好,那咱们就先建库,脚本如下,很简单。

use master;
go

-- 创建数据库
create database schoolDB;
go

use schoolDB;
go

-- 创建表
create table [tb_students]
(
    -- 基类字段
    id int identity not null,
    [name] nvarchar(20) not null,
    [age] int not null,
    -- “转校生”字段
    src_school nvarchar(40) null,
    -- “留级生”字段
    repeat_grade int null,
    -- 鉴别器字段
    _type char(1) not null,
    -- 主键
    constraint [PK_Student] primary key ([id] asc)
);
go

-- 添加点数据
insert into tb_students
    ([name], age, src_school, repeat_grade, _type)
values
    (N'王番薯', 19, NULL, NULL, 'S'),
    (N'吴正经', 20, N'华中聊汉大学', NULL, 'T'),
    (N'余小琳', 17, NULL, 3, 'R'),
    (N'欧皮革', 20, NULL, NULL, 'Z');
go

上述脚本做了三件事:

1、创建数据库,命名为 schoolDB;

2、在库中建表,名为 tb_students;

3、往表中写入新数据,用于示例。

tb_students 表其实包含了三个实体:

A、正常学生(id、name、age);

B、转校生,在正常学生基础上增加了 src_school 列,表示从哪个学校转过来的;

C、留级生,在正常学生基础上增加 repeat_grade 列,重读的年级。

用作类型鉴别器的是 _type 列,S 指代正常学生,T 指代转校生,R 指代留级生,Z 无意义。

好了,数据库搞好了,下面弄 EF Core。

先定义三个实体类。

/// <summary>
/// 正常学生
/// </summary>
public class Student
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public int Age { get; set; }
}

/// <summary>
/// 转校生
/// </summary>
public class TransferStudent : Student
{
    public string SourceSchool { get; set; } = null!;
}

/// <summary>
/// 留级生
/// </summary>
public class RepeatStudent:Student
{
    public int RepeatGrade { get; set; }
}

在数据库上下文的 OnModelCreating 方法中配置模型。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 映射策略和主键都要在基类上配置
    modelBuilder.Entity<Student>(ent =>
    {
        ent.UseTphMappingStrategy();
        ent.HasKey(x => x.Id);
        // 表映射
        ent.ToTable("tb_students");
        // 列映射
        ent.Property(x => x.Id).HasColumnName("id");
        ent.Property(x => x.Name).HasColumnName("name").HasMaxLength(20);
        ent.Property(x => x.Age).HasColumnName("age");
        // 鉴别器
        ent.HasDiscriminator<string>("StuType")
            .HasValue<Student>("S")
            .HasValue<TransferStudent>("T")
            .HasValue<RepeatStudent>("R");
        ent.Property<string>("StuType").HasColumnName("_type").HasMaxLength(1);
    });

    // 派生类的映射
    modelBuilder.Entity<TransferStudent>(ent =>
    {
        ent.Property(x => x.SourceSchool).HasMaxLength(40).HasColumnName("src_school");
    });
    modelBuilder.Entity<RepeatStudent>(ent =>
    {
        ent.Property(u => u.RepeatGrade).HasColumnName("repeat_grade");
    });
}

现在咱们尝试把所有数据查询出来。

// 配置连接字符串
DbContextOptionsBuilder<MyContext> opbuilder = new();
opbuilder.UseSqlServer("Data Source=.\\TEST;Initial Catalog=schoolDB;Integrated Security=True;Persist Security Info=False;Encrypt=True;TrustServerCertificate=True");

using var context = new MyContext(opbuilder.Options);
// 获取数据集合
DbSet<Student> stus = context.Set<Student>();
// 打印
foreach(var s in stus)
{
    Console.WriteLine("id:  {0}", s.Id);
    Console.WriteLine("name: {0}", s.Name);
    Console.WriteLine("age: {0}", s.Age);
    if(s is TransferStudent tfstu)
    {
        Console.WriteLine("source school: {0}", tfstu.SourceSchool);
    }
    if(s is RepeatStudent rpstu)
    {
        Console.WriteLine("repeat grade: {0}", rpstu.RepeatGrade);
    }
    Console.WriteLine();
}

这个代码在运行后,你会看到该错误:

image

现在回过头看看鉴别器配置。

ent.HasDiscriminator<string>("StuType")
    .HasValue<Student>("S")
    .HasValue<TransferStudent>("T")
    .HasValue<RepeatStudent>("R");

再看看数据库中的数据。

select _type from tb_students

image

根据咱们的配置,Student 类由 S 表示,TransferStudent 类由 T 表示,RepeatStudent 类由 R 表示。Z 是没有类型映射的,这个异常的意思就是类型的鉴别值不完整——就是多了个Z出来,EF Core 不知道 Z 跟哪个实体类有关。

这种情况,我们要明确告诉 EF Core,咱们这个数据库中的鉴别器的值与实际的实体类型没有完全匹配的,我们所配置的类型鉴别的值是不完整的。

ent.HasDiscriminator<string>("StuType")
    .HasValue<Student>("S")
    .HasValue<TransferStudent>("T")
    .HasValue<RepeatStudent>("R")
    .IsComplete(false);

true 表示类型列表是完整的,false 是不完整的。这样配置后就不会抛异常了。

--------------------------------------------------------------------------------------------------------------------------

现在进入主题,今天咱们聊 TPT 策略。TPT 会为每个实体类型独立映射一个数据表,但表中的列仅限于当前类所定义的成员,不包含从基类继承的成员。

咱们依旧使用上面那三个【学生】实体,不过,这次配置为 TPT 映射策略。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 映射策略和主键都要在基类上配置
    modelBuilder.Entity<Student>(ent =>
    {
        ent.UseTptMappingStrategy();
        ent.HasKey(x => x.Id);
        // 表映射
        ent.ToTable("tb_students", tb =>
        {
            tb.Property(u => u.Id).HasColumnName("id");
            tb.Property(u => u.Name).HasColumnName("name");
            tb.Property(u => u.Age).HasColumnName("age");
        });

        ent.Property(x => x.Name).HasMaxLength(20);
    });

    // 派生类的映射
    modelBuilder.Entity<TransferStudent>(ent =>
    {
        ent.Property(x => x.SourceSchool).HasMaxLength(40);
        // 表映射
        ent.ToTable("tb_trf_students", tb =>
        {
            tb.Property(i => i.Id).HasColumnName("mid");
            tb.Property(i => i.SourceSchool).HasColumnName("src_school");
        });
    });
    modelBuilder.Entity<RepeatStudent>(ent =>
    {
        // 表映射
        ent.ToTable("tb_rpt_students", tb =>
        {
            tb.Property(w => w.Id).HasColumnName("mid");
            tb.Property(w => w.RepeatGrade).HasColumnName("repeat_grade");
        });
    });
}

不管你用哪种映射策略,UseXXXMappingStrategy 方法必须在配置基类实体时调用,不能在派生类的配置中调用,那样会报错。

由于 TPT 是每个类型一个表,所以你可以用 ToTable 方法为各个表自定义名称。

这里各位要注意:列映射的自定义名称最好在 ToTable 方法中通过 TableBuilder 对象来配置,不要在实体属性上直接配置(ent.Property(...).HasColumnName(...))。这是因为在 PropertyBuilder 上配置的列名是通过 Annotations 字典(Key = Relational:ColumnName)来存储的,这表明这个列名你能存储一个值。如果这个属性被多次列映射,那么,后面设置的列名会覆盖掉前面设置的列名,而不管你映射的是否为同一个表。

对 TPT 策略而言,只有主键列会被多次映射,其他属性不会有覆盖的问题(派生类的表不包含基类成员,自然就不会重复映射了)。比如,基类 Student,在 tb_students 表中映射了 Id、Name、Age 属性;到了 TransferStudent 类,它只定义了 SourceSchool 属性,所以表  tb_trf_students 中只映射 SourceSchool 成员。RepeatStudent 实体同理。

从上面的配置代码看到,只有 Id 属性被做了多次列映射。所以,除了 Id 属性以外,其他属性是可以在 PropertyBuilder 上用 HasColumnName 方法配置列映射的,但为了代码更好看,统一用 TableBuilder 来配置最好。尤其在 TPC 策略下各个属性都会多次映射(本文先不提)。

那么,为什么 TPT 策略要把基类的主键映射多次呢?看看它生成的 SQL 语句,你或许就明白了。

CREATE TABLE [tb_students] (
    [id] int NOT NULL IDENTITY,
    [name] nvarchar(20) NOT NULL,
    [age] int NOT NULL,
    CONSTRAINT [PK_tb_students] PRIMARY KEY ([id])
);
GO

CREATE TABLE [tb_rpt_students] (
    [mid] int NOT NULL,
    [repeat_grade] int NOT NULL,
    CONSTRAINT [PK_tb_rpt_students] PRIMARY KEY ([mid]),
    CONSTRAINT [FK_tb_rpt_students_tb_students_mid] FOREIGN KEY ([mid]) REFERENCES [tb_students] ([id]) ON DELETE CASCADE
);
GO

CREATE TABLE [tb_trf_students] (
    [mid] int NOT NULL,
    [src_school] nvarchar(40) NOT NULL,
    CONSTRAINT [PK_tb_trf_students] PRIMARY KEY ([mid]),
    CONSTRAINT [FK_tb_trf_students_tb_students_mid] FOREIGN KEY ([mid]) REFERENCES [tb_students] ([id]) ON DELETE CASCADE
);
GO

不知道大伙伴们看出啥门道了没有。在 TPT 映射策略中,只有基类的主键列会生成/插入新值,其他派生类表都是通过外键来引用基类表的主键的。正因为这样,所以在查询数据时,就等于做联表查询,这使得 TPT 策略的性能会比其他策略低。

 啥意思呢,咱们试着插入几条记录就知道了。

using var context = new MyContext(opbuilder.Options);

context.Database.EnsureCreated();       // 运行时创建数据库
// 获取数据集合
DbSet<Student> students = context.Set<Student>();
// 添加新记录
students.AddRange([
        new Student{Name = "吴珍珠", Age = 18},
        new TransferStudent{Name = "王大山", Age = 18, SourceSchool = "飓风中学"},
        new RepeatStudent{Name = "陆大锤", Age = 17, RepeatGrade = 2}
    ]);
// 保存数据
context.SaveChanges();

咱们每个类型各添加一条记录,看看数据库怎么存储它们。

select * from tb_students;
select * from tb_trf_students;
select * from tb_rpt_students;

image

【吴珍珠】同学的 Id 为2,因为它是 Student 类,作为基类,只用到 tb_students 表;

【王大山】同学的 Id 为 3,它是 TransferStudent 类。从基类继承的 Name 和 Age 属性存放到 tb_students 表中,而 SourceSchool 属性的值则存放在 tb_trf_students 表的 src_school 列中;

【陆大锤】同学的 Id 为1,它是 RepeatStudent 类,其中 Name、Age 属性存入 tb_students 列,而它所定义的 RepeatGrade 属性的值就存入 tb_rpt_students 表的 repeat_grade 列。

最后,咱们把注意力放在主键列上。所有记录的主键值都在基类表中生成(tb_students.id 列),然后

对于【吴珍珠】同学,它就在基类表中,不需要外键引用;

对于【王大山】同学,tb_trf_students.mid 列通过外键,引用了主键值 3;

对于【陆大锤】同学,tb_rpt_students.mid 列通过外键引用了主键值 1;

目前 EF Core 在配置主键的约束名称是有限制的,所以不要去自定义主键的约束。

// 不要调用 HasName 方法
ent.HasKey(x => x.Id).HasName("PK_what_the_fk");

下面老周解释一下为什么会有这个局限。

1、派生类中不允许配置主键。看看 EntityType.SetPrimaryKey 方法的源代码。

public virtual Key? SetPrimaryKey(
    IReadOnlyList<Property>? properties,
    ConfigurationSource configurationSource)
{
    EnsureMutable();
    Check.DebugAssert(IsInModel, "The entity type has been removed from the model");

    if (BaseType != null)
    throw new InvalidOperationException(CoreStrings.DerivedEntityTypeKey(DisplayName(), GetRootType().DisplayName()));
    }

   ……
}

意思就是如果你正在配置的实体存在基类,那就抛出异常。所以,你只能在基类上配置主键。

2、对于 EF Core 的数据库模型,如果实体存在继承关系,那么,派生类实体所继承的成员,与基类实体所定义的同一个成员,它们之间使用相同的元数据。这后果是,如果你在 Student 类中配置了主键的约束名为 PK_XXX,那么,TransferStudent 类和 RepeatStudent 类的 Id 属性都从 Student 害继承,即它们的元数据相同,导致所有数据表的主键的约束名都变成 PK_XXX。多个表使用相同的约束名,在数据库中会报错。

所以,你不能改变约束名,一改就全部一起改掉了。但保留 EF Core 的默认配置就没有问题,因为 EF Core 在生成 SQL 语句时,主键默认的名字是 PK_<表名>,外键是 FK_<表名>,这样就不会出现重复约束名了。

 public static string? GetDefaultName(
     this IReadOnlyKey key,
     in StoreObjectIdentifier storeObject,
     IDiagnosticsLogger<DbLoggerCategory.Model.Validation>? logger)
 {
     if (storeObject.StoreObjectType != StoreObjectType.Table
         || key.DeclaringEntityType.IsMappedToJson())
     {
         return null;
     }

     if (key.DeclaringEntityType.IsMappedToJson())
     {
         return null;
     }

     string? name;
     if (key.IsPrimaryKey())
     {
         var rootKey = key;
         // Limit traversal to avoid getting stuck in a cycle (validation will throw for these later)
         // Using a hashset is detrimental to the perf when there are no cycles
         for (var i = 0; i < RelationalEntityTypeExtensions.MaxEntityTypesSharingTable; i++)
         {
             var linkingFk = rootKey!.DeclaringEntityType.FindRowInternalForeignKeys(storeObject)
                 .FirstOrDefault();
             if (linkingFk == null)
             {
                 break;
             }

             rootKey = linkingFk.PrincipalEntityType.FindPrimaryKey();
         }

         if (rootKey != null
             && rootKey != key)
         {
             return rootKey.GetName(storeObject);
         }

         name = "PK_" + storeObject.Name;
     }
     else
     {
         var columnNames = key.Properties.GetColumnNames(storeObject);
         if (columnNames == null)
         {
             if (logger != null)
             {
                 var table = storeObject;
                 if (key.DeclaringEntityType.GetMappingFragments(StoreObjectType.Table)
                     .Any(t => t.StoreObject != table && key.Properties.GetColumnNames(t.StoreObject) != null))
                 {
                     return null;
                 }

                 if (key.DeclaringEntityType.GetMappingStrategy() != RelationalAnnotationNames.TphMappingStrategy
                     && key.DeclaringEntityType.GetDerivedTypes()
                         .Select(e => StoreObjectIdentifier.Create(e, StoreObjectType.Table))
                         .Any(t => t != null && key.Properties.GetColumnNames(t.Value) != null))
                 {
                     return null;
                 }

                 logger.KeyPropertiesNotMappedToTable((IKey)key);
             }

             return null;
         }

         var rootKey = key;

         // Limit traversal to avoid getting stuck in a cycle (validation will throw for these later)
         // Using a hashset is detrimental to the perf when there are no cycles
         for (var i = 0; i < RelationalEntityTypeExtensions.MaxEntityTypesSharingTable; i++)
         {
             IReadOnlyKey? linkedKey = null;
             foreach (var otherKey in rootKey.DeclaringEntityType
                          .FindRowInternalForeignKeys(storeObject)
                          .SelectMany(fk => fk.PrincipalEntityType.GetKeys()))
             {
                 var otherColumnNames = otherKey.Properties.GetColumnNames(storeObject);
                 if ((otherColumnNames != null)
                     && otherColumnNames.SequenceEqual(columnNames))
                 {
                     linkedKey = otherKey;
                     break;
                 }
             }

             if (linkedKey == null)
             {
                 break;
             }

             rootKey = linkedKey;
         }

         if (rootKey != key)
         {
             return rootKey.GetName(storeObject);
         }

         name = new StringBuilder()
             .Append("AK_")
             .Append(storeObject.Name)
             .Append('_')
             .AppendJoin(columnNames, "_")
             .ToString();
     }

     return Uniquifier.Truncate(name, key.DeclaringEntityType.Model.GetMaxIdentifierLength());
 }

这时候有大伙伴可能想到了使用约定来修改主键的约束名称。

public class MyConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        var entStudent = modelBuilder.Metadata.FindEntityType(typeof(Student));
        if(entStudent != null)
        {
            var key = entStudent.FindPrimaryKey() as IMutableKey;
            if(key != null)
            {
                key.SetName("PK_Stu_base");
            }
        }
        var entTrfStudent = modelBuilder.Metadata.FindEntityType(typeof(TransferStudent));
        if(entTrfStudent != null)
        {
            var key = entTrfStudent.FindPrimaryKey() as IMutableKey;
            if(key != null)
            {
                key.SetName("PK_Transf_stu");
            }
        }
        var entRptStudent = modelBuilder.Metadata.FindEntityType(typeof(RepeatStudent));
        if (entRptStudent != null)
        {
            var key = entRptStudent.FindPrimaryKey() as IMutableKey;
            if (key != null)
            {
                key.SetName("PK_Rpt_stu");
            }
        }
    }
}

数据库模型一旦 Finalized 阶段就变成只读了,无法修改元数据。所以你不能在实例化 DbContext 之后修改,那时候已经改不了。故,咱们要用约定的话,只能在 Finalizing 阶段。这时候模型的配置已经完成,但还未被固化(只读),即实现 IModelFinalizingConvention 接口,这样做可以避免被其他约定干扰。

约定类写好后,重写 DbContext.ConfigureConventions 方法,将其注册到约定集合中。

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ => new MyConvention());
}

然而,结果会让你失望的。

CREATE TABLE [tb_students] (
    [id] int NOT NULL IDENTITY,
    [name] nvarchar(20) NOT NULL,
    [age] int NOT NULL,
    CONSTRAINT [PK_Rpt_stu] PRIMARY KEY ([id])
);
GO

CREATE TABLE [tb_rpt_students] (
    [mid] int NOT NULL,
    [repeat_grade] int NOT NULL,
    CONSTRAINT [PK_Rpt_stu] PRIMARY KEY ([mid]),
    CONSTRAINT [FK_tb_rpt_students_tb_students_mid] FOREIGN KEY ([mid]) REFERENCES [tb_students] ([id]) ON DELETE CASCADE
);
GO

CREATE TABLE [tb_trf_students] (
    [mid] int NOT NULL,
    [src_school] nvarchar(40) NOT NULL,
    CONSTRAINT [PK_Rpt_stu] PRIMARY KEY ([mid]),
    CONSTRAINT [FK_tb_trf_students_tb_students_mid] FOREIGN KEY ([mid]) REFERENCES [tb_students] ([id]) ON DELETE CASCADE
);
GO

只要改其中一个,等于全部主键都改了。这时可以初步推断,由于主键是从基类继承的,所以,派生类实体的元数据中,使用的主键对象是同一个实例。

要证明这个推测也很容易,我们打印出三个实体的 Key 对象的内存地址。

public class MyConvention : IModelFinalizingConvention
{
    private void PrintObjectAddress(string tag, object obj)
    {
        GCHandle handle = GCHandle.Alloc(obj, GCHandleType.WeakTrackResurrection);
        IntPtr addr = GCHandle.ToIntPtr(handle);
        handle.Free();
        Console.WriteLine("{0}: 0x{1:X}", tag, addr);
    }

    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        var entStudent = modelBuilder.Metadata.FindEntityType(typeof(Student));
        if(entStudent != null)
        {
            var key = entStudent.FindPrimaryKey() as IMutableKey;
            if(key != null)
            {
                key.SetName("PK_Stu_base");
                PrintObjectAddress("Student Key", key);
            }
        }
        var entTrfStudent = modelBuilder.Metadata.FindEntityType(typeof(TransferStudent));
        if(entTrfStudent != null)
        {
            var key = entTrfStudent.FindPrimaryKey() as IMutableKey;
            if(key != null)
            {
                key.SetName("PK_Transf_stu");
                PrintObjectAddress("TransferStudent Key", key);
            }
        }
        var entRptStudent = modelBuilder.Metadata.FindEntityType(typeof(RepeatStudent));
        if (entRptStudent != null)
        {
            var key = entRptStudent.FindPrimaryKey() as IMutableKey;
            if (key != null)
            {
                key.SetName("PK_Rpt_stu");
                PrintObjectAddress("RepeatStudent Key", key);
            }
        }
    }
}

然后得到以下结果:

Student Key:              0x245CD862820
TransferStudent Key:      0x245CD862820
RepeatStudent Key:        0x245CD862820            

看吧,它们的地址一样。

所以,现阶段,在 TPT 策略下你不能自定义主键的约束名称,但微软说以后的版本会支持。