.NET EFCore批量插入性能优化实战:30秒 → 0.5秒

那是一个周五下午,离下班就剩半小时,马上要发版,大家都收拾好东西准备溜了。

产品经理火急火燎跑过来,拍着我桌子说:"快看看!那个Excel导入功能炸了,客户导5000条数据,页面转圈转了一分钟,直接超时报错,客户都投诉了,能不能赶紧搞快点?"

我心里咯噔一下,周五发版出问题,妥妥的加班节奏。打开代码一看,我差点没背过气去------这代码写得,简直是教科书级别的反面案例。

cs 复制代码
foreach (var row in excelRows)
{
    var entity = new Order
    {
        OrderNo = row.OrderNo,
        ProductId = row.ProductId,
        Price = row.Price
    };
    _context.Orders.Add(entity);
    _context.SaveChanges();   // ← 就是这一行,坑死我了
}

你们敢信吗?每一条数据,都单独调用一次SaveChanges

5000条数据,就意味着5000次网络往返,5000次事务开启和提交。数据库相当于被按在地上反复摩擦,不卡才怪。

后来我问写这段代码的同事,为啥这么写?他挠挠头说:"网上找的例子都是这么写的啊,我以为没问题......"

今天我就把这个坑从头到尾讲透,不光说我改的4个版本,还有线上真实遇到的死锁事故------都是实打实踩过的坑,你们看完绝对能避开,别再走我的弯路。

一、先看原始代码有多离谱

先说明下,实际业务比我上面贴的demo复杂一点:除了插入订单主表,还要插入订单明细、更新统计数据、写操作日志。但核心的循环逻辑,跟下面差不多,你们感受下:

cs 复制代码
foreach (var dto in dtos)
{
    using var trans = _context.Database.BeginTransaction();
    try
    {
        var order = MapToOrder(dto);
        _context.Orders.Add(order);
        _context.SaveChanges();   // 第一次提交

        foreach (var detail in dto.Details)
        {
            var orderDetail = MapToDetail(order.Id, detail);
            _context.OrderDetails.Add(orderDetail);
        }
        _context.SaveChanges();   // 第二次提交

        UpdateStatistics(order);
        _context.SaveChanges();   // 第三次提交

        trans.Commit();
    }
    catch
    {
        trans.Rollback();
        throw;
    }
}

一条数据,要提交3次;5000条数据,就是15000次数据库交互。我当时加了日志监控,看完直接懵了:

  • 插入5000条主数据 + 平均每条3个明细(总共15000条明细) - 总执行时间:43秒(客户说的一分钟超时,还是保守了) - 数据库CPU直接飙到80%,服务器告警都炸了

更可怕的是,这个功能不是偶尔用一次------每天有几十个人在用,都是批量导数据,数据库压力直接拉满,再这么下去,迟早要崩。

二、第一版优化:批量AddRange + 单次SaveChanges

最基础、最不用动脑子的优化,就是把循环里的SaveChanges移到外面,先把所有数据存到集合里,最后一次性提交。

cs 复制代码
var orders = new List<Order>();
var allDetails = new List<OrderDetail>();

foreach (var dto in dtos)
{
    var order = MapToOrder(dto);
    orders.Add(order);
    
    foreach (var detail in dto.Details)
    {
        allDetails.Add(MapToDetail(order.Id, detail));
    }
}

_context.Orders.AddRange(orders);
_context.OrderDetails.AddRange(allDetails);
_context.SaveChanges();   // 只提交两次,一次主表,一次明细

效果立竿见影,我当时跑了一遍测试,直接惊了:

  • 执行时间从43秒 → 6秒(直接砍了近85%) - 数据库交互从15000次 → 2次

但高兴得太早了,很快就发现一个致命问题:无法拿到刚刚生成的Order.Id。因为SaveChanges没执行之前,订单的自增Id还是0,明细要关联主表Id,根本关联不上。

这不是我瞎想的,实际业务里,主表和明细的关联是刚需,这个方案看似简单,其实根本没法用。于是有了第二版优化。

三、第二版优化:利用Identity自动返回Id

其实EF Core有个很实用的特性,很多人可能不知道:执行SaveChanges后,自增Id会自动填充到实体对象上。

我调整了代码顺序,先提交主表,拿到所有主表Id,再关联明细、提交明细,具体代码如下:

cs 复制代码
// 先把所有订单添加到上下文,提交主表
_context.Orders.AddRange(orders);
_context.SaveChanges();   // 此时orders里的每个order.Id都已经生成好了

// 用临时Id关联明细(我在dto里加了TempId,用来匹配订单和明细)
foreach (var order in orders)
{
    var details = detailMap[order.TempId];
    foreach (var d in details)
    {
        d.OrderId = order.Id; // 现在能拿到真实Id了,关联成功
        _context.OrderDetails.Add(d);
    }
}
// 最后提交所有明细
_context.OrderDetails.AddRange(allDetails);
_context.SaveChanges();

这次优化后,功能是完整了,能正确处理主表和明细的关联,但性能稍微降了一点:

  • 执行时间:6.5秒(比纯批量多了0.5秒,能接受) - 核心优势:功能无缺陷,代码改动不大,容易理解

我以为这样就可以交差了,结果产品经理又来找我:"6秒还是久,用户反馈导入的时候还是要等,能不能再优化到3秒以内?"

没办法,只能往更深的地方挖,于是有了第三版,也是最"极端"的一版。

四、第三版优化:SqlBulkCopy黑科技

我后来研究了一下,EF Core的AddRange虽然比循环SaveChanges好,但本质上还是生成一条一条的insert语句,只是把所有语句放在一个事务里执行。真正的性能天花板,其实是.NET自带的SqlBulkCopy。

这东西是原生操作数据库,批量插入速度快到离谱,直接上代码:

cs 复制代码
using var bulkCopy = new SqlBulkCopy(connectionString, SqlBulkCopyOptions.Default);
bulkCopy.DestinationTableName = "Orders"; // 对应数据库表名
bulkCopy.BatchSize = 1000; // 每1000条一批提交,避免内存溢出

// 构建DataTable,和数据库表结构对应
var dataTable = new DataTable();
dataTable.Columns.Add("OrderNo", typeof(string));
dataTable.Columns.Add("ProductId", typeof(int));
// 其他字段依次添加...

// 把订单数据填充到DataTable
foreach (var order in orders)
{
    dataTable.Rows.Add(order.OrderNo, order.ProductId, ...);
}

// 执行批量插入
bulkCopy.WriteToServer(dataTable);

你们猜执行结果怎么样?性能直接爆炸:

  • 插入5000条订单:0.3秒 (快到不敢信) - 加上15000条明细,总计耗时:0.9秒

但天下没有免费的午餐,SqlBulkCopy有两个致命缺点,也是我最后没用到线上的原因:

  1. 不兼容EF Core的特性:不会触发SaveChanges拦截器,也不会更新_context中的本地跟踪,相当于绕开了EF Core的封装,后续如果有逻辑依赖拦截器(比如审计日志),会出大问题;

  2. 不会自动返回自增Id:虽然可以用OUTPUT inserted.*子句取回Id,但配置起来非常麻烦,还要处理DataTable和实体的映射,代码量翻倍;

我当时折腾了大半天,搞出了一个混合方案:先用SqlBulkCopy插主表,用OUTPUT取回Id,再构建明细的DataTable插入子表,最后手动更新内存中的实体。

虽然性能拉满,但我心里很清楚,这个方案维护成本太高------下一个接手的同事,大概率会对着一堆DataTable和SqlBulkCopy配置骂街。作为一个有职业操守的后端,这种"炫技式"优化,不能用在生产环境。

五、第四版(最终选择):EF Core + 批次提交 + 禁用跟踪

最后我放弃了追求极致性能,选择了一个平衡点:在EF Core的易用性和原生性能之间找最优解,既保证性能,又兼顾可维护性。

核心思路有两个:关闭EF Core的自动跟踪(减少性能消耗)、分批提交(避免单事务过大,导致内存溢出和锁表),具体代码如下:

cs 复制代码
// 1. 关闭自动跟踪,EF Core默认开启,批量操作时会拖慢性能(亲测能差3倍)
_context.ChangeTracker.AutoDetectChangesEnabled = false;
_context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

// 2. 分批提交,每500条一批,可根据实际情况调整
int batchSize = 500;
for (int i = 0; i < orders.Count; i += batchSize)
{
    var batchOrders = orders.Skip(i).Take(batchSize).ToList();
    _context.Orders.AddRange(batchOrders);
    _context.SaveChanges();   // 每批提交一次,拿到主表Id

    // 拿到Id后,关联对应的明细
    var batchDetails = new List<OrderDetail>();
    foreach (var order in batchOrders)
    {
        var details = detailMap[order.TempId];
        foreach (var d in details)
        {
            d.OrderId = order.Id;
            batchDetails.Add(d);
        }
    }
    // 提交当前批次的明细
    _context.OrderDetails.AddRange(batchDetails);
    _context.SaveChanges();
}

这个方案的最终效果,完全符合预期:

  • 5000条主表 + 15000条明细,总执行时间:2.1秒(满足产品3秒以内的要求) - 内存可控:分批提交避免了一次性加载大量数据到内存,不会出现OOM - 事务粒度合理:每500条一批,即使失败,回滚也不会影响所有数据 - 代码易维护:基于EF Core,后续同事接手,一看就懂,不用额外学习SqlBulkCopy

虽然比SqlBulkCopy慢一点,但综合可维护性、功能完整性,这绝对是生产环境的最优解。

六、你以为结束了吗?不,还有死锁

优化完成,测试环境跑了几天,一切正常,我以为这下终于可以安心下班了。结果上线第一周没事,第二周,监控平台开始偶尔出现死锁异常,报错信息如下:

Transaction \(Process ID 68\) was deadlocked on lock resources with another process and has been chosen as the deadlock victim\.

翻译过来就是:两个进程竞争锁资源,当前进程被选为死锁牺牲品,事务回滚了。

排查了半天,终于找到原因:批量插入时,多个用户同时导入数据,对同一张订单表产生了间隙锁冲突------批量插入会占用表锁,多个用户同时操作,就会出现锁竞争,进而导致死锁。

解决方案很简单,两步搞定:

  1. 事务隔离级别降级:把默认的Serializable(串行化)降级为ReadCommitted(读已提交),避免产生不必要的间隙锁;

  2. 增加死锁重试机制:检测到死锁异常后,延迟100毫秒重试,最多重试3次,避免一次死锁就导致功能失败。

最终的重试代码,加在批量插入逻辑外面:

cs 复制代码
int retryCount = 3; // 最多重试3次
while (retryCount-- > 0)
{
    try
    {
        // 批量插入逻辑(主表+明细)
        break; // 执行成功,跳出循环
    }
    catch (SqlException ex) when (ex.Number == 1205) // 1205是死锁的错误码
    {
        await Task.Delay(100); // 延迟100毫秒,避免立即重试再次冲突
    }
}

加上重试机制后,线上再也没出现过死锁导致的功能失败,这个批量插入功能,终于稳定运行了。

七、一张表总结所有方案(避坑指南)

方案 5000条耗时 优点 缺点 推荐度
循环SaveChanges(原始) 43秒 代码最简单,上手快 性能极差,数据库压力大,高并发必崩 ❌ 别用(除非数据量&lt;10条)
AddRange批量提交 6秒 代码改动小,易理解,性能提升明显 无法处理主表-明细关联(拿不到自增Id) ⭐⭐(无关联场景可用)
分批 + 关闭跟踪(最终方案) 2.1秒 平衡性能与可维护性,无功能缺陷,内存可控 需要手动管理批次,代码稍多 ⭐⭐⭐⭐(生产环境首选)
SqlBulkCopy原生批量 0.9秒 性能最优,大数据量场景优势明显 配置复杂,不返回自增Id,维护成本高 ⭐⭐⭐(高手用,需结合业务场景)

八、我后来学到的教训(血的经验)

这次优化,我踩了不少坑,也总结了5条教训,分享给你们,避免你们再走弯路:

  1. 绝对不要在循环里调用SaveChanges,除非你明确知道数据量极少(比如不到10条),否则就是在给数据库"下毒";

  2. 批量操作前,一定要关掉EF Core的AutoDetectChanges,亲测这个配置能让EF Core的批量性能提升3倍以上;

  3. 不要迷信ORM能搞定一切,EF Core虽然方便,但大数据量批量操作时,原生API(SqlBulkCopy)才是王道------但要权衡维护成本;

  4. 高并发场景下的批量插入,一定要考虑死锁问题,事务隔离级别降级(ReadCommitted)+ 重试机制,是标配;

  5. 性能优化的顺序很重要:先改代码逻辑(循环→批量)→ 再改框架配置(关闭跟踪、调整事务级别)→ 最后考虑换工具(EF Core→SqlBulkCopy),不要一上来就炫技。

最后

其实很多时候,数据库性能问题,不是因为技术不够牛,而是因为写代码的时候太"随意"------网上找个例子,复制粘贴,不考虑数据量,不做测试,上线后就出问题。

如果你现在项目里也有类似的Excel导入、数据同步、批量ETL逻辑,建议你打开日志看看,说不定也隐藏着一个"循环Insert",在慢慢拖垮你的数据库。

优化这个东西,很多时候不是你不会,是你没去跑一下测试、没去看一眼慢日志。

我是[云中小生],一个踩过批量插入坑的后端。

相关推荐
Esofar8 小时前
Dddify:给 ASP.NET Core 项目一套轻量、清晰、可落地的 DDD 基础设施
c#·ddd·asp.net core·cqrs·dddify·clean architecture
步步为营DotNet8 小时前
深挖.NET 11:.NET Aspire 在云原生应用状态管理的创新与实践
云原生·.net·wpf
Coder_Shenshen9 小时前
【基于LibUA库的OPC UA服务器与客户端Demo——协议解析与Bug修复实践】
网络·c#·bug
Swift社区10 小时前
鸿蒙 PC 性能优化实战:从卡顿到丝滑
华为·性能优化·harmonyos
斜阳日落10 小时前
Qt 框架深度解析与性能优化
qt·性能优化·系统架构
信必诺10 小时前
C# —— VS2022配置终端程序跨平台发布方法(部署Ubuntu22.04举例,详细多图)
ubuntu·c#·跨平台部署
我是唐青枫10 小时前
C#.NET YARP 跨域配置详解:网关统一处理 CORS
开发语言·c#·.net
lzhdim10 小时前
C#性能优化技巧
开发语言·性能优化·c#
weixin_4280053010 小时前
C#调用 AI学习从0开始-第1阶段(基础与工具)-第5天完善请求结构
windows·学习·c#·ai请求结构