C# 性能优化演进之路:从N+1到百万级数据处理
本文档将以个真实的业务场景——“每日生成查寝记录”为例,完整地展示一个接口从存在严重性能问题,到最终能够稳定、高效地处理百万级数据的全过程。
V1: 未经优化的原始代码 (N+1 查询)
这是优化的起点。代码逻辑直接,但存在严重的性能陷阱。
瓶颈分析:未经培训的服务员
此版本的“服务员”(代码)效率极低。他接到一个大订单后,为订单上的每一道菜(每个寝室)都单独往返一次厨房(数据库)。如果存在2000个寝室,他就要往返厨房2000多次,绝大部分时间都浪费在了网络延迟和无谓的等待上。
完整的“V1”代码
[ApiTask]
[HttpGet, Route("TimingInsertCheckRecord_V1_Original"), AllowAnonymous]
public async Task<IActionResult> TimingInsertCheckRecord_V1_Original()
{
var context = _repository.DbContext;
var now = DateTime.Now;
// 用于收集主表和明细表的所有记录
var dormCheckRecords = new List<school_dorm_check_record>();
var checkAttendanceDetail = new List<school_check_attendance_detail>();
// 第1次查询:获取所有寝室
var dorms = await context.Set<school_dormitory>().ToListAsync();
// 循环开始,N+1问题出现
foreach (var dorm in dorms)
{
// 第N次查询:为每个寝室单独查询学生
var students = await context.Set<school_student>()
.Where(s => s.dormitoryid == dorm.dormitoryid)
.ToListAsync();
if (!students.Any()) continue;
var firstStudent = students.First();
var record = new school_dorm_check_record
{
departmentid = firstStudent.departmentid,
classid = firstStudent.classid,
buildingid = dorm.buildingid,
dormitoryid = dorm.dormitoryid,
hygienicphotos = "待上传",
bedphotos = "待上传",
status = 0,
CreateDate = now
};
dormCheckRecords.Add(record);
}
// 保存主表,此时EF Core会填充每个record的ID
await context.Set<school_dorm_check_record>().AddRangeAsync(dormCheckRecords);
await context.SaveChangesAsync();
// 第二个N+1问题出现
foreach (var record in dormCheckRecords)
{
// 再次为每个寝室查询学生
var students = await context.Set<school_student>()
.Where(s => s.dormitoryid == record.dormitoryid)
.ToListAsync();
foreach (var stu in students)
{
var detail = new school_check_attendance_detail
{
buildingid = record.buildingid,
departmentid = stu.departmentid,
classid = stu.classid,
recordid = record.recordid,
dormitoryid = record.dormitoryid,
studentid = stu.studentid,
attendancestatus = 0,
CreateDate = now
};
checkAttendanceDetail.Add(detail);
}
}
// 保存子表
await context.Set<school_check_attendance_detail>().AddRangeAsync(checkAttendanceDetail);
await context.SaveChangesAsync();
return Json(new { message = "V1 执行完毕,存在严重的N+1性能问题。" });
}
V2: I/O 优化 (全量加载入内存)
这是优化的第一步,也是您当前文件中的实现。它解决了N+1问题,在数据量适中时性能极佳。
瓶颈分析:备餐台虽大但有限
此版本的“服务员”变得非常高效。他只往返厨房两次,就取回了所有需要的原材料(寝室和学生),然后在“备餐台”(内存)上快速整理和加工。
但这个方案有一个致命的假设:备餐台足够大。当学生数据达到百万级时,ToListAsync() 会尝试将所有数据一次性放入内存,导致内存溢出,餐厅直接停业。
完整的“V2”代码
[ApiTask]
[HttpGet, HttpPost, Route("TimingInsertCheckRecord_V2_InMemory"), AllowAnonymous]
public async Task<IActionResult> TimingInsertCheckRecord_V2_InMemory()
{
var context = _repository.DbContext;
var now = DateTime.Now;
// 1. 两次查询,获取所有数据
var allDorms = await context.Set<school_dormitory>().ToListAsync();
var allStudents = await context.Set<school_student>().ToListAsync();
// 2. 在内存中构建高效的查找结构 (ToLookup)
var studentsByDorm = allStudents.ToLookup(s => s.dormitoryid);
var dormCheckRecords = new List<school_dorm_check_record>();
var checkAttendanceDetails = new List<school_check_attendance_detail>();
// 3. 在内存中处理第一个循环
foreach (var dorm in allDorms)
{
var studentsInDorm = studentsByDorm[dorm.dormitoryid];
if (!studentsInDorm.Any()) continue;
var firstStudent = studentsInDorm.First();
var record = new school_dorm_check_record
{
departmentid = firstStudent.departmentid,
classid = firstStudent.classid,
buildingid = dorm.buildingid,
dormitoryid = dorm.dormitoryid,
hygienicphotos = "待上传",
bedphotos = "待上传",
status = 0,
CreateDate = now
};
dormCheckRecords.Add(record);
}
await context.Set<school_dorm_check_record>().AddRangeAsync(dormCheckRecords);
await context.SaveChangesAsync();
// 4. 在内存中处理第二个循环,同样高效
foreach (var record in dormCheckRecords)
{
var studentsInDorm = studentsByDorm[record.dormitoryid];
foreach (var stu in studentsInDorm)
{
checkAttendanceDetails.Add(new school_check_attendance_detail
{
buildingid = record.buildingid,
departmentid = stu.departmentid,
classid = stu.classid,
recordid = record.recordid,
dormitoryid = record.dormitoryid,
studentid = stu.studentid,
attendancestatus = 0,
CreateDate = now
});
}
}
await context.Set<school_check_attendance_detail>().AddRangeAsync(checkAttendanceDetails);
await context.SaveChangesAsync();
return Json(new { message = "V2 执行完毕,I/O性能极佳,但存在内存瓶颈。" });
}
V3: 终极优化 (分批处理,应对海量数据)
这是最终的、最具扩展性的解决方案。它确保了即使在百万甚至千万级数据量下,程序依然能以稳定、可预测的低内存消耗来运行。
解决方案:大厨的新规 (分批处理)
大厨(系统架构师)制定了新规:服务员不再一次性拿回所有原材料,而是分批取货。这样,“备餐台”(内存)永远不会被堆满。
核心思路:
-
获取一个数据量很小的“主键列表”(比如所有寝室的ID)。
-
将这个ID列表分批(例如每批500个)。
-
在循环中,每一轮只获取当前批次ID对应的学生数据。
-
为这一小批数据创建一个临时的、小型的
Lookup。 -
处理完这一批后,进入下一轮循环,之前批次的数据和
Lookup都会被GC回收,内存得到释放。
这样,内存占用永远只和“批次大小”有关,而与总数据量无关,保证了系统的稳定性和可扩展性。
完整的“V3”代码
[ApiTask]
[HttpGet, Route("TimingInsertCheckRecord_V3_Batching"), AllowAnonymous]
public async Task<IActionResult> TimingInsertCheckRecord_V3_Batching()
{
var context = _repository.DbContext;
var now = DateTime.Now;
const int batchSize = 500; // 定义批次大小,可根据实际情况调整
// 1. 先获取所有寝室的ID,ID列表本身很小,可以安全加载到内存
var allDormIds = await context.Set<school_dormitory>().Select(d => d.dormitoryid).ToListAsync();
var allCheckRecords = new List<school_dorm_check_record>();
var allCheckDetails = new List<school_check_attendance_detail>();
// 2. 对ID列表进行分批循环
for (int i = 0; i < allDormIds.Count; i += batchSize)
{
// 3. 取回一小批寝室的ID
var dormIdBatch = allDormIds.Skip(i).Take(batchSize).ToList();
// 4. 只从厨房获取这一小批ID对应的寝室和学生数据
var dormsInBatch = await context.Set<school_dormitory>()
.Where(d => dormIdBatch.Contains(d.dormitoryid))
.AsNoTracking()
.ToListAsync();
var studentsInBatch = await context.Set<school_student>()
.Where(s => s.dormitoryid.HasValue && dormIdBatch.Contains(s.dormitoryid.Value))
.AsNoTracking()
.ToListAsync();
// 5. 为这一小批数据创建一个临时的、小型的Lookup
var studentsByDormInBatch = studentsInBatch.ToLookup(s => s.dormitoryid);
// 6. 在内存中安全地处理这一小批数据
var recordsForThisBatch = new List<school_dorm_check_record>();
foreach (var dorm in dormsInBatch)
{
var studentsInDorm = studentsByDormInBatch[dorm.dormitoryid];
if (!studentsInDorm.Any()) continue;
var firstStudent = studentsInDorm.First();
var record = new school_dorm_check_record
{
departmentid = firstStudent.departmentid,
classid = firstStudent.classid,
buildingid = dorm.buildingid,
dormitoryid = dorm.dormitoryid,
hygienicphotos = "待上传",
bedphotos = "待上传",
status = 0,
CreateDate = now
};
recordsForThisBatch.Add(record);
}
// 7. 将本批次创建的主记录保存,以便获取ID
await context.Set<school_dorm_check_record>().AddRangeAsync(recordsForThisBatch);
await context.SaveChangesAsync();
// 8. 处理本批次的子表
foreach (var record in recordsForThisBatch)
{
var studentsInDorm = studentsByDormInBatch[record.dormitoryid];
foreach (var stu in studentsInDorm)
{
allCheckDetails.Add(new school_check_attendance_detail
{
buildingid = record.buildingid,
departmentid = stu.departmentid,
classid = stu.classid,
recordid = record.recordid,
dormitoryid = record.dormitoryid,
studentid = stu.studentid,
attendancestatus = 0,
CreateDate = now
});
}
}
// 将本批次的主记录也收集起来,用于最终返回
allCheckRecords.AddRange(recordsForThisBatch);
}
// 9. 所有循环结束后,一次性保存所有子表记录
await context.Set<school_check_attendance_detail>().AddRangeAsync(allCheckDetails);
await context.SaveChangesAsync();
return Json(new
{
message = "V3 分批处理执行完毕,系统能稳定处理海量数据。",
totalMain = allCheckRecords.Count,
totalDetail = allCheckDetails.Count
});
}
关键优化点解析
-
ToLookup()的妙用:ToLookup<TKey, TElement>()是 LINQ 中一个非常强大的方法。它会创建一个ILookup<TKey, TElement>实例,类似于Dictionary<TKey, IEnumerable<TElement>>。它将allStudents列表转换成一个以dormitoryid为键,学生列表为值的内存索引。后续通过studentsByDorm[dorm.dormitoryid]来获取学生列表,这是一个 O(1) 的高效操作。 -
SaveChangesAsync()的魔法: 当你调用AddRangeAsync后再调用SaveChangesAsync,EF Core 会智能地将这些操作打包成一个数据库事务,并尽可能地使用数据库的批量插入(Bulk Insert)功能。更重要的是,在SaveChanges之后,所有新插入的dormCheckRecords实体的主键recordid都会被数据库生成的值自动填充,可以直接在后续代码中使用,完美解决了主子表关联的问题。
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐


所有评论(0)