如何从EF导航集合属性中删除元素

Entity Framework 的性能一直被开发人员诟病。但我认为开发人员对EF一知半解、不求甚解才是问题的根源。EF中的上下文管理、延迟加载、变更追踪、并发冲突、事务等主题是我们熟练掌握EF的基础。我不敢说自己对这些主题也十分了解,抱着查缺补漏的心态,趁着双十一打折,我入手了汪鹏的《你必须掌握的Entity Framework 6.x与Core 2.0》。读下来有所收获,但我不得不说书中很多地方有的叙述不通畅,有的则显得过于冗长。同为程序员,我不能苛责太多,对作者还是很钦佩的。在看这本书的过程中,我想起了自己的项目中遇到的一个EF性能问题,但这本书中没有提到,于是想写下来和大家分享。

案例

我们以学生选课这个场景来展示我遇到的这个性能问题。一个学生可以选多门课,一门课程可以被多名学生选择,因此它们之间是多对多的关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Student
{
public int Id { get; set; }
public string Name { get; set; }

public virtual ICollection<Course> Courses { get; set; } = new HashSet<Course>();
}

public class Course
{
public int Id { get; set; }
public string CourseName { get; set; }
public string Description { get; set; }

public virtual ICollection<Student> Students { get; set; } = new HashSet<Student>();
}

OnModelCreating方法中,我们为这两个表创建多对多关系并指定左右外键和连接表(associative/junction table)的名称。

1
2
3
4
5
6
7
8
9
modelBuilder.Entity<Student>()
.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.Map(cs =>
{
cs.MapLeftKey("StudentId");
cs.MapRightKey("CourseId");
cs.ToTable("StudentCourse");
});

上述代码使用EF 6.3,运行在.NET 4.5.2上,文章末尾有全部代码的链接。EF会根据实体模型的定义推断出表之间的关系。上述代码是可选的,只是创建出来的左右键和表名是根据约定(Convention)来命名的。

associative/junction table 应该翻译成连接表,而不是链接表

将上述Code First实体模型应用到数据库后,我们可以得到下面的三张表:
database schema

现在问题来了。例如我们需要为Id为1的学生取消Id为1的已经选择的课程,该怎么做呢?你会想这很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using (var context = new EFDemoContext())
{
context.Database.Log = log => Debug.WriteLine(log);

var student = context.Student.Find(1);
var course = context.Course.Find(1);
//var course = context.Student
// .Where(s => s.Id == 1)
// .SelectMany(s => s.Courses.Where(c => c.Id == 1))
// .SingleOrDefault();

if (course != null)
{
student.Courses.Remove(course);
context.SaveChanges();
}
}

没错,首先找到这两个实体,然后使用第14行的代码,从该学生的课程集合中删除指定的课程对象就可以了。这有什么问题吗?大部分的EF教程不也是这样教我们的吗?

要看到潜在的问题,我们需要分析EF生成的SQL语句。上述第3行代码可以将EF生成的SQL语句输出到Visual Studio的Output窗口。
2019-11-16_171717

2019-11-16_171832

可以看到,在执行完第14行的代码后,EF先将该学生所有选择的课程查询出来(导航集合属性的延迟加载),加载到内存,然后将要删除的那个课程对象标记为EntityState.Deleted。在执行context.SaveChanges()时才会生成delete语句。

读到这里,你也许还没明白我所想要表达的问题。我的问题是,使用实体对象的导航集合属性来对集合元素进行增、删、改操作会导致所有的集合元素被被延迟加载,因为在这种情况无法使用where条件进行事先的过滤。如果导航集合属性的数据集很大,那么应用程序的性能会受到显著的影响。这不仅包括将数据集从数据库传输到应用程序的内存,还包括EF要在这么大的数据集上应用变更追踪检查所带来的损耗。在我的真实场景中,实体对象的导航集合属性大约有2万多条记录,使用上述的方法删除某一个元素,花费了将近20秒!

解决方案

  1. 直接手写SQL语句
    最容易的办法应该是手写SQL语句,但这样就违背了使用EF的初衷。既然使用了ORM框架,就不应该再直接操作数据库了。

    1
    2
    3
    context.Database.ExecuteSqlCommand("DELETE [dbo].[StudentCourse] WHERE StudentId = @p0 AND CourseId = @p1",
    new SqlParameter("@p0", 1),
    new SqlParameter("@p1", 1));

    这个方案简单直接,但对追求卓越代码的程序员而言不完美。

  2. 使用ObjectContext.ObjectStateManager.ChangeRelationshipState方法
    这个方案我是在Stack Overflow上找到的。ObjectContext是EF 4.0及以下版本中用来进行数据访问的类。EF 4.1及以上的版本就开始使用DbContext来进行数据访问了。但是DbContext的底层还是基于ObjectContext,可以看成是ObjectContext的一个包装器,提供了简化和方便易用的API。如果要使用复杂的功能,则需要从DbContext中获取ObjectContext并且使用基于它的API。

    1
    2
    3
    4
    ((IObjectContextAdapter)context)
    .ObjectContext
    .ObjectStateManager
    .ChangeRelationshipState(student, course, s => s.Courses, EntityState.Deleted);

    这个方案使用了一个比较底层和陌生的API,虽然是基于EF自身的方法,但总觉得有些取巧(tricky)。

  3. 显式声明连接表
    本质上我们遇到的问题是无法直接操作连接表,因为它是EF自动生成的。那么我们可以显式地声明这个连接表吗?答案是肯定的。我们定义如下的连接表:

    1
    2
    3
    4
    5
    6
    7
    8
    public class StudentCourse
    {
    public int StudentId { get; set; }
    public virtual Student Student { get; set; }

    public int CourseId { get; set; }
    public virtual Course Course { get; set; }
    }

    此时,Student与Course之间的多对多的关系,就转换成了Student和Course分别与StudentCourse之间的两个一对多关系。在OnModelCreating方法中,我们配置如下的关系:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    modelBuilder.Entity<Student>()
    .HasMany(s => s.StudentCourse)
    .WithRequired(j => j.Student)
    .HasForeignKey(j => j.StudentId);

    modelBuilder.Entity<Course>()
    .HasMany(s => s.StudentCourse)
    .WithRequired(j => j.Course)
    .HasForeignKey(j => j.CourseId);

    与此同时,我们需要为连接表定义复合主键:

    1
    2
    modelBuilder.Entity<StudentCourse>()
    .HasKey(j => new { j.StudentId, j.CourseId });

    完成上述的准备工作后,我们就可以用如下的代码来完成同样的任务而不用担心性能问题了。

    1
    2
    3
    4
    5
    6
    7
    8
    using (var context = new EFDemoContext())
    {
    context.Database.Log = log => Debug.WriteLine(log);

    var studentCourse = context.StudentCourse.Find(1, 1);
    context.StudentCourse.Remove(studentCourse);
    context.SaveChanges();
    }

    这样EF产生的SQL语句就会非常干净、清爽。
    2019-11-21_232019.png

    你还可以用使用基于ObjectContext的API来删除这个连接关系实体,而不用先从数据库中将其查询出来,从而节省一次数据库访问。这里就不展开叙述了,具体的细节可以参考这里

总结

本文分析了使用Entity Framework在构建多对多关系模型中潜在的性能问题,并给出了解决问题的不同方案。需要特别指出的是,Entity Framework Core 不存在这样的问题。原因在于EF Core不支持自动生成连接表,在配置多对多的关系时,必须显式地声明连接表。本文相关的代码已分享到Github。

相关链接