首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >.NET EFCore批量插入性能优化实战:30秒 → 0.5秒

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

作者头像
云中小生
发布2026-05-20 15:15:48
发布2026-05-20 15:15:48
1550
举报

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

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

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

代码语言:javascript
复制
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复杂一点:除了插入订单主表,还要插入订单明细、更新统计数据、写操作日志。但核心的循环逻辑,跟下面差不多,你们感受下:

代码语言:javascript
复制
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移到外面,先把所有数据存到集合里,最后一次性提交。

代码语言:javascript
复制
var orders = new List();
var allDetails = new List();
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,再关联明细、提交明细,具体代码如下:

代码语言:javascript
复制
// 先把所有订单添加到上下文,提交主表
_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。

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

代码语言:javascript
复制
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的自动跟踪(减少性能消耗)、分批提交(避免单事务过大,导致内存溢出和锁表),具体代码如下:

代码语言:javascript
复制
// 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();
    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次,避免一次死锁就导致功能失败。

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

代码语言:javascript
复制
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”,在慢慢拖垮你的数据库。

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

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

(点击关注,修炼不迷路👇

▌转载请注明出处,渡人渡己

🌟 感谢道友结缘! 若本文助您突破修为瓶颈,不妨【打赏灵丹】或【转发功德】,让更多道友共参.NET天道玄机。修真之路漫漫,我们以代码为符,共绘仙途!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-05-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 .NET修仙日记 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、先看原始代码有多离谱
  • 二、第一版优化:批量AddRange + 单次SaveChanges
  • 三、第二版优化:利用Identity自动返回Id
  • 四、第三版优化:SqlBulkCopy黑科技
  • 五、第四版(最终选择):EF Core + 批次提交 + 禁用跟踪
  • 六、你以为结束了吗?不,还有死锁
  • 七、一张表总结所有方案(避坑指南)
  • 八、我后来学到的教训(血的经验)
  • 最后
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档