后端接口的 “文件上传与下载” 优化:从 “简单实现” 到 “高可用方案”

136 阅读5分钟

文件上传下载是后端接口的常见需求(如头像上传、报表下载),但简单实现往往存在隐患 —— 大文件上传超时、下载并发过高导致服务器带宽耗尽、文件存储占用过多磁盘空间。优化方案需兼顾可靠性(不丢文件)、性能(快传快下)、经济性(节省存储和带宽),构建高可用的文件处理体系。

文件上传:突破大小限制与并发瓶颈

1. 基础实现的问题

传统单文件上传接口(如 Spring MVC 的MultipartFile)在处理大文件(如 100MB 以上)时容易出现:

  • 超时:上传时间超过接口超时设置
  • 内存溢出:文件临时存储占用过多内存
  • 并发低:单线程处理,无法利用多线程加速

2. 分片上传:将大文件 “化整为零”

将大文件拆分为多个小分片(如 5MB / 片),分多次上传,最后合并:

前端流程

  1. 计算文件 MD5(用于校验完整性)

  2. 拆分文件为分片(如file_1.partfile_2.part...)

  3. 依次上传分片(支持并发上传)

  4. 所有分片上传完成后,请求合并

后端实现

@RestController
@RequestMapping("/files")
public class FileUploadController {
    @Autowired
    private FileService fileService;
    
    // 上传分片
    @PostMapping("/upload/part")
    public Result uploadPart(
            @RequestParam("file") MultipartFile file,
            @RequestParam("fileMd5") String fileMd5,
            @RequestParam("partIndex") int partIndex,
            @RequestParam("totalParts") int totalParts) {
        fileService.savePart(fileMd5, partIndex, file);
        return Result.success();
    }
    
    // 合并分片
    @PostMapping("/upload/merge")
    public Result mergeFile(
            @RequestParam("fileMd5") String fileMd5,
            @RequestParam("fileName") String fileName) {
        String filePath = fileService.mergeParts(fileMd5, fileName);
        return Result.success(filePath);
    }
}

// 服务层实现
@Service
public class FileService {
    // 临时存储分片(使用MD5作为目录名)
    private final String TEMP_DIR = "/data/temp/";
    
    public void savePart(String fileMd5, int partIndex, MultipartFile file) {
        String partDir = TEMP_DIR + fileMd5 + "/";
        File dir = new File(partDir);
        if (!dir.exists()) {
            dir.mkdirs();
        }
        // 保存分片到临时目录
        File partFile = new File(partDir + partIndex);
        file.transferTo(partFile);
    }
    
    public String mergeParts(String fileMd5, String fileName) {
        String partDir = TEMP_DIR + fileMd5 + "/";
        File dir = new File(partDir);
        File[] parts = dir.listFiles();
        if (parts == null || parts.length == 0) {
            throw new BusinessException("分片不存在");
        }
        // 按索引排序分片
        Arrays.sort(parts, Comparator.comparingInt(f -> Integer.parseInt(f.getName())));
        
        // 合并分片到目标文件
        String targetPath = "/data/files/" + fileName;
        try (FileOutputStream out = new FileOutputStream(targetPath)) {
            for (File part : parts) {
                try (FileInputStream in = new FileInputStream(part)) {
                    byte[] buffer = new byte[1024 * 1024];
                    int len;
                    while ((len = in.read(buffer)) != -1) {
                        out.write(buffer, 0, len);
                    }
                }
                // 删除临时分片
                part.delete();
            }
        } catch (IOException e) {
            throw new BusinessException("合并文件失败");
        }
        // 删除临时目录
        dir.delete();
        return targetPath;
    }
}

分片上传优势

  • 支持大文件:规避单文件大小限制
  • 断点续传:某分片上传失败,只需重传该分片
  • 并发加速:多线程同时上传不同分片

3. 上传优化:减少服务端压力

  • 前端校验:上传前检查文件大小、类型(如 “仅支持 jpg/png,最大 50MB”),减少无效请求

  • CDN 加速:静态文件(如头像、附件)通过 CDN 上传,绕过应用服务器

  • 对象存储:将文件存储到 OSS(如阿里云 OSS、AWS S3),避免占用服务器磁盘

// 直传OSS示例(前端获取签名后直接上传到OSS)
@GetMapping("/upload/sign")
public Result getOssSign(@RequestParam("fileName") String fileName) {
    // 生成OSS上传签名(有效期30分钟)
    String policy = ossService.generatePolicy(fileName, 30);
    return Result.success(policy);
}

文件下载:提升速度与并发能力

1. 基础下载的问题

简单的response.getOutputStream()下载存在:

  • 大文件下载占用服务器内存
  • 并发高时带宽被占满,影响其他服务
  • 无断点续传,网络中断需重新下载

2. 断点续传:支持部分下载

通过Range请求头实现断点续传,客户端可指定下载的字节范围:

@GetMapping("/files/{fileName}")
public void downloadFile(
        @PathVariable String fileName,
        HttpServletRequest request,
        HttpServletResponse response) {
    String filePath = "/data/files/" + fileName;
    File file = new File(filePath);
    if (!file.exists()) {
        response.setStatus(404);
        return;
    }
    
    try {
        long fileLength = file.length();
        // 获取Range请求头(如Range: bytes=1024-2047)
        String range = request.getHeader("Range");
        if (range != null) {
            // 处理断点续传
            response.setStatus(206); // 部分内容
            String[] rangeParts = range.replace("bytes=", "").split("-");
            long start = Long.parseLong(rangeParts[0]);
            long end = rangeParts.length > 1 ? Long.parseLong(rangeParts[1]) : fileLength - 1;
            // 设置响应头:Content-Range
            response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);
            // 传输指定范围的字节
            try (FileInputStream in = new FileInputStream(file);
                 OutputStream out = response.getOutputStream()) {
                in.skip(start);
                byte[] buffer = new byte[1024 * 1024];
                long remaining = end - start + 1;
                while (remaining > 0) {
                    int len = in.read(buffer, 0, (int) Math.min(buffer.length, remaining));
                    out.write(buffer, 0, len);
                    remaining -= len;
                }
            }
        } else {
            // 正常下载(全量)
            response.setHeader("Content-Length", String.valueOf(fileLength));
            try (FileInputStream in = new FileInputStream(file);
                 OutputStream out = response.getOutputStream()) {
                byte[] buffer = new byte[1024 * 1024];
                int len;
                while ((len = in.read(buffer)) != -1) {
                    out.write(buffer, 0, len);
                }
            }
        }
    } catch (IOException e) {
        log.error("下载文件失败:{}", fileName, e);
    }
}

3. 下载优化:减轻服务器负担

  • 使用 CDN 分发:热门文件(如报表模板)通过 CDN 缓存,用户从 CDN 节点下载

  • 压缩传输:对文本类文件(如 CSV 报表)启用 Gzip 压缩(参考 “数据压缩” 实践)

  • 异步生成大文件:如 “导出 100 万条数据”,先异步生成文件,完成后通知用户下载

// 异步生成并通知下载
@PostMapping("/reports/export")
public Result exportReport(@RequestBody ReportParam param) {
    // 生成任务ID
    String taskId = UUID.randomUUID().toString();
    // 异步生成报表
    CompletableFuture.runAsync(() -> {
        String filePath = reportService.generateLargeReport(param);
        // 生成完成后,通知用户(如发送站内信)
        notificationService.send(param.getUserId(), "报表已生成,可下载:" + filePath);
    });
    return Result.success("报表生成中,任务ID:" + taskId);
}

避坑指南

  • 限制文件大小和类型:防止恶意上传超大文件或可执行文件(如.exe

  • 校验文件完整性:通过 MD5/SHA256 校验上传文件,避免损坏

  • 清理临时文件:定时删除未合并的分片、过期的下载文件,释放磁盘空间

  • 监控存储使用:设置磁盘使用率告警(如超过 80% 时预警)

文件上传下载的优化,核心是 “将压力从应用服务器转移出去”—— 通过分片、CDN、对象存储等手段,让应用服务器专注于业务逻辑,而非文件 IO。一个稳定的文件处理体系,既能提升用户体验(快传快下),又能保障系统在高并发下的可用性,这是后端接口 “细节决定成败” 的典型场景。