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

在教育信息化场景中,"题库批量导入" 是课程认证系统的核心需求之一。学校教师常常需要将包含数千道题的 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 分钟内";
  • 体验:前端无超时、可查进度,教师导入题库更顺畅。

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

相关推荐
程序员飞哥3 小时前
如何设计多级缓存架构并解决一致性问题?
java·后端·面试
一只小松许️3 小时前
深入理解:Rust 的内存模型
java·开发语言·rust
CS Beginner4 小时前
【Linux】Mysql的基本文件组成和配置
linux·运维·mysql
前端小马4 小时前
前后端Long类型ID精度丢失问题
java·前端·javascript·后端
Lisonseekpan4 小时前
Java Caffeine 高性能缓存库详解与使用案例
java·后端·spring·缓存
点灯小铭4 小时前
基于单片机的自动存包柜设计
数据库·单片机·mongodb·毕业设计·课程设计
失散134 小时前
软件设计师——09 数据库技术基础
数据库·软考·软件设计师
SXJR4 小时前
Spring前置准备(七)——DefaultListableBeanFactory
java·spring boot·后端·spring·源码·spring源码·java开发
养生技术人4 小时前
Oracle OCP认证考试题目详解082系列第53题
数据库·sql·oracle·database·开闭原则·ocp