Skip to content
Go back

EF Core 关系建模完全指南:一对多、多对多与一对一

EF Core 里的实体关系,属于那种”看上去很简单,踩坑的人一点也不少”的话题。一对多、一对一、多对多——概念大家都会,但一到 Fluent API 里配置外键、级联删除、导航属性,就容易写出让数据库行为出乎意料的代码。

这篇文章从三种关系类型出发,把每种的典型配置、常见陷阱和加载策略都写清楚。所有示例基于 .NET 10 和 EF Core 10。

一对多:最常见的关系

一对多是最基本的关系模式。一个 Blog 有多篇 Post,一个 Order 有多条 OrderItem。模型本身不复杂,但配置时要关注外键的归属方和级联行为。

public sealed class Blog
{
    public int Id { get; init; }
    public string Title { get; init; } = string.Empty;
    public ICollection<Post> Posts { get; init; } = [];
}

public sealed class Post
{
    public int Id { get; init; }
    public string Title { get; init; } = string.Empty;
    public string Content { get; init; } = string.Empty;
    public int BlogId { get; init; }
    public Blog Blog { get; init; } = null!;
}

public sealed class BloggingDbContext : DbContext
{
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Post> Posts => Set<Post>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .HasMany(b => b.Posts)
            .WithOne(p => p.Blog)
            .HasForeignKey(p => p.BlogId)
            .IsRequired();
    }
}

几点值得注意的地方。BlogId 是外键,位于 Post 端(“多”的那一方)。Posts 是集合导航属性,Blog 是引用导航属性。EF Core 按约定就能推断出这个关系——因为 BlogId 符合预期的 FK 命名模式。

但即使约定能够推断,显式 Fluent API 配置仍然值得写。它把意图写在明面上,也避免了命名不按预期时 EF Core 悄悄创建 shadow property 的情况。

一对一:外键归属方必须明确

一对一比看上去要复杂。两边的实体都能持有外键,但 EF Core 需要你明确指出哪一方是 dependent(持有 FK 的一端)。经典示例:User 有一个 UserProfile。

public sealed class User
{
    public int Id { get; init; }
    public string Email { get; init; } = string.Empty;
    public UserProfile? Profile { get; init; }
}

public sealed class UserProfile
{
    public int Id { get; init; }
    public string DisplayName { get; init; } = string.Empty;
    public string Bio { get; init; } = string.Empty;
    public int UserId { get; init; }
    public User User { get; init; } = null!;
}

public sealed class UserDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<UserProfile> UserProfiles => Set<UserProfile>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>()
            .HasOne(u => u.Profile)
            .WithOne(p => p.User)
            .HasForeignKey<UserProfile>(p => p.UserId)
            .IsRequired();
    }
}

注意 HasForeignKey<UserProfile>——配置一对一关系时必须指明 dependent 类型,否则 EF Core 不知道 FK 在哪一端,模型验证时直接报错。

什么时候用一对一而不是直接内嵌?如果关联数据始终一起访问、且 profile 表不会独立增长,可以考虑用 OwnsOne 把 profile 列嵌入 User 表。但如果 profile 有自己的生命周期、可选、或者需要独立查询,拆分到单独的表加一对一关系更合适。

EF Core 8 引入了 ComplexProperty 来建模永远不会为 null、没有独立标识的值对象,比 OwnsOne 更进一步。如果你的内嵌类型是纯值对象,可以考虑这个选项。

多对多——显式连接实体

多对多是 EF Core 演进最明显的领域。EF Core 5 之前必须手动建模连接表;5 之后可以省掉连接实体,EF Core 自动生成连接表。但大多数真实场景还是需要显式连接实体——因为连接表上往往有额外数据:成绩、注册时间、状态。

以学生选课为例。选课本身上就有数据:成绩、注册日期。这些数据既不能放 Student,也不能放 Course。

public sealed class Student
{
    public int Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public ICollection<Enrollment> Enrollments { get; init; } = [];
}

public sealed class Course
{
    public int Id { get; init; }
    public string Title { get; init; } = string.Empty;
    public ICollection<Enrollment> Enrollments { get; init; } = [];
}

public sealed class Enrollment
{
    public int StudentId { get; init; }
    public int CourseId { get; init; }
    public double? Grade { get; init; }
    public DateTimeOffset EnrolledAt { get; init; }
    public Student Student { get; init; } = null!;
    public Course Course { get; init; } = null!;
}

public sealed class AcademicDbContext : DbContext
{
    public DbSet<Student> Students => Set<Student>();
    public DbSet<Course> Courses => Set<Course>();
    public DbSet<Enrollment> Enrollments => Set<Enrollment>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Enrollment>()
            .HasKey(e => new { e.StudentId, e.CourseId });

        modelBuilder.Entity<Enrollment>()
            .HasOne(e => e.Student)
            .WithMany(s => s.Enrollments)
            .HasForeignKey(e => e.StudentId);

        modelBuilder.Entity<Enrollment>()
            .HasOne(e => e.Course)
            .WithMany(c => c.Enrollments)
            .HasForeignKey(e => e.CourseId);
    }
}

复合主键 (StudentId, CourseId) 直接在数据库层面保证唯一性。Grade 是连接表上的 payload——没有显式连接实体就无法存储的数据。

多对多——隐式连接表

当关系本身不带额外数据时,可以不写连接实体。EF Core 5+ 会根据约定自动生成连接表。

public sealed class Article
{
    public int Id { get; init; }
    public string Title { get; init; } = string.Empty;
    public ICollection<Tag> Tags { get; init; } = [];
}

public sealed class Tag
{
    public int Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public ICollection<Article> Articles { get; init; } = [];
}

// OnModelCreating:
modelBuilder.Entity<Article>()
    .HasMany(a => a.Tags)
    .WithMany(t => t.Articles);

EF Core 会自动生成 ArticleTag 连接表,包含 ArticleIdTagId 两列。简单、干净。

怎么选?关系真正没有任何自身数据、也不需要直接查询连接表时,用隐式连接。一旦需要 payload 数据——时间戳、状态、排序号——立刻换显式连接实体。先加比后改容易得多,尤其是在生产数据库已存在隐式连接表之后。

Shadow Property:FK 不污染实体类

Shadow Property 是只存在于数据库和 EF Core 模型、但在 C# 实体类上没有对应属性的列。想保持领域模型干净、不让 FK 属性出现在实体代码里时可以派上用场。

public sealed class Comment
{
    public int Id { get; init; }
    public string Body { get; init; } = string.Empty;
    // 没有 BlogId 属性——它作为 shadow property 存在
}

public sealed class ShadowPropertyDbContext : DbContext
{
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Comment> Comments => Set<Comment>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Comment>()
            .Property<int>("BlogId"); // 声明 shadow property

        modelBuilder.Entity<Blog>()
            .HasMany<Comment>()
            .WithOne()
            .HasForeignKey("BlogId"); // 用它作为 FK
    }
}

查询时用 EF.Property<T> 来访问 shadow property:

var commentsForBlog = await context.Comments
    .Where(c => EF.Property<int>(c, "BlogId") == targetBlogId)
    .ToListAsync();

Shadow Property 功能强大,但代价是失去了编译期安全——属性名是字符串。如果你决定用它,在 IEntityTypeConfiguration<T> 里写清楚注释,让下一个人知道这列存在。

级联删除:不要依赖默认行为

级联删除决定主实体被删除时,依赖实体发生什么。EF Core 提供四种选项:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Cascade:删除 Blog 自动删除所有 Post
    modelBuilder.Entity<Blog>()
        .HasMany(b => b.Posts)
        .WithOne(p => p.Blog)
        .HasForeignKey(p => p.BlogId)
        .OnDelete(DeleteBehavior.Cascade);

    // Restrict:有 Comment 就不能删 User
    modelBuilder.Entity<User>()
        .HasMany<Comment>()
        .WithOne()
        .HasForeignKey("UserId")
        .OnDelete(DeleteBehavior.Restrict);
}

默认行为取决于关系是否 required。required 关系默认 Cascade,optional 关系默认 ClientSetNull。不了解这一点就是一个定时炸弹。

建议:每条关系都显式配置 OnDelete,永远不要依赖默认行为。生产环境里的意外级联删除几乎无法撤销。

Fluent API vs Data Annotations

EF Core 支持两种配置方式:Fluent API(在 OnModelCreating 中)和 Data Annotations(实体类上的特性)。

Data Annotations 写起来快,跟实体放在一起:

public sealed class Post
{
    public int Id { get; init; }

    [Required]
    [ForeignKey(nameof(Blog))]
    public int BlogId { get; init; }
    public Blog Blog { get; init; } = null!;
}

Fluent API 更啰嗦但控制力更强。Shadow Property、复合主键、OnDelete、表拆分等场景只能用 Fluent API。它也把基础设计关注点从实体类中分离出去。

推荐用 IEntityTypeConfiguration<T> 把配置拆到每个实体的独立类中,保持 OnModelCreating 清爽:

public sealed class BlogConfiguration : IEntityTypeConfiguration<Blog>
{
    public void Configure(EntityTypeBuilder<Blog> builder)
    {
        builder.HasKey(b => b.Id);

        builder.Property(b => b.Title)
            .IsRequired()
            .HasMaxLength(200);

        builder.HasMany(b => b.Posts)
            .WithOne(p => p.Blog)
            .HasForeignKey(p => p.BlogId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}

// 在 DbContext 中:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(
        typeof(BloggingDbContext).Assembly);
}

模型越大,这种拆分方式越有价值。新增一个实体就是新增一个配置类,而不是把已经很长的 OnModelCreating 方法继续撑大。

加载策略

EF Core 的实体关系能不能用好,很大程度上取决于加载策略。三种方式各有场景。

Eager Loading(Include)

在初始查询中一起加载关联数据。对 web 应用来说是最可预测、最推荐的方式。

var blogs = await context.Blogs
    .Include(b => b.Posts)
    .ToListAsync();

多层导航用 .ThenInclude() 链式处理。EF Core 5 开始支持筛选 Include:.Include(b => b.Posts.Where(p => p.IsPublished)),可以只加载满足条件的关联数据。

Explicit Loading

主实体已取出后,按需加载关联数据:

var blog = await context.Blogs.FirstAsync(b => b.Id == blogId);

// 只有确定需要时才加载 Posts
await context.Entry(blog).Collection(b => b.Posts).LoadAsync();

适合有条件加载的场景,避免不需要时白费一次 Include。

Lazy Loading(Web 应用里尽量避免)

懒加载在第一次访问导航属性时自动触发一次数据库查询。需要代理类或直接注入 ILazyLoader

在 web 应用里,懒加载最容易触发 N+1 问题——遍历 100 个 Blog,每个触发一次 Posts 查询,就是 101 次数据库往返。数据量小的时候开发环境感觉不到,上生产就崩。

ASP.NET Core 应用默认用 Eager Loading,只在确实需要条件加载时用 Explicit Loading。把 EF Core 查询日志在开发环境打开,意外多出来的查询一下就能发现。

常见坑

关系循环和多级联路径:SQL Server 会拒绝生成导致多条级联路径指向同一张表的迁移。解决方案是把冲突关系之一设为 DeleteBehavior.Restrict

缺失导航属性:EF Core 没有导航属性也能工作,但你会失去 Include 和类型安全的查询组合能力。经常查询的关系两边都定义导航属性。

FK 命名不符合约定:EF Core 的约定要求 FK 命名为 {NavigationPropertyName}Id{TypeName}Id。不匹配时 EF Core 不会报错——它悄悄创建一个 shadow property。新增关系后务必检查生成的迁移。

Owned Entity vs 关系OwnsOneOwnsMany 跟普通一对一是两回事。Owned Entity 属于聚合根的一部分——删除所有者时自动删除、没有自己的 DbSet、只能通过所有者查询。设计领域模型时别把它们混用。

可变导航集合:如果集合导航属性有 public setter、或者用 List<T>,EF Core 可能在变更追踪中意外替换它。用 ICollection<T> 配合空数组初始化器(= []),属性设为 init 或 private setter。

常见问题

一对多和多对多的本质区别是什么?

一对多中,“多”方的每个实体只指向一个”一”方。多对多中,双方都有集合指向对方。多对多需要连接表——要么 EF Core 约定自动生成(无 payload 时),要么显式定义连接实体(需要存储额外数据时)。

什么时候必须用显式连接实体?

只要关系携带自身数据——时间戳、状态、成绩、排序号等超过两个 FK 的信息——就必须用显式连接实体。连接表真的只有 FK 列时,隐式约定就够用。判断标准很简单:这段关系有自己独立的概念吗,还是只是一个连接?

级联删除选哪个默认值?

不要有”默认值”这个思维。每条关系都显式配置。大多数生产系统从 DeleteBehavior.Restrict 开始最安全,除非你确定需要自动删除依赖记录。

Fluent API 还是 Data Annotations?

略复杂一点的项目就选 Fluent API + IEntityTypeConfiguration<T>。Data Annotations 适合简单场景,但它把基础设施关注点混进了实体类,而且不支持 Shadow Property、复合主键、OnDelete 等高级配置。

ASP.NET Core 里能用 Lazy Loading 吗?

不建议。Lazy Loading 在 web 应用里最容易触发 N+1 查询。默认用 Eager Loading(Include),在确实需要条件加载时才用 Explicit Loading。开发环境打开查询日志,能帮你第一时间发现意外多出来的数据库往返。

如果你关注 .NET 开发、数据访问和软件工程实践,可以关注 Aide Hub。这里会继续分享能落地的工具教程、数据建模经验和技术观察。

参考


Tags


Previous

认识 Agent Harness:用 Microsoft Agent Framework 三步搭建个人理财助手

Next

EF Core 性能优化最佳实践(.NET 10)