那是一个周五下午,离下班就剩半小时,马上要发版,大家都收拾好东西准备溜了。
产品经理火急火燎跑过来,拍着我桌子说:"快看看!那个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有两个致命缺点,也是我最后没用到线上的原因:
-
不兼容EF Core的特性:不会触发SaveChanges拦截器,也不会更新_context中的本地跟踪,相当于绕开了EF Core的封装,后续如果有逻辑依赖拦截器(比如审计日志),会出大问题;
-
不会自动返回自增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\.
翻译过来就是:两个进程竞争锁资源,当前进程被选为死锁牺牲品,事务回滚了。
排查了半天,终于找到原因:批量插入时,多个用户同时导入数据,对同一张订单表产生了间隙锁冲突------批量插入会占用表锁,多个用户同时操作,就会出现锁竞争,进而导致死锁。
解决方案很简单,两步搞定:
-
事务隔离级别降级:把默认的Serializable(串行化)降级为ReadCommitted(读已提交),避免产生不必要的间隙锁;
-
增加死锁重试机制:检测到死锁异常后,延迟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秒 | 代码最简单,上手快 | 性能极差,数据库压力大,高并发必崩 | ❌ 别用(除非数据量<10条) |
| AddRange批量提交 | 6秒 | 代码改动小,易理解,性能提升明显 | 无法处理主表-明细关联(拿不到自增Id) | ⭐⭐(无关联场景可用) |
| 分批 + 关闭跟踪(最终方案) | 2.1秒 | 平衡性能与可维护性,无功能缺陷,内存可控 | 需要手动管理批次,代码稍多 | ⭐⭐⭐⭐(生产环境首选) |
| SqlBulkCopy原生批量 | 0.9秒 | 性能最优,大数据量场景优势明显 | 配置复杂,不返回自增Id,维护成本高 | ⭐⭐⭐(高手用,需结合业务场景) |
八、我后来学到的教训(血的经验)
这次优化,我踩了不少坑,也总结了5条教训,分享给你们,避免你们再走弯路:
-
绝对不要在循环里调用SaveChanges,除非你明确知道数据量极少(比如不到10条),否则就是在给数据库"下毒";
-
批量操作前,一定要关掉EF Core的AutoDetectChanges,亲测这个配置能让EF Core的批量性能提升3倍以上;
-
不要迷信ORM能搞定一切,EF Core虽然方便,但大数据量批量操作时,原生API(SqlBulkCopy)才是王道------但要权衡维护成本;
-
高并发场景下的批量插入,一定要考虑死锁问题,事务隔离级别降级(ReadCommitted)+ 重试机制,是标配;
-
性能优化的顺序很重要:先改代码逻辑(循环→批量)→ 再改框架配置(关闭跟踪、调整事务级别)→ 最后考虑换工具(EF Core→SqlBulkCopy),不要一上来就炫技。
最后
其实很多时候,数据库性能问题,不是因为技术不够牛,而是因为写代码的时候太"随意"------网上找个例子,复制粘贴,不考虑数据量,不做测试,上线后就出问题。
如果你现在项目里也有类似的Excel导入、数据同步、批量ETL逻辑,建议你打开日志看看,说不定也隐藏着一个"循环Insert",在慢慢拖垮你的数据库。
优化这个东西,很多时候不是你不会,是你没去跑一下测试、没去看一眼慢日志。
我是[云中小生],一个踩过批量插入坑的后端。