Spring Boot 优雅处理大文件上传:分片 + 断点续传 + 秒传

引言

"产品经理上传一个 2GB 的安装包,进度条爬到 92% 时网络闪断,浏览器弹了个'上传失败'——一切从头再来。第三次传到 80% 时,Tomcat 那边先 OOM 了。"

这个事故里其实藏着三层问题:普通 multipart 上传把整个文件吃进服务器内存/临时目录,2GB 文件等于让每个请求都背着一颗内存炸弹;HTTP 是无状态的,传了 92% 断开,服务端对'已经收到多少'毫无记忆,重传只能从零开始;同一个安装包团队里十个人各传一遍,服务器接收了十遍完全相同的 2GB 字节。

对应的三个解法已经是行业标配:分片上传把大文件切成 5MB 小块逐个传,单次请求小、失败只需重传一块;断点续传用 Redis 记录已上传分片,重连后只传缺口;秒传在上传前先拿文件 MD5 问服务端"这个文件是不是已经有了",有就直接返回成功,一个字节都不用传。这篇文章给出从前端 File.slice 到后端合并落盘的完整实现:前端 TS + Axios 并发池,后端 Spring Boot + Redis 分片状态 + FileChannel 零内存合并,文末再讲生产环境演进到 OSS/S3 原生分片时的关键取舍——本地合并方案能让你彻底理解原理,而理解了"uploadId + 分片编号"这套模型,切换对象存储只是换一层 API。


一、全景:大文件为什么必须分片

1.1 普通上传在大文件下的四个死穴

浏览器一次性 POST multipart/form-data(2GB)
  │
  ├─ 浏览器/反向代理层:nginx client_max_body_size 默认 1MB,直接 413
  ├─ Tomcat 层:maxSwallowSize / multipart 临时文件占满磁盘
  ├─ Spring 层:MultipartFile 全量接收,内存+临时目录双份开销
  ├─ 网络层:任何一次抖动 = 2GB 全部重传,用户传一晚上传不完
  └─ 业务层:十个人传同一个包,服务器收十遍,带宽和磁盘全浪费

1.2 三功能的协作流程

① 前端计算文件 MD5(spark-md5 分片抽样/全量)
   │
   ② GET /upload/check?fileMd5=xxx&size=2147483648
   │     服务端返回:
   │      ├─ uploaded=true                    → ★ 秒传:直接完成,0 字节上传
   │      ├─ uploaded=false, chunks=[3,7]     → 断点续传:第3、7片已传,跳过
   │      └─ uploaded=false, chunks=[]        → 全新上传
   │
   ③ 前端 File.slice 切片(如每片 5MB),并发上传缺失分片
   │     POST /upload/chunk  (fileMd5, chunkIndex, chunkTotal, blob)
   │     服务端:存临时分片 + Redis 记录该片已到
   │
   ④ 全部分片到齐 → POST /upload/merge (fileMd5, fileName, chunkTotal)
   │     服务端:按编号顺序合并为最终文件、校验总 MD5/大小、登记秒传
   │
   ⑤ 返回最终文件访问 URL

1.3 数据模型与存储布局

Redis 里记两类状态:

upload:chunk:{fileMd5}        → Set,成员=已上传分片编号,如 {0,1,2,5}
upload:meta:{fileMd5}         → Hash:fileName/size/chunkTotal/uploaderId/createTime
                                TTL 24 小时,过期后分片由清理任务回收
sec:file:{md5}                → String,秒传命中表:md5 → 最终文件路径/URL
                                (持久化可落 MySQL,Redis 做缓存)

服务器磁盘布局:

/data/tmp-upload/{fileMd5}/
    ├── 0.part
    ├── 1.part
    └── ...
/data/files/2026/10/03/{fileMd5}_installer.dmg     ← 合并后的最终文件

用 fileMd5 当临时目录名的好处:天然隔离不同文件、天然支持同一用户重传、断点续传时目录还在就能继续。


二、前端:File.slice、MD5 与并发池

2.1 切片与 MD5 计算

浏览器端 MD5 用 spark-md5,为避免 2GB 文件一次性读入内存,分片读取、增量追加:

import SparkMD5 from 'spark-md5'

export const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB 一片

/** 分片读取计算整文件 MD5;大文件可在此加进度回调,避免主线程卡死建议放 Web Worker */
export function calcFileMd5(file: File, onProgress?: (p: number) => void): Promise<string> {
  return new Promise((resolve, reject) => {
    const chunks = Math.ceil(file.size / CHUNK_SIZE)
    const spark = new SparkMD5.ArrayBuffer()
    const reader = new FileReader()
    let current = 0

    reader.onload = (e) => {
      spark.append(e.target!.result as ArrayBuffer)
      current++
      onProgress?.(current / chunks)
      if (current < chunks) {
        loadNext()
      } else {
        resolve(spark.end())
      }
    }
    reader.onerror = () => reject(reader.error)

    function loadNext() {
      const start = current * CHUNK_SIZE
      const end = Math.min(start + CHUNK_SIZE, file.size)
      reader.readAsArrayBuffer(file.slice(start, end))
    }
    loadNext()
  })
}

export function sliceFile(file: File, index: number): Blob {
  const start = index * CHUNK_SIZE
  const end = Math.min(start + CHUNK_SIZE, file.size)
  return file.slice(start, end)
}

生产建议:MD5 计算放进 Web Worker,2GB 文件在主线程算会长时间卡死页面;对超大文件(10GB+)业界常用"抽样 MD5"(首尾固定字节 + 中间抽样 + 文件大小)做秒传初判,命中后再走全量校验,平衡 CPU 与准确率。

2.2 开传前检查:一个接口同时承载秒传与续传

export interface CheckResult {
  uploaded: boolean           // true=秒传命中
  uploadedChunks: number[]    // 已上传的分片编号(断点续传缺口表)
  url?: string
}

export async function checkUpload(fileMd5: string, size: number): Promise<CheckResult> {
  const { data } = await axios.get<CheckResult>('/api/upload/check', {
    params: { fileMd5, size },
  })
  return data
}

2.3 并发池:信号量模式,别用 Promise.race 野路子

分片串行传太慢,无限并发会把浏览器连接池打满(HTTP/1.1 同域名 6 条连接上限)。用一个固定大小的并发池(3~6 个),按"每片失败重试 3 次"调度:

export interface UploadCallbacks {
  onProgress?: (loaded: number, total: number) => void
  concurrency?: number
}

export async function uploadChunks(
  file: File,
  fileMd5: string,
  chunkTotal: number,
  uploadedChunks: Set<number>,
  cb: UploadCallbacks = {},
): Promise<void> {
  const concurrency = cb.concurrency ?? 4
  const pending: number[] = []
  for (let i = 0; i < chunkTotal; i++) {
    if (!uploadedChunks.has(i)) pending.push(i)
  }

  let loadedBytes = uploadedChunks.size * CHUNK_SIZE
  let cursor = 0

  async function worker() {
    while (cursor < pending.length) {
      const index = pending[cursor++]
      const blob = sliceFile(file, index)
      await uploadOneChunkWithRetry(fileMd5, index, chunkTotal, blob, () => {
        loadedBytes += blob.size
        cb.onProgress?.(Math.min(loadedBytes, file.size), file.size)
      })
    }
  }

  await Promise.all(Array.from({ length: concurrency }, () => worker()))
}

async function uploadOneChunkWithRetry(
  fileMd5: string, index: number, chunkTotal: number, blob: Blob,
  onLoaded: () => void,
) {
  const form = new FormData()
  form.append('fileMd5', fileMd5)
  form.append('chunkIndex', String(index))
  form.append('chunkTotal', String(chunkTotal))
  form.append('chunk', blob)

  let lastErr: unknown
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      await axios.post('/api/upload/chunk', form, {
        // ★ 单片超时要短:5MB 内网/正常宽带几秒内必完成,卡住快速失败重试
        timeout: 30_000,
        onUploadProgress: (e) => { if (e.total != null && e.loaded >= e.total) onLoaded() },
      })
      return
    } catch (err) {
      lastErr = err
      await new Promise((r) => setTimeout(r, 500 * attempt)) // 线性退避
    }
  }
  throw new Error(`分片 ${index} 重试3次仍失败`, { cause: lastErr })
}

这里有个踩过的坑值得强调:不要用 Promise.race 去管理并发池——race 返回的是兑现值而不是 Promise 引用,删除任务时会删错对象,池大小逐渐失控。worker 游标模式(每个 worker 从共享游标抢任务)实现最简单、池大小恒定。

2.4 编排主流程

export async function uploadFile(file: File, cb: UploadCallbacks = {}) {
  const chunkTotal = Math.ceil(file.size / CHUNK_SIZE)

  // ① MD5(实际项目放 Worker;MD5 阶段是本地计算,不计入网络上传进度)
  const fileMd5 = await calcFileMd5(file)

  // ② 检查:秒传 or 续传
  const check = await checkUpload(fileMd5, file.size)
  if (check.uploaded) {
    cb.onProgress?.(file.size, file.size)
    return { url: check.url!, instant: true }
  }

  // ③ 传缺口分片
  await uploadChunks(file, fileMd5, chunkTotal, new Set(check.uploadedChunks), cb)

  // ④ 合并
  const { data } = await axios.post('/api/upload/merge', null, {
    params: { fileMd5, fileName: file.name, chunkTotal, size: file.size },
  })
  return { url: data.url, instant: false }
}

三、后端:配置、Redis 状态与异常响应

3.1 上传相关配置

spring:
  servlet:
    multipart:
      max-file-size: 10MB          # ★ 单片大小上限(略大于5MB分片),不是整个文件
      max-request-size: 12MB
  data:
    redis:
      host: redis-prod
      timeout: 2000ms

upload:
  tmp-dir: /data/tmp-upload       # 分片临时目录,独立挂载大磁盘
  final-dir: /data/files          # 合并后文件目录
  chunk-expire-hours: 24          # 未完成上传的分片保留时间
  chunk-size: 5242880
server:
  tomcat:
    max-swallow-size: -1          # 关键:让 Tomcat 吞完异常请求体,避免连接重置干扰前端重试

注意 max-file-size 限制的是单个请求,分片模式下设为略大于单片即可——这正是大文件不撑爆服务端的关键:服务端在任何时刻都只持有一片 5MB。

3.2 Redis 状态服务

@Service
@Slf4j
public class UploadStateService {

    private static final String CHUNK_KEY = "upload:chunk:";
    private static final String META_KEY = "upload:meta:";
    private static final String SEC_KEY = "sec:file:";
    private static final Duration TTL = Duration.ofHours(24);

    private final StringRedisTemplate redis;

    public UploadStateService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    /** 记录一片到达:SADD + 刷新 TTL(每次上传都续期,活跃任务不过期) */
    public void markChunkUploaded(String fileMd5, int chunkIndex, UploadMeta meta) {
        redis.executePipelined(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) {
                operations.opsForSet().add(CHUNK_KEY + fileMd5, String.valueOf(chunkIndex));
                operations.expire(CHUNK_KEY + fileMd5, TTL);
                operations.opsForHash().putAll(META_KEY + fileMd5, meta.toMap());
                operations.expire(META_KEY + fileMd5, TTL);
                return null;
            }
        });
    }

    /** 取已上传分片,供断点续传 */
    public List<Integer> uploadedChunks(String fileMd5) {
        Set<String> members = redis.opsForSet().members(CHUNK_KEY + fileMd5);
        if (members == null || members.isEmpty()) {
            return Collections.emptyList();
        }
        return members.stream().map(Integer::parseInt).sorted().collect(Collectors.toList());
    }

    public long uploadedCount(String fileMd5) {
        Long size = redis.opsForSet().size(CHUNK_KEY + fileMd5);
        return size == null ? 0 : size;
    }

    /** 合并完成后清理上传态 */
    public void clearUploadState(String fileMd5) {
        redis.delete(CHUNK_KEY + fileMd5, META_KEY + fileMd5);
    }

    /** 秒传表:命中返回文件访问路径 */
    public String hitInstant(String fileMd5) {
        return redis.opsForValue().get(SEC_KEY + fileMd5);
    }

    public void registerInstant(String fileMd5, String relativePath) {
        // 秒传记录长期保存(配合数据库持久化,Redis 缓存即可)
        redis.opsForValue().set(SEC_KEY + fileMd5, relativePath, Duration.ofDays(30));
    }
}
@Data
@AllArgsConstructor
public class UploadMeta {
    private String fileName;
    private long size;
    private int chunkTotal;
    private String uploaderId;

    public Map<String, String> toMap() {
        Map<String, String> m = new HashMap<>();
        m.put("fileName", fileName);
        m.put("size", String.valueOf(size));
        m.put("chunkTotal", String.valueOf(chunkTotal));
        m.put("uploaderId", uploaderId);
        m.put("createTime", String.valueOf(System.currentTimeMillis()));
        return m;
    }
}

四、后端:检查、分片接收与合并三个接口

4.1 检查接口:秒传与断点续传的统一入口

@RestController
@RequestMapping("/api/upload")
@Slf4j
public class FileUploadController {

    private final UploadStateService stateService;
    private final UploadProperties props;

    @GetMapping("/check")
    public R<CheckVO> check(@RequestParam String fileMd5,
                            @RequestParam(required = false) Long size) {
        // ① 秒传:文件库里已经有完全相同的文件
        String existPath = stateService.hitInstant(fileMd5);
        if (existPath != null) {
            log.info("秒传命中 md5={}", fileMd5);
            return R.ok(CheckVO.instant(existPath));
        }
        // ② 断点续传:返回缺口表
        List<Integer> uploaded = stateService.uploadedChunks(fileMd5);
        return R.ok(CheckVO.resumable(uploaded));
    }
}
@Data
public class CheckVO {
    private boolean uploaded;
    private List<Integer> uploadedChunks;
    private String url;

    public static CheckVO instant(String path) {
        CheckVO vo = new CheckVO();
        vo.uploaded = true;
        vo.uploadedChunks = Collections.emptyList();
        vo.url = "/files/" + path;
        return vo;
    }
    public static CheckVO resumable(List<Integer> chunks) {
        CheckVO vo = new CheckVO();
        vo.uploaded = false;
        vo.uploadedChunks = chunks;
        return vo;
    }
}

4.2 分片接收:落临时目录,幂等写

@PostMapping("/chunk")
public R<Void> uploadChunk(@RequestParam String fileMd5,
                           @RequestParam int chunkIndex,
                           @RequestParam int chunkTotal,
                           @RequestParam MultipartFile chunk,
                           @AuthenticationPrincipal LoginUser user) throws IOException {
    // 基础校验:防越界、防伪造
    if (chunkIndex < 0 || chunkIndex >= chunkTotal) {
        throw new BizException(ErrorCode.BAD_REQUEST, "分片编号非法");
    }
    if (chunk.getSize() > props.getChunkSize() + 1024) {
        throw new BizException(ErrorCode.BAD_REQUEST, "分片过大");
    }
    String safeMd5 = validateMd5(fileMd5);   // 只允许十六进制字符,防路径穿越

    Path dir = Paths.get(props.getTmpDir(), safeMd5);
    Files.createDirectories(dir);
    Path partFile = dir.resolve(chunkIndex + ".part");

    // ★ 幂等:同一片重传直接覆盖写临时文件再原子 move,避免半片残留
    Path tmp = dir.resolve(chunkIndex + ".part.tmp");
    try (InputStream in = chunk.getInputStream()) {
        Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
    }
    Files.move(tmp, partFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);

    stateService.markChunkUploaded(safeMd5, chunkIndex,
            new UploadMeta("", 0L, chunkTotal, user.getUserId()));
    return R.ok();
}

/** MD5 白名单校验:拼路径的参数永远不能直接来自用户输入 */
private String validateMd5(String md5) {
    if (md5 == null || !md5.matches("^[a-fA-F0-9]{32}$")) {
        throw new BizException(ErrorCode.BAD_REQUEST, "fileMd5 非法");
    }
    return md5.toLowerCase();
}

几个安全与可靠性的细节:

  1. 防路径穿越:fileMd5 直接拼目录,必须正则白名单(../../etc/passwd 这类输入一律拒绝)。
  2. 先写 .tmp 再原子 move:写到一半进程重启/请求中断,留下的是 .tmp 残片而不是损坏的 .part;下次重传覆盖即可。
  3. 接口天然幂等:同编号分片重传结果一致,断点续传时前端无脑重传缺口也不会出错。

4.3 合并接口:FileChannel 零内存拼接

合并绝不能把 2GB 字节读到 byte[] 里(那就是事故里的 OOM)。用 NIO 的 transferTo/transferFrom 在内核态完成文件拼接:

@PostMapping("/merge")
public R<MergeVO> merge(@RequestParam String fileMd5,
                        @RequestParam String fileName,
                        @RequestParam int chunkTotal,
                        @RequestParam long size,
                        @AuthenticationPrincipal LoginUser user) throws IOException {
    String safeMd5 = validateMd5(fileMd5);
    Path dir = Paths.get(props.getTmpDir(), safeMd5);

    // ① 分片完整性校验:数量、编号连续、无 .tmp 残片
    List<Path> parts = collectAndVerifyParts(dir, chunkTotal, safeMd5);

    // ② 合并到最终目录(按日期分桶,避免单目录文件过多)
    String datePart = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
    Path finalDir = Paths.get(props.getFinalDir(), datePart);
    Files.createDirectories(finalDir);
    String safeName = sanitizeFileName(fileName);
    Path target = finalDir.resolve(safeMd5 + "_" + safeName);
    Path merging = target.resolveSibling(target.getFileName() + ".merging");

    long mergedBytes = mergeWithChannel(parts, merging);

    // ③ 大小校验:字节数不符说明分片损坏/伪造,直接失败
    if (mergedBytes != size) {
        Files.deleteIfExists(merging);
        log.warn("合并大小不符 md5={} expect={} actual={}", safeMd5, size, mergedBytes);
        throw new BizException(ErrorCode.UPLOAD_BROKEN, "文件大小校验失败,请重新上传");
    }

    // ④ 整体 MD5 校验(流式,不进内存),通过后才原子转正
    String actualMd5 = DigestUtils.md5DigestAsHex(Files.newInputStream(merging));
    if (!safeMd5.equals(actualMd5)) {
        Files.deleteIfExists(merging);
        throw new BizException(ErrorCode.UPLOAD_BROKEN, "MD5 校验失败");
    }
    Files.move(merging, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);

    // ⑤ 登记秒传 + 清理临时分片和 Redis 状态
    String relative = datePart + "/" + target.getFileName();
    stateService.registerInstant(safeMd5, relative);
    stateService.clearUploadState(safeMd5);
    cleanPartsQuietly(dir);

    return R.ok(new MergeVO("/files/" + relative));
}

private List<Path> collectAndVerifyParts(Path dir, int chunkTotal, String md5) throws IOException {
    if (!Files.isDirectory(dir)) {
        throw new BizException(ErrorCode.UPLOAD_NOT_FOUND, "分片不存在");
    }
    // Redis 计数快速判定,磁盘清单做最终事实校验(Redis 与磁盘可能短暂不一致)
    long redisCount = stateService.uploadedCount(md5);
    if (redisCount < chunkTotal) {
        throw new BizException(ErrorCode.UPLOAD_INCOMPLETE,
                "分片不完整,缺少 " + (chunkTotal - redisCount) + " 片");
    }
    List<Path> parts = new ArrayList<>(chunkTotal);
    for (int i = 0; i < chunkTotal; i++) {
        Path p = dir.resolve(i + ".part");
        if (!Files.isRegularFile(p)) {
            throw new BizException(ErrorCode.UPLOAD_INCOMPLETE, "缺少分片 " + i);
        }
        parts.add(p);
    }
    return parts;
}

/** ★ 核心:transferTo 利用操作系统 sendfile,字节不进 JVM 堆 */
private long mergeWithChannel(List<Path> parts, Path target) throws IOException {
    long total = 0;
    try (FileChannel out = FileChannel.open(target,
            StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
        for (Path part : parts) {
            try (FileChannel in = FileChannel.open(part, StandardOpenOption.READ)) {
                long transferred = 0;
                while (transferred < in.size()) {
                    long n = in.transferTo(transferred, in.size() - transferred, out);
                    if (n <= 0) break;
                    transferred += n;
                }
                total += transferred;
            }
        }
    }
    return total;
}

两个工程细节:

  1. .merging 临时名 + 原子转正:合并过程中目标文件名不可见,其他请求不会读到半成品;校验通过才 move。
  2. Redis 计数 + 磁盘清单双校验:Redis 说有 100 片但磁盘可能因清理/故障缺一片,以磁盘文件为准;Redis 的价值是快速返回缺口表,不是事实来源。

4.4 秒传的安全边界

秒传的信任链是"MD5 相同 → 文件相同",工程上要注意:

  • MD5 只用于去重标识,不用于安全场景(防碰撞要用 SHA-256)。普通业务文件去重 MD5 足够;高安全要求的场景用 SHA-256。
  • 秒传命中后仍要做权限校验:用户 B 不能因为猜中/得知某个 MD5 就拿到用户 A 私有文件的 URL。私有文件的秒传命中应返回"服务端已存,为你建立引用关系",URL 走带鉴权的临时签名,而不是返回公开地址。
  • 合并后校验整文件 MD5 是秒传可信的前提——只有校验过的文件才能进 sec:file: 表。

五、配套机制:过期清理、Nginx 与进度

5.1 半途而废的分片谁来收

用户传一半关了浏览器,Redis 的 TTL 只清状态,磁盘上的 .part 不会自己消失。用定时任务按目录扫:

@Component
@Slf4j
public class ChunkCleaner {

    private final UploadProperties props;

    @Scheduled(cron = "0 30 3 * * ?")   // 每天凌晨 3:30
    public void cleanExpired() {
        Path root = Paths.get(props.getTmpDir());
        if (!Files.isDirectory(root)) return;
        Instant deadline = Instant.now().minus(props.getChunkExpireHours(), ChronoUnit.HOURS);
        try (Stream<Path> dirs = Files.list(root)) {
            dirs.filter(Files::isDirectory).forEach(dir -> {
                try {
                    Instant lastModified = Files.getLastModifiedTime(dir).toInstant();
                    if (lastModified.isBefore(deadline)) {
                        deleteRecursively(dir);
                        log.info("清理过期分片目录 {}", dir);
                    }
                } catch (IOException e) {
                    log.warn("清理失败 {}", dir, e);
                }
            });
        } catch (IOException e) {
            log.warn("扫描分片目录失败", e);
        }
    }
    // deleteRecursively 用 Files.walk 逆序删除,略
}

5.2 Nginx 网关配置

大文件上传经过 Nginx 时,分片接口与最终访问要分开配置:

server {
    listen 443 ssl;
    client_max_body_size 12m;              # 与单片大小匹配,不是2GB
    client_body_buffer_size 1m;
    proxy_request_buffering off;           # ★ 关闭请求缓冲,边收边转发给应用
    proxy_read_timeout 120s;
    proxy_send_timeout 120s;

    location /api/upload/ {
        proxy_pass http://upload_cluster;
    }
}

5.3 秒传率与可观测性

上传服务至少埋这几个指标:秒传命中率、断点续传率(触发缺口上传的会话占比)、单片 P99 耗时、合并 P99 耗时、合并失败率(MD5/大小不符)、临时目录磁盘占用。秒传命中率超过 40% 的团队(安装包、设计素材类场景),带宽账单通常能直接砍掉三分之一以上——这是三功能里 ROI 最高的一个。


六、生产演进:本地磁盘到 OSS/S3 原生分片

当应用走向多实例/容器化,本地磁盘方案会遇到新问题:分片散落在各应用节点上(除非做 sticky 路由或共享存储)、扩容时数据不迁移、没有 11 个 9 的持久性。这时不要自己写"分片传服务器→服务器再传 OSS→回读合并"的中转链路(真实踩坑:40MB 文件产生 80MB+ 传输量,耗时翻倍),直接用对象存储的原生 Multipart Upload 模型——它和本文的模型几乎一一对应:

本文本地方案OSS/S3 原生分片
fileMd5 标识一次上传uploadId(initiate 后返回)
0.part/1.part 写本地磁盘前端直传 PUT Part,返回 ETag
Redis Set 记录已传分片ListParts 接口直接查询已传分片
FileChannel 合并completeMultipartUpload(uploadId, ETag列表),服务端无回读
合并后 MD5 校验登记回调/轮询确认对象存在后登记秒传
秒传表服务端 MD5 索引,命中后走 CopyObject 或直接返回已有对象

标准流程:前端先请求后端拿 STS 临时凭证 + uploadId → 浏览器直传分片到 OSS(不占应用带宽)→ 完成时调后端 complete,后端用 ETag 列表收尾并登记。本文的 Redis 状态、秒传表、MD5 校验逻辑全部保留,只是把"分片落在哪里"从本地磁盘换成对象存储。 这也是为什么建议先用本地方案理解原理——核心模型(切片编号、缺口查询、最终合并、完整性校验)在任何存储后端都是同一套。


七、常见问题

7.1 分片大小怎么定?5MB 是铁律吗?

不是,5MB 是通用甜点值,按场景调:太小(如 100KB)请求数爆炸,HTTP 头部和 TLS 握手开销占比高,2GB 文件要 2 万次请求;太大(如 50MB)单片失败重传成本高、弱网下超时率上升。经验区间 2MB~10MB:内网/带宽好用 10MB 减少请求数,移动弱网用 2MB~3MB 提高单片成功率。OSS/S3 要求分片 ≥5MB(最后一片除外),最大 1 万片——这也是选片大小要过的硬约束(2GB/5MB=400 片,远低于上限)。

7.2 浏览器关了再打开,Redis 记录还在但分片文件被清理了怎么办?

Redis 状态与磁盘事实可能不一致,所以合并接口用"Redis 快速计数 + 磁盘最终校验"双层判定(4.3 节)。更稳妥的做法是清理任务同时删除 Redis 状态(按目录名即 fileMd5 反查删除 key),让两边一致;断点续传检查时如果发现 Redis 说有分片但磁盘抽样缺失,应返回空缺口表让前端全量重传。永远不要仅凭 Redis 就告诉前端"这一片不用传了"。

7.3 MD5 算 2GB 文件太慢,页面卡死怎么优化?

三层优化:① MD5 计算放 Web Worker,主线程零卡顿;② 用"抽样指纹"做秒传初判(文件名+大小+首尾各 1MB+中间 N 段的哈希),服务端命中抽样指纹后再要求关键场景做全量校验,未命中则全量上传,用低代价排除"大概率不同"的文件;③ 抽样指纹相同的文件服务端合并后仍做全量 MD5 终检,保证秒传表零误判。注意抽样指纹只用于性能优化,不能替代最终完整性校验。

7.4 合并接口超时或应用重启,文件会坏吗?

不会,靠三道保险:单片先写 .part.tmp 再原子 rename;合并写 .merging 临时名,MD5 和大小双校验通过后才原子转正为最终文件;重启后残留的 .tmp/.merging 由清理任务回收。前端合并请求失败后的正确动作是重试 merge,而不是重传分片——merge 是幂等的(目标存在且校验一致直接返回成功),分片文件还在就能反复合并。

7.5 多实例部署时分片落在不同机器上怎么办?

三种方案按规模选:① Nginx 对同一 fileMd5 做一致性 hash 路由(sticky),同一会话固定到一个实例,简单但实例宕机会话失效;② 分片直接放共享存储(NFS/OSS),应用节点无状态,最常见的过渡方案;③ 直接上 OSS/S3 原生分片直传(第六章),应用节点只做凭证和状态管理,是云上的终态方案。不建议自己做"跨节点搬分片",复杂度和故障面都不划算。

7.6 怎么防止恶意用户伪造分片刷磁盘?

入口约束:登录鉴权 + 单片大小上限 + chunkTotal 上限(防申请天文数字分片);为每个用户/租户统计"未完成上传"总占用并设配额,超限拒绝新任务;fileMd5 严格白名单防路径穿越;临时目录所在磁盘独立挂载并配使用率告警(85% 告警、95% 触发上传降级);清理任务 TTL 必须短于磁盘容量可承受的堆积周期。秒传接口也要防遍历:接口加频率限制,避免被用来探测"某个 MD5 文件是否存在"。


八、总结

全链路速查卡

【前端】
  5MB 切片:file.slice(start,end)
  MD5:spark-md5 分片增量追加,生产放 Web Worker
  GET /check:uploaded=true 秒传结束;否则拿 uploadedChunks 缺口表
  并发池:worker游标模式,并发3~6,单片超时30s,失败3次线性退避
  POST /chunk × N(只传缺口)→ POST /merge

【后端 Redis】
  upload:chunk:{md5}  Set 已传编号(TTL 24h,每片续期)
  upload:meta:{md5}   Hash 文件名/大小/片数/上传者
  sec:file:{md5}      秒传命中表(建议数据库持久化 + Redis 缓存)

【后端接口】
  /check:秒传 > 断点续传缺口表
  /chunk:MD5白名单校验 → 写 .part.tmp → 原子 move .part(幂等)
  /merge:Redis计数+磁盘清单双校验
          → FileChannel.transferTo 内核态拼接(零堆内存)
          → 大小校验 + 流式整文件MD5 → 原子转正
          → 登记秒传 + 清理分片与状态

【运维】
  multipart.max-file-size=10MB(单片不是整文件)
  nginx client_max_body_size 12m + proxy_request_buffering off
  Tomcat max-swallow-size=-1
  分片目录独立挂载 + 定时清理过期目录(与Redis状态一起清)
  终态:OSS/S3 原生 Multipart(uploadId+ETag+complete),拒绝中转双传

给团队的建议

项建议
切片大小默认 5MB,弱网调小、内网调大;受 OSS 5MB 下限和 1 万片上限约束
合并永远用 FileChannel/对象存储 complete,禁止把整片读进 byte[]
一致性临时名 + 原子 move;Redis 只做加速查询,磁盘/对象存储才是事实源
安全fileMd5 白名单防穿越、私有文件秒传仍鉴权、配额与磁盘告警、清理 TTL
重试语义单片失败重单片;merge 失败重试 merge(幂等);不要整文件重传
架构演进单机本地磁盘理解原理,多实例/容器直接上 OSS 原生分片,模型原样平移
指标秒传命中率、续传率、单片/合并 P99、合并失败率、临时磁盘占用

一句话

大文件上传的三个功能本质上是在给"一个必然会失败的长过程"增加状态:普通 multipart 上传让 2GB 文件一次性穿过浏览器、Nginx、Tomcat 和 JVM 堆,任何一层内存不足或网络抖动都会导致全部归零,而分片上传用 File.slice 把它切成 5MB 的独立请求,服务端每次只接收一片,配合先写 .tmp 再原子 rename 让单片接收天然幂等,失败只重传一块而不是重传 2GB;断点续传的关键是给上传过程建立外部记忆——Redis 的 Set 按 fileMd5 记录已到分片编号、check 接口返回缺口表,前端 worker 并发池只传缺失部分,但要牢记 Redis 只是加速查询,合并时必须以磁盘上连续编号的 .part 清单做最终校验,并用统一 TTL 的清理任务让 Redis 状态和临时目录同生共死;秒传则是在传输发生之前用文件 MD5 问一句"这个字节集服务端是不是已经有了",命中就零字节完成,三功能里 ROI 最高但前提是合并后做过流式整文件 MD5 校验、且私有文件命中后仍然走鉴权;最后的合并必须用 FileChannel.transferTo 借内核 sendfile 完成拼接,字节不进 JVM 堆,配合 .merging 临时名和大小+MD5 双校验后的原子转正,彻底杜绝半成品可见。理解了"MD5 标识一次上传、分片编号提供随机访问、缺口查询实现续传、完整校验保证最终一致"这套模型,从本地磁盘演进到 OSS/S3 原生 Multipart 时就会发现 uploadId、ETag、ListParts、complete 只是同一套思想的对象存储方言——而真正决定成败的永远不是 API 选择,而是并发池别用 Promise.race、单片超时要短、merge 要幂等、磁盘配额要告警这些工程细节。

互动话题:你们的大文件上传最大支持到多少 GB?有没有遇到过 90% 断网重传、合并 OOM 或者多实例分片散落在不同机器上的问题?最终是自建分片还是直接上了 OSS 直传?秒传命中率最高的业务场景是什么?评论区聊聊你的方案和踩坑。


参考资料


标题:Spring Boot 优雅处理大文件上传:分片 + 断点续传 + 秒传
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/10/05/1791036626826.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消