Java文件分片上传与下载实战:秒传、断点续传、并发合并与Range下载

 更新时间:2026年11月04日 09:44:17   作者:王大师王文峰  
还在为大文件上传下载头疼,本文手把手教你用SpringBoot3实现分片上传、秒传、断点续传和Range下载,附完整代码和前端切片逻辑,彻底解决网络抖动、超时重传问题,让你的文件传输又快又稳,需要的朋友可以参考下

工单系统里用户要传设备日志包、现场视频——几十 MB 起步,网络一抖全白传;下载也是,断了就得从头来。这篇把文件分片上传(切片、并发、秒传、断点续传、合并)和大文件下载(Range 断点续传、流式输出)一次讲透,Spring Boot 3 + Java 21 完整代码 + 前端切片逻辑 + 对象存储进阶方案,工程细节全部给到。

一、开篇:为什么大文件必须分片

痛点整包上传分片上传
网络抖动整个文件重传只重传失败分片
上传超时大文件必超时单片小,逐片成功
秒传无从谈起MD5 校验,已有文件秒回
进度显示只能装样子按分片精确到 1%
并发能力单连接顶满多分片并发,带宽打满

核心思想:把"一个大问题"切成"很多小问题",每个小问题可独立成功、可独立重试——和微服务拆分异曲同工。

二、总体设计:四个接口走完全流程

①check   秒传/续传检查   → 已存在?已传哪些分片?
②chunk   分片上传        → 失败只重传该片
③merge   合并分片        → 校验完整性,落正式存储
④download 下载          → Range 断点续传 + 流式输出

数据模型(MySQL,MyBatis-Plus 风格):

CREATE TABLE file_info (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  file_md5    VARCHAR(32)  NOT NULL,           -- 秒传主键
  file_name   VARCHAR(255) NOT NULL,
  file_size   BIGINT       NOT NULL,
  file_path   VARCHAR(500) NOT NULL,
  status      TINYINT      NOT NULL DEFAULT 0, -- 0 上传中 1 完成
  uploader_id BIGINT       NOT NULL,
  create_time DATETIME     NOT NULL,
  UNIQUE KEY uk_md5 (file_md5)
);

CREATE TABLE file_chunk (
  id         BIGINT PRIMARY KEY AUTO_INCREMENT,
  file_md5   VARCHAR(32) NOT NULL,
  chunk_idx  INT         NOT NULL,
  chunk_size BIGINT      NOT NULL,
  UNIQUE KEY uk_md5_idx (file_md5, chunk_idx)
);

目录约定:临时分片 /data/upload/tmp/{md5}/{index}.part,正式文件 /data/upload/files/{md5}{suffix}——用 MD5 当文件名,天然去重、天然防重名。

三、核心实现:上传三接口

1. 秒传/续传检查(check)

@RestController
@RequestMapping("/file")
@RequiredArgsConstructor
public class FileUploadController {

    private final FileInfoMapper fileInfoMapper;
    private final FileChunkMapper fileChunkMapper;
    private final FileStorageService storage;

    /** ① 秒传 + 断点续传检查 */
    @PostMapping("/check")
    public Result<UploadCheckVO> check(@RequestBody FileCheckReq req) {
        UploadCheckVO vo = new UploadCheckVO();
        // 秒传:同 MD5 文件已存在
        FileInfo exist = fileInfoMapper.selectOne(
                new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileMd5, req.getMd5()));
        if (exist != null) {
            vo.setUploaded(true);
            vo.setFileId(exist.getId());
            return Result.ok(vo);
        }
        // 断点续传:返回已传分片索引集合
        vo.setUploaded(false);
        vo.setUploadedChunks(fileChunkMapper.selectList(
                        new LambdaQueryWrapper<FileChunk>().eq(FileChunk::getFileMd5, req.getMd5()))
                .stream().map(FileChunk::getChunkIdx).collect(Collectors.toSet()));
        return Result.ok(vo);
    }

2. 分片上传(chunk)——流式写盘,绝不 OOM

    /** ② 单分片上传(幂等:同分片重复上传覆盖即可) */
    @PostMapping("/chunk")
    public Result<Void> uploadChunk(@RequestParam String md5,
                                    @RequestParam int chunkIdx,
                                    @RequestParam("file") MultipartFile part) throws IOException {
        // ⚠️ 路径安全:chunkIdx 必须校验范围,md5 必须正则校验,防目录穿越
        if (!md5.matches("[0-9a-f]{32}")) {
            return Result.fail("非法参数");
        }
        storage.saveChunk(md5, chunkIdx, part.getInputStream(), part.getSize());

        // 记录分片(幂等 upsert)
        fileChunkMapper.insert(new FileChunk()
                .setFileMd5(md5).setChunkIdx(chunkIdx).setChunkSize(part.getSize()));
        return Result.ok();
    }

3. 合并分片(merge)——顺序流合并 + 完整性校验

    /** ③ 合并:顺序写 + 校验大小 + 校验 MD5 */
    @PostMapping("/merge")
    public Result<Long> merge(@RequestBody FileMergeReq req) throws Exception {
        String md5 = req.getMd5();
        File tmpDir = new File(storage.chunkDir(md5));
        File[] parts = tmpDir.listFiles((d, n) -> n.endsWith(".part"));
        if (parts == null || parts.length != req.getTotalChunks()) {
            return Result.fail("分片缺失,请重传");
        }

        File target = new File(storage.filePath(md5, req.getSuffix()));
        long total = 0;
        // ⚠️ 别用 Files.write 一次性读入 byte[](OOM 元凶),用流式追加
        try (FileOutputStream fos = new FileOutputStream(target)) {
            for (int i = 0; i < req.getTotalChunks(); i++) {
                File part = new File(tmpDir, i + ".part");
                try (InputStream in = new FileInputStream(part)) {
                    total += in.transferTo(fos);     // 流式拷贝,JDK 21 transferTo
                }
            }
        }

        // 完整性校验:大小 + MD5(合并后重算一次)
        if (total != req.getTotalSize() || !req.getMd5().equals(storage.md5(target))) {
            target.delete();
            return Result.fail("文件校验失败");
        }

        Long fileId = storage.saveFileInfo(req, target);   // 落库 + 清理临时分片
        FileUtils.deleteDirectory(tmpDir);                 // 合并成功即清分片
        return Result.ok(fileId);
    }
}

前端配合(三行核心逻辑):

// 切片(每片 5MB)
const CHUNK_SIZE = 5 * 1024 * 1024;
const chunks = [];
for (let i = 0; i * CHUNK_SIZE < file.size; i++) {
  chunks.push(file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));
}
// MD5 用 Web Worker 算(别卡主线程);并发上传用 Promise 池(并发 3~4)
const { uploaded, uploadedChunks } = await axios.post('/file/check', { md5 });
if (uploaded) return;                                   // 秒传直接完成
await pool(3, chunks.map((c, i) =>
  uploadedChunks.has(i) ? Promise.resolve() :         // 断点续传跳过
    () => uploadChunk(md5, i, c)));                    // 失败自动重试 3 次
await axios.post('/file/merge', { md5, totalChunks: chunks.length, totalSize: file.size });

四、大文件下载:Range 断点续传 + 流式输出

浏览器/下载工具的断点续传靠 HTTP Range 协议:请求带 Range: bytes=100-,服务端返回 206 Partial Content。

    /** ④ 支持 Range 的流式下载 */
    @GetMapping("/download/{id}")
    public void download(@PathVariable Long id,
                         HttpServletRequest request,
                         HttpServletResponse response) throws IOException {
        FileInfo info = fileInfoMapper.selectById(id);
        File file = new File(info.getFilePath());
        long fileLen = file.length();

        // 解析 Range 头:bytes=start-end
        String range = request.getHeader("Range");
        long start = 0, end = fileLen - 1;
        if (range != null && range.startsWith("bytes=")) {
            String[] parts = range.substring(6).split("-");
            start = Long.parseLong(parts[0]);
            if (parts.length > 1 && !parts[1].isEmpty()) {
                end = Long.parseLong(parts[1]);
            }
            response.setStatus(HttpStatus.PARTIAL_CONTENT.value());   // 206
            response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLen);
        } else {
            response.setStatus(HttpStatus.OK.value());
        }

        response.setContentType("application/octet-stream");
        response.setHeader("Accept-Ranges", "bytes");
        // ⚠️ 中文文件名:RFC 5987 编码,直接放会乱码
        String encoded = URLEncoder.encode(info.getFileName(), StandardCharsets.UTF_8)
                .replace("+", "%20");
        response.setHeader("Content-Disposition",
                "attachment; filename*=UTF-8''" + encoded);
        response.setContentLengthLong(end - start + 1);

        // 流式输出(RandomAccessFile 支持从任意位置读)
        try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
            raf.seek(start);
            OutputStream out = response.getOutputStream();
            byte[] buf = new byte[8192];
            long remaining = end - start + 1;
            while (remaining > 0) {
                int read = raf.read(buf, 0, (int) Math.min(buf.length, remaining));
                if (read < 0) break;
                out.write(buf, 0, read);
                remaining -= read;
            }
        }
    }

关键点:

  • 200 vs 206:无 Range 头返回 200 全量,有则 206 + Content-Range;
  • Accept-Ranges: bytes 声明支持断点,下载工具(IDM/迅雷)才会分段并发下载;
  • 流式:RandomAccessFile.seek + 8KB 缓冲,10GB 文件内存占用恒定;
  • Nginx 注意:反向代理要透传 Range 头(默认透传,但 proxy_buffering on 会缓冲大响应,建议下载接口配 X-Accel-Redirect 或关缓冲):
location /file/download/ {
    proxy_buffering off;            # 大文件下载关缓冲
    proxy_set_header Range $http_range;
    proxy_set_header If-Range $http_if_range;
}

五、工程细节:并发、幂等与可靠性

问题方案
分片并发上传乱序每片独立文件 {idx}.part,合并时按序号取,顺序无关
同分片重复上传覆盖写 + uk_md5_idx 唯一键 upsert,幂等
合并中途失败分片不删,用户重进走 check 拿到已传分片继续(断点续传)
上传一半放弃定时任务清理 tmp/{md5} 下 7 天未动的分片(垃圾回收)
MD5 计算卡页面前端 Web Worker + 大文件抽样 MD5(首中尾各 2MB,精度换速度)
并发合并冲突merge 加分布式锁(Redis SETNX md5),防止双击触发两次合并
校验被绕过后端合并后重算 MD5,不信前端声明的值

六、进阶:对象存储(OSS)直传方案

单机存储到瓶颈后(磁盘/多实例共享),标准答案是 OSS 分片直传:

前端 → 向后端要"分片上传凭证"(STS 临时密钥/签名)
     → 直传 OSS(Multipart Upload,不占应用带宽)
     → 全部分片完成后调"合并"(OSS CompleteMultipartUpload)
     → 回调通知后端落库
维度本地磁盘OSS 直传
带宽走应用服务器客户端直连 OSS
扩容换盘/挂载无限
秒传自己实现OSS 也有秒传(同 bucket 同 MD5)
成本低按量付费

架构建议:应用起步用本地磁盘(简单可控),用户规模上来再迁 OSS——存储层抽个 FileStorageService 接口,两种实现切换,业务代码零改动(这也是面试加分点:留好演进空间)。

七、实战场景:工单系统附件模块

把上面的能力放进工单系统:

  • 上传约束:日志包限 500MB、图片限 10MB、格式白名单(.log .zip .jpg)——上传前前端校验 + 上传后后端魔数校验(防改后缀传木马);
  • 安全钩子:文件落盘后异步病毒扫描(ClamAV),扫描通过才允许下载;
  • 权限:下载接口校验 uploader_id 或工单归属(数据权限切面统一注入)——文件链接不能是裸的公网可猜 ID;
  • 审计:上传/下载都走 AOP 审计日志(谁、何时、哪个文件)——和之前工单系统的 @OperateLog 切面复用。

八、避坑清单(8 条血泪)

#坑后果解法
1用 byte[] 存整个文件大文件 OOM流式 transferTo
2文件名直接落路径目录穿越漏洞MD5 命名 + 参数正则校验
3中文文件名乱码用户投诉filename*=UTF-8'' RFC 5987
4前端传的 MD5 不复核校验形同虚设合并后重算比对
5分片永不清理磁盘被打满定时清理 + 监控告警
6Nginx 缓冲下载大文件卡死proxy_buffering off
7合并不加锁双击出两个文件分布式锁 + 幂等
8下载链接永久有效越权/盗链签名 URL + 过期时间

九、面试追问 Top5

问题答题要点
分片上传流程?check(秒传/续传)→ chunk(并发+幂等)→ merge(校验+落库)
怎么实现秒传?文件 MD5 全局唯一键,命中即秒回
断点续传原理?上传:记录已传分片;下载:HTTP Range + 206
大文件下载怎么防 OOM?流式 + RandomAccessFile.seek + 小缓冲
多实例部署怎么共享文件?OSS 对象存储 + FileStorageService 抽象层

十、总结与自测清单

  • ✅ 分片上传四接口:check / chunk / merge / download,职责清晰;
  • ✅ 秒传 = MD5 唯一键;断点续传 = 已传分片记录 + Range 协议;
  • ✅ 流式读写是底线,byte[] 大文件必 OOM;
  • ✅ 幂等三件套:分片唯一键、覆盖写、合并分布式锁;
  • ✅ 安全三防线:路径校验(防穿越)、魔数校验(防木马)、签名 URL(防盗链);
  • ✅ 存储层留抽象(本地/OSS 可切换),是架构演进的伏笔;
  • ✅ 下载接口配套 Nginx 配置(透传 Range、关缓冲),别只调 Java。

以上就是Java文件分片上传与下载实战:秒传、断点续传、并发合并与Range下载的详细内容,更多关于Java文件分片上传与下载的资料请关注脚本之家其它相关文章!

相关文章

  • SpringBoot整合Springsecurity实现数据库登录及权限控制功能

    SpringBoot整合Springsecurity实现数据库登录及权限控制功能

    本教程详细介绍了如何使用SpringBoot整合SpringSecurity实现数据库登录和权限控制,本文分步骤结合实例代码给大家介绍的非常详细,感兴趣的朋友跟随小编一起看看吧
    2024-10-10
  • JDK与Dubbo中的SPI详细介绍

    JDK与Dubbo中的SPI详细介绍

    这篇文章主要介绍了JDK中的SPI与Dubbo中的SPI,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-09-09
  • Java代码耗时统计的5种实现方式

    Java代码耗时统计的5种实现方式

    这篇文章主要介绍了Java代码耗时统计的5种实现方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2026-03-03
  • Java服务调用RestTemplate与HttpClient的使用详解

    Java服务调用RestTemplate与HttpClient的使用详解

    无论是微服务还是SOA,都面临着服务间的远程调用,这篇文章主要介绍了服务调用RestTemplate与HttpClient的使用,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-06-06
  • Jmeter基于JDBC请求实现MySQL数据库测试

    Jmeter基于JDBC请求实现MySQL数据库测试

    这篇文章主要介绍了Jmeter基于JDBC请求实现MySQL数据库测试,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2020-10-10
  • SpringIOC DI循环依赖实例详解

    SpringIOC DI循环依赖实例详解

    这篇文章主要介绍了SpringIOC——DI循环依赖,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2020-03-03
  • 一文带你搞懂Spring响应式编程

    一文带你搞懂Spring响应式编程

    相信响应式编程经常会在各种地方被提到。本篇就为大家从函数式编程一直到Spring WeFlux做一次简单的讲解,并给出一些示例,希望大家可以更好的理解响应式编程
    2022-07-07
  • 非常实用的java自动答题计时计分器

    非常实用的java自动答题计时计分器

    这篇文章主要为大家详细介绍了非常实用的java自动答题计时计分器的实现方法,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2018-01-01
  • java 读写文件[多种方法]

    java 读写文件[多种方法]

    前两天用到读写文件的操作,上网搜了一些这方面的资料。很有用的。
    2008-11-11
  • Springboot中静态文件的两种引入方式总结

    Springboot中静态文件的两种引入方式总结

    这篇文章主要介绍了Springboot中静态文件的两种引入方式总结,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2022-03-03

最新评论