Featured image of post Spring Boot 文件操作实战:URL 代理下载 + GridFS 大文件存储

Spring Boot 文件操作实战:URL 代理下载 + GridFS 大文件存储

Spring Boot 中转内网文件 URL 给外网 + MongoDB GridFS 存储大文件完整方案

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.filesfs.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

方案选型

场景推荐方案
小文件 < 16MBMySQL BLOB / OSS / MinIO
大文件 16MB-1GBMongoDB GridFS(轻量、零运维)
视频 / 备份 > 1GBMinIO / 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 对比

维度MinIOAWS 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 目标
使用 Hugo 构建
主题 StackJimmy 设计