10万人考试成绩导出为什么会拖垮服务器?流式Excel、分页查询与异步导出架构设计 在线考试系统中“成绩导出”看起来只是一个很普通的功能。管理员选择一场考试点击“导出成绩”系统生成一个Excel文件然后下载到本地。如果只有几百名员工这个过程往往没有任何问题。但当系统运行在大型集团、煤矿、制造企业、学校或职业技能竞赛场景中一场考试可能有几千人、几万人累计考试记录甚至达到几十万、上百万条。这时候一个看似简单的“导出Excel”很可能突然变成系统性能问题。常见现象包括点击导出后页面长时间没有响应浏览器请求直接超时Java进程内存突然升高数据库CPU占用明显增加GC频率变高其他正在考试的用户访问变慢导出几万条数据时服务器直接OOM管理员连续点击多次导出系统同时生成多个大文件大型考试结束后多人同时查询、统计、导出服务器压力集中爆发。问题的关键并不是“Excel导出功能有没有”而是当考试数据从1000条增长到10万、50万甚至100万条以后系统还能不能稳定地完成导出同时不影响其他考生正常考试这实际上涉及数据库查询、JVM内存、Excel生成、线程池、异步任务、文件存储等一整套架构设计。一、为什么普通Excel导出在大数据量下容易出问题很多系统早期实现成绩导出时代码逻辑非常直接接收导出请求 ↓ 查询全部成绩 ↓ 把数据放入List ↓ 生成Excel ↓ 写入Response ↓ 浏览器下载对于1000条数据这种实现完全没有问题。真正的问题出现在1000条 ↓ 10000条 ↓ 50000条 ↓ 100000条 ↓ 500000条数据规模每增加一个数量级后端承担的压力都会明显上升。一个完整的考试成绩导出通常并不只有几个字段。例如姓名 工号 部门 岗位 考试名称 考试时间 交卷时间 考试时长 客观题得分 主观题得分 总分 是否通过 考试次数 排名 证书状态如果进一步导出答题明细还可能包含题目 考生答案 标准答案 是否正确 知识点 题型 得分 阅卷结果10万人×几十个字段真正需要生成的数据量已经非常大。二、第一个风险一次性把所有数据查询到内存一种比较常见的写法是ListExamResult list examResultMapper.selectAll(examId);然后for (ExamResult result : list) { // 生成Excel }这段代码本身没有问题。问题在于selectAll到底会返回多少数据假设100000条成绩每条Java对象在数据库字段之外还包含对象头、String对象、引用、集合结构等额外内存占用。最终实际占用的JVM内存通常远大于数据库中看到的数据大小。如果同时还需要进行部门名称转换 岗位名称转换 考试状态转换 成绩计算 排名 时间格式化 证书状态查询甚至出现N1查询整个导出过程就会进一步放大资源消耗。因此大数据量导出的第一个原则应该是不要一次性把全部考试成绩加载到Java内存。三、分页查询把10万条数据拆成1000条一批一种更加稳妥的方式是分页处理。例如每页1000条那么10万条数据只需要执行100页流程变成查询第1页1000条 ↓ 写入Excel ↓ 释放对象 查询第2页1000条 ↓ 写入Excel ↓ 释放对象 …… 查询第100页 ↓ 写入ExcelJava伪代码可以设计成int pageSize 1000; int pageNo 1; while (true) { ListExamResultDTO data examResultService.queryExportData( examId, pageNo, pageSize ); if (data.isEmpty()) { break; } excelWriter.write(data, writeSheet); data.clear(); pageNo; }这样系统内存中始终只保留一个相对较小的数据批次。即使总成绩达到几十万条也不会一次性形成巨大的List。四、但分页查询还有一个坑深分页很多系统最初会使用LIMIT 90000,1000数据量小时没有明显问题。但是当offset越来越大时数据库仍然需要扫描和跳过大量前置数据。因此大型系统中可以进一步考虑使用基于ID游标的分页方式。例如SELECT id, user_name, department_name, score, pass_status FROM exam_result WHERE exam_id ? AND id ? ORDER BY id LIMIT 1000;第一次lastId 0返回1 1000下一次lastId 1000继续查询1001 2000这样可以减少大offset带来的额外查询成本。当然具体采用哪种分页方式还需要结合索引设计 查询条件 排序字段 数据量 数据库类型综合判断。五、第二个风险Excel本身也会占用大量内存有些开发人员解决了数据库分页以后仍然会发现导出10万人时Java内存还是很高。原因可能已经不在数据库而是在Excel生成方式。传统Excel写入方式可能先在内存中构建完整Workbook。也就是说10万行数据虽然数据库已经分100批查询但最终10万行Excel对象仍然全部保留在内存里。分页查询解决的是数据库数据不要一次性进内存。而流式Excel解决的是生成完成的Excel行不要一直留在内存。两个问题必须一起解决。六、流式写入不要等10万行生成完再保存大型考试成绩导出更适合采用Streaming Excel其核心思想就是读取一批数据 ↓ 立即写Excel ↓ 已经写出的数据刷到磁盘 ↓ 继续读取下一批而不是10万条全部放内存 ↓ 构造完整Workbook ↓ 最后一次性保存整体链路可以设计为Database ↓ 1000条数据 ↓ Excel Writer ↓ 临时文件 ↓ 继续下一批最终Database ↓ 分页读取 ↓ 流式写入 ↓ 磁盘临时文件 ↓ 生成完成 ↓ 提供下载这是一种典型的“边读取、边处理、边写入”架构。七、为什么“直接在HTTP请求中生成Excel”仍然不够即便数据库和Excel都已经做了流式处理还有一个问题假设生成10万条数据需要几十秒。用户点击导出HTTP请求会一直保持Request ↓ Database ↓ Excel ↓ Response这会产生几个问题。首先是HTTP超时。例如Nginx超时 网关超时 浏览器超时 应用服务器超时其次是用户体验。管理员点击以后看到页面一直转圈并不知道正在查询 正在生成 系统卡死 已经失败还有一个更加危险的问题管理员以为系统没有响应于是连续点击导出 导出 导出 导出最终后台同时生成4个10万行Excel。这时候系统压力反而进一步放大。因此大规模数据导出最好不要直接绑定HTTP生命周期。八、正确思路把“下载Excel”变成“创建导出任务”企业级系统可以将流程设计成管理员点击导出 ↓ 系统创建ExportTask ↓ 立即返回任务ID ↓ 后台线程执行导出 ↓ 分页查询 ↓ 流式生成Excel ↓ 保存文件 ↓ 更新任务状态 ↓ 管理员进入下载中心 ↓ 下载文件例如创建export_task字段可以包括id task_name user_id business_type business_id status progress file_path file_size create_time start_time finish_time expire_time error_message任务状态可以设计为WAITING PROCESSING SUCCESS FAILED EXPIRED前端不需要一直等待。管理员点击导出以后可以直接提示导出任务已创建请在下载中心查看进度。这时候整个导出过程就从同步HTTP请求变成了后台异步任务。九、推荐的企业级考试成绩导出架构完整链路可以设计为管理员 ↓ 成绩查询页面 ↓ 创建导出任务 ↓ ExportTask ↓ 任务队列 / 线程池 ↓ 分页查询ExamResult ↓ 流式Excel Writer ↓ 临时文件 ↓ 文件存储 ↓ 更新任务SUCCESS ↓ 下载中心 ↓ 管理员下载如果系统规模进一步扩大还可以增加Redis MQ 对象存储 任务调度 文件生命周期管理最终架构可以演变为Web Server ↓ Export Service ↓ Message Queue ↓ Export Worker ↓ Database ↓ Excel Streaming ↓ File Storage这样导出任务甚至可以与核心考试服务进行一定程度的资源隔离。十、为什么考试系统尤其需要考虑“资源隔离”普通后台管理系统中导出慢一点可能只是影响管理员。考试系统不一样。因为同一时间可能有5000名考生答题 10000名考生提交答案 管理员实时查看监考 后台统计考试进度 系统不断保存答题记录与此同时考试刚结束。另外一批管理员开始查成绩 看排名 生成统计 导出Excel这就意味着核心考试业务和后台统计业务可能同时争抢资源。例如数据库连接 CPU 内存 磁盘IO 线程池因此大型在线考试系统在设计时应该明确区分考试核心链路和报表统计链路优先保证登录 获取试卷 保存答案 自动保存 提交试卷不能因为有人导出一份大Excel导致正在参加考试的人出现卡顿。这是考试系统与普通OA后台非常重要的区别。十一、数据库索引同样非常关键如果成绩导出SQL本身没有合理索引那么即使已经分页仍然可能给数据库造成很大压力。例如WHERE exam_id ? ORDER BY id则需要根据实际情况分析exam_id索引 id索引 exam_id id联合索引如果还要按照部门 岗位 通过状态 考试时间筛选就不能看到一个字段就机械地增加一个索引。需要通过实际SQL执行计划分析查询扫描行数 索引命中情况 排序 回表 临时表最终再决定索引策略。否则非常容易出现另一种情况Excel代码已经优化得很好但SQL查询本身需要30秒。十二、不要在循环里面查询部门和用户另外一个非常常见的问题是N1查询。例如for (ExamResult result : results) { User user userService.getById(result.getUserId()); Department department departmentService.getById(user.getDepartmentId()); }如果有10万条成绩可能导致1次成绩查询 10万次人员查询 10万次部门查询这种代码在500条数据时可能感觉不到。到了10万条以后性能会迅速恶化。更合理的方式是通过JOIN 批量查询 本地Map Redis缓存等方式解决。例如一次查询1000条成绩 ↓ 收集1000个userId ↓ 批量查询人员 ↓ 构建UserMap ↓ 组装导出DTO一定不要简单地for循环里面不断访问数据库。十三、导出任务还要考虑“重复点击”很多性能问题并不是单次导出造成的。而是一个管理员连续点击5次。或者5个管理员同时导出同一场10万人考试。理论上系统可能同时执行5 × 100000的数据读取和Excel生成任务。因此还可以增加重复任务控制。例如生成taskKey userId examId exportType queryConditionHash在任务仍然处于WAITING PROCESSING状态时再次点击可以提示当前已有相同导出任务正在处理中。而不是重复创建。这种设计看起来只是一个小细节但对于大型企业系统来说非常重要。十四、线程池不能无限开异步导出并不意味着来一个任务就新建一个线程。如果同时来了50个导出请求系统就创建50个大数据导出线程结果一样可能把数据库拖垮。因此建议独立配置导出线程池。例如corePoolSize 2 maxPoolSize 4 queueCapacity 100意味着最多4个大型导出同时运行其他任务进入队列。这样即使突然有几十个管理员同时导出也不会瞬间把服务器资源吃满。核心原则是宁可让后台导出排队也不能让正式考试卡顿。十五、进度到底怎么计算异步任务还有一个用户体验问题管理员希望看到正在生成 35%可以首先查询总数量SELECT COUNT(*) FROM exam_result WHERE exam_id ?假设total 100000已经生成35000那么progress 35%每完成一批可以更新一次10% 20% 30% ……但不要每处理一条记录都更新数据库。否则生成10万行Excel又产生10万次UPDATE反而制造新的性能问题。可以每完成1000条或者每隔1~3秒更新一次任务进度。十六、生成好的文件不能永久保留如果系统每天生成大量Excel100MB 200MB 500MB一年以后服务器可能堆积大量历史导出文件。因此下载中心还需要设计expire_time例如生成后7天自动删除后台定时任务扫描过期文件 ↓ 删除物理文件 ↓ 任务状态修改为EXPIRED管理员如果需要可以重新生成。这也是企业系统经常被忽略的一项设计。十七、一个完整的导出任务应该记录哪些日志对于企业培训考试系统来说数据导出本身也应该进入审计范围。建议至少记录谁导出的 什么时候导出的 导出了哪场考试 筛选条件是什么 导出多少条 文件大小 是否成功 耗时多少 下载时间例如管理员张三 考试2026年度安全生产考试 人数98632 创建时间10:03:21 完成时间10:05:48 耗时147秒 文件大小82MB 状态SUCCESS这样以后如果出现谁导出了员工成绩 谁导出了人员信息系统能够追溯。这不仅是性能设计也是数据安全设计。十八、以宏远培训考试系统为例为什么不能只考虑“导不导得出来”以企业级培训考试平台的设计思路来看宏远培训考试系统所面对的并不仅是单场考试成绩。随着系统长期运行后台通常还需要处理人员档案 课程学习记录 培训计划记录 练习数据 正式考试成绩 答题明细 证书记录 部门统计 岗位统计 年度培训数据因此真正有价值的并不是简单增加一个“导出Excel”按钮。而是要从系统整体稳定性角度考虑大数据查询、分页读取、文件生成、任务排队、权限控制、下载记录和操作日志之间的关系。例如在大型企业使用场景中更合理的设计逻辑应该是管理端发起报表请求 ↓ 系统判断数据规模 ↓ 小数据直接导出 ↓ 大数据自动转入后台任务 ↓ 分页读取 ↓ 流式生成 ↓ 下载中心统一管理这类架构的优势并不是让管理员感觉“多了一个功能”而是当系统中的人员从1000人增加到1万人、5万人历史培训考试记录从几万条增加到几百万条以后系统仍然能够保持相对稳定的处理方式。对于长期运行的企业培训考试平台来说这往往比某一个页面增加多少按钮更加重要。宏远培训考试系统在实际产品设计中更强调培训、考试、人员、证书和档案数据之间的统一管理。对于大规模企业应用而言这类数据最终都会进入统计、查询和报表体系因此在架构层面对后台数据处理能力进行控制是系统长期稳定运行的重要基础。十九、哪些情况可以同步导出哪些应该异步导出并不是所有导出都必须异步。例如200条 500条 1000条完全可以直接下载。因此可以设计一个简单策略。例如≤ 5000条采用同步流式导出超过5000条自动进入异步导出任务如果达到10万条以上还可以进一步提醒管理员数据量较大系统将在后台生成文件。具体阈值不能机械照搬。需要根据服务器配置 数据库性能 字段数量 Excel复杂度 查询复杂度 并发数量通过实际压测确定。二十、一个成熟的大数据导出方案至少应该解决这8个问题总结下来一套真正适合大型培训考试系统的Excel导出机制至少应该考虑1. 数据库不能一次加载全部数据采用分页查询 游标查询2. Excel不能全部放内存采用Streaming Write3. 大任务不要绑定HTTP请求采用异步ExportTask4. 并发任务必须限流采用独立线程池 任务队列5. 防止重复导出采用TaskKey 幂等控制6. 文件必须有生命周期采用ExpireTime 定期清理7. 导出操作必须有日志记录Who When What How Many Result8. 导出任务不能影响正式考试这是最重要的一点。系统资源优先级应该始终是答题保存 提交试卷 考试Session高于报表 统计 Excel导出二十一、结语在线考试系统最容易被低估的性能问题往往并不是登录页面也不是试题展示页面。很多问题真正出现的时间反而是在考试结束以后。几万人同时交卷之后管理员开始查询成绩 统计排名 分析通过率 导出报表 生成证书大量后台任务会在短时间内集中出现。如果系统仍然使用SELECT * ↓ List ↓ Workbook ↓ Response这种适合小数据量的实现方式当数据规模不断扩大以后系统迟早会遇到性能瓶颈。因此大型培训考试系统真正合理的成绩导出架构应该是导出请求 ↓ 任务创建 ↓ 异步执行 ↓ 分页查询 ↓ 流式写Excel ↓ 文件存储 ↓ 下载中心 ↓ 日志审计从技术角度看这只是一个Excel导出功能。但从系统架构角度看它体现的其实是另外一个问题系统设计到底是按照“现在只有1000人”来做还是按照“未来可能有10万人”来做。对于需要长期运行的企业培训考试平台来说后者显然更加重要。