题库批量(文件)导入的全链路优化实践
在教育信息化场景中,“题库批量导入” 是课程认证系统的核心需求之一。学校教师常常需要将包含数千道题的 Excel 文件快速导入系统,但传统的 “直接上传 + 同步解析 + 单条入库” 模式,在面对 大文件(100MB+)、海量数据(万级试题 时,会暴露出 “上传失败率高、内存溢出、入库慢、接口超时” 等问题。
本文结合「基于教学规范的课程认证系统」
的实战经验,分享从文件上传、数据解析、数据库入库到异步解耦的全链路优化方案。
基于教学规范的课程认证系统是我在实验室中开发过的一个项目
一、痛点:传统题库导入的三大瓶颈
在优化前,我们的题库导入流程是 “前端直接上传 Excel → 后端同步解析 → 单条插入 MySQL”,遇到了这些核心问题:
- 大文件上传不稳定:100MB 以上的 Excel 文件,因网络波动容易上传中断,且一旦失败需重新上传整个文件。
- 内存溢出风险:数万条试题的 Excel 解析时,传统 POI 会将全表加载到内存,直接触发 OOM(内存溢出)。
- 入库效率极低:单条插入 1 万条数据,需要执行 1 万次 SQL,耗时超 30 分钟,数据库 IO 被打满。
优化一:大文件上传 —— 分片 + 断点续传(基于阿里云 OSS)
问题分析
大文件 “一次性上传” 的痛点:
- 网络波动导致上传中断即失败,用户体验差;
- 浏览器内存有限,大文件(如 100MB)一次性读取易卡顿甚至崩溃。
解决方案:分片上传 + 断点续传
利用阿里云 OSS 的 “分片上传” 能力,将大文件切割为小片段,分批上传 + 智能续传。
前端实现(Vue3 + OSS JS SDK)
import OSS from 'ali-oss';
import SparkMD5 from 'spark-md5';// 1. 计算文件MD5(用于断点续传标识)
const calculateFileMD5 = (file) => {return new Promise((resolve) => {const spark = new SparkMD5.ArrayBuffer();const fileReader = new FileReader();fileReader.onload = (e) => {spark.append(e.target.result);resolve(spark.end());};fileReader.readAsArrayBuffer(file.slice(0, 1024 * 1024)); // 取前1MB计算MD5(平衡效率与唯一性)});
};// 2. 分片上传核心逻辑
const multipartUpload = async (file, md5) => {const client = new OSS({region: '你的OSS区域',accessKeyId: '你的AccessKeyId',accessKeySecret: '你的AccessKeySecret',bucket: '你的Bucket名',});// 初始化分片上传,获取uploadIdconst initRes = await client.multipartUpload(null, // 先不传文件名,后续合并时指定file,{partSize: 5 * 1024 * 1024, // 每片5MBprogress: (p, done) => {console.log(`上传进度:${p * 100}%`);// 前端进度条更新逻辑},async checkpoint(checkpoint) {// 记录断点(实际项目中可存到 localStorage 或后端)localStorage.setItem('ossCheckpoint', JSON.stringify(checkpoint));},});return initRes.res.requestUrls[0]; // 返回文件访问URL
};// 3. 页面调用:先算MD5,再上传
const handleFileUpload = async (file) => {const md5 = await calculateFileMD5(file);// 调用后端接口,查询该MD5是否已上传过分片const { hasUploaded, checkpoint } = await fetch(`/api/oss/check?md5=${md5}`).then(res => res.json());let resultUrl = '';if (hasUploaded) {// 断点续传:用已有的checkpoint继续上传const client = new OSS({/* 配置同上 */});const resumeRes = await client.multipartUpload(file.name, file, { partSize: 5 * 1024 * 1024,progress: (p, done) => {/* 进度更新 */},checkpoint: JSON.parse(checkpoint),});resultUrl = resumeRes.res.requestUrls[0];} else {// 全新上传resultUrl = await multipartUpload(file, md5);}// 上传完成,通知后端处理该文件await fetch('/api/question/import/task', {method: 'POST',body: JSON.stringify({ fileUrl: resultUrl, md5 }),});
};
后端实现(SpringBoot + OSS Java SDK)
@Service
public class OSSService {private final OSS ossClient;public OSSService(OSSClientBuilder ossClientBuilder) {// 初始化OSS客户端(建议用配置中心管理参数)this.ossClient = ossClientBuilder.build("your-region", "your-accessKeyId", "your-accessKeySecret");}/*** 检查文件是否已上传过分片*/public CheckpointDTO checkMultipart(String md5) {// 实际项目中,用md5为key,在Redis中查询是否有未完成的分片记录String checkpointJson = redisTemplate.opsForValue().get("oss:checkpoint:" + md5);if (checkpointJson != null) {return new CheckpointDTO(true, checkpointJson);}return new CheckpointDTO(false, "");}/*** 合并分片,生成完整文件*/public String completeMultipart(String bucketName, String objectName, String uploadId, List<PartETag> partETags) {// 调用OSS SDK合并分片CompleteMultipartUploadRequest request = new CompleteMultipartUploadRequest(bucketName, objectName, uploadId, partETags);ossClient.completeMultipartUpload(request);// 返回文件的访问URLreturn "https://" + bucketName + ".oss-" + "your-region" + ".aliyuncs.com/" + objectName;}
}
效果
- 100MB 文件上传成功率从 60% 提升至 99%;
- 网络中断后,可从断点继续上传,无需重传整个文件;
- 前端内存占用从 “直接加载 100MB” 降至 “每次加载 5MB 分片”,无卡顿。
优化二:Excel 解析 —— 流式读取,避免内存溢出
问题分析
传统 POI 的UserModel
模式(如XSSFWorkbook
)会将整个 Excel 加载到内存,当 Excel 包含10 万行数据时,内存占用会爆炸式增长(甚至超过 1GB),直接触发 OOM。
解决方案:EasyExcel 流式解析
使用阿里开源的EasyExcel
,它支持逐行读取 + 回调处理,内存占用极低。
@Service
public class ExcelQuestionParser {/*** 流式解析Excel,逐行转换为试题对象*/public List<QuestionDTO> parseExcel(String ossFileUrl) {List<QuestionDTO> questionList = new ArrayList<>();// 从OSS下载文件到临时流(也可直接流式读取,需OSS SDK支持)InputStream inputStream = ossClient.getObject(new GetObjectRequest(bucketName, objectName)).getObjectContent();// EasyExcel监听每行数据EasyExcel.read(inputStream, QuestionDTO.class, new AnalysisEventListener<QuestionDTO>() {// 每解析一行,回调该方法@Overridepublic void invoke(QuestionDTO question, AnalysisContext context) {questionList.add(question);// 每积累1000条,就临时存到内存(或直接丢给后续批量入库)if (questionList.size() >= 1000) {// 这里可直接调用批量入库方法,避免内存堆积questionService.batchInsert(questionList);questionList.clear();}}// 解析完成后回调@Overridepublic void doAfterAllAnalysed(AnalysisContext context) {// 处理剩余不足1000条的数据if (!questionList.isEmpty()) {questionService.batchInsert(questionList);}}}).sheet().doRead();return questionList;}
}// 试题DTO示例
@Data
public class QuestionDTO {private String title; // 题目内容private String options; // 选项(JSON格式)private String answer; // 答案private Long courseId; // 所属课程ID// 其他字段...
}
效果
- 解析 10 万行的 Excel,内存占用从 1GB + 降至50MB 以内;
- 解析速度从 “分钟级” 提升至 “秒级”(因无需加载全表)。
优化三:数据库入库 —— 批量插入,减少 IO
问题分析
单条插入 SQL(如INSERT INTO question VALUES (...)
)每次都要与数据库建立连接、执行、关闭,1 万条数据需要 1 万次 IO,效率极低。
解决方案:MyBatis 批量插入
利用 MyBatis 的标签,将多条数据合并为一条 SQL 插入。
<!-- QuestionMapper.xml 中的批量插入语句 -->
<insert id="batchInsert" parameterType="java.util.List">INSERT INTO question (title, options, answer, course_id)VALUES<foreach collection="list" item="item" separator=",">(#{item.title}, #{item.options}, #{item.answer}, #{item.courseId})</foreach>
</insert>
Java 代码中,每积累 1000 条数据,调用一次批量插入:
@Service
public class QuestionService {@Autowiredprivate QuestionMapper questionMapper;public void batchInsert(List<QuestionDTO> questionList) {// 分批插入,每批1000条(避免SQL过长)int batchSize = 1000;for (int i = 0; i < questionList.size(); i += batchSize) {List<QuestionDTO> subList = questionList.subList(i, Math.min(i + batchSize, questionList.size()));questionMapper.batchInsert(subList);}}
}
效果
- 1 万条试题入库时间从30 分钟 +缩短至1 分钟内;
- 数据库 IO 压力显著降低,其他业务不受影响。
优化四:异步解耦 —— 消息队列避免接口超时
问题分析
“上传→解析→入库” 全流程若同步执行,大文件可能耗时数分钟,导致前端请求超时(一般接口超时时间为 30 秒内)。
解决方案:RabbitMQ 异步处理
将 “题库导入” 作为异步任务,丢入消息队列,前端只需接收 “任务已提交” 的响应,后续通过任务 ID 查询进度。
- 发送任务到队列
@Service
public class ImportTaskService {@Autowiredprivate RabbitTemplate rabbitTemplate;public String createImportTask(String ossFileUrl) {// 生成唯一任务IDString taskId = UUID.randomUUID().toString();// 构造任务消息ImportTaskMsg msg = new ImportTaskMsg(taskId, ossFileUrl);// 发送到RabbitMQ队列rabbitTemplate.convertAndSend("question.import.queue", msg);return taskId;}
}
- 消费者处理任务
@Component
@RabbitListener(queues = "question.import.queue")
public class ImportTaskConsumer {@Autowiredprivate ExcelQuestionParser excelParser;@Autowiredprivate QuestionService questionService;@Autowiredprivate RedisTemplate<String, String> redisTemplate;@RabbitHandlerpublic void handleImportTask(ImportTaskMsg msg) {// 1. 记录任务状态:处理中redisTemplate.opsForValue().set("task:" + msg.getTaskId(), "{\"status\":\"processing\"}",24 * 60 * 60, // 缓存1天TimeUnit.SECONDS);try {// 2. 解析ExcelList<QuestionDTO> questionList = excelParser.parseExcel(msg.getOssFileUrl());// 3. 批量入库(解析时已调用batchInsert,这里可补充最终状态)redisTemplate.opsForValue().set("task:" + msg.getTaskId(), "{\"status\":\"success\", \"count\":" + questionList.size() + "}",24 * 60 * 60, TimeUnit.SECONDS);} catch (Exception e) {// 4. 异常处理,记录失败原因redisTemplate.opsForValue().set("task:" + msg.getTaskId(), "{\"status\":\"failed\", \"error\":\"" + e.getMessage() + "\"}",24 * 60 * 60, TimeUnit.SECONDS);}}
}
- 前端查询任务进度
const getTaskProgress = async (taskId) => {const res = await fetch(`/api/task/${taskId}`);return res.json(); // 示例返回:{ status: "success", count: 12345 }
};
效果
- 前端上传后立即收到响应(耗时 < 100ms),无超时风险;
- 后端异步处理,充分利用服务器资源,多任务可并行执行;
- 用户可通过任务 ID 实时查询导入进度,体验更友好。
总结:全链路优化的收益
通过 “分片上传(OSS)→ 流式解析(EasyExcel)→ 批量入库(MyBatis)→ 异步解耦(RabbitMQ)” 的全链路优化,我们实现了:
- 稳定性:100MB + 文件上传成功率达 99%,大 Excel 解析无 OOM 风险;
- 效率:万级试题导入从 “30 分钟 +” 缩短至 “1 分钟内”;
- 体验:前端无超时、可查进度,教师导入题库更顺畅。
这些优化思路不仅适用于教育系统的题库导入,也能迁移到 “大文件上传、批量数据处理” 的各类业务场景中。