题库批量(文件)导入的全链路优化实践

在教育信息化场景中,"题库批量导入" 是课程认证系统的核心需求之一。学校教师常常需要将包含数千道题的 Excel 文件快速导入系统,但传统的 "直接上传 + 同步解析 + 单条入库" 模式,在面对 大文件(100MB+)、海量数据(万级试题 时,会暴露出 "上传失败率高、内存溢出、入库慢、接口超时" 等问题。

本文结合「基于教学规范的课程认证系统」的实战经验,分享从文件上传、数据解析、数据库入库到异步解耦的全链路优化方案。

基于教学规范的课程认证系统是我在实验室中开发过的一个项目

一、痛点:传统题库导入的三大瓶颈

在优化前,我们的题库导入流程是 "前端直接上传 Excel → 后端同步解析 → 单条插入 MySQL",遇到了这些核心问题:

  1. 大文件上传不稳定:100MB 以上的 Excel 文件,因网络波动容易上传中断,且一旦失败需重新上传整个文件。
  2. 内存溢出风险:数万条试题的 Excel 解析时,传统 POI 会将全表加载到内存,直接触发 OOM(内存溢出)。
  3. 入库效率极低:单条插入 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 查询进度。

  1. 发送任务到队列
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;
    }
}
  1. 消费者处理任务
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
            );
        }
    }
}
  1. 前端查询任务进度
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 分钟内";
  • 体验:前端无超时、可查进度,教师导入题库更顺畅。

这些优化思路不仅适用于教育系统的题库导入,也能迁移到 "大文件上传、批量数据处理" 的各类业务场景中。

相关推荐
王卫东16 分钟前
深入HBase:原理剖析与优化实战
大数据·数据库·hbase
呆呆小金人32 分钟前
SQL键类型详解:超键到外键全解析
大数据·数据库·数据仓库·sql·数据库开发·etl·etl工程师
9523641 分钟前
数据结构-顺序表
java·数据结构·学习
chxii44 分钟前
Apache Tomcat 介绍
java·tomcat·apache
Doro再努力44 分钟前
Neo4j图数据库:简述增删改查
数据库·neo4j
码界奇点1 小时前
Java Web学习 第1篇前端基石HTML 入门与核心概念解析
java·前端·学习·xhtml
.ZGR.1 小时前
蓝桥杯高校新生编程赛第二场题解——Java
java·算法·蓝桥杯
陈果然DeepVersion1 小时前
Java大厂面试真题:Spring Boot+Kafka+AI智能客服场景全流程解析(四)
java·spring boot·微服务·kafka·面试题·rag·ai智能客服
好学且牛逼的马1 小时前
【JavaWeb|day16 Web前端基础】
java
dlhto1 小时前
Oracle Linux 9 的 MySQL 8.0 完整安装与远程连接配置
linux·mysql·oracle