程序员狂喜!最强 OSS 分片上传实战:断点续传、去重防毒一站搞定

那天产品经理又来了句经典台词:“用户要传 10G 的设计图,你让数据库帮忙存一下?” 答案显然是否定的——数据库不是为大文件设计的。对象存储(OSS/S3)才是文件的“云端豪宅”: 能抗(高可用)、能装(海量)、自带 CDN 加速,按需计费更省钱。

那天产品经理又来了句经典台词:“用户要传 10G 的设计图,你让数据库帮忙存一下?” 答案显然是否定的——数据库不是为大文件设计的。对象存储(OSS/S3)才是文件的“云端豪宅”: 能抗(高可用)、能装(海量)、自带 CDN 加速,按需计费更省钱。

本篇从入门到进阶、从概念到实战,带你掌握对象存储在后端文件上传中的完整流程:

  • 快速起手:简单上传
  • 大文件方案:分片上传 + 断点续传
  • 秒传去重:MD5 指纹检测
  • 文件安全:文件头 + 内容检测 + 隔离策略
  • 前端实战:Thymeleaf + JS + Bootstrap(分片、MD5、进度条)
  • 后端实战:Spring Boot 控制器 /check-file/upload-chunk/merge-chunks

对象存储(OSS)不是“远程文件夹”

对象存储的文件是以对象形式保存(数据 + 元数据),通常分布式存储在集群里,与传统文件系统概念不同。

特点与适用场景
  • 多副本容灾、PB 级容量、按使用量付费
  • 适合:图片/视频托管、日志归档、用户文件上传、静态资源分发(结合 CDN)
项目依赖(pom.xml 摘要)

在 Spring Boot 项目中添加阿里云 OSS 依赖(示例):

<!-- pom.xml 中添加 -->
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.15.1</version>
</dependency>

安全提示: AK/SK 不要硬编码在代码中。优先使用 application.yml、环境变量或凭证服务(RAM、STS)。

快速体验:OSS 简单上传(Java)

文件较小、无需断点续传时可直接用简单上传逻辑:

文件路径(示例):oss/src/main/java/com/icoderoad/oss/OssSimpleUpload.java`

package com.icoderoad.oss;


import com.aliyun.oss.*;
import java.io.*;
import java.util.Date;


public class OssSimpleUpload {


    private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
    private static final String ACCESS_KEY = System.getenv("ALIYUN_ACCESS_KEY");
    private static final String SECRET_KEY = System.getenv("ALIYUN_SECRET_KEY");
    private static final String BUCKET_NAME = "your-bucket-name";


    public static void uploadFile(File file) {
        OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY, SECRET_KEY);
        try (FileInputStream fis = new FileInputStream(file)) {
            String key = "user-uploads/" + file.getName();
            ossClient.putObject(BUCKET_NAME, key, fis);


            Date expiration = new Date(System.currentTimeMillis() + 3600 * 1000); // 1 hour
            String url = ossClient.generatePresignedUrl(BUCKET_NAME, key, expiration).toString();
            System.out.println("上传成功,访问链接:" + url);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            ossClient.shutdown();
        }
    }
}
大文件痛点与分片上传思路

直接上传大文件的问题:网络中断、重传代价高、超时等。分片(multipart)上传将大文件切成小块,支持断点续传,只补传丢失的分片。

分片上传核心三步:

  1. 前端切片(File.slice()
  2. 单片上传(携带 fileMd5chunkIndextotalChunks
  3. 后端合并(或 OSS 提供的 completeMultipartUpload
后端合并示例(合并临时分片并可扩展为上传至 OSS)

文件路径(示例):oss/src/main/java/com/icoderoad/oss/OssMultipartMerge.java

package com.icoderoad.oss;


import com.aliyun.oss.*;
import com.aliyun.oss.model.*;
import org.apache.commons.io.FileUtils;
import java.io.*;
import java.util.*;


public class OssMultipartMerge {
    private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
    private static final String ACCESS_KEY = System.getenv("ALIYUN_ACCESS_KEY");
    private static final String SECRET_KEY = System.getenv("ALIYUN_SECRET_KEY");
    private static final String BUCKET_NAME = "your-bucket-name";


    /**
     * 将本地临时分片上传到 OSS 并发起合并(示例:从 /tmp/oss-chunks/{fileMd5}/{index} 读取)
     */
    public void mergeChunksToOss(String fileMd5, String fileName, int totalChunks) throws Exception {
        OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY, SECRET_KEY);
        try {
            InitiateMultipartUploadResult init = ossClient.initiateMultipartUpload(
                    new InitiateMultipartUploadRequest(BUCKET_NAME, "user-uploads/" + fileName)
            );
            String uploadId = init.getUploadId();
            List<PartETag> partETags = new ArrayList<>();


            for (int i = 0; i < totalChunks; i++) {
                File chunkFile = new File("/tmp/oss-chunks/" + fileMd5 + "/" + i + ".part");
                if (!chunkFile.exists()) {
                    throw new RuntimeException("缺少分片: " + i);
                }
                UploadPartRequest uploadPartRequest = new UploadPartRequest()
                        .withBucketName(BUCKET_NAME)
                        .withKey("user-uploads/" + fileName)
                        .withUploadId(uploadId)
                        .withPartNumber(i + 1)
                        .withInputStream(new FileInputStream(chunkFile))
                        .withPartSize(chunkFile.length());


                UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
                partETags.add(uploadPartResult.getPartETag());
            }


            ossClient.completeMultipartUpload(new CompleteMultipartUploadRequest(
                    BUCKET_NAME, "user-uploads/" + fileName, uploadId, partETags
            ));
        } finally {
            ossClient.shutdown();
            // 可选:删除本地临时分片
            FileUtils.deleteDirectory(new File("/tmp/oss-chunks/" + fileMd5));
        }
    }
}

也可以选择在后端仅合并到本地磁盘(示例控制器中展示),再上传整文件到 OSS。

避免重复上传(秒传):MD5 指纹机制

实现思路:

  1. 前端计算文件 MD5(分片计算以避免卡 UI)。
  2. 调用后端接口 /file/check-file(发送 fileMd5)。
  3. 后端查询数据库或文件存储映射:若存在则返回文件 URL(秒传),否则允许上传。

注意:OSS 的 ETag 在分片上传时不一定等同于完整文件 MD5(取决于实现),最好自行维护 MD5 映射表。

防毒机制(文件安全三道防线)

  1. 文件头校验(Magic Number):不信任扩展名,读取文件前若干字节判断实际类型。
  2. 内容扫描:接入 ClamAV 或云安全 API(阿里云内容安全等)进行深度检测。
  3. 权限隔离(双桶策略):上传到临时桶,检测通过后再转移到正式桶;正式桶限制执行与强制下载头 Content-Disposition: attachment

示例:文件头检测(Java)

public boolean checkFileHeader(File file) {
    byte[] header = new byte[8];
    try (FileInputStream fis = new FileInputStream(file)) {
        fis.read(header);
        String headerHex = bytesToHex(header);
        return headerHex.startsWith("FFD8FF")  // JPG
                || headerHex.startsWith("89504E47")  // PNG
                || headerHex.startsWith("47494638"); // GIF
    } catch (Exception e) {
        return false;
    }
}

Spring Boot 后端接口(完整控制层实现)

路径(示例):oss/src/main/java/com/icoderoad/controller/FileUploadController.java包名:com.icoderoad.controller`

说明:示例实现将分片保存到本地临时目录(可配置),并支持 /check-file/upload-chunk/merge-chunks。合并后可扩展为将文件推到 OSS 或入库记录。

package com.icoderoad.controller;


import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;


import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;


/**
 * 文件上传控制器:支持分片上传、断点续传、MD5 秒传检查、合并分片
 */
@RestController
@RequestMapping("/file")
public class FileUploadController {


    @Value("${upload.temp-dir:uploads/temp}")
    private String tempDir;


    @Value("${upload.final-dir:uploads/merged}")
    private String finalDir;


    /**
     * 检查文件是否已存在或已上传的分片(前端通过 fileMd5 调用)
     * 返回示例:
     * { "skipUpload": true, "uploadedChunks": [] } 或
     * { "skipUpload": false, "uploadedChunks": [0,1,2] }
     */
    @PostMapping(value = "/check-file", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public Map<String, Object> checkFile(@RequestParam("fileMd5") String fileMd5) {
        Map<String, Object> result = new HashMap<>();
        if (!StringUtils.hasText(fileMd5)) {
            result.put("skipUpload", false);
            result.put("uploadedChunks", Collections.emptyList());
            return result;
        }


        // 1)检查最终目录是否已存在(实现秒传)
        File mergedFile = new File(finalDir, fileMd5 + ".dat");
        if (mergedFile.exists()) {
            result.put("skipUpload", true);
            result.put("uploadedChunks", Collections.emptyList());
            return result;
        }


        // 2)检查临时目录已上传的分片
        File chunkFolder = new File(tempDir, fileMd5);
        if (chunkFolder.exists() && chunkFolder.isDirectory()) {
            File[] files = chunkFolder.listFiles((dir, name) -> name.endsWith(".part"));
            List<Integer> uploaded = new ArrayList<>();
            if (files != null) {
                for (File f : files) {
                    String name = f.getName(); // e.g., "0.part"
                    try {
                        String idxStr = name.split("\\.")[0];
                        uploaded.add(Integer.parseInt(idxStr));
                    } catch (Exception ignored) {}
                }
                Collections.sort(uploaded);
            }
            result.put("uploadedChunks", uploaded);
        } else {
            result.put("uploadedChunks", Collections.emptyList());
        }
        result.put("skipUpload", false);
        return result;
    }


    /**
     * 接收并保存单个分片
     * 参数:
     * - file (MultipartFile) : 分片内容
     * - fileMd5 (String) : 整个文件 MD5
     * - chunkIndex (int) : 当前分片编号(从 0 开始)
     */
    @PostMapping("/upload-chunk")
    public Map<String, Object> uploadChunk(@RequestParam("file") MultipartFile chunk,
                                           @RequestParam("fileMd5") String fileMd5,
                                           @RequestParam("chunkIndex") int chunkIndex) throws IOException {


        if (chunk == null || chunk.isEmpty()) {
            throw new RuntimeException("上传分片为空");
        }


        File chunkFolder = new File(tempDir, fileMd5);
        if (!chunkFolder.exists()) {
            chunkFolder.mkdirs();
        }


        File chunkFile = new File(chunkFolder, chunkIndex + ".part");
        try (InputStream in = chunk.getInputStream();
             OutputStream out = new FileOutputStream(chunkFile)) {
            byte[] buffer = new byte[8192];
            int len;
            while ((len = in.read(buffer)) != -1) {
                out.write(buffer, 0, len);
            }
        }


        Map<String, Object> result = new HashMap<>();
        result.put("uploaded", true);
        result.put("chunkIndex", chunkIndex);
        return result;
    }


    /**
     * 合并分片到最终文件(可选:再上传到 OSS)
     * 参数:
     * - fileMd5
     * - fileName
     * - totalChunks
     */
    @PostMapping("/merge-chunks")
    public Map<String, Object> mergeChunks(@RequestParam("fileMd5") String fileMd5,
                                           @RequestParam("fileName") String fileName,
                                           @RequestParam("totalChunks") int totalChunks) throws IOException {
        File chunkFolder = new File(tempDir, fileMd5);
        if (!chunkFolder.exists() || !chunkFolder.isDirectory()) {
            throw new RuntimeException("分片目录不存在");
        }


        File mergedFolder = new File(finalDir);
        if (!mergedFolder.exists()) mergedFolder.mkdirs();


        File mergedFile = new File(mergedFolder, fileName);
        try (OutputStream os = new BufferedOutputStream(new FileOutputStream(mergedFile, true))) {
            for (int i = 0; i < totalChunks; i++) {
                File part = new File(chunkFolder, i + ".part");
                if (!part.exists()) {
                    throw new RuntimeException("缺少分片: " + i);
                }
                Files.copy(part.toPath(), os);
            }
        }


        // 合并完成后删除分片(清理)
        for (File f : Objects.requireNonNull(chunkFolder.listFiles())) {
            f.delete();
        }
        chunkFolder.delete();


        // TODO: 可在此处调用 OSS Client 上传 mergedFile 到对象存储,并保存 MD5 -> URL 的映射到数据库。
        Map<String, Object> result = new HashMap<>();
        result.put("merged", true);
        result.put("filePath", mergedFile.getAbsolutePath());
        return result;
    }
}
    前端(Thymeleaf + Bootstrap + JS)完整示例

    路径/src/main/resources/templates/upload.html

    该页面功能:

    • 计算文件 MD5(分片计算,避免卡 UI)
    • 调用 /file/check-file 判断是否秒传或哪些分片已存在
    • 逐片上传 /file/upload-chunk
    • 上传进度条显示
    • 上传完成后触发 /file/merge-chunks
    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>OSS 分片上传演示</title>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"/>
        <script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
    </head>
    <body class="bg-light">
    <div class="container mt-5">
        <h2 class="mb-4 text-center">OSS 分片上传 + 秒传 + 进度条演示</h2>
        <div class="card p-4 shadow-sm">
            <input type="file" id="fileInput" class="form-control mb-3"/>
            <button id="uploadBtn" class="btn btn-primary w-100">开始上传</button>
    
    
            <div class="progress mt-3" style="height: 25px;">
                <div id="uploadProgress" class="progress-bar progress-bar-striped progress-bar-animated" 
                     style="width: 0%;">0%</div>
            </div>
            <div id="uploadStatus" class="mt-3 text-center text-secondary"></div>
        </div>
    </div>
    
    
    <script>
    const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB per chunk
    
    
    document.getElementById("uploadBtn").addEventListener("click", async () => {
        const file = document.getElementById("fileInput").files[0];
        if (!file) return alert("请选择文件");
    
    
        // 1. 计算 MD5(分片方式)
        document.getElementById("uploadStatus").innerText = "计算文件 MD5 中...";
        const fileMd5 = await calcFileMd5(file);
        const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
        document.getElementById("uploadStatus").innerText = `文件 MD5: ${fileMd5}`;
    
    
        // 2. 检查文件是否存在或已上传分片
        const checkResp = await fetch('/file/check-file', {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams({ fileMd5 })
        });
        const checkData = await checkResp.json();
    
    
        if (checkData.skipUpload) {
            document.getElementById("uploadStatus").innerText = "文件已存在,跳过上传 ";
            return;
        }
    
    
        const uploaded = new Set(checkData.uploadedChunks || []);
    
    
        // 3. 分片上传
        for (let i = 0; i < totalChunks; i++) {
            if (uploaded.has(i)) {
                updateProgress(i + 1, totalChunks);
                continue;
            }
            const start = i * CHUNK_SIZE;
            const end = Math.min(file.size, start + CHUNK_SIZE);
            const chunk = file.slice(start, end);
    
    
            const formData = new FormData();
            formData.append("file", chunk);
            formData.append("fileMd5", fileMd5);
            formData.append("chunkIndex", i);
    
    
            try {
                const res = await fetch('/file/upload-chunk', { method: 'POST', body: formData });
                if (!res.ok) throw new Error("上传分片失败");
            } catch (e) {
                alert("上传失败: " + e.message);
                return;
            }
    
    
            updateProgress(i + 1, totalChunks);
        }
    
    
        // 4. 合并分片
        const mergeResp = await fetch('/file/merge-chunks', {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams({
                fileMd5,
                fileName: file.name,
                totalChunks
            })
        });
        const mergeData = await mergeResp.json();
        if (mergeData.merged) {
            document.getElementById("uploadStatus").innerText = "上传并合并完成 ,路径:" + mergeData.filePath;
        } else {
            document.getElementById("uploadStatus").innerText = "合并失败";
        }
    });
    
    
    function updateProgress(done, total) {
        const pct = Math.floor((done / total) * 100);
        const bar = document.getElementById("uploadProgress");
        bar.style.width = pct + "%";
        bar.innerText = pct + "%";
    }
    
    
    async function calcFileMd5(file) {
        return new Promise((resolve, reject) => {
            const chunkSize = 10 * 1024 * 1024;
            const chunks = Math.ceil(file.size / chunkSize);
            const spark = new SparkMD5.ArrayBuffer();
            let currentChunk = 0;
            const reader = new FileReader();
    
    
            reader.onload = (e) => {
                spark.append(e.target.result);
                currentChunk++;
                if (currentChunk < chunks) loadNext();
                else resolve(spark.end());
            };
    
    
            reader.onerror = () => reject("MD5计算失败");
    
    
            function loadNext() {
                const start = currentChunk * chunkSize;
                const end = Math.min(file.size, start + chunkSize);
                reader.readAsArrayBuffer(file.slice(start, end));
            }
            loadNext();
        });
    }
    </script>
    </body>
    </html>
    配置(application.yml 示例)
    server:
      port: 8080
    
    
    upload:
      temp-dir: /var/data/uploads/temp   # 临时分片存储目录(Linux 风格)
      final-dir: /var/data/uploads/merged # 合并后文件存储目录

    请确保运行的服务账号对上述目录有读写权限。

    实战建议与优化点
    • 密钥管理:用 RAM/STS 或环境变量,不要写死 AK/SK。生产建议使用临时凭证或密钥轮换策略。
    • 分片大小:根据网络与客户端能力调整(常用 5MB / 10MB / 20MB)。分片过小增加请求开销,过大增加单次传输失败代价。
    • 并发上传:前端可并发上传 N 个分片,后端限制并发流量与速率,避免突发流量打垮服务器。
    • 断点续传/check-file 返回已上传分片索引,前端跳过已上传分片实现续传。
    • 重试策略:前端对失败的分片做指数退避重试(例如 3 次),避免短暂网络抖动导致整体失败。
    • 分布式合并:在高并发场景可采用 OSS 的 multipart 合并接口,或将合并任务异步化(消息队列 + 后台 worker)。
    • 安全检测:合并完成或上传到临时桶后,触发内容扫描(ClamAV / 云 API),通过后再移到正式桶并记录元数据。
    • 数据库映射:在合并成功后,把 fileMd5 -> 文件 URL 写入数据库,支持秒传和查看历史记录。
    后端 ER 的“文件上传生存指南”
    • 小文件:直接简单上传,轻量快速。
    • 大文件:分片上传(切片→传片→合并),支持断点续传与并发上传。
    • 去重(秒传):前端 MD5 + 后端校验(存在则跳过上传)。
    • 防毒:文件头检测 + 内容扫描 + 桶隔离(临时桶→正式桶)。

    掌握以上套路后,再被产品经理逼着“让用户上传 20G 设计图”时,你只需淡定回应:“放心,OSS 已经帮我们扛住了。”

    AI大模型学习福利

    作为一名热心肠的互联网老兵,我决定把宝贵的AI知识分享给大家。 至于能学习到多少就看你的学习毅力和能力了 。我已将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。

    一、全套AGI大模型学习路线

    AI大模型时代的学习之旅:从基础到前沿,掌握人工智能的核心技能!

    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取

    二、640套AI大模型报告合集

    这套包含640份报告的合集,涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示。

    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

    三、AI大模型经典PDF籍

    随着人工智能技术的飞速发展,AI大模型已经成为了当今科技领域的一大热点。这些大型预训练模型,如GPT-3、BERT、XLNet等,以其强大的语言理解和生成能力,正在改变我们对人工智能的认识。 那以下这些PDF籍就是非常不错的学习资源。


    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

    四、AI大模型商业化落地方案

    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

    作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量

    评论
    成就一亿技术人!
    拼手气红包6.0元
    还能输入1000个字符
     
    红包 添加红包
    表情包 插入表情
     条评论被折叠 查看
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值