Spring Boot 大文件处理实战:分片上传、断点续传与 OSS 集成

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


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

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

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

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


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

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

2.1 基础字段约定

json 复制代码
{
  "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 分片接收

java 复制代码
@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 做零拷贝。

java 复制代码
@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 分钟,超时即焚,防被爬。

java 复制代码
@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 后端查状态

java 复制代码
@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 对象云存储或后端根本解析不出来。

javascript 复制代码
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 状态记录和临时文件清理能不能兜住。灰度放量时盯紧几个核心指标:分片堆积率、合并失败率、临时目录磁盘水位。把控制面和数据面彻底剥离开,大文件上传这关才算真正趟平。


🎁 福利时间

如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。

知识库地址:https://farerboy.com/


相关推荐
敲个大西瓜1 小时前
JAVA 并发编程
java·并发编程
卷无止境2 小时前
Python 依赖管理这件事,到底该看哪个文件
后端·python
vHelios2 小时前
【电商项目】商品服务模块的问题解决与代码逻辑思考
java·sql·mybatis
rannn_1112 小时前
【力扣hot100】238、41、73题解
java·算法·leetcode·开发
卷无止境2 小时前
FastAPI 缓存方案全解析:从内存到分布式的工程实践
后端·python
程序员爱钓鱼2 小时前
Rust 可变借用详解:&mut T 与安全修改数据
后端·面试·rust
程序员爱钓鱼2 小时前
Go 类型转换详解
后端·面试·go
今天的砖头有点烫手啊2 小时前
Spring Boot
java·人工智能
mldong10 小时前
工作流引擎的灵魂:状态机与 submitType
后端