Spring Boot 大文件下载实战:从 OOM 暴涨到 Range 断点续传与架构设计

2026-10-01 1点热度 0人点赞 0条评论

在日常开发中,文件下载往往是从小文件(几 KB 的 PDF、几 MB 的 Excel)开始的。但随着业务扩展,当文件规格膨胀到数 GB 甚至 10GB 时,原有的简单实现不仅会引发 JVM OOM(内存溢出),还会让用户在网络波动时反复从 0% 重新下载。

本文梳理大文件下载的三代代码演进、Spring MVC 原生 Range 机制的避坑指南,以及生产级方案的架构考量。


1. 代码演进史

V1:内存全量加载(埋雷写法)

@GetMapping("/files/{id}/download")
public ResponseEntity<byte[]> download(@PathVariable Long id) throws IOException {
    FileInfo file = fileService.findById(id);
    byte[] bytes = Files.readAllBytes(Path.of(file.getPath())); // 致命点

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"")
            .body(bytes);
}
  • 问题:Files.readAllBytes() 会将整个文件读入 JVM Heap。下载 10GB 文件就意味着申请一个 10GB 的 byte[],高并发下服务瞬间瘫痪并触发 OOM。


V2:流式下载(解决内存问题,未解决体验问题)

@GetMapping("/files/{id}/download")
public void download(@PathVariable Long id, HttpServletResponse response) throws IOException {
    FileInfo file = fileService.findById(id);
    Path path = Path.of(file.getPath());

    response.setContentType("application/octet-stream");
    response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"");

    try (InputStream input = Files.newInputStream(path);
         OutputStream output = response.getOutputStream()) {
        input.transferTo(output); // 边读边发,内存平稳
    }
}
  • 优点:数据分块按流读取并写出,内存占用降至极小。

  • 致命缺陷:不支持断点续传。用户下载 8GB 文件,如果在 7.6GB 处断网,重新点击必须从 0% 重新下载。


V3:借助 Spring MVC 原生能力支持 Range 断点续传(终极方案)

HTTP 规范(RFC 9110)原生定义了分段传输协议:

  • 客户端携带请求头:Range: bytes=104857600-(表示从第 100MB 开始拉取)

  • 服务端返回响应头:状态码 206 Partial Content,返回 Accept-Ranges: bytes 及 Content-Range: bytes 104857600-1073741823/1073741824

在 Spring MVC 中,无需手动解析 Range 请求头和计算偏移量。只要 Controller 返回 Resource 或 ResponseEntity<Resource>,Spring 会自动完成 Range 切片和 206 状态组装:

@RestController
@RequestMapping("/api/files")
public class FileDownloadController {

    private final FileService fileService;

    public FileDownloadController(FileService fileService) {
        this.fileService = fileService;
    }

    @GetMapping("/{id}/download")
    public ResponseEntity<Resource> download(@PathVariable Long id) throws IOException {
        FileInfo file = fileService.findById(id);
        Path path = Path.of(file.getStoragePath());

        if (!Files.exists(path) || !Files.isRegularFile(path)) {
            throw new FileNotFoundException("file not found");
        }

        // 关键点:使用 FileSystemResource
        Resource resource = new FileSystemResource(path);

        return ResponseEntity.ok() // 保持返回 200,Spring 会自动转换为 206
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.attachment()
                                .filename(file.getOriginalName(), StandardCharsets.UTF_8)
                                .build()
                                .toString())
                .header(HttpHeaders.ACCEPT_RANGES, "bytes")
                .body(resource);
    }
}

客户端测试验证

  • 常规下载:curl -v http://localhost:8080/api/files/100/download

  • 分段获取:curl -v -H "Range: bytes=1048576-2097151" http://localhost:8080/api/files/100/download

  • 断点续传测试:curl -C - -O http://localhost:8080/api/files/100/download


2. 核心踩坑点:为什么不能用 InputStreamResource?

很多开发者习惯封装流对象:

// 错误写法
Resource resource = new InputStreamResource(Files.newInputStream(path));

Spring 官方文档明确指出:Range 请求的 Resource 绝对不能是 InputStreamResource。

  • 原因:Range 请求依赖于对底层文件的随机访问/位置定位(Seekable),而 InputStreamResource 是一次性的单向顺序流,无法反复定位读取位置。本地文件请直接使用 FileSystemResource。


3. 生产环境必查的 5 个关键问题

① 路径穿越与元数据隔离

  • 绝对不要让前端直接传文件路径(如 GET /download?path=/data/a.zip),极易遭受 ../ 目录遍历攻击。

  • 正确做法:对外暴露不透明的 fileId,后端通过数据库映射物理存储路径 storagePath。原始文件名仅作为下载响应头的元数据展示。

② 严格水平鉴权(越权风险)

  • 常见 Bug:文件列表接口做了权限校验,下载接口却仅凭 fileId 直接放行,导致用户猜 ID 越权拉取敏感文件。

  • 正确做法:查询时强制绑定租户与用户上下文:

    fileRepository.findByIdAndUserId(fileId, currentUserId);
    
    

③ 断点续传时的文件版本一致性(防拼接损坏)

  • 风险:用户昨天下载了前 3GB,今天继续下载后 5GB,但在此期间服务器上的文件被覆盖更新了,拼出来的数据直接报废。

  • 对策:

    1. 存储层实行不可变存储:新版本文件生成新的 storage_key / 哈希记录,不原地覆盖。

    2. 配合 HTTP 缓存/校验头:使用 ETag、Last-Modified 及客户端的 If-Range 请求头,由校验机制确保前后下载的是同一个版本。

④ 什么时候该用对象存储直出(OSS / S3 / MinIO)?

如果文件本身保存在云存储(如 AWS S3、阿里云 OSS),千万不要让 Java 服务当数据中转站(10GB 进 -> 10GB 出,占满应用服务器网卡带宽与 IO):

  • 推荐架构:

    客户端 -> 请求下载 -> Spring Boot 校验权限 -> 生成带过期时间的预签名 URL (Presigned URL) -> 客户端直接从 CDN/对象存储下载
  • 适用边界:

    • 应用返回 Resource:内部系统、本地轻量存储、并发要求不高、必须强管控网络内网的场景。

    • 对象存储直传:安装包、高清视频、备份包、AI 模型文件、公网高并发下载。

⑤ 并发限流与防拖垮

大文件下载即使不打爆 JVM 内存,也会消耗大量的网络带宽、磁盘 IO 和 Servlet 线程。

  • 必须设置单用户/单租户并发下载限制(如普通用户限 2 个,企业用户限 10 个)。

  • 生产环境建议结合 Nginx 限速模块(limit_rate)、API 网关或 CDN 带宽治理,而非把流量压力全压在 Java 层。


总结  

优化大文件下载的关键,不是将 JVM 从 2GB 调到 16GB,而是遵循两条根本原则:

  1. 本地流式处理:绝不物化整个字节数组到内存;利用 Spring MVC + FileSystemResource 原生支持 Range 断点续传。

  2. 架构流量解耦:海量或超大文件直接走“后端鉴权生成签名 URL + 对象存储/CDN 分发”,让业务服务专心处理权限,把流量卸载给更擅长做 IO 的底层基础设施。  

admin

这个人很懒,什么都没留下

文章评论

您需要 登录 之后才可以评论