一、为什么大文件上传是个技术活

想象一下你要把一部4K电影上传到网盘,或者把几个GB的日志文件传给同事。如果直接扔进普通表单,浏览器可能会卡死,服务器也可能崩溃。这就像用自行车运冰箱——不是不行,但肯定不是最优解。

大文件上传的核心痛点有三个:

  1. 稳定性:网络抖动或中断会导致前功尽弃
  2. 性能:内存溢出风险高,尤其Java应用默认堆内存有限
  3. 体验:用户需要看到进度条,而不是"假死"的页面

二、分片上传:把大象切成牛排

分片上传是当前最主流的解决方案,原理就像用快递寄家具——拆成零件分批运输,到货后再组装。

技术栈选择:Spring Boot + 阿里云OSS SDK(其他云服务类似)

// 文件分片工具类示例
public class FileSplitter {
    /**
     * 将文件切分为指定大小的分片
     * @param sourceFile 原始文件
     * @param chunkSize 分片大小(单位:MB)
     * @return 分片临时文件列表
     */
    public static List<File> split(File sourceFile, int chunkSize) throws IOException {
        List<File> chunks = new ArrayList<>();
        try (InputStream in = new FileInputStream(sourceFile)) {
            byte[] buffer = new byte[chunkSize * 1024 * 1024];
            int bytesRead;
            int index = 0;
            
            while ((bytesRead = in.read(buffer)) > 0) {
                File chunkFile = new File(sourceFile.getParent(), 
                    sourceFile.getName() + ".part" + index);
                
                try (OutputStream out = new FileOutputStream(chunkFile)) {
                    out.write(buffer, 0, bytesRead);
                }
                chunks.add(chunkFile);
                index++;
            }
        }
        return chunks;
    }
}

关键参数建议

  • 分片大小通常设为5-10MB(平衡网络开销和并发效率)
  • 需要记录分片序号用于服务端重组

三、断点续传:让上传像游戏存档

分片只是基础,真正的可靠性要靠断点续传实现。这里需要解决两个问题:

  1. 客户端记录:本地存储已上传分片信息
  2. 服务端校验:避免重复上传

前端实现示例(Vue + axios)

// 上传状态管理类
class UploadTracker {
  constructor(fileId) {
    this.fileId = fileId;
    this.chunks = JSON.parse(localStorage.getItem(`upload_${fileId}`)) || [];
  }

  // 记录成功上传的分片
  markChunkComplete(chunkIndex) {
    if (!this.chunks.includes(chunkIndex)) {
      this.chunks.push(chunkIndex);
      localStorage.setItem(`upload_${fileId}`, JSON.stringify(this.chunks));
    }
  }

  // 获取待上传分片
  getPendingChunks(totalChunks) {
    return Array.from({length: totalChunks}, (_, i) => i)
           .filter(i => !this.chunks.includes(i));
  }
}

服务端校验接口(Spring MVC)

@RestController
@RequestMapping("/upload")
public class UploadController {
    
    @GetMapping("/check")
    public ResponseEntity<Map<String, Object>> checkChunk(
            @RequestParam String fileMd5,
            @RequestParam int chunkIndex) {
        
        // 实际项目中应查询数据库或分布式缓存
        boolean exists = checkChunkExistsInStorage(fileMd5, chunkIndex);
        
        Map<String, Object> response = new HashMap<>();
        response.put("exists", exists);
        response.put("chunkIndex", chunkIndex);
        
        return ResponseEntity.ok(response);
    }
    
    private boolean checkChunkExistsInStorage(String fileMd5, int chunkIndex) {
        // 伪代码:检查OSS等存储服务
        return OSSClient.doesObjectExist(bucketName, 
            "temp/" + fileMd5 + "/" + chunkIndex);
    }
}

四、实战中的进阶技巧

4.1 秒传优化

通过文件指纹(MD5/SHA1)实现"文件去重":

// 使用Apache Commons Codec计算文件指纹
public static String calculateFileMd5(File file) throws IOException {
    try (InputStream is = new FileInputStream(file)) {
        return DigestUtils.md5Hex(is);
    }
}

4.2 并发控制

浏览器并发请求数有限制,建议采用令牌桶算法控制:

// 简易令牌桶实现
public class UploadThrottler {
    private final Semaphore semaphore;
    
    public UploadThrottler(int maxConcurrent) {
        this.semaphore = new Semaphore(maxConcurrent);
    }
    
    public boolean tryAcquire() {
        return semaphore.tryAcquire();
    }
    
    public void release() {
        semaphore.release();
    }
}

4.3 服务端合并策略

分片全部上传后,建议用异步任务合并:

// 使用Spring异步任务合并文件
@Service
public class FileMergeService {
    
    @Async
    public void mergeFiles(String fileMd5, String fileName) {
        List<OSSObjectSummary> chunks = listChunksFromOSS(fileMd5);
        // 按分片序号排序后合并
        chunks.sort(Comparator.comparingInt(this::extractChunkIndex));
        
        try (OutputStream mergedStream = new FileOutputStream(finalFile)) {
            for (OSSObjectSummary chunk : chunks) {
                OSSObject object = ossClient.getObject(bucketName, chunk.getKey());
                try (InputStream chunkStream = object.getObjectContent()) {
                    IOUtils.copy(chunkStream, mergedStream);
                }
            }
        }
        // 清理临时分片
        deleteChunks(chunks); 
    }
}

五、避坑指南

  1. 内存泄漏:务必关闭所有InputStream/OutputStream
  2. 文件名冲突:使用UUID代替原始文件名存储
  3. 权限控制:临时分片目录需要设置过期时间
  4. 监控报警:对失败分片建立重试机制

六、技术选型对比

方案 优点 缺点
普通表单上传 实现简单 超过100MB风险极高
分片上传 支持断点续传 需要前后端协同开发
WebSocket 实时性高 服务端资源消耗大
第三方SDK 快速集成 可能有vendor lock-in

七、总结

大文件上传就像搬家——用对工具才能省时省力。分片上传是基础,断点续传是保障,而良好的用户体验才是终极目标。在实际项目中,建议:

  1. 优先使用成熟的对象存储服务(OSS/S3)
  2. 前端做好错误处理和进度展示
  3. 服务端实现幂等性接口

记住:没有完美的方案,只有最适合业务场景的取舍。