【EF Core】 EF Core 批量操作的进化之路——从传统变更跟踪到无跟踪更新

文章目录


前言

众所周知,批量操作曾经一直是 EF Core被人广泛吐槽的一个点。当我们调用RemoveRangAsync()等批量操作方法时,想当然以为EF Core能将该代码翻译成一行删除语句。没想到底层实际执行的却是单独的一行一行删除语句。其实EF Core 实体跟踪的底层特性也注定了真正意义上的批量操作的实现难以实现,不过官方团队一直到EF Core 7 的版本终于开始实现了真正意义上的批量更新。

关于实体跟踪,大家可以去翻看我的这篇博客

链接: 《探秘EF Core 更改跟踪:实体状态、快照机制与调试优化技巧》


一、批量操作(Rang)

AddRange和RemoveRange的底层执行细节在第二小节里讨论

1.1 AddRange()

批量新增数据。通过将多个实体标记为 Added 状态,调用 SaveChanges() 时会将这些实体插入到数据库中。

有异步的AddRangeAsync()方法

csharp 复制代码
var student1st = new Student
{
    Name = "王学生",
    Age = 18,
    ClassId = 3
};
var student2sd = new Student
{
    Name = "李学生",
    Age = 18,
    ClassId = 3
};
List<Student> students = new List<Student>()
{
    student1st,
    student2sd
};
await dbContext.AddRangeAsync(students);
dbContext.SaveChanges();

1.2 UpdateRange()

批量修改数据。通过将多个实体标记为 Modified 状态,调用 SaveChanges() 时会将这些实体更新到数据库中。

csharp 复制代码
var student1st = await dbContext.Students.FindAsync(21);
var student2sd = await dbContext.Students.FindAsync(22);
student1st.Name = "王学生(新)";
student2sd.Name = "李学生(新)";
List<Student> students = new List<Student>()
{
    student1st,
    student2sd
};
dbContext.UpdateRange(students);
dbContext.SaveChanges();

执行结果

csharp 复制代码
info: 5/29/2025 14:40:10.904 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (34ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT TOP(1) [t].[Id], [t].[Age], [t].[ClassId], [t].[Name]
      FROM [T_Student] AS [t]
      WHERE [t].[Id] = @__p_0
info: 5/29/2025 14:40:10.957 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT TOP(1) [t].[Id], [t].[Age], [t].[ClassId], [t].[Name]
      FROM [T_Student] AS [t]
      WHERE [t].[Id] = @__p_0
info: 5/29/2025 14:40:11.024 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (13ms) [Parameters=[@p3='?' (DbType = Int32), @p0='?' (DbType = Int32), @p1='?' (DbType = Int32), @p2='?' (Size = 450), @p7='?' (DbType = Int32), @p4='?' (DbType = Int32), @p5='?' (DbType = Int32), @p6='?' (Size = 450)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [T_Student] SET [Age] = @p0, [ClassId] = @p1, [Name] = @p2
      OUTPUT 1
      WHERE [Id] = @p3;
      UPDATE [T_Student] SET [Age] = @p4, [ClassId] = @p5, [Name] = @p6
      OUTPUT 1
      WHERE [Id] = @p7;

我们观察到生成的SQL语句分别是三条,前两条是查询指定的学生语句,最后一句才是批量更新。

我们知道EF Core是通过更新实体的状态来生成对应的语句,那么是不是通过人为来指定实体状态,对一个已存在的实体进行批量操作呢

这便是接下来要介绍的AttachRange

1.3 AttachRange()

批量附加数据。AttachRange的用法有些特殊,其核心作用是让 EF Core 开始跟踪一组实体,而不会自动将它们标记为需要插入、更新或删除。如果已存在于数据库中,将多个实体的状态设置为 Unchanged。如果是新实体,这设置为 Added。

该方法源自Attach,Attach方法主要目的就是把一个没有被dbContext跟踪的对象附加到dbCotext中使其被dbContext跟踪

1.1 中我们添加了两个学生,接下来我们手动创建包含这两个学生的List并批量附加到DbSet上。最后修改并批量更新一下这个List集合,观察会发生什么。

csharp 复制代码
List<Student> students = new List<Student>
{
    new Student
    {
        Id = 23,
        Name = "王学生",
        Age = 18,
        ClassId = 3
    },
    new Student
    {
        Id = 24,
        Name = "李学生",
        Age = 18,
        ClassId = 3
    }
};
dbContext.Students.AttachRange(students);
students.Where(e => e.Id == 23).FirstOrDefault().Name = "王学生(新)";
students.Where(e => e.Id == 24).FirstOrDefault().Name = "李学生(新)";
dbContext.UpdateRange(students);
dbContext.SaveChanges();

执行结果

csharp 复制代码
info: 5/29/2025 14:53:06.536 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (36ms) [Parameters=[@p3='?' (DbType = Int32), @p0='?' (DbType = Int32), @p1='?' (DbType = Int32), @p2='?' (Size = 450), @p7='?' (DbType = Int32), @p4='?' (DbType = Int32), @p5='?' (DbType = Int32), @p6='?' (Size = 450)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [T_Student] SET [Age] = @p0, [ClassId] = @p1, [Name] = @p2
      OUTPUT 1
      WHERE [Id] = @p3;
      UPDATE [T_Student] SET [Age] = @p4, [ClassId] = @p5, [Name] = @p6
      OUTPUT 1
      WHERE [Id] = @p7;

比起1.2里的UpdateRange执行日志里的不同,这里并没有生成查询语句。只有一条批量更新语句。也就是说如果在特定的这种我们知道数据库数据对象的情况下,是可以通过AttachRange操作直接将数据对象附加到实体上去更新。常见于对从缓存中获取数据的操作。

1.4 RemoveRange()

批量删除数据。通过将多个实体标记为 Deleted 状态,调用 SaveChanges() 时会从数据库中删除这些实体。调用 SaveChanges() 时会将这些实体从数据库中删除。

csharp 复制代码
var student1st = await dbContext.Students.FindAsync(23);
var student2sd = await dbContext.Students.FindAsync(24);
List<Student> students = new List<Student>()
{
    student1st,
    student2sd
};
dbContext.RemoveRange(students);
dbContext.SaveChanges();

二、Range操作的底层优化

2.1 EF Core 7 前举步维艰

在EF Core 6的版本,我们尝试调用AddRangeAsync()。批量添加一批数据。
EF Core 6 AddRangeAsync代码如下

csharp 复制代码
var student1st = new Student
{
    Name = "王学生",
    Age = 18,
    ClassId = 3
};
var student2nd = new Student
{
    Name = "李学生",
    Age = 18,
    ClassId = 3
};
await dbContext.AddRangeAsync(student1st,student2sd);
dbContext.SaveChanges();

执行结果

csharp 复制代码
info: 5/29/2025 11:16:17.840 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (49ms) [Parameters=[@p0='?' (DbType = Int32), @p1='?' (DbType = Int32), @p2='?' (Size = 450)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [T_Student] ([Age], [ClassId], [Name])
      VALUES (@p0, @p1, @p2);
      SELECT [Id]
      FROM [T_Student]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
info: 5/29/2025 11:16:17.859 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[@p0='?' (DbType = Int32), @p1='?' (DbType = Int32), @p2='?' (Size = 450)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [T_Student] ([Age], [ClassId], [Name])
      VALUES (@p0, @p1, @p2);
      SELECT [Id]
      FROM [T_Student]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

通过执行日志里的SQL信息,我们能清晰的观察到,EF Core 6的版本是将student1st, student2nd 分别用两行语句发送给数据库执行。包括UpdateRange(),RemoveRange()。这也是大家对EF Core的批量更新抱怨已久的由来。

EF Core 6的RemoveRange也是生成一个个独立的删除语句
EF Core 6 RemoveRange代码如下

csharp 复制代码
var student1st = await dbContext.Students.FindAsync(21);
var student2sd = await dbContext.Students.FindAsync(22);
dbContext.Students.RemoveRange(student1st, student2sd);
dbContext.SaveChanges();

执行结果

csharp 复制代码
info: 5/29/2025 13:38:10.774 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (28ms) [Parameters=[@p0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DELETE FROM [T_Student]
      WHERE [Id] = @p0;
      SELECT @@ROWCOUNT;
info: 5/29/2025 13:38:10.778 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[@p0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DELETE FROM [T_Student]
      WHERE [Id] = @p0;
      SELECT @@ROWCOUNT;

执行日志里的SQL信息显示出现了两条删除语句,这点和AddRangeAsync类似。

2.2 EF Core 7后焕然一新

但是这一切在EF Core 7.0 里不一样了。EF Core团队改进了批量操作的执行策略,在添加大量实体时,会智能分组并批量提交 SQL 语句,而不是逐条执行。减少跟踪实体时的内存开销,避免在处理大量数据时出现内存溢出。

我们还是尝试调用AddRangeAsync()和RemoveRange()。批量添加一批数据并删除,观察EF Core 7的底层执行有何不同。
EF Core 7 AddRangeAsync代码如下

csharp 复制代码
var student1st = new Student
{
    Name = "王学生",
    Age = 18,
    ClassId = 3
};
var student2sd = new Student
{
    Name = "李学生",
    Age = 18,
    ClassId = 3
};
await dbContext.AddRangeAsync(student1st, student2sd);
dbContext.SaveChanges();

执行结果

csharp 复制代码
info: 5/29/2025 14:08:50.013 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (89ms) [Parameters=[@p0='?' (DbType = Int32), @p1='?' (DbType = Int32), @p2='?' (Size = 450), @p3='?' (DbType = Int32), @p4='?' (DbType = Int32), @p5='?' (Size = 450)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [T_Student] USING (
      VALUES (@p0, @p1, @p2, 0),
      (@p3, @p4, @p5, 1)) AS i ([Age], [ClassId], [Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Age], [ClassId], [Name])
      VALUES (i.[Age], i.[ClassId], i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

而EF Core 7后,执行AddRangeAsync后生成的日志信息里只生成了一行SQL语句。这才是批量更新该有的样子嘛。RemoveRange和其他批量操作同理 ,7.0版本的EF Core都会帮我们优化生成搞笑的SQL语句来执行。

三、无跟踪的批量更新与删除

EF Core 7.0 中已引入两个方法,ExecuteUpdate 和 ExecuteDelete。无需使用 EF 的传统更改跟踪和 SaveChanges() 方法,是无跟踪的批量更新与删除。下面分别介绍二者。

目前仅支持更新和删除;批量新增必须通过 DbSet< TEntity>.AddRange 和 SaveChanges() 完成插入。详细用法在第一小节有介绍。

3.1 ExecuteUpdate

前面我们了解到ExecuteUpdate 没有使用 EF 的传统更改跟踪。也就是说与不会先将数据加载到内存中,而是直接在数据库中执行更新操作,这使得它在处理大量数据时非常高效。

我们试着将查出来的数据,批量更新其中的两个属性。
批量更新------ExecuteUpdate

csharp 复制代码
int[] studentIds =
{
    23,
    24
};
var students =   dbContext.Students.Where(e => studentIds.Contains(e.Id));
await students.ExecuteUpdateAsync(s => s
    .SetProperty(u => u.Name, u => u.Name + "(新)")
    .SetProperty(u => u.Age, u => u.Age + 1)
);

执行结果

csharp 复制代码
info: 5/30/2025 11:11:25.332 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (52ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      UPDATE [t]
      SET [t].[Age] = [t].[Age] + 1,
          [t].[Name] = [t].[Name] + N'(新)'
      FROM [T_Student] AS [t]
      WHERE [t].[Id] IN (23, 24)

执行结果的日志里我们观察到只有一行update语句,并没有先查询的SQL语句。

3.2 ExecuteDelete

ExecuteDelete和ExecuteUpdate 类似,也没有使用 EF 的传统更改跟踪。在执行删除的时候不会先将数据加载到内存中。

csharp 复制代码
int[] studentIds =
{
    23,
    24
};
var students =   dbContext.Students.Where(e => studentIds.Contains(e.Id));
await students.ExecuteDeleteAsync();

执行结果

csharp 复制代码
info: 5/30/2025 11:24:35.693 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (60ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      DELETE FROM [t]
      FROM [T_Student] AS [t]
      WHERE [t].[Id] IN (23, 24)

执行结果的日志里我们观察到只有一行delete语句,并没有先查询的SQL语句。

3.3 状态冲突

非常重要的一点是,当调用 ExecuteUpdate 且在数据库中更新时,EF 的更改跟踪器不会更新,并且跟踪的实例仍具有其原始值。

我们通过混合使用SaveChanges和ExecuteUpdate举例。

假设学生的年龄最初为 18;执行ExecuteUpdate更新后,数据库中的学生的年龄为19,而跟踪的实体实例中的年龄还是18。 调用 SaveChanges 时,如果再在原来年龄的基础上加一,EF 检测到新值 19 与原始值 18 不同,并保留该更改,最终保存到数据库的年龄还是19。 由 ExecuteUpdate 执行的更改将被覆盖且不考虑在内。

这是一种特殊情况,如果查询的是多条数据,并且对这个IQueryable对象遍历了,这会触发EF Core懒加载的执行,触发数据库查询的执行。这样查出来的结果就是更新后的数据。

csharp 复制代码
int[] studentIds =
{
    25,
    26
};
var students =  await dbContext.Students.FirstOrDefaultAsync(e => e.Id == 25);

//先执行ExecuteUpdateAsync
await dbContext.Students.ExecuteUpdateAsync(s => s
    .SetProperty(u => u.Age, u => u.Age + 1)
);

//再执行实体追踪的批量更新
students.Age += 2;
await dbContext.SaveChangesAsync();

执行结果

csharp 复制代码
info: 5/30/2025 11:42:37.335 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (44ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT TOP(1) [t].[Id], [t].[Age], [t].[ClassId], [t].[Name]
      FROM [T_Student] AS [t]
      WHERE [t].[Id] = 25
info: 5/30/2025 11:42:37.446 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (15ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      UPDATE [t]
      SET [t].[Age] = [t].[Age] + 1
      FROM [T_Student] AS [t]
info: 5/30/2025 11:42:37.553 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (34ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      UPDATE [T_Student] SET [Age] = @p0
      OUTPUT 1
      WHERE [Id] = @p1;

因此,通常最好避免通过实体追踪更新数据SaveChanges的方式 与 ExecuteUpdate无跟踪更新数据的方式混用


总结

本文对比了 EF Core 7 前后批量操作(如 AddRange/RemoveRange)的底层实现差异,介绍了 AttachRange 的特殊用法,并重点解析了无跟踪批量更新 ExecuteUpdate 和删除 ExecuteDelete 的原理与状态冲突问题。

相关推荐
大数据魔法师39 分钟前
MongoDB(七) - MongoDB副本集安装与配置
数据库·mongodb
朝九晚五ฺ1 小时前
【MySQL基础】库的操作:创建、删除与管理数据库
数据库·sql·mysql
ephemerals__1 小时前
【Linux】基础文件IO
linux·运维·数据库
小哈里1 小时前
【DBA】MySQL经典250题,改自OCP英文题库中文版(2025完整版)
数据库·mysql·dba·开闭原则·ocp
英英_1 小时前
mysql分布式教程
数据库·分布式·mysql
珹洺2 小时前
数据库系统概论(十五)详细讲解数据库视图
android·java·数据库·sql
敲键盘的小夜猫2 小时前
Retrievers检索器+RAG文档助手项目实战
java·数据库·算法
✿ ༺ ོIT技术༻2 小时前
MySQL:视图+用户管理+访问+连接池原理
数据库·mysql
菜菜小蒙3 小时前
【MySQL】视图与用户管理
数据库·mysql
王景程3 小时前
常见ADB指令
数据库