Spring Boot 文件操作实战:URL 代理下载 + GridFS 大文件存储
背景与价值
Spring Boot 项目中常见的文件场景:
- 场景 1:内网文件代理下载。A 系统对外暴露,B 系统在内网跑 MinIO 等对象存储。A 需要把 B 的文件地址代理出去给外网用户。
- 场景 2:大文件存储。100MB+ 的文件、PDF、视频不能用 MySQL
BLOB(性能差),改用 MongoDB GridFS 分片存储。
本文给出两个场景的完整代码方案。
场景 1:URL 代理中转下载
1. 架构
1
2
3
4
5
| 用户浏览器
↓ http://A_HOST:8080/file/test/xxx.mp3
A 系统(外网)
↓ http://B_HOST:9000/test/xxx.mp3
B 系统(内网 MinIO,不对外)
|
问题:如果 A 把 B 的 URL 直接返回给用户,用户无法访问 B(B 在内网)。
方案:A 解析 /file/xxx 路径 → A 自己用 HttpURLConnection 去 B 下载 → A 把字节流转发给用户。
2. 核心工具类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
| public class FileOperateUtil {
private static final Logger log = LoggerFactory.getLogger(FileOperateUtil.class);
/**
* URL 文件代理中转
* @param response Servlet 响应
* @param address 内网真实下载地址
* @param contentType MIME 类型(null 自动判断)
* @param fileName 下载文件名
*/
public static void proxyUrlFile(HttpServletResponse response, String address,
String contentType, String fileName) throws IOException {
InputStream inputStream = null;
ServletOutputStream outputStream = null;
HttpURLConnection httpURLConnection = null;
try {
// 1. 连接内网真实地址
URL url = new URL(address);
httpURLConnection = (HttpURLConnection) url.openConnection();
httpURLConnection.setConnectTimeout(5000);
httpURLConnection.setReadTimeout(30000);
httpURLConnection.connect();
int code = httpURLConnection.getResponseCode();
if (code != 200) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
inputStream = httpURLConnection.getInputStream();
outputStream = response.getOutputStream();
// 2. 设置响应头
if (contentType != null) {
response.setContentType(contentType);
} else {
response.setContentType("application/octet-stream");
}
if (fileName != null) {
String encoded = new String(fileName.getBytes("utf-8"), "ISO8859-1");
response.setHeader("Content-disposition", "attachment; filename=\"" + encoded + "\"");
}
// 3. 流式转发(1024 字节 buffer)
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
outputStream.flush();
} catch (Exception e) {
log.error("Proxy URL File error, address={}", address, e);
throw e;
} finally {
if (inputStream != null) inputStream.close();
if (outputStream != null) outputStream.close();
if (httpURLConnection != null) httpURLConnection.disconnect();
}
}
}
|
3. Controller 暴露
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| @RestController
public class FileProxyController {
/**
* 通用文件代理接口
* @param url 内网真实 URL
* @param fileName 下载文件名
*/
@GetMapping("/file/proxy")
public void proxy(@RequestParam("url") String url,
@RequestParam(value = "fileName", required = false) String fileName,
HttpServletResponse response) throws IOException {
FileOperateUtil.proxyUrlFile(response, url, null, fileName);
}
}
|
4. 替代方案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // ✅ 方案 B:Spring 5+ Resource 方式
@GetMapping("/file/proxy2")
public ResponseEntity<Resource> proxy2(@RequestParam String url) throws IOException {
URL urlObj = new URL(url);
InputStreamResource resource = new InputStreamResource(urlObj.openStream());
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
// ✅ 方案 C:直接 redirect(适用于 B 系统本身能对外)
@GetMapping("/file/redirect")
public ResponseEntity<Void> redirect(@RequestParam String url) {
return ResponseEntity.status(302)
.header("Location", url)
.build();
}
|
5. 实战坑
坑 1:大文件 OOM
1
2
3
4
5
6
7
8
9
| // ❌ 错误:把整个文件读进内存
byte[] data = inputStream.readAllBytes();
response.getOutputStream().write(data);
// ✅ 正确:流式 1024 字节 buffer 转发
byte[] buffer = new byte[1024];
while ((len = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
|
坑 2:Content-Length 缺失导致下载速度慢
1
2
| // 主动设置 Content-Length(浏览器可显示进度条)
response.setContentLengthLong(httpURLConnection.getContentLengthLong());
|
坑 3:中文文件名乱码
1
2
| // 文件名 UTF-8 → ISO8859-1 编码(HTTP Header 只支持 ASCII)
String encoded = new String(fileName.getBytes("utf-8"), "ISO8859-1");
|
坑 4:Nginx 反代超时
如果 A 在 Nginx 后面,Nginx 默认 proxy_read_timeout 60s 会切断长文件传输:
1
2
3
4
5
6
| location /file/ {
proxy_pass http://A_BACKEND;
proxy_read_timeout 600s; # 10 分钟
proxy_send_timeout 600s;
proxy_buffering off; # 关闭缓冲,支持流式
}
|
场景 2:MongoDB GridFS 大文件存储
1. 适用场景
- 单文件 > 16MB(MySQL BLOB 不合适)
- 文件数量大(百万级),需要分布式存储
- 不想引入 MinIO / HDFS 额外组件
2. 依赖
1
2
3
4
| <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
|
1
2
3
4
5
| spring:
data:
mongodb:
uri: mongodb://{{MONGO_USER}}:{{MONGO_PASSWORD}}@{{MONGO_HOST}}:27017/files
database: files
|
3. GridFS 存储原理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 文件 > 16MB 自动分片存储:
fs.files collection:
{
_id: ObjectId("..."),
filename: "video.mp4",
length: 524288000, // 总大小
chunkSize: 261120, // 单 chunk 255KB
uploadDate: ISODate("..."),
md5: "abc123..."
}
fs.chunks collection:
{ files_id: ObjectId("..."), n: 0, data: BinData(...) } // chunk 0
{ files_id: ObjectId("..."), n: 1, data: BinData(...) } // chunk 1
...
|
4. Service 层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| @Service
public class GridfsService {
@Autowired
private MongoDbFactory mongoDbFactory;
/**
* 上传文件
*/
public String save(MultipartFile file) throws IOException {
GridFS gridFS = new GridFS(mongoDbFactory.getDb());
try (InputStream in = file.getInputStream()) {
GridFSInputFile gridFile = gridFS.createFile(in);
gridFile.setFilename(file.getOriginalFilename());
gridFile.setContentType(file.getContentType());
gridFile.save();
return gridFile.getId().toString(); // 返回 ObjectId
}
}
/**
* 按 ID 查询
*/
public GridFSDBFile getById(String id) {
GridFS gridFS = new GridFS(mongoDbFactory.getDb());
return gridFS.findOne(new BasicDBObject("_id", new ObjectId(id)));
}
/**
* 删除
*/
public void remove(String id) {
GridFS gridFS = new GridFS(mongoDbFactory.getDb());
gridFS.remove(new ObjectId(id));
}
/**
* 分片查询(分页展示文件列表)
*/
public List<GridFSDBFile> listByFilename(String filename, int skip, int limit) {
GridFS gridFS = new GridFS(mongoDbFactory.getDb());
return gridFS.find(filename == null ? new BasicDBObject()
: new BasicDBObject("filename", filename))
.skip(skip)
.limit(limit)
.toList();
}
}
|
5. Controller 层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| @RestController
public class FileController {
@Autowired
private GridfsService gridfsService;
@PostMapping("/file/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException {
String id = gridfsService.save(file);
Map<String, Object> result = new HashMap<>();
result.put("id", id);
result.put("name", file.getOriginalFilename());
result.put("size", file.getSize());
result.put("url", "/file/download/" + id);
return result;
}
@GetMapping("/file/download/{id}")
public void download(@PathVariable String id, HttpServletResponse response) throws IOException {
GridFSDBFile file = gridfsService.getById(id);
if (file == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.setContentType(file.getContentType());
response.setContentLengthLong(file.getLength());
response.setHeader("Content-Disposition",
"attachment; filename=\"" +
new String(file.getFilename().getBytes("utf-8"), "ISO8859-1") + "\"");
try (OutputStream out = response.getOutputStream()) {
file.writeTo(out);
}
}
@DeleteMapping("/file/{id}")
public Map<String, Object> delete(@PathVariable String id) {
gridfsService.remove(id);
return Map.of("success", true, "id", id);
}
}
|
6. Spring Boot 3.x 用 Spring Data MongoDB 4.x 替代
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| @Service
public class GridfsServiceV2 {
@Autowired
private GridFsTemplate gridFsTemplate;
@Autowired
private GridFsOperations gridFsOperations;
public String save(MultipartFile file) throws IOException {
DBObject metaData = new BasicDBObject();
metaData.put("uploadUser", "anonymous");
GridFSFile gridFile = gridFsTemplate.store(
file.getInputStream(),
file.getOriginalFilename(),
file.getContentType(),
metaData
);
return gridFile.getObjectId().toString();
}
public void download(String id, HttpServletResponse response) throws IOException {
GridFSFile gridFile = gridFsOperations.findOne(
new Query(Criteria.where("_id").is(new ObjectId(id)))
);
if (gridFile == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.setContentType(gridFile.getMetadata().get("_contentType").toString());
response.setContentLengthLong(gridFile.getLength());
try (OutputStream out = response.getOutputStream()) {
gridFsOperations.getResource(gridFile).getInputStream().transferTo(out);
}
}
}
|
7. 实战坑
坑 1:Spring Boot 3.x 不再使用 MongoDbFactory
com.mongodb.gridfs.GridFS API 在 Spring Data MongoDB 4.x 中被移除,改用 GridFsTemplate + GridFsOperations。
坑 2:GridFS 单文档 16MB 限制的真相
- MongoDB BSON 单文档限制 16MB
- GridFS 自动分片:文件 > 16MB 自动拆成 255KB 的 chunk 存储
- GridFS 适合任意大小文件(KB 到 GB)
坑 3:分片集群下 GridFS 性能
GridFS 用 fs.files + fs.chunks 两个 collection,推荐对 fs.chunks.files_id + n 建复合索引:
1
| db.fs.chunks.createIndex({ files_id: 1, n: 1 });
|
坑 4:删除文件后磁盘不释放
GridFS 删除只是删除 fs.files 和 fs.chunks 文档,MongoDB 不会立即归还磁盘。需在主节点上执行 db.runCommand({ compact: "fs.chunks" }) 释放空间(运维慎用)。
坑 5:上传大文件超时
1
2
3
4
5
6
7
8
| spring:
servlet:
multipart:
max-file-size: 500MB # 单文件最大
max-request-size: 500MB
data:
mongodb:
uri: mongodb://...?socketTimeoutMS=60000&connectTimeoutMS=10000
|
Nginx 也需要配置 client_max_body_size 500m; 和 proxy_read_timeout。
方案选型
| 场景 | 推荐方案 |
|---|
| 小文件 < 16MB | MySQL BLOB / OSS / MinIO |
| 大文件 16MB-1GB | MongoDB GridFS(轻量、零运维) |
| 视频 / 备份 > 1GB | MinIO / S3(对象存储、CDN 友好) |
| 实时流媒体 | ZLMediaKit + go2rtc(专用流媒体) |
| 跨外网文件下载 | Spring URL 代理(A 系统转发 B 系统) |
前置知识与下一步
前置:
- Spring Boot 基础
- HTTP 协议(响应头、流式传输)
- MongoDB 基础(可选)
下一步:
- 用 MinIO 替代 GridFS(专业对象存储)
- 断点续传:前端切片 + 后端合并,避开单文件上传限制
- CDN 加速:OSS / MinIO + CDN 域名降源站压力
小结
两个场景都是企业级常见需求:
- URL 代理下载:用
HttpURLConnection + Servlet 流式转发,避开 A→B 内网穿透限制 - GridFS 大文件:MongoDB 自动分片(255KB/chunk),适合 16MB-1GB 范围
关键注意点:
- URL 代理必须流式转发(不要
readAllBytes 全部读进内存) - GridFS 在 Spring Boot 3.x 改用
GridFsTemplate API - 大文件链路全栈(Spring + Nginx + MongoDB)都要调超时参数
2024+ 视角:MinIO / S3 主导的大文件时代
对象存储是 2024+ 的"正确答案"
2024 之后,MinIO / S3 / OSS 已成为大文件存储的事实标准——GridFS 仅在"不想引入额外组件"的小型项目里用。
1
2
3
4
5
6
7
| // Spring Boot 3.x + MinIO(生产推荐)
@Dependency
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio-java</artifactId>
<version>8.5.10</version>
</dependency>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| @Service
public class MinioService {
@Autowired
private MinioClient minioClient;
public String upload(MultipartFile file) throws Exception {
String objectName = UUID.randomUUID() + "-" + file.getOriginalFilename();
minioClient.putObject(
PutObjectArgs.builder()
.bucket("my-bucket")
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
return objectName;
}
public InputStream download(String objectName) throws Exception {
return minioClient.getObject(
GetObjectArgs.builder()
.bucket("my-bucket")
.object(objectName)
.build()
);
}
public String presignedUrl(String objectName, int expirySeconds) throws Exception {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket("my-bucket")
.object(objectName)
.expiry(expirySeconds)
.build()
);
}
}
|
预签名 URL(2024+ 推荐方案)
1
2
3
4
5
6
7
8
9
10
11
| // 上传预签名:客户端直传 MinIO,不经过 Java 后端
String uploadUrl = minioClient.getPresignedObjectUrl(
PutObjectUrlArgs.builder()
.bucket("my-bucket")
.object(objectName)
.expiry(600) // 10 分钟
.build()
);
// 客户端拿到 URL 直接 PUT 文件
// curl -X PUT --data-binary @file.mp4 "https://minio.example.com/..."
|
核心优势:
- 大文件不经过 Java 后端——降低带宽、内存压力
- URL 有时效(默认 10 分钟),防盗链
- 客户端断点续传:
Range: bytes=0-10485759 原生支持
MinIO 2024+ 核心特性
- 纠删码(Erasure Coding):12 块盘允许坏 4 块,数据不丢
- 多云联邦:一份数据跨 AWS S3 / 阿里云 OSS / 腾讯云 COS 自动同步
- S3 API 100% 兼容——任何 S3 SDK 都能直接对接
- K8s Operator GA——MinIO 集群在 K8s 上的官方部署方案
MinIO vs OSS / S3 / COS 对比
| 维度 | MinIO | AWS S3 | 阿里云 OSS | 腾讯云 COS |
|---|
| 部署 | 自建 | 云 | 云 | 云 |
| 成本 | 硬件成本 | 按量付费 | 按量付费 | 按量付费 |
| 性能 | 极高 | 极高 | 高 | 高 |
| S3 兼容 | 原生 | 100% | 99% | 95% |
| 适合 | 私有化 / 信创 | 出海 / 全球 | 国内 ToB | 国内 ToC |
大文件上传的 2024+ 范式:客户端直传 + 预签名
1
2
3
4
5
6
| 1. 客户端 → Java 后端:请求上传 URL
2. Java 后端 → MinIO:申请预签名 URL(10 分钟有效)
3. Java 后端 → 客户端:返回预签名 URL + objectName
4. 客户端 → MinIO:直接 PUT 文件(不经过 Java 后端)
5. 客户端 → Java 后端:通知上传完成
6. Java 后端 → DB:保存文件元数据(objectName、大小、MD5)
|
优势:Java 后端零带宽消耗——100GB 文件上传也不会压垮应用。
大文件下载的 2024+ 范式:CDN + Range
1
2
3
| 客户端 → CDN(缓存命中)→ 直接返回
↓(未命中)
MinIO(带 Range 头分片)
|
1
2
3
4
5
6
7
| # Nginx 反代 MinIO,开启 Range 支持
location /files/ {
proxy_pass http://minio-backend:9000;
proxy_set_header Range $http_range;
proxy_buffering off;
proxy_request_buffering off; # 大文件不缓存请求体
}
|
2024+ GridFS 的真实定位
GridFS 仍适用的场景:
- 已经在用 MongoDB,不想引入 MinIO
- 文件量少(百万级以内)
- 单文件 < 1GB
- 团队没人懂 MinIO / S3
不适合:
- 视频、备份等 GB 级文件
- 大量小文件(百万级以上)
- 需要 CDN 加速
实战坑(2024+)
- MinIO 在 K8s 上:必须用 distributed 模式(4 节点起步),单节点有数据丢失风险
- Nginx 反代 MinIO:必须设
proxy_request_buffering off 否则大文件上传会缓存到磁盘 - 预签名 URL 时区:MinIO 默认 UTC,签名时系统时区不一致会导致签名失败
- Spring Cloud 2024+ 推荐方案:直接用 Spring Cloud AWS / Spring Cloud Alibaba OSS 抽象层,跨云切换零成本
经验补记
- MinIO Console(2024+ 改名):内置管理 UI 替代旧的
mc 命令行 - MinIO Bucket Replication:跨集群复制"开箱可用"
- Rust 写的:Rustic 是 Rust 写的备份工具,原生支持 S3 / MinIO 目标