当前位置: 代码网 > it编程>编程语言>Java > SpringBoot大文件处理实战指南(分片上传、断点续传与OSS集成)

SpringBoot大文件处理实战指南(分片上传、断点续传与OSS集成)

2026年08月10日 Java 我要评论
引言线上跑着跑着突然报 outofmemoryerror,或者传个几百兆的视频动不动就网络中断,这种坑做 java 基本都踩过。文件上传模块平时看着不起眼,一到 gb 级别就开始暴露各种底层问题。传统

引言

线上跑着跑着突然报 outofmemoryerror,或者传个几百兆的视频动不动就网络中断,这种坑做 java 基本都踩过。文件上传模块平时看着不起眼,一到 gb 级别就开始暴露各种底层问题。传统的那套 multipartfile 同步接收根本扛不住,今天咱们就按生产环境的实际标准,把分片协议、服务端异步合并、云端直传和断点续传这条链路理清楚。不整虚的,直接上干货和踩坑记录。

1. 传统直传为什么扛不住大文件?

spring boot 默认的上传走的是 servlet 容器的临时文件机制。文件一过百兆,堆外内存直接报警。就算你老老实实配了 spring.servlet.multipart.max-file-size,把临时文件落到磁盘,同步读取整个文件再做处理,照样会卡住 tomcat 的工作线程。

公网环境更是不讲武德。wi-fi 切 4g、用户锁屏、运营商限流,http 长连接说断就断。失败后只能从 0 开始重传,体验极差不说,还白白浪费带宽。最致命的是应用服务器本来就该处理业务逻辑的,非让它当文件中转网关,出口带宽一打满,核心查询接口全跟着拖慢,雪崩就是这么来的。客户端 → 应用服务器 → 对象存储,流量硬生生走了两遍,io 放大一倍,这种架构在微服务时代基本属于反面教材。

解决思路其实就一条:把大文件拆成能独立传输的小块,服务端只做路由和元数据记录,数据流能绕开应用服务器就坚决不经过它。

2. 分片协议怎么设计才不扯皮?

协议定好了,前后端和存储端才能对齐。一套能跑通的分片协议,核心就盯住三个东西:任务怎么标识、传输怎么控、合并怎么排。

2.1 基础字段约定

{
  "uploadid": "biz_20231024_x9d2",
  "filehash": "a1b2c3d4e5f6...",
  "filename": "raw_video.mp4",
  "totalsize": 1048576000,
  "totalchunks": 200,
  "chunkindex": 15,
  "chunksize": 5242880
}

uploadid 是全局任务标识,chunkindex 从 0 开始计数。总分片数 totalchunks 必须和前端切片逻辑强一致,别两边各算各的。

2.2 hash 校验要不要上?

文件级 hash(通常是 md5 或 sha256)是必须的,前端传过来做秒传判断,最后合并完了再对一遍,防篡改。分片级 hash 看业务场景,一般生产环境不推荐全开。https 本身有完整性校验,云存储底层也有 crc,服务端每片再算一遍摘要,cpu 开销直接翻倍。除非你做金融级强校验,否则别折腾自己。

2.3 并发与乱序处理

浏览器并发请求数别开太高,3 到 5 个活跃窗口刚好。开多了容易被网关限流,或者触发云厂商的 429 too many requests;开少了带宽跑不满,传得慢。乱序是网络常态,index=18index=5 先到服务端太正常了。记住,合并的时候严格按 chunkindex 排序,绝对不要按文件修改时间或者接收时间排,否则出来的文件全是乱码。

3. 服务端接收与异步合并(附生产级代码)

服务端角色要尽量做薄。接收接口只管落盘和记流水,合并操作必须抽离 http 请求线程。

3.1 分片接收

@restcontroller
@requestmapping("/api/v1/upload")
@requiredargsconstructor
public class chunkuploadcontroller {

    private final chunkuploadservice chunkservice;

    @postmapping("/chunk")
    public responseentity<void> uploadchunk(
            @requestparam("file") multipartfile chunk,
            @requestparam string uploadid,
            @requestparam int chunkindex) {
        chunkservice.savechunk(uploadid, chunk, chunkindex);
        return responseentity.ok().build();
    }
}

3.2 异步合并逻辑

合并千万别放在主线程里,容易把网关请求拖超时。用独立线程池,配合 filechannel 做零拷贝。

@service
@slf4j
@requiredargsconstructor
public class chunkuploadservice {

    private final string tempbasedir = system.getproperty("java.io.tmpdir") + "/uploads";
    private final threadpooltaskexecutor mergeexecutor;

    public void savechunk(string uploadid, multipartfile chunk, int index) {
        path taskdir = paths.get(tempbasedir, uploadid);
        try {
            files.createdirectories(taskdir);
            path target = taskdir.resolve("chunk_" + index);
            // 用 files.copy 替代 transferto,规避部分容器临时文件被提前清理的坑
            try (inputstream is = chunk.getinputstream()) {
                files.copy(is, target, standardcopyoption.replace_existing);
            }
            // 记录已上传分片到 redis set,key: upload:chunks:{uploadid}
        } catch (ioexception e) {
            throw new runtimeexception("chunk save failed", e);
        }
    }

    @async("mergeexecutor")
    public void mergechunks(string uploadid, string filename, int totalchunks) {
        path taskdir = paths.get(tempbasedir, uploadid);
        try {
            list<path> chunks = files.list(taskdir)
                    .filter(p -> p.getfilename().tostring().startswith("chunk_"))
                    .sorted(comparator.comparingint(this::extractindex))
                    .collect(collectors.tolist());

            if (chunks.size() != totalchunks) {
                log.warn("chunk mismatch for {}: expected {}, got {}", uploadid, totalchunks, chunks.size());
                // 实际生产应触发重试或告警,这里抛异常示意
                throw new illegalstateexception("missing chunks");
            }

            path finaldir = paths.get("/data/files/final");
            files.createdirectories(finaldir);
            path merged = finaldir.resolve(filename);

            // 零拷贝合并
            try (filechannel out = filechannel.open(merged, standardopenoption.create, 
                    standardopenoption.write, standardopenoption.truncate_existing)) {
                for (path chunk : chunks) {
                    try (filechannel in = filechannel.open(chunk, standardopenoption.read)) {
                        out.transferfrom(in, out.position(), in.size());
                    }
                }
            }

            // 清理临时任务目录
            deleterecursively(taskdir.tofile());
            log.info("merge success: {}", filename);

        } catch (exception e) {
            log.error("merge failed for uploadid: {}", uploadid, e);
            // 生产环境建议记录失败日志并推入重试队列
        }
    }

    private int extractindex(path p) {
        string name = p.getfilename().tostring();
        return integer.parseint(name.substring(name.indexof("_") + 1));
    }
}

几点实际经验

  1. filechannel.transferfrom 在 linux 下效率很高,但要注意 position 的累加。代码里直接用 out.position() 更稳妥,避免手动维护变量出错。
  2. 临时目录一定要配定时清理。跑个 xxl-job 或 spring @scheduled,扫 ttl 超过 24 小时的孤儿任务目录,不然服务器磁盘几天就爆了。
  3. 合并失败要有降级策略。比如重试 3 次后标记 merge_failed,前端提示重新触发合并或人工介入。

4. 流量绕开应用服务器:oss/minio 直传

文件一上 gb,就别让流量经过 spring boot 了。架构演进到这一步,应用服务器只干两件事:发令牌、记元数据。数据读写全交给云存储。

4.1 sts 临时凭证 vs 预签名 url

sts 适合客户端自己调云厂商 sdk 的场景,权限控制粒度细,能严格限定 bucket、object 前缀、最大体积,甚至只允许 putobject。预签名 url 更轻量,服务端算好带签名的 put 地址直接丢给前端,客户端拿标准 http 请求就能传。两种都行,看你们前端基建。预签名 url 记得把有效期压到 10~15 分钟,超时即焚,防被爬。

@service
public class osspresignservice {
    private final s3client ossclient; // minio 或 aws sdk 兼容客户端
    
    public string generatepresignedurl(string objectkey, int expireminutes) {
        generatepresignedurlrequest req = new generatepresignedurlrequest("your-bucket", objectkey);
        req.setexpiration(date.from(instant.now().plusminutes(expireminutes)));
        req.setmethod(httpmethod.put);
        return ossclient.generatepresignedurl(req).tostring();
    }
}

云厂商底层对分片上传做了深度优化,支持 cdn 加速和全球传输,服务端合并也是自动的。咱们把 initiatemultipartuploaduploadpartcompletemultipartupload 这几个状态机跑通,剩下的交给基础设施就行。

5. 断点续传与前端进度对接

断点续传说白了就是查缺补漏+状态同步。

5.1 后端查状态

@getmapping("/status")
public responseentity<list<integer>> getuploadedchunks(@requestparam string filehash) {
    set<string> indices = redis.opsforset().members("upload:chunks:" + filehash);
    return responseentity.ok(indices.stream()
            .map(integer::parseint)
            .sorted()
            .tolist());
}

redis set 存已上传的片号索引。前端拿这个列表对比总片数,缺哪片补哪片。

5.2 前端续传逻辑(注意 formdata 坑)

很多教程这里写错了。axios 发文件必须用 formdata,直接传 json 对象云存储或后端根本解析不出来。

async function resumeupload(file, hash) {
  const { data: uploaded } = await axios.get(`/api/v1/upload/status?filehash=${hash}`);
  const chunksize = 5 * 1024 * 1024;
  const total = math.ceil(file.size / chunksize);
  const missing = [];
  for (let i = 0; i < total; i++) {
    if (!uploaded.includes(i)) missing.push(i);
  }
  const uploadpromises = missing.map(index => {
    const start = index * chunksize;
    const end = math.min(start + chunksize, file.size);
    const chunkblob = file.slice(start, end);
    const formdata = new formdata();
    formdata.append('file', chunkblob);
    formdata.append('uploadid', generateuploadid(hash));
    formdata.append('chunkindex', index);
    return axios.post(`/api/v1/upload/chunk`, formdata, {
      headers: { 'content-type': 'multipart/form-data' },
      onuploadprogress: (evt) => updateprogress(index, evt.loaded, chunksize)
    });
  });
  await promise.all(uploadpromises);
  // 通知后端触发合并
}

进度条计算别搞复杂了:(已传完片数 * 单片大小 + 当前片已传字节) / 总大小 * 100 足够精准。大文件 hash 计算极耗 cpu,千万别放主线程。上 web worker + sparkmd5,分块读取 file 对象,浏览器才不会假死。

6. 安全防线必须前置

大文件通道是黑产和爬虫的乐园,安全不做好,服务器早晚被塞满。

6.1 别信 content-type

前端传过来的 content-type 和文件后缀随便都能伪造。服务端得读文件头 magic number。apache tika 挺好使,但注意别全量读大文件,截取前 4kb 流进去检测就行。.php, .jsp, .sh 这类可执行文件直接拦截。图片视频最好结合 exif 头再校验一层,防木马藏进图片里。

6.2 访问控制与限流

云存储侧配好 referer 和 ip 白名单,只允许业务域名拉取。预签名 url 用完即废,别留长尾漏洞。限流用 redis + lua 做滑动窗口,比如单用户每分钟最多发起 20 个分片请求。结合业务上下文校验用户可用配额,防止恶意占满存储桶。上传完成后,异步丢给 clamav 或云安全中心扫一遍,命中病毒直接标记隔离并告警。

7. 落地建议

早年做文件服务,基本是客户端直传应用服务器,落本地盘。跑内部小系统还行,人一多就 oom 加带宽打满。后来升级到服务端接分片,异步合并再推给 nfs/ftp,线程池调优和临时目录清理能折腾掉半条命。现在生产环境基本都切到云端直传了。应用服务器退居二线只做控制面,数据面全交给 oss。架构看着是变简单了,但细节反而更考究:怎么保证元数据最终一致?合并失败怎么告警重试?sts 过期前端怎么无感刷新?这些才是真正卡脖子的地方。

实际落地别一上来就全量切分片。先压一压网络波动模拟断线,看看 redis 状态记录和临时文件清理能不能兜住。灰度放量时盯紧几个核心指标:分片堆积率、合并失败率、临时目录磁盘水位。把控制面和数据面彻底剥离开,大文件上传这关才算真正趟平。

以上就是springboot大文件处理实战指南(分片上传、断点续传与oss集成)的详细内容,更多关于springboot大文件处理的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com