一种文件分片上传的实现方法

一种文件分片上传的实现方法

_

一种文件分片上传的实现方法

在现代Web应用中,文件上传是一个高频需求。处理小文件时,传统的单次上传方式简单直接。但面对大文件(如几百MB甚至数GB的视频、安装包等),传统方式会暴露出几个棘手的问题:网络波动易导致上传中断且需重新开始、服务器内存压力大、长时间占用连接易超时。

文件分片上传是解决这些问题的经典方案。其核心思想是:化整为零,逐个击破。将大文件在客户端切割成多个小块(分片),分别独立上传,全部上传完成后由服务端将这些分片按顺序合并还原成原始文件。本文将介绍一种基于 Spring Boot + 原生JavaScript 的具体实现。

slice_upload.gif

一、技术选型与核心设计思路

在动手编码之前,先梳理几个关键的技术决策:

1.1 为什么分片大小选择 10MB?

代码中设置的分片大小为 10 * 1024 * 1024 字节。这是一个工程上的经验值,主要基于以下权衡:

  • 分片过小:HTTP请求数量剧增,服务端处理开销增大,浏览器并发连接数容易成为瓶颈。
  • 分片过大:失去了分片的意义,单次上传失败的重传成本高,对大内存的消耗也更大。

综合来看,1MB ~ 10MB 是业界普遍采用的范围。阿里云OSS等云服务也默认采用 10MB 作为分片大小。对于大多数内网或普通宽带环境,10MB 是一个兼顾了传输效率和系统开销的平衡点。在实际项目中,也可以将此值设计为可配置项,根据网络环境动态调整。

1.2 为什么用 RandomAccessFile 做随机写入?

后端的核心写入逻辑是:

try (RandomAccessFile tmpRaf = new RandomAccessFile(file, "rw");
     FileChannel channel = tmpRaf.getChannel()) {
    long offset = reqDto.getSize() * reqDto.getChunk();
    MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, offset, bytes.length);
    buffer.put(bytes);
}

这里的关键是 RandomAccessFile。与 FileOutputStream 只能从文件头顺序写入不同,RandomAccessFile 支持通过 seek() 方法将指针移动到文件的任意位置进行读写。其核心优势在于:

  • 支持乱序写入:网络传输中,分片到达服务端的顺序可能是乱的(第5片先到,第3片后到)。RandomAccessFile 可以根据分片的偏移量(offset,直接将数据写入文件的指定位置,无需等待所有分片按顺序到达后再合并。
  • 避免额外合并开销:如果使用普通流按顺序写入,就需要先将所有分片暂存,最后再合并成一个文件。而 RandomAccessFile 实现了边收边写、直接落盘,省去了最后的合并步骤,也节省了临时的磁盘空间。

1.3 为什么用 MappedByteBuffer

在上述代码中,还使用了 MappedByteBuffer,它是 Java NIO 中提供的内存映射文件机制。它将文件的一块区域直接映射到虚拟内存中,对这块内存的读写会由操作系统直接同步到文件,绕过了 JVM 堆内存和系统内核之间的数据复制,在大量数据写入的场景下能显著提升 IO 性能。对于大文件分片写入这种高频 IO 操作,这是一个非常有效的性能优化手段。

1.4 序列图

%%{init: { 'theme': 'base', 'themeVariables': { 'background': '#ffffff', 'primaryColor': '#F0F4FF', 'primaryBorderColor': '#4A6CF7', 'primaryTextColor': '#1E293B', 'lineColor': '#94A3B8', 'secondaryColor': '#FEF3C7', 'tertiaryColor': '#F0FDF4', 'fontFamily': 'system-ui, -apple-system, "PingFang SC", sans-serif', 'fontSize': '13px', 'edgeLabelBackground': '#ffffff', 'nodeBorder': '2px' }, 'sequence': { 'mirrorActors': false, 'bottomMarginAdj': 10, 'actorMargin': 30, 'boxTextMargin': 6, 'noteMargin': 10, 'messageMargin': 25, 'useMaxWidth': true } }}%% sequenceDiagram actor Client as <<🧑‍💻Brower>> <br/> Client participant frontend as <<📦Js>> <br/> frontend participant backend as <<☕Java>> <br/> backend autonumber Client->>+frontend: 📤 submit frontend->>frontend: 🔄 _getFileMd5(file, chunkSize) rect rgb(230, 244, 255) Note over frontend,backend: ⚡ 分片上传循环 loop repeat to upload sliced files frontend->>+backend: 📤 _sliceUploadFile <br/> `POST /api/kyyee/v2/sps/files/slice-upload` backend->>backend: ⚙️ sliceUpload(FileReqDto reqDto) backend->>backend: 📋 if (reqDto.getChunk() + 1) == reqDto.getChunks() <br/> return uri backend-->>-frontend: 📥 res frontend->>frontend: 🔍 if (uri) end end frontend-->>-Client: 📥 res

这个序列图清晰地展示了从文件选择到上传完成的完整链路。

二、前端实现:分片、MD5与进度追踪

前端使用原生 JavaScript 实现,核心依赖 SparkMD5 库计算文件哈希。

2.1 UI:file.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>File Upload</title>
  <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.0/css/bootstrap.min.css" rel="stylesheet">
  <style>
    /* 自定义进度条样式 */
    .percent input[type=range] {
      -webkit-appearance: none;
      /*清除系统默认样式*/
      width: 7.8rem;
      /* background: -webkit-linear-gradient(#ddd, #ddd) no-repeat, #ddd; */
      /*设置左边颜色为#61bd12,右边颜色为#ddd*/
      background-size: 75% 100%;
      /*设置左右宽度比例*/
      height: 0.6rem;
      /*横条的高度*/
      border-radius: 0.4rem;
      border: 1px solid #ddd;
      box-shadow: 0 0 10px rgba(0,0,0,.125) inset ;
    }

    /*拖动块的样式*/
    .percent input[type=range]::-webkit-slider-thumb {
      -webkit-appearance: none;
      /*清除系统默认样式*/
      height: .9rem;
      /*拖动块高度*/
      width: .9rem;
      /*拖动块宽度*/
      background: #fff;
      /*拖动块背景*/
      border-radius: 50%;
      /*外观设置为圆形*/
      border: solid 1px #ddd;
      /*设置边框*/
    }

    a {
      height: 300px;
    }

  </style>
</head>
<body>
<a href="./index.html">返回首页</a>
<h1>文件上传</h1>
<form id="upload-form" enctype="multipart/form-data">
  <input id="uploadFile" type="file" name="file"/>
  <input id="uploadSubmitBtn" type="button" value="提交"/>
  <div>
    <a id="uploadFileHref" href="" target="_self">文件地址</a>
  </div>
</form>
<h1>大文件分片上传</h1>
<form id="slice-upload-form" enctype="multipart/form-data">
  <input id="sliceUploadFile" type="file" name="file"/>
  <input id="sliceUploadSubmitBtn" type="button" value="提交"/>
  <div class="percent">
    <input id="percentRange" type="range" value="0"/><span id="percentSpan">0%</span>
  </div>
  <div>
    <a id="sliceUploadFileHref" href="" target="_self">文件地址</a>
  </div>
</form>
<footer class="text-center panel-footer" role="contentinfo">
  <div class="container">
    <p class="">Designed by <a href="https://github.com/kyyee" target="_blank">@kyyee</a> with <a
      href="https://github.com/twbs/bootstrap" target="_blank">Bootstrap</a></p>
  </div>
</footer>
</body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.0/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.js"></script>
<script src="file.js" type="module"></script>
</html>

2.2 核心类:file.js

'use strict';

import { Snowflake } from './snowflake.js'

class FileSlice {
    // 每个chunk的大小,设置为10兆
    static chunkSize = 10 * 1024 * 1024;
    constructor(configs) {
        this.configs = {
        };
        if (configs) {
            Object.assign(this.configs, configs);
        }
    };

    init() {
        // 检测 DOMContentLoaded 是否已完成
        document.readyState !== 'loading' ? this._doStart(this.configs) : document.addEventListener('DOMContentLoaded', () => this._doStart(this.configs));
    }

    _doStart(configs) {
        // 获取slice方法,做兼容处理
        // 提交
        const sliceUploadSubmitBtn = document.getElementById('sliceUploadSubmitBtn');
        sliceUploadSubmitBtn.addEventListener('click', async() => {
            let index = 0
            // 1.读取文件
            let fileDom = document.getElementById("sliceUploadFile");
            let files = fileDom.files;
            let file = files[0];
            if (!file) {
                alert('请选择文件');
                return;
            }
            // 2.设置分片参数属性、获取文件MD5值
            let chunkSize = FileSlice.chunkSize;
            sliceUploadSubmitBtn.disabled = true;
            fileDom.disabled = true;
            const md5 = await this._getFileMd5(file, chunkSize).then(md5 => {
                console.log(md5);
                sliceUploadSubmitBtn.disabled = false;
                fileDom.disabled = false;
                let id = Snowflake.getId();
                this._sliceUploadFile(file, index, chunkSize, id, md5);
            })
            // todo 暂停,断点续传
        });

        const uploadSubmitBtn = document.getElementById('uploadSubmitBtn');
        uploadSubmitBtn.addEventListener('click', async() => {
            let index = 0
            // 1.读取文件
            let fileDom = document.getElementById("uploadFile");
            let files = fileDom.files;
            let file = files[0];
            if (!file) {
                alert('请选择文件');
                return;
            }
            // 2.设置分片参数属性、获取文件MD5值
            let chunkSize = FileSlice.chunkSize;
            uploadSubmitBtn.disabled = true;
            fileDom.disabled = true;
            const md5 = await this._getFileMd5(file, chunkSize).then(md5 => {
                console.log(md5);
                uploadSubmitBtn.disabled = false;
                fileDom.disabled = false;
                let id = Snowflake.getId();
                this._uploadFile(file, id, md5);
            })
        });
    }

    // 对文件进行MD5加密(文件内容+文件标题形式)
    _getFileMd5(file, chunkSize) {
        return new Promise((resolve, reject) => {
            let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
            let index = 0;
            let chunks = Math.ceil(file.size / chunkSize);
            let spark = new SparkMD5.ArrayBuffer();
            let fileReader = new FileReader();

            fileReader.onload = (e) => {
                spark.append(e.target.result); // Append array buffer
                index++;
                if (index < chunks) {
                    let start = index * chunkSize;
                    let end = Math.min(file.size, start + chunkSize);
                    fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
                } else {
                    let result = spark.end();
                    resolve(result);
                }
            };
            fileReader.onerror = (e) => {
                reject(e);
                alert('文件读取失败!');
            };
            // 文件分片
            let start = index * chunkSize;
            let end = Math.min(file.size, start + chunkSize);
            fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
        })
        .catch(err => { console.error(err); });
    }

    // 文件上传
    _sliceUploadFile(file, index, chunkSize, id, md5) {
        if (file) {
            let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;

            let chunks = Math.ceil(file.size / chunkSize); // 分片总数
            if (index > chunks - 1) {
                return;
            }
            let start = index * chunkSize;
            const end = Math.min(file.size, start + chunkSize);
            // 构建表单
            let form = new FormData();
            // blobSlice.call(file, start, end)方法是用于进行文件分片
            form.append('file', blobSlice.call(file, start, end));
            form.append('md5', md5);
            form.append('chunk', index);
            form.append('chunks', chunks);
            form.append('filename', file.name);
            form.append('size', chunkSize);
            form.append('id', id);
            // ajax提交 分片,此时 content-type 为 multipart/form-data
            fetch('/api/kyyee/v2/sps/files/slice-upload', {
                method: 'post',
//              // 不能设置 headers :{ 'Content-Type': 'multipart/form-data', }
                body: form,
                })
            .then(response => response.json())
            .catch(error => { console.error(error) })
            .then(async success => {
                console.debug(success);
                let isSuccess = success.code === '0000000000' && success.data;
                if (isSuccess) {
                    // 判断分片是否上传完成
                    let uri = success.data.uri;
                    if (uri) {
                        // 4.所有分片上传后,请求合并分片文件
                        this._setPercent(chunks, chunks); // 全部上传完成
                        let fileInfo = {
                            name: file.name,
                            size: file.size,
                            type: file.type,
                            uid: file.uid,
                            status: 'done',
                            uri: uri,
                            server_ip: success.data.server_ip,
                        };
                        const sliceUploadFileHref = document.getElementById('sliceUploadFileHref');
                        sliceUploadFileHref.href = window.location.protocol + '//' + window.location.host + uri;
                        sliceUploadFileHref.textContent = fileInfo.name;
                        console.log(fileInfo);
                    } else {
                        this._setPercent(index, chunks);
                        await this._sliceUploadFile(file, ++index, chunkSize, id, md5);
                    }
                } else {
                    alert("上传失败");
                    return;
                }
            });
        }
    }

    // 文件上传
    _uploadFile(file, id, md5) {
        if (file) {
            let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;

            // 构建表单
            let form = new FormData();
            // blobSlice.call(file, start, end)方法是用于进行文件分片
            form.append('file', file);
            form.append('md5', md5);
            form.append('filename', file.name);
            form.append('size', file.size);
            form.append('id', id);
            // ajax提交 分片,此时 content-type 为 multipart/form-data
            fetch('/api/kyyee/v2/sps/files/upload', {
                method: 'post',
//              // 不能设置 headers :{ 'Content-Type': 'multipart/form-data', }
                body: form,
                })
            .then(response => response.json())
            .catch(error => { console.error(error) })
            .then(async success => {
                console.debug(success);
                let isSuccess = success.code === '0000000000' && success.data;
                if (isSuccess) {
                    // 判断分片是否上传完成
                    let uri = success.data.uri;
                    if (uri) {
                        let fileInfo = {
                            name: file.name,
                            size: file.size,
                            type: file.type,
                            uid: file.uid,
                            status: 'done',
                            uri: uri,
                            server_ip: success.data.server_ip,
                        };
                        const uploadFileHref = document.getElementById('uploadFileHref');
                        uploadFileHref.href = window.location.protocol + '//' + window.location.host + uri;
                        uploadFileHref.textContent = fileInfo.name;
                        console.log(fileInfo);
                    }
                } else {
                    alert("上传失败");
                    return;
                }
            });
        }
    }

    // 设置进度条
    _setPercent(index, chunks) {
        let percentValue = Math.ceil(index / chunks * 100);
        // 进度条
        const percentDom = document.getElementById('percentRange');
        percentDom.value = percentValue;
        percentDom.style.background = `-webkit-linear-gradient(top, #059CFA, #059CFA) 0% 0% / ${percentValue}% 100% no-repeat`;
        // 进度条值对应dom
        const percentSpan = document.getElementById('percentSpan');
        percentSpan.textContent = percentValue + '%';
     }
}

new FileSlice({}).init();

2.3 雪花ID:snowflake.js

'use strict';

class Snowflake {
    static dataCenterId = 0n;
    static workerId = 0n;
    static twepoch = 1661906860000n;
    static dataCenterIdBits = 5n;
    static workerIdBits = 5n;
    static maxDataCenterId = -1n ^ ( - 1n << Snowflake.dataCenterIdBits); // 值为:31
    static maxWorkerId = -1n ^ ( - 1n << Snowflake.workerIdBits); // 值为:31
    static sequenceBits = 12n;
    static workerIdShift = Snowflake.sequenceBits; // 值为:12
    static dataCenterIdShift = Snowflake.sequenceBits + Snowflake.workerIdBits; // 值为:17
    static timestampLeftShift = Snowflake.sequenceBits + Snowflake.workerIdBits + Snowflake.dataCenterIdBits; // 值为:22
    static sequenceMask = -1n ^ ( - 1n << Snowflake.sequenceBits); // 值为:4095

    static singleton;

    constructor(workerId, dataCenterId) {
        //设置默认值,从环境变量取
        this.dataCenterId = 1n;
        this.workerId = 1n;
        this.sequence = 0n;
        this.lastTimestamp = -1n;

        if (workerId > Snowflake.maxWorkerId || workerId < 0) {
            throw new Error('_worker Id can\'t be greater than maxWorkerId-[' + Snowflake.maxWorkerId + '] or less than 0');
        }
        if (dataCenterId > Snowflake.maxDataCenterId || dataCenterId < 0) {
            throw new Error('dataCenter Id  can\'t be greater than maxDataCenterId-[' + Snowflake.maxDataCenterId + '] or less than 0');
        }

        console.debug('worker starting. timestamp left shift ' + Snowflake.timestampLeftShift + ', datacenter id bits ' + Snowflake.dataCenterIdBits + ', worker id bits '
        + Snowflake.workerIdBits + ', sequence bits ' + Snowflake.sequenceBits + ', worker id ' + Snowflake.workerId);
        this.workerId = BigInt(workerId);
        this.dataCenterId = BigInt(dataCenterId);
    }

    static getId() {
        if (!Snowflake.singleton) {
            Snowflake.singleton = new Snowflake(Snowflake.dataCenterId, Snowflake.workerId);
        }
        return Snowflake.singleton._nextId();
    }

    _nextId = function() {
        let timestamp = this._timeGen();
        if (timestamp < this.lastTimestamp) {
            throw new Error('Clock moved backwards.  Refusing to generate id for ' + (this.lastTimestamp - timestamp) + 'milliseconds');
        }

        if (this.lastTimestamp === timestamp) {
            this.sequence = (this.sequence + 1n) & Snowflake.sequenceMask;
            if (this.sequence === 0n) {
                timestamp = this._tilNextMillis(this.lastTimestamp);
            }
        } else {
            this.sequence = 0n;
        }
        this.lastTimestamp = timestamp;
        //移位并通过或运算拼到一起组成64位的ID
        return ((timestamp - Snowflake.twepoch) << Snowflake.timestampLeftShift)
         | (this.dataCenterId << Snowflake.dataCenterIdShift)
         | (this.workerId << Snowflake.workerIdShift)
         | this.sequence;
    };

    _tilNextMillis(lastTimestamp) {
        let timestamp = this._timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = this._timeGen();
        }
        return BigInt(timestamp);
    };

    _timeGen() {
        return BigInt(Date.now());
    };
}

export { Snowflake };

关于断点续传的扩展思路:要实现断点续传,需要将已上传的分片状态持久化(如存入数据库或Redis)。每次上传前,先查询哪些分片已上传,从未上传的分片开始续传即可。文中代码里的 id 字段(基于雪花算法生成)可以作为文件的唯一标识。

2.4 计算文件MD5

在上传开始前,先计算整个文件的 MD5 值。这个值会在服务端做最终校验,确保文件完整未被篡改。

    // 对文件进行MD5加密(文件内容+文件标题形式)
    _getFileMd5(file, chunkSize) {
        return new Promise((resolve, reject) => {
            let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
            let index = 0;
            let chunks = Math.ceil(file.size / chunkSize);
            let spark = new SparkMD5.ArrayBuffer();
            let fileReader = new FileReader();

            fileReader.onload = (e) => {
                spark.append(e.target.result); // Append array buffer
                index++;
                if (index < chunks) {
                    let start = index * chunkSize;
                    let end = Math.min(file.size, start + chunkSize);
                    fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
                } else {
                    let result = spark.end();
                    resolve(result);
                }
            };
            fileReader.onerror = (e) => {
                reject(e);
                alert('文件读取失败!');
            };
            // 文件分片
            let start = index * chunkSize;
            let end = Math.min(file.size, start + chunkSize);
            fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
        })
        .catch(err => { console.error(err); });
    }

注意:这里采用了分片读取的方式计算 MD5,而不是一次性将整个文件读入内存,这对大文件非常友好。

三、后端实现:分片接收与随机写入

后端基于 Spring Boot + JDK 21,通过 MultipartFile 接收前端提交的分片数据。

3.1 Controller 层:FileController.java

package com.kyyee.sps.controller;

import com.kyyee.sps.common.component.validated.group.File;
import com.kyyee.sps.dto.request.FileReqDto;
import com.kyyee.sps.dto.response.FileResDto;
import com.kyyee.sps.service.FileService;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("${api-prefix}/files")
@Slf4j
@Tag(name = "文件管理")
@Validated
public class FileController {

    @Resource
    private FileService service;

    @PostMapping("/upload")
    public FileResDto upload(@Validated({File.Upload.class}) FileReqDto reqDto) {
        return service.upload(reqDto);
    }

    @PostMapping("/slice-upload")
    public FileResDto sliceUpload(@Validated({File.SliceUpload.class}) FileReqDto resDto) {
        return service.sliceUpload(resDto);
    }

    @DeleteMapping("{filename}")
    public void delete(@PathVariable(name = "filename", required = false) String filename) {
        service.delete(filename);
    }
}


3.2 Service 层:FileServiceImpl.java

package com.kyyee.sps.service.impl;

import com.kyyee.framework.common.exception.BaseErrorCode;
import com.kyyee.framework.common.exception.BaseException;
import com.kyyee.sps.dto.request.FileReqDto;
import com.kyyee.sps.dto.response.FileResDto;
import com.kyyee.sps.service.FileService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.util.Locale;

@Service
@Slf4j
public class FileServiceImpl implements FileService {
    // todo 区分操作系统语言
    Locale locale = Locale.getDefault();
    /**
     * No space left on device
     */
    public static final String NO_SPACE_LEFT_EN = "[\\s\\S]*No space left on device[\\s\\S]*";

    /**
     * 磁盘空间不足
     */
    public static final String NO_SPACE_LEFT_CN = "[\\s\\S]*磁盘空间不足[\\s\\S]*";

    @Value("${api-prefix:NA}")
    private String apiPrefix;

    @Value("${kyyee.file.save-dir:/home/file/}")
    private String saveDir;

    @Override
    public FileResDto upload(FileReqDto reqDto) {
        // 此处可以对数据库中的file md5进行比对,如存在则直接返回file uri
        MultipartFile multipartFile = reqDto.getFile();
        // 创建文件保存目录和文件压缩目录
        File file = copyMultipartFileToFile(multipartFile);
        verifyFileMd5(file, reqDto.getMd5());

        return FileResDto.builder().filename(multipartFile.getOriginalFilename())
            .uri(apiPrefix + "/fs/" + multipartFile.getOriginalFilename())
            .build();
    }

    /**
     * 创建文件目录
     *
     * @param dirPath 目录
     */
    public static void mkdir(String dirPath) {
        if (dirPath == null) {
            return;
        }

        File directory = new File(dirPath);
        if (directory.exists()) {
            return;
        }
        try {
            FileUtils.forceMkdir(directory);
        } catch (IOException e) {
            // ignored exception
            log.error("mkdir failed, message:{}:", e.getMessage());
            if (directory.getUsableSpace() <= 0) {
                throw BaseException.of(BaseErrorCode.FILE_CREATE_ERROR, "剩余空间不足,导出失败,请清理磁盘空间后重试");
            }
            if (e.getMessage().matches(NO_SPACE_LEFT_EN)) {
                throw BaseException.of(BaseErrorCode.FILE_CREATE_ERROR, "剩余空间不足,导出失败,请清理磁盘空间后重试");
            }
        }
    }

    /**
     * 将输入流转换为文件
     *
     * @param multipartFile
     * @return
     */
    public File copyMultipartFileToFile(MultipartFile multipartFile) {
        mkdir(saveDir);

        final String fileName = multipartFile.getOriginalFilename();
        if (StringUtils.isEmpty(fileName)) {
            throw BaseException.of(BaseErrorCode.FILE_READ_ERROR.of(), "文件转换异常");
        }
        File file = new File(saveDir.concat(multipartFile.getOriginalFilename()));
        try {
            multipartFile.transferTo(file);
        } catch (IOException e) {
            log.error("文件存储到本地失败,{}", e.getMessage());
            throw BaseException.of(BaseErrorCode.FILE_UPLOAD_ERROR.of(), "文件转换异常");
        }
        return file;
    }

    @Override
    public FileResDto sliceUpload(FileReqDto reqDto) {
        // 此处可以对数据库中的file md5进行比对,如存在则直接返回file uri
        mkdir(saveDir);

        File file = new File(saveDir, reqDto.getFilename());

        final String filename = file.getName();
        String baseName = FilenameUtils.getBaseName(filename);
        String fileType = FilenameUtils.getExtension(filename);

        MappedByteBuffer buffer = null;
        try (final RandomAccessFile tmpRaf = new RandomAccessFile(file, "rw");
             final FileChannel channel = tmpRaf.getChannel()) {
            final long offset = (reqDto.getSize()) * reqDto.getChunk();

            final byte[] bytes = reqDto.getFile().getBytes();
            buffer = channel.map(FileChannel.MapMode.READ_WRITE, offset, bytes.length);
            buffer.put(bytes);
        } catch (IOException e) {
            log.warn("upload slice file failed, message:{}", e.getMessage(), e);
            throw BaseException.of(BaseErrorCode.FILE_UPLOAD_ERROR.of(), "文件转换异常");
        } finally {
            unmap(buffer);
        }

        // chunk 从0开始
        if ((reqDto.getChunk() + 1) == reqDto.getChunks()) {
            verifyFileMd5(file, reqDto.getMd5());

            return FileResDto.builder().filename(filename)
                .uri(apiPrefix + "/fs/" + filename)
                .build();

        }

        return FileResDto.builder().filename(filename)
            .build();
    }

    private void unmap(MappedByteBuffer buffer) {
        if (buffer != null) {
            buffer.force();
        }
    }

    private void verifyFileMd5(File destFile, String md5) {
        //判断文件是否被更改
        try (FileInputStream fileInputStream = new FileInputStream(destFile)) {
            String realMd5 = org.springframework.util.DigestUtils.md5DigestAsHex(fileInputStream);
            if (!md5.equals(realMd5)) {
                throw BaseException.of(BaseErrorCode.FILE_UPLOAD_ERROR.of(), "文件md5不一致");
            }
        } catch (IOException e) {
            throw BaseException.of(BaseErrorCode.FILE_UPLOAD_ERROR.of(), "校验文件md5异常");
        }
    }

    /**
     * v1暂无此功能
     */
    public void delete(String filepath) {
        // 1.删除zip文件
        try {
            File file = new File(saveDir);
            if (!file.exists()) {
                return;
            }
  
            File directory = new File(saveDir + filepath);
            if (directory.exists()) {
                log.info("delete file:{}", directory.getName());
                if (directory.isDirectory()) {
                    FileUtils.deleteDirectory(directory);
                }
                if (directory.isFile()) {
                    FileUtils.deleteQuietly(directory);
                }
            }
        } catch (IOException e) {
            log.warn("delete filename:{} failed. message:{}", filepath, e.getMessage(), e);
        }
    }

}


注意:代码中使用了 MappedByteBuffer,它的内存分配不受 JVM 堆大小(-Xmx)限制,但单次映射大小受操作系统限制。如果文件超大(如超过2GB),可以通过分段映射的方式处理。

3.3 文件MD5校验

    private void verifyFileMd5(File destFile, String md5) {
        //判断文件是否被更改
        try (FileInputStream fileInputStream = new FileInputStream(destFile)) {
            String realMd5 = org.springframework.util.DigestUtils.md5DigestAsHex(fileInputStream);
            if (!md5.equals(realMd5)) {
                throw BaseException.of(BaseErrorCode.FILE_UPLOAD_ERROR.of(), "文件md5不一致");
            }
        } catch (IOException e) {
            throw BaseException.of(BaseErrorCode.FILE_UPLOAD_ERROR.of(), "校验文件md5异常");
        }
    }

四、扩展能力与优化方向

4.1 断点续传

基于现有架构,实现断点续传只需要增加一个分片状态记录的环节:

  • 在服务端用 文件ID + 分片序号 作为唯一键,记录每个分片的上传状态。
  • 前端在上传前,先请求服务端获取已上传的分片列表
  • 然后从第一个未上传的分片开始续传,而不是从头开始。

4.2 秒传

当用户上传一个文件时,先计算其 MD5 值,然后在服务端查询是否已存在相同 MD5 的文件。如果存在,则直接返回已有文件的访问链接,无需重复上传。

4.3 异常重试

当前端某个分片上传失败时,可以增加自动重试机制(如最多重试3次,每次间隔递增),避免因偶发的网络抖动导致整个上传流程中断。

五、总结

本文实现了一套完整的大文件分片上传方案,核心要点如下:

  1. 前端:使用 File.prototype.slice 将大文件切割为 10MB 的分片,递归上传并实时更新进度条。上传前计算文件 MD5,用于最终完整性校验。
  2. 后端:使用 RandomAccessFile 实现按偏移量随机写入,支持分片乱序到达,无需额外合并步骤。配合 MappedByteBuffer 提升大文件 IO 性能。
  3. 完整性:所有分片上传完成后,服务端对完整文件进行 MD5 校验,确保文件未被篡改。
  4. 扩展性:在现有架构上,可以方便地扩展出断点续传秒传功能。

完整的源代码可参考GitHub项目:springboot-project-seed

从0到1:构建SpringBoot项目脚手架的设计思路与实战 2026-06-05
一种超大规模数据导出Excel的实现方法 2026-06-19

评论区