在日常开发中,文件下载往往是从小文件(几 KB 的 PDF、几 MB 的 Excel)开始的。但随着业务扩展,当文件规格膨胀到数 GB 甚至 10GB 时,原有的简单实现不仅会引发 JVM OOM(内存溢出),还会让用户在网络波动时反复从 0% 重新下载。
本文梳理大文件下载的三代代码演进、Spring MVC 原生 Range 机制的避坑指南,以及生产级方案的架构考量。
1. 代码演进史
V1:内存全量加载(埋雷写法)
-
问题:
Files.readAllBytes()会将整个文件读入 JVM Heap。下载 10GB 文件就意味着申请一个 10GB 的byte[],高并发下服务瞬间瘫痪并触发 OOM。
V2:流式下载(解决内存问题,未解决体验问题)
-
优点:数据分块按流读取并写出,内存占用降至极小。
-
致命缺陷:不支持断点续传。用户下载 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 状态组装:
客户端测试验证
-
常规下载:
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?
很多开发者习惯封装流对象:
Spring 官方文档明确指出:Range 请求的 Resource 绝对不能是 InputStreamResource。
-
原因:Range 请求依赖于对底层文件的随机访问/位置定位(Seekable),而
InputStreamResource是一次性的单向顺序流,无法反复定位读取位置。本地文件请直接使用FileSystemResource。
3. 生产环境必查的 5 个关键问题
① 路径穿越与元数据隔离
-
绝对不要让前端直接传文件路径(如
GET /download?path=/data/a.zip),极易遭受../目录遍历攻击。 -
正确做法:对外暴露不透明的
fileId,后端通过数据库映射物理存储路径storagePath。原始文件名仅作为下载响应头的元数据展示。
② 严格水平鉴权(越权风险)
-
常见 Bug:文件列表接口做了权限校验,下载接口却仅凭
fileId直接放行,导致用户猜 ID 越权拉取敏感文件。 -
正确做法:查询时强制绑定租户与用户上下文:
③ 断点续传时的文件版本一致性(防拼接损坏)
-
风险:用户昨天下载了前 3GB,今天继续下载后 5GB,但在此期间服务器上的文件被覆盖更新了,拼出来的数据直接报废。
-
对策:
-
存储层实行不可变存储:新版本文件生成新的
storage_key/ 哈希记录,不原地覆盖。 -
配合 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,而是遵循两条根本原则:
-
本地流式处理:绝不物化整个字节数组到内存;利用 Spring MVC +
FileSystemResource原生支持 Range 断点续传。 -
架构流量解耦:海量或超大文件直接走“后端鉴权生成签名 URL + 对象存储/CDN 分发”,让业务服务专心处理权限,把流量卸载给更擅长做 IO 的底层基础设施。
文章评论