本文档将以个真实的业务场景——“每日生成查寝记录”为例,完整地展示一个接口从存在严重性能问题,到最终能够稳定、高效地处理百万级数据的全过程。

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: 终极优化 (分批处理,应对海量数据)

这是最终的、最具扩展性的解决方案。它确保了即使在百万甚至千万级数据量下,程序依然能以稳定、可预测的低内存消耗来运行。

解决方案:大厨的新规 (分批处理)

大厨(系统架构师)制定了新规:服务员不再一次性拿回所有原材料,而是分批取货。这样,“备餐台”(内存)永远不会被堆满。

核心思路:

  1. 获取一个数据量很小的“主键列表”(比如所有寝室的ID)。

  2. 将这个ID列表分批(例如每批500个)。

  3. 在循环中,每一轮只获取当前批次ID对应的学生数据

  4. 为这一小批数据创建一个临时的、小型的 Lookup

  5. 处理完这一批后,进入下一轮循环,之前批次的数据和 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 都会被数据库生成的值自动填充,可以直接在后续代码中使用,完美解决了主子表关联的问题。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐