.NET EFCore批量插入性能优化实战:30秒 → 0.5秒
那是一个周五下午离下班就剩半小时马上要发版大家都收拾好东西准备溜了。产品经理火急火燎跑过来拍着我桌子说“快看看那个Excel导入功能炸了客户导5000条数据页面转圈转了一分钟直接超时报错客户都投诉了能不能赶紧搞快点”我心里咯噔一下周五发版出问题妥妥的加班节奏。打开代码一看我差点没背过气去——这代码写得简直是教科书级别的反面案例。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复杂一点除了插入订单主表还要插入订单明细、更新统计数据、写操作日志。但核心的循环逻辑跟下面差不多你们感受下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移到外面先把所有数据存到集合里最后一次性提交。var orders new ListOrder(); var allDetails new ListOrderDetail(); 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再关联明细、提交明细具体代码如下// 先把所有订单添加到上下文提交主表 _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。这东西是原生操作数据库批量插入速度快到离谱直接上代码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的自动跟踪减少性能消耗、分批提交避免单事务过大导致内存溢出和锁表具体代码如下// 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 ListOrderDetail(); 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次避免一次死锁就导致功能失败。最终的重试代码加在批量插入逻辑外面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虽然方便但大数据量批量操作时原生APISqlBulkCopy才是王道——但要权衡维护成本4. 高并发场景下的批量插入一定要考虑死锁问题事务隔离级别降级ReadCommitted 重试机制是标配5. 性能优化的顺序很重要先改代码逻辑循环→批量→ 再改框架配置关闭跟踪、调整事务级别→ 最后考虑换工具EF Core→SqlBulkCopy不要一上来就炫技。最后其实很多时候数据库性能问题不是因为技术不够牛而是因为写代码的时候太“随意”——网上找个例子复制粘贴不考虑数据量不做测试上线后就出问题。如果你现在项目里也有类似的Excel导入、数据同步、批量ETL逻辑建议你打开日志看看说不定也隐藏着一个“循环Insert”在慢慢拖垮你的数据库。优化这个东西很多时候不是你不会是你没去跑一下测试、没去看一眼慢日志。我是[云中小生]一个踩过批量插入坑的后端。