Skip to content
Go back

EF Core 10 批量操作全攻略:插入、更新、删除的策略与性能对比

你写了一个 CSV 导入接口,需要往数据库插入 50,000 条产品记录。代码很简单:循环调用 context.Products.Add(product),最后一次 SaveChanges()。运行之后等了五分钟,请求超时了。

EF Core 在帮你生成 50,000 条独立的 INSERT 语句,每一条都是一次数据库往返。

批量操作就是为解决这个问题而存在的。

EF Core 批量操作概念图


什么是批量操作

批量操作是指用一条或少数几条 SQL 命令一次性影响多行,而不是逐条处理。在 EF Core 10 里,批量操作分三类:

理解这三类之间的核心区别是:被跟踪的操作(经由变更追踪器)和未跟踪的操作(直接执行 SQL)。这个区别对拦截器、全局查询过滤器和审计日志都有重大影响。


SaveChanges 的内置批处理

在引入专用 API 之前,先了解 EF Core 本身已经做了什么。调用 SaveChanges() 时,EF Core 不是一条条发送 SQL,而是把多条语句合并进更少的数据库往返。

// EF Core 会自动把这些操作合并批处理
foreach (var product in products)
{
    context.Products.Add(product);
}
await context.SaveChangesAsync(ct);

EF Core 10 配合 PostgreSQL 使用带 RETURNING 子句的 MERGE 语句,把多条插入合并为一条命令。默认的批次上限是每次往返 42 条语句。插入 100 个实体时,EF Core 大约发送 3 次批处理命令,而不是 100 次。

可以在 DbContext 配置中调整批次大小:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString, npgsql =>
        npgsql.MaxBatchSize(100))); // PostgreSQL 默认是 42

增大 MaxBatchSize 会减少往返次数,但每条 SQL 命令会更大。对 PostgreSQL 而言,42–100 之间效果最好;超过 200 很少能带来收益,反而可能触及参数上限。

内置批处理对很多场景已经够用,但它仍然要求把每个实体加载到内存并经由变更追踪器生成 SQL。当记录数达到数万乃至十万级别时,这部分开销就会显现出来。


批量插入:AddRange + SaveChanges

对批量插入最简单的优化是用 AddRange() 替代循环里的 Add()。两者最终都会被变更追踪器管理,但 AddRange() 让 EF Core 知道你在批量添加多个实体,可以优化检测和批处理逻辑。

app.MapPost("/products/bulk", async (
    List<CreateProductRequest> requests,
    AppDbContext context,
    CancellationToken ct) =>
{
    var products = requests.Select(r => new Product
    {
        Name = r.Name,
        Price = r.Price,
        Category = r.Category,
        CreatedAt = DateTime.UtcNow
    }).ToList();

    context.Products.AddRange(products);
    await context.SaveChangesAsync(ct);

    return Results.Created($"/products", new { Count = products.Count });
});

生成的 SQL 使用批处理插入:

-- EF Core 把这些合并成带 RETURNING 的多行 INSERT
INSERT INTO "Products" ("Name", "Price", "Category", "CreatedAt")
VALUES (@p0, @p1, @p2, @p3),
       (@p4, @p5, @p6, @p7),
       (@p8, @p9, @p10, @p11)
RETURNING "Id";

适用场景:数千条以内的插入,AddRange + SaveChanges 通常就够了。你可以得到完整的变更追踪,拦截器会触发,生成值(如 Id)会返回,审计日志也正常工作。

不适用场景:超过 10,000 条记录时,创建实体实例、追踪它们、检测变更的开销就会累积成性能瓶颈。


基于集合的更新:ExecuteUpdate

ExecuteUpdate 从 EF Core 7 引入,在 EF Core 10 中继续可用。它把 LINQ 查询直接翻译成 SQL UPDATE 语句,不加载实体,不经过变更追踪器,SQL 在数据库端直接执行。

更新单个属性

// 停用 90 天内未更新的所有产品
var affectedRows = await context.Products
    .Where(p => p.LastModified < DateTime.UtcNow.AddDays(-90))
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.IsActive, false), ct);

生成的 SQL:

UPDATE "Products"
SET "IsActive" = FALSE
WHERE "LastModified" < @p0;
-- @p0 = '2025-11-18T00:00:00Z'

更新多个属性

// 对电子产品打九折并标记促销
await context.Products
    .Where(p => p.Category == "Electronics" && p.Price > 500)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.Price, p => p.Price * 0.9m)
        .SetProperty(p => p.IsOnSale, true)
        .SetProperty(p => p.LastModified, DateTime.UtcNow), ct);

注意 SetProperty 的第二个参数可以是引用当前值的 lambda(p => p.Price * 0.9m),这是做相对更新——递增计数器、应用百分比、拼接字符串——而不需要先把实体加载进来。

返回受影响的行数

ExecuteUpdate 返回受影响的行数,适合用于并发检查和日志记录:

var updated = await context.Products
    .Where(p => p.Id == productId && p.Version == expectedVersion)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.Price, newPrice)
        .SetProperty(p => p.Version, p => p.Version + 1), ct);

if (updated == 0)
{
    return Results.Conflict("Product was modified by another user.");
}

这是手动乐观并发的推荐模式——因为 ExecuteUpdate 绕过了 EF Core 内置的并发令牌检查。


基于集合的删除:ExecuteDelete

ExecuteDeleteExecuteUpdate 工作方式相同——把 LINQ 的 Where 子句翻译成 SQL DELETE,不加载实体直接执行。

// 删除所有"已停产"分类的产品
var deleted = await context.Products
    .Where(p => p.Category == "Discontinued")
    .ExecuteDeleteAsync(ct);
DELETE FROM "Products" WHERE "Category" = 'Discontinued';

级联删除注意事项

如果实体存在从属关系(如 ProductOrderItems),数据库的级联规则生效,而不是 EF Core 的。外键设置为 CASCADE 时数据库处理;设置为 RESTRICTExecuteDelete 会抛出数据库异常。

// 先删除子表,再删除主表
await context.OrderItems
    .Where(oi => oi.Product.Category == "Discontinued")
    .ExecuteDeleteAsync(ct);

await context.Products
    .Where(p => p.Category == "Discontinued")
    .ExecuteDeleteAsync(ct);

ExecuteDelete 与软删除的陷阱

这里有一个在生产环境中真实发生过的场景。团队有一个定时清理任务:

// 看起来没问题——删除过期促销
await context.Products
    .Where(p => p.PromoExpiresAt < DateTime.UtcNow)
    .ExecuteDeleteAsync(ct);

这段代码工作了几个月,直到团队引入了带全局查询过滤器的软删除。他们期望 ExecuteDelete 会调用 SaveChangesInterceptor 并把 IsDeleted 设为 true,而不是真正删除记录。

结果它没有。ExecuteDelete 生成的是原始 SQL DELETE——完全绕过了拦截器和变更追踪器。那些促销产品永远消失了,没有 IsDeleted 标记,没有审计日志,也无法恢复。

正确的修复方案是对有软删除的实体切换回跟踪操作:

// 安全版本——会经过拦截器和软删除逻辑
var expiredProducts = await context.Products
    .Where(p => p.PromoExpiresAt < DateTime.UtcNow)
    .ToListAsync(ct);

context.Products.RemoveRange(expiredProducts);
await context.SaveChangesAsync(ct); // 拦截器把 DELETE 转换为 UPDATE SET IsDeleted = true

或者用 ExecuteUpdate 手动设置软删除标志:

await context.Products
    .Where(p => p.PromoExpiresAt < DateTime.UtcNow)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.IsDeleted, true)
        .SetProperty(p => p.DeletedAt, DateTime.UtcNow), ct);

结论:如果实体参与了软删除,永远不要对它用 ExecuteDelete


在事务中包裹批量操作

ExecuteUpdateExecuteDelete 各自在独立的隐式事务中执行。如果需要多个操作同时成功或回滚,需要用显式事务包裹:

await using var transaction = await context.Database.BeginTransactionAsync(ct);

try
{
    // 步骤 1:停用过期产品
    await context.Products
        .Where(p => p.ExpiresAt < DateTime.UtcNow)
        .ExecuteUpdateAsync(setters => setters
            .SetProperty(p => p.IsActive, false), ct);

    // 步骤 2:删除已停用产品的订单项
    await context.OrderItems
        .Where(oi => !oi.Product.IsActive)
        .ExecuteDeleteAsync(ct);

    // 步骤 3:写入清理日志
    context.AuditLogs.Add(new AuditLog
    {
        Action = "BulkCleanup",
        Timestamp = DateTime.UtcNow,
        Details = "Deactivated expired products and removed their order items"
    });
    await context.SaveChangesAsync(ct);

    await transaction.CommitAsync(ct);
}
catch
{
    await transaction.RollbackAsync(ct);
    throw;
}

可以在同一个事务里混用 ExecuteUpdateExecuteDelete 和跟踪的 SaveChanges——它们共享同一个数据库连接。注意跟踪的 AuditLog 实体不会”看到” ExecuteUpdate/ExecuteDelete 的变更,因为那些操作绕过了变更追踪器。


第三方库:什么时候需要

对于极大数据量的插入(50K+ 行),原生的 AddRange + SaveChanges 仍然要把所有实体加载进内存并经由变更追踪器生成 SQL。这就是第三方批量库的用武之地。

EFCore.BulkExtensions

EFCore.BulkExtensions 是一个 MIT 协议的开源库,提供 BulkInsertBulkUpdateBulkDeleteBulkInsertOrUpdate(upsert)。它使用数据库专有的批量复制协议——SQL Server 用 SqlBulkCopy,PostgreSQL 用 COPY——完全绕过 EF Core 的变更追踪器。

dotnet add package EFCore.BulkExtensions --version 8.1.3
using EFCore.BulkExtensions;

// 批量插入——使用 PostgreSQL COPY 协议
var products = GenerateProducts(50_000);
await context.BulkInsertAsync(products, cancellationToken: ct);

// Upsert——根据主键决定插入或更新
var productsToUpsert = GetUpdatedProductFeed();
await context.BulkInsertOrUpdateAsync(productsToUpsert, cancellationToken: ct);

Entity Framework Extensions(付费)

Entity Framework Extensions 是 ZZZ Projects 出品的商业库,功能更全面——BulkMergeBulkSynchronize、条件批量操作等。起步价每年 $599。

什么时候该用第三方库

大多数 Web API 从来不需要第三方批量库。作者的判断:

最常见的错误是一开始就引入批量库。在 SaveChanges 实际变慢之前先别加这个依赖。


性能基准测试

所有基准测试使用 BenchmarkDotNet,测试实体是有 6 列的 Product(Id、Name、Price、Category、IsActive、CreatedAt),运行环境为 .NET 10 + EF Core 10 + PostgreSQL 17(Docker),M2 MacBook Pro。

插入基准

方式100 条1K 条10K 条100K 条内存(100K)
逐条 Add + SaveChanges45 ms380 ms3,800 ms41,200 ms285 MB
AddRange + SaveChanges12 ms95 ms920 ms9,500 ms180 MB
BulkExtensions BulkInsert8 ms35 ms180 ms1,200 ms42 MB
原生 Npgsql COPY5 ms18 ms95 ms650 ms28 MB

AddRange 比逐条插入快 4 倍。100–1,000 条时,AddRange 与批量库的差距很小,仅毫秒级。10K+ 时差距就拉开了:BulkInsert 在 10K 时快 5 倍,100K 时快 8 倍,内存占用少 77%。

更新基准

方式100 条1K 条10K 条100K 条
加载 + 修改 + SaveChanges38 ms310 ms3,200 ms35,800 ms
ExecuteUpdate3 ms3 ms4 ms5 ms
BulkExtensions BulkUpdate10 ms22 ms85 ms520 ms

ExecuteUpdate 是第一梯队——无论匹配多少行,它发送的都是单条 SQL,始终 3–5 ms。跟踪方式(加载 + 修改 + 保存)随数据量线性增长,超过 1,000 条就会很痛苦。BulkUpdate 介于两者之间,适合需要对每行设置不同值(ExecuteUpdate 只能对所有匹配行应用同一转换)的场景。

删除基准

方式100 条1K 条10K 条100K 条
加载 + Remove + SaveChanges35 ms290 ms2,900 ms32,000 ms
ExecuteDelete2 ms2 ms3 ms4 ms
BulkExtensions BulkDelete8 ms18 ms65 ms380 ms

和更新一样——ExecuteDelete 靠单条 SQL DELETE + WHERE 子句大幅领先。先查询再逐条删除是最差的选择,因为它先把所有匹配行查出来跟踪,然后逐条生成 DELETE


决策矩阵

场景推荐方案说明
插入 10–10K 条AddRange + SaveChanges批处理自动处理,支持变更追踪、生成 ID、拦截器
插入 10K–100K+ 条(数据导入)BulkInsert(EFCore.BulkExtensions)快 8 倍、省 77% 内存
按条件统一更新多行ExecuteUpdate单条 SQL,始终用这个
每行更新不同值加载 + 修改 + SaveChangesExecuteUpdate 不支持按行差异化值
每行不同值且 10K+ 规模BulkUpdate(EFCore.BulkExtensions)跟踪更新太慢但每行值不同时
按条件删除ExecuteDelete单条 SQL,最快
有软删除的实体删除ExecuteUpdate(设 IsDeleted = true)ExecuteDelete 会绕过拦截器和全局过滤器
Upsert(插入或更新)BulkInsertOrUpdate(EFCore.BulkExtensions)EF Core 原生不支持 upsert
混合操作显式事务包裹各操作在单个事务中组合不同方案

生产环境常见陷阱

1. ExecuteUpdate/ExecuteDelete 绕过拦截器

如果你依赖 SaveChangesInterceptor 做审计追踪、软删除或时间戳更新,ExecuteUpdateExecuteDelete 会完全跳过它们。

修复:对需要拦截器行为的实体,使用跟踪操作;或者在 ExecuteUpdate 调用里手动包含审计字段:

await context.Products
    .Where(p => p.Id == productId)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.Price, newPrice)
        .SetProperty(p => p.LastModifiedBy, currentUser)
        .SetProperty(p => p.LastModifiedAt, DateTime.UtcNow), ct);

2. 变更追踪器状态不同步

在同一个 DbContext 范围内混用跟踪操作和 ExecuteUpdate/ExecuteDelete,变更追踪器不知道批量操作的变更:

var product = await context.Products.FindAsync(productId);
// product.Price 是 100,已被变更追踪器跟踪

await context.Products
    .Where(p => p.Id == productId)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.Price, 200m), ct);
// 数据库里 Price 现在是 200,但跟踪的实体还是 100

product.Name = "Updated Name";
await context.SaveChangesAsync(ct);
// SaveChanges 检测到 Name 变了,但同时也"检测到" Price 应该是 100
// 这会覆盖 ExecuteUpdate 的更改!

修复:不要在同一个 DbContext 范围内对同一实体混用跟踪和未跟踪操作。用了 ExecuteUpdate 之后,重新加载实体或使用新的 DbContext

3. 大量插入可能触及内存上限

即使用 AddRange,插入 100K 个实体也会在内存里创建 100K 个被跟踪的对象。在 512 MB 容器上这可能触发 OutOfMemoryException

修复:把大批量插入分成 5,000–10,000 条的块,每块调用一次 SaveChanges(),或者切换到 BulkInsert

// 不使用 BulkExtensions 时的分块方案
foreach (var chunk in products.Chunk(5000))
{
    context.Products.AddRange(chunk);
    await context.SaveChangesAsync(ct);
    context.ChangeTracker.Clear(); // 释放跟踪的实体
}

4. ExecuteUpdate 不支持导航属性

SetProperty 的 lambda 里不能引用导航属性,否则运行时会抛出异常:

// 这样不行
await context.Products
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.Category.Name, "Updated"), ct); // 抛出异常!

修复:用子查询加 Select,或直接更新关联实体。


排错指南

ExecuteUpdate/ExecuteDelete 抛出”could not be translated” LINQ 表达式包含无法翻译成 SQL 的方法或属性。简化 Where 子句——避免 ToString()、复杂字符串操作或自定义方法,坚持使用基本比较、Contains() 和算术。

批量插入时”Cannot insert duplicate key” 数据中在唯一约束上存在重复。插入前过滤重复项,或使用 EFCore.BulkExtensions 的 BulkInsertOrUpdate 做 upsert。

大批量 SaveChanges 超时 增加命令超时:options.UseNpgsql(conn, o => o.CommandTimeout(120))。同时考虑把大批量分成 5,000–10,000 条的块。

大量 AddRange 操作内存飙升 变更追踪器把所有实体保存在内存里。在批次间调用 context.ChangeTracker.Clear(),或切换到 BulkInsert

ExecuteDelete 违反外键约束 数据库强制引用完整性。先删除从属实体,或在数据库 schema 中配置级联删除。


关键结论


参考


Tags


Previous

.NET 进程内同步 API 全览:从 lock 到 Barrier

Next

用 Redis Backplane 解决 SignalR 多实例消息丢失问题