在教育信息化场景中,"题库批量导入" 是课程认证系统的核心需求之一。学校教师常常需要将包含数千道题的 Excel 文件快速导入系统,但传统的 "直接上传 + 同步解析 + 单条入库" 模式,在面对 大文件(100MB+)、海量数据(万级试题 时,会暴露出 "上传失败率高、内存溢出、入库慢、接口超时" 等问题。
本文结合「基于教学规范的课程认证系统」
的实战经验,分享从文件上传、数据解析、数据库入库到异步解耦的全链路优化方案。
基于教学规范的课程认证系统是我在实验室中开发过的一个项目
一、痛点:传统题库导入的三大瓶颈
在优化前,我们的题库导入流程是 "前端直接上传 Excel → 后端同步解析 → 单条插入 MySQL",遇到了这些核心问题:
- 大文件上传不稳定:100MB 以上的 Excel 文件,因网络波动容易上传中断,且一旦失败需重新上传整个文件。
- 内存溢出风险:数万条试题的 Excel 解析时,传统 POI 会将全表加载到内存,直接触发 OOM(内存溢出)。
- 入库效率极低:单条插入 1 万条数据,需要执行 1 万次 SQL,耗时超 30 分钟,数据库 IO 被打满。
优化一:大文件上传 ------ 分片 + 断点续传(基于阿里云 OSS)
问题分析
大文件 "一次性上传" 的痛点:
- 网络波动导致上传中断即失败,用户体验差;
- 浏览器内存有限,大文件(如 100MB)一次性读取易卡顿甚至崩溃。
解决方案:分片上传 + 断点续传
利用阿里云 OSS 的 "分片上传" 能力,将大文件切割为小片段,分批上传 + 智能续传。
前端实现(Vue3 + OSS JS SDK)
javascript
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名',
});
// 初始化分片上传,获取uploadId
const initRes = await client.multipartUpload(
null, // 先不传文件名,后续合并时指定
file,
{
partSize: 5 * 1024 * 1024, // 每片5MB
progress: (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)
java
@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);
// 返回文件的访问URL
return "https://" + bucketName + ".oss-" + "your-region" + ".aliyuncs.com/" + objectName;
}
}
效果
- 100MB 文件上传成功率从 60% 提升至 99%;
- 网络中断后,可从断点继续上传,无需重传整个文件;
- 前端内存占用从 "直接加载 100MB" 降至 "每次加载 5MB 分片",无卡顿。
优化二:Excel 解析 ------ 流式读取,避免内存溢出
问题分析
传统 POI 的UserModel
模式(如XSSFWorkbook
)会将整个 Excel 加载到内存,当 Excel 包含10 万行数据时,内存占用会爆炸式增长(甚至超过 1GB),直接触发 OOM。
解决方案:EasyExcel 流式解析
使用阿里开源的EasyExcel
,它支持逐行读取 + 回调处理,内存占用极低。
java
@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>() {
// 每解析一行,回调该方法
@Override
public void invoke(QuestionDTO question, AnalysisContext context) {
questionList.add(question);
// 每积累1000条,就临时存到内存(或直接丢给后续批量入库)
if (questionList.size() >= 1000) {
// 这里可直接调用批量入库方法,避免内存堆积
questionService.batchInsert(questionList);
questionList.clear();
}
}
// 解析完成后回调
@Override
public 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 插入。
xml
<!-- 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 条数据,调用一次批量插入:
java
@Service
public class QuestionService {
@Autowired
private 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 查询进度。
- 发送任务到队列
java
@Service
public class ImportTaskService {
@Autowired
private RabbitTemplate rabbitTemplate;
public String createImportTask(String ossFileUrl) {
// 生成唯一任务ID
String taskId = UUID.randomUUID().toString();
// 构造任务消息
ImportTaskMsg msg = new ImportTaskMsg(taskId, ossFileUrl);
// 发送到RabbitMQ队列
rabbitTemplate.convertAndSend("question.import.queue", msg);
return taskId;
}
}
- 消费者处理任务
java
@Component
@RabbitListener(queues = "question.import.queue")
public class ImportTaskConsumer {
@Autowired
private ExcelQuestionParser excelParser;
@Autowired
private QuestionService questionService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@RabbitHandler
public void handleImportTask(ImportTaskMsg msg) {
// 1. 记录任务状态:处理中
redisTemplate.opsForValue().set(
"task:" + msg.getTaskId(),
"{\"status\":\"processing\"}",
24 * 60 * 60, // 缓存1天
TimeUnit.SECONDS
);
try {
// 2. 解析Excel
List<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
);
}
}
}
- 前端查询任务进度
java
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 分钟内";
- 体验:前端无超时、可查进度,教师导入题库更顺畅。
这些优化思路不仅适用于教育系统的题库导入,也能迁移到 "大文件上传、批量数据处理" 的各类业务场景中。