Spring Boot 实现分片上传+断点续传+实时进度条,彻底解决大文件上传痛点!

3 阅读11分钟

Spring Boot 实现分片上传+断点续传+实时进度条,彻底解决大文件上传痛点!

大家好,我是你们的技术博主~平时做项目或者日常使用网盘、后台管理系统时,肯定都遇到过这种糟心场景:

上传几个G的视频、安装包或者备份文件,传了一半网络断了、页面刷新了,只能从头重新上传;漫长的上传过程中,只有一个转圈加载动画,完全不知道传了多少、还要等多久;大文件直接整包上传,服务器压力拉满,还容易超时失败。

这些问题,其实靠分片上传+断点续传+实时进度条就能完美解决!今天就带大家从零到一,用 Spring Boot 后端搭配简易前端,完整实现这套实用功能,代码可直接移植到项目里,新手也能看懂上手~


一、核心原理先搞懂,知其然更知其所以然

很多同学一上来就敲代码,结果遇到bug不知道根源在哪,咱们先把核心逻辑捋清楚,后续写代码思路会特别顺。

1. 分片上传:把大文件“拆碎了”传

分片上传的核心,就是把一个大文件按照固定大小切割成若干个小分片,比如每片5MB、10MB,然后逐个上传这些小分片,而不是一次性上传整个大文件。

这样做的好处特别明显:单个分片体积小,上传失败概率大幅降低,就算某一片传失败了,只需要重传这一片,不用动其他已经传好的;服务器接收小分片,内存和IO压力小很多,不会出现大文件上传导致的服务卡顿、超时问题;还能支持多线程并行上传分片,进一步提速。

所有分片上传完成后,后端再按照分片序号,把这些小分片按顺序合并成完整的原始文件,整个流程就完成了。

2. 断点续传:断网刷新也不怕,接着上次进度传

断点续传是在分片上传基础上做的优化,核心是记录已上传成功的分片信息,避免重复上传。

具体逻辑:上传文件前,先给文件生成一个唯一标识(通常用文件MD5值,文件内容不变,MD5就不变,精准标识文件);每次上传前,后端根据这个唯一标识,查询已经上传成功的分片列表;前端拿到已上传分片后,跳过这些分片,只上传剩下未完成的,就算中途断网、关闭页面,下次上传同一个文件,直接从断点继续,不用从头再来。

3. 实时进度条:上传进度看得见,告别盲等

进度条实现不难,核心是前后端配合计算上传比例:前端统计已成功上传的分片数量/总分片数量,或者已上传字节数/总文件字节数,实时计算进度百分比,同步渲染到页面进度条;后端也可以缓存上传进度,支持前端主动查询,就算页面刷新,也能回显上次进度,体验更丝滑。

核心关键点总结:文件唯一标识(MD5)+ 分片存储 + 已上传分片记录 + 后端合并分片 + 进度实时计算,这五个点是整套功能的灵魂,缺一不可。


二、项目环境搭建,极简配置不折腾

这套方案采用Spring Boot 2.7.x做后端(稳定兼容,大部分公司都在用),前端用原生HTML+JS+Axios实现,不用搭建Vue/React脚手架,打开浏览器就能跑,测试起来特别方便。

后端 Spring Boot 项目搭建

1. 核心依赖(pom.xml)

只需要引入基础Web依赖和文件操作相关工具,不用多余依赖,轻量无负担:

<!-- Spring Web -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- lombok 简化实体类 -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

<!-- commons-lang3 工具类 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>

2. application.yml 配置

主要配置文件上传大小限制、临时分片存储路径、文件最终存储路径,避免上传报错:

server:
  port: 8080
spring:
  servlet:
    multipart:
      # 单个分片文件大小限制
      max-file-size: 10MB
      # 单次请求大小限制
      max-request-size: 100MB
# 自定义配置
upload:
  # 临时分片存储目录
  chunk-path: D:/upload/chunk
  # 最终完整文件存储目录
  file-path: D:/upload/file

提前手动创建对应的文件夹,避免后端启动报错找不到路径~

前端准备

前端只需要一个HTML页面,引入Axios做接口请求,引入spark-md5计算文件MD5(用于生成文件唯一标识),全程原生代码,逻辑清晰,方便修改适配自己的页面。


三、后端实战代码,核心接口全实现

后端主要实现四大核心接口,覆盖整个上传流程:文件MD5校验(秒传+断点查询)、分片上传接口、分片合并接口、上传进度查询接口

1. 自定义配置类读取路径

用ConfigurationProperties读取配置文件里的存储路径,方便后续复用:

@Component
@ConfigurationProperties(prefix = "upload")
@Data
public class UploadProperties {
    /**
     * 分片存储路径
     */
    private String chunkPath;
    /**
     * 完整文件存储路径
     */
    private String filePath;
}

2. 分片上传DTO(接收前端参数)

接收前端传递的分片相关参数,包含文件唯一标识、分片序号、总分片数、分片文件等:

@Data
public class ChunkUploadDTO {
    /**
     * 文件唯一标识(MD5值)
     */
    private String fileMd5;
    /**
     * 文件名
     */
    private String fileName;
    /**
     * 当前分片索引(从0开始)
     */
    private Integer chunkIndex;
    /**
     * 总分片数
     */
    private Integer totalChunks;
    /**
     * 分片文件
     */
    private MultipartFile chunkFile;
}

3. 核心上传Controller(四大接口)

这是整个后端的核心,把所有逻辑封装在Controller里,业务简单的场景直接用,复杂业务可以抽离Service层:

@RestController
@RequestMapping("/upload")
@CrossOrigin(origins = "*") // 跨域配置,本地测试方便
@RequiredArgsConstructor
public class UploadController {

    private final UploadProperties uploadProperties;

    /**
     * 1. 检查文件是否已上传(秒传)+ 查询已上传分片(断点续传)
     */
    @GetMapping("/check")
    public R check(String fileMd5, String fileName) {
        // 构建完整文件路径
        File targetFile = new File(uploadProperties.getFilePath(), fileName);
        // 如果文件已存在,直接返回秒传
        if (targetFile.exists()) {
            return R.success().message("文件已存在,秒传成功!").data("exists", true);
        }
        // 如果文件不存在,查询已上传的分片
        File chunkDir = new File(uploadProperties.getChunkPath(), fileMd5);
        List<Integer> uploadedChunks = new ArrayList<>();
        if (chunkDir.exists()) {
            File[] chunks = chunkDir.listFiles();
            if (chunks != null) {
                for (File chunk : chunks) {
                    // 分片文件名是索引,解析成数字
                    uploadedChunks.add(Integer.parseInt(chunk.getName()));
                }
            }
        }
        return R.success().data("uploadedChunks", uploadedChunks).data("exists", false);
    }

    /**
     * 2. 分片上传接口
     */
    @PostMapping("/chunk")
    public R uploadChunk(ChunkUploadDTO dto) throws IOException {
        // 分片存储目录:按文件MD5创建文件夹,隔离不同文件的分片
        File chunkDir = new File(uploadProperties.getChunkPath(), dto.getFileMd5());
        if (!chunkDir.exists()) {
            chunkDir.mkdirs();
        }
        // 分片文件:以分片索引为文件名
        File chunkFile = new File(chunkDir, dto.getChunkIndex().toString());
        // 写入分片文件
        dto.getChunkFile().transferTo(chunkFile);
        return R.success().message("分片上传成功");
    }

    /**
     * 3. 合并分片接口
     */
    @PostMapping("/merge")
    public R merge(String fileMd5, String fileName, Integer totalChunks) throws IOException {
        File chunkDir = new File(uploadProperties.getChunkPath(), fileMd5);
        File targetFile = new File(uploadProperties.getFilePath(), fileName);
        if (!targetFile.getParentFile().exists()) {
            targetFile.getParentFile().mkdirs();
        }
        // 创建文件输出流,按顺序合并分片
        try (FileOutputStream fos = new FileOutputStream(targetFile)) {
            byte[] buffer = new byte[1024 * 1024];
            // 按分片索引从小到大合并
            for (int i = 0; i < totalChunks; i++) {
                File chunkFile = new File(chunkDir, String.valueOf(i));
                if (!chunkFile.exists()) {
                    return R.error().message("分片缺失,合并失败");
                }
                // 读取分片写入目标文件
                try (FileInputStream fis = new FileInputStream(chunkFile)) {
                    int len;
                    while ((len = fis.read(buffer)) != -1) {
                        fos.write(buffer, 0, len);
                    }
                }
                // 合并后删除分片(可选,节省磁盘空间)
                chunkFile.delete();
            }
        }
        // 删除空的分片目录
        chunkDir.delete();
        return R.success().message("文件合并成功");
    }

    /**
     * 4. 查询上传进度
     */
    @GetMapping("/progress")
    public R getProgress(String fileMd5, Integer totalChunks) {
        File chunkDir = new File(uploadProperties.getChunkPath(), fileMd5);
        int uploadedCount = 0;
        if (chunkDir.exists()) {
            File[] chunks = chunkDir.listFiles();
            uploadedCount = chunks != null ? chunks.length : 0;
        }
        // 计算进度百分比
        int progress = (int) ((uploadedCount * 1.0 / totalChunks) * 100);
        return R.success().data("progress", progress);
    }
}

4. 统一返回结果类R

标准化接口返回格式,前端处理更方便:

@Data
public class R {
    private Integer code;
    private String message;
    private Map<String, Object> data = new HashMap<>();

    public static R success() {
        R r = new R();
        r.setCode(200);
        return r;
    }

    public static R error() {
        R r = new R();
        r.setCode(500);
        return r;
    }

    public R message(String message) {
        this.setMessage(message);
        return this;
    }

    public R data(String key, Object value) {
        this.data.put(key, value);
        return this;
    }
}

四、前端极简实现,进度条+断点续传一步到位

前端核心做这几件事:选择文件 → 计算MD5 → 查询已上传分片(断点)→ 分片切割 → 并行上传 → 实时更新进度 → 合并分片。

直接贴完整HTML代码,新建一个html文件,替换接口地址就能运行:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>大文件分片上传</title>
    <style>
        .upload-box {width: 600px;margin: 50px auto;}
        .progress-bar {width: 100%;height: 20px;background: #f5f5f5;border-radius: 10px;margin: 20px 0;}
        .progress {height: 100%;background: #409eff;border-radius: 10px;width: 0%;transition: width 0.3s;}
        .progress-text {text-align: center;font-size: 16px;}
        button {padding: 8px 20px;background: #409eff;color: #fff;border: none;border-radius: 4px;cursor: pointer;}
    </style>
</head>
<body>
    <div class="upload-box">
        <input type="file" id="fileInput" multiple>
        <button onclick="uploadFile()">开始上传</button>
        <div class="progress-bar"><div class="progress" id="progress"></div></div>
        <div class="progress-text" id="progressText">0%</div>
    </div>

    <!-- 引入axios和spark-md5 -->
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
    <script>
        // 配置分片大小(10MB,可根据网络调整)
        const CHUNK_SIZE = 10 * 1024 * 1024;
        let file = null;
        let fileMd5 = '';
        let totalChunks = 0;
        let uploadedChunkCount = 0;

        // 选择文件
        document.getElementById('fileInput').addEventListener('change', (e) => {
            file = e.target.files[0];
        });

        // 计算文件MD5
        function calculateMd5(file) {
            return new Promise((resolve) => {
                const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
                const spark = new SparkMD5.ArrayBuffer();
                const fileReader = new FileReader();
                let start = 0;
                const end = start + CHUNK_SIZE;

                function loadNext() {
                    fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
                }

                fileReader.onload = (e) => {
                    spark.append(e.target.result);
                    start += CHUNK_SIZE;
                    if (start < file.size) {
                        loadNext();
                    } else {
                        resolve(spark.end());
                    }
                };
                loadNext();
            });
        }

        // 上传文件主函数
        async function uploadFile() {
            if (!file) {alert('请选择文件');return;}
            // 1. 计算文件MD5
            fileMd5 = await calculateMd5(file);
            totalChunks = Math.ceil(file.size / CHUNK_SIZE);
            // 2. 查询已上传分片(断点续传+秒传)
            const checkRes = await axios.get('/upload/check', {
                params: {fileMd5, fileName: file.name}
            });
            // 秒传逻辑
            if (checkRes.data.data.exists) {
                document.getElementById('progressText').innerText = checkRes.data.message;
                document.getElementById('progress').style.width = '100%';
                return;
            }
            // 获取已上传分片列表
            const uploadedChunks = checkRes.data.data.uploadedChunks || [];
            uploadedChunkCount = uploadedChunks.length;
            // 更新初始进度
            updateProgress();
            // 3. 循环上传未完成的分片
            for (let i = 0; i < totalChunks; i++) {
                if (uploadedChunks.includes(i)) continue; // 跳过已上传分片
                const start = i * CHUNK_SIZE;
                const end = Math.min(start + CHUNK_SIZE, file.size);
                const chunk = file.slice(start, end);
                // 构建表单数据
                const formData = new FormData();
                formData.append('fileMd5', fileMd5);
                formData.append('fileName', file.name);
                formData.append('chunkIndex', i);
                formData.append('totalChunks', totalChunks);
                formData.append('chunkFile', chunk);
                // 上传分片
                await axios.post('/upload/chunk', formData);
                uploadedChunkCount++;
                updateProgress();
            }
            // 4. 所有分片上传完成,调用合并接口
            await axios.post('/upload/merge', null, {
                params: {fileMd5, fileName: file.name, totalChunks}
            });
            document.getElementById('progressText').innerText = '上传合并完成!';
        }

        // 更新进度条
        function updateProgress() {
            const progress = Math.round((uploadedChunkCount / totalChunks) * 100);
            document.getElementById('progress').style.width = progress + '%';
            document.getElementById('progressText').innerText = progress + '%';
        }
    </script>
</body>
</html>

五、实测效果+常见避坑要点

实测效果演示

  1. 正常上传:选择大文件,页面实时显示进度条,从0%稳步涨到100%,最后提示合并完成;

  2. 断点续传:上传到50%时关闭页面,重新选择同一个文件,直接从50%开始继续上传;

  3. 秒传功能:文件已经上传过,再次选择直接提示秒传成功,不用重复上传;

常见避坑要点(踩坑总结)

1. 跨域问题:本地测试前端和后端端口不一样,一定要加@CrossOrigin跨域注解,生产环境用Nginx配置跨域;

2. 文件大小限制:Spring Boot默认上传大小有限制,必须在yml里配置max-file-size和max-request-size,否则分片过大会报错;

3. 分片大小适配:分片不要太小(请求次数多),也不要太大(容易超时),普通网络建议5-10MB最合适;

4. MD5计算卡顿:超大文件计算MD5会阻塞页面,可优化成WebWorker异步计算,提升前端体验;

5. 磁盘权限:后端存储目录一定要有读写权限,Linux环境尤其注意,否则会出现文件写入失败;

6. 分片清理:合并完成后记得删除临时分片,避免占用服务器磁盘空间,也可以加定时任务清理过期分片。


六、总结与拓展方向

今天这套 Spring Boot + 原生前端的分片上传、断点续传、进度条方案,已经覆盖了90%的业务场景,代码简洁易懂,没有多余的第三方中间件,拿来就能改、改完就能用。

如果想进一步优化,还可以往这些方向拓展:

  • 接入云存储:把本地存储换成MinIO、阿里云OSS、腾讯云COS,适配分布式部署;

  • 多线程并行上传:前端优化成多线程同时上传多个分片,大幅提升上传速度;

  • 进度持久化:把上传进度存入数据库,支持多设备、跨页面同步进度;

  • 失败重试机制:单个分片上传失败自动重试,提升弱网环境下的成功率;

  • 权限校验:加上登录校验、文件上传权限控制,适配企业级后台系统。

大文件上传是后端开发的高频考点和实用技能,吃透这套逻辑,不管是面试还是做项目,都能轻松拿捏~

觉得有用的话,记得点赞、在看、转发,下期给大家更进阶的分布式场景下大文件上传优化,咱们下期见!