做后端这几年,文件上传下载这种功能看起来不起眼,但真正放到生产环境跑一段时间,你会发现坑全藏在细节里。springboot 文件下载,我写过不下十种版本,从静态资源直连到对象存储签名 URL,每个阶段的理解都不一样。今天这篇文章想把基于 Spring Boot 做文件下载的完整思路梳理一遍,包括最基础的静态资源映射、动态流式下载、断点续传、大文件性能优化,以及和 MinIO 这类对象存储的整合方式。适合正在用 springboot 开发后台管理系统,或者准备搭一个独立文件服务的同学参考,读完基本能覆盖日常开发中 90% 的文件下载场景。
1. 文件下载为什么会单独拿出来说
1.1 常见的下载需求场景
很多人觉得文件下载无非就是给前端返回一个地址,但真实的业务场景远没有这么简单。按照我接触过的项目,需求大概能分成这么几类:第一种是普通的附件下载,比如导出 Excel、下载合同 PDF、拉取用户上传的头像,文件不大,几 KB 到几十 MB;第二种是大文件下载,比如视频课程、离线安装包、测试数据包,动不动就是几个 GB,这种必须考虑内存占用和用户体验;第三种是私有文件下载,比如内部资料、付费内容,不能直接放到静态目录里让任何人访问,需要做权限校验和时效控制;第四种是对象存储场景,文件放在 MinIO、OSS 或者云存储上,Spring Boot 只负责生成下载地址或者做流量转发。
这几种场景的技术选型完全不同,如果把第一种的思路直接套到第二种,生产上很快就会出现内存溢出或者连接超时。我见过一个项目,用最原始的Files.readAllBytes()去下载一个 2GB 的安装包,服务端直接内存被打满,接口超时,最后不得不重启机器。这类问题本质上就是没有理解文件下载的本质:文件下载不是一个“读取文件并返回”的操作,而是一个“把数据流从磁盘或者云存储正确传输到客户端”的过程,里面的每一步——输入流的读取方式、响应头的设置、缓冲区的选择——都会影响最终结果。
1.2 我为什么推荐从 IO 流理解文件下载
我在和很多初级开发者交流时发现,大家最容易卡住的地方不是写不出下载接口,而是出了问题不知道从哪里排查。比如下载下来的文件总是比原文件少几个字节,或者中文文件名在浏览器里变成乱码,又或者下载大文件时客户端进度条卡住不动。这些问题如果只停留在“接一个接口、调一个方法”的层面,根本找不到原因。所以我一直建议把文件下载当成一个完整的 IO 流程来理解:服务端要明确知道文件在哪、文件有多大、用什么方式分段写出去;客户端要明确知道这个响应是什么类型、是附件还是内联展示、长度是多少。Spring Boot 只是帮你把整个流程里的“路由”和“模板”部分简化了,核心的 IO 处理仍然需要你自己掌握。
理解了这一点之后,你再回头看 Spring Boot 提供的各种下载方法,就会清晰很多:它们本质上都是对“输入流到输出流”这一过程的封装,区别只在于封装的程度和适用的场景。下面我按实战中从简单到复杂的顺序,把几种主流的下载方案逐个拆开讲。
2. 静态资源下载:最简单的直通方案
2.1 静态资源映射约定
如果你的文件本来就是想给所有人公开访问的,比如软件安装包、公开的模板文件、产品介绍 PPT,那最简单的方式就是把文件放到 Spring Boot 的静态资源目录里。默认约定是classpath:/static,你放进去之后,浏览器直接访问http://ip:8080/文件名就能下载。也可以同时配置多个静态资源位置,比如把外部的磁盘目录也挂进来,在application.yml里这样写:
spring: web: resources: static-locations: classpath:/static/,file:/data/public/这里的file:/data/public/表示把服务器上的/data/public目录也映射为静态资源路径,之后访问http://ip:8080/report/2024/summary.pdf实际上读取的就是文件系统里的/data/public/report/2024/summary.pdf。这个方案最大的优势是零代码,Spring Boot 自带的ResourceHttpRequestHandler会处理所有细节,包括媒体类型判断、缓存控制等。配置完之后不需要重启的修改也可以直接生效,非常适合快速交付一些内部小工具。
不过要提醒一句,静态资源映射的下载行为取决于请求路径的设计。Spring Boot 对已知资源类型会自动返回对应的Content-Type,比如.pdf会返回application/pdf,浏览器拿到这个类型之后通常会直接内联打开而不是下载。如果你希望所有静态资源都强制触发下载,需要写一个WebMvcConfigurer自定义ResourceHttpRequestHandler的Content-Disposition响应头,或者干脆不要把这类文件放在静态目录里,而是走后面说的 Controller 方案。
2.2 静态资源的两个实际坑
第一个坑是路径穿越。如果配置了外部磁盘目录映射,并且路径拼接没有做约束,攻击者可以用../尝试读取你服务器上的其他文件。虽然 Spring Boot 默认带了一些防护,但我还是建议不要直接开放整个根目录,尽量映射到专用的子目录,并且通过拦截器限制允许访问的文件后缀。第二个坑是缓存问题。默认情况下,Spring Boot 对静态资源会返回带有效期和 ETag 的缓存头,这本来是个好事,但如果你更新了同名文件,客户端可能还在使用旧的缓存版本。这时候可以在配置里调整缓存策略:
spring: web: resources: cache: period: 0这样每次请求都会重新读取文件,代价是性能下降,所以生产环境建议只在文件名带版本号或者更新不频繁的场景下使用静态映射,否则还是用我下面写的 Controller 方案更灵活。
3. 动态文件下载:最常用的三种写法
3.1 方式一:ResponseEntity + FileSystemResource
当文件路径存储在数据库里,或者需要根据登录用户动态指定下载文件时,就不能靠静态映射了,需要自己写 Controller。我最常用的是ResponseEntity<Resource>这种方式,因为写法干净,返回值本身就是完整的 HTTP 响应对象,方便测试和调试。核心代码如下:
@GetMapping("/download/{fileName}") public ResponseEntity<Resource> download(@PathVariable String fileName) throws IOException { Path basePath = Path.of("/data/private").toAbsolutePath().normalize(); Path fullPath = basePath.resolve(fileName).normalize(); // 防止路径穿越 if (!fullPath.startsWith(basePath)) { return ResponseEntity.badRequest().build(); } Resource resource = new FileSystemResource(fullPath); if (!resource.exists()) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"") .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(resource.contentLength()) .body(resource); }这段代码有几个地方值得留意。第一,Path.of(...).normalize()必须做,否则fileName传../application.yml就能读到配置文件了,这是文件下载接口最常见的安全漏洞。第二,ResponseEntity里的contentLength一定要写,不要省略。客户端下载文件时依靠这个响应头显示进度条,如果没有它,有些下载工具会直接判定文件大小未知,导致进度条不动或者无法合并分段下载。第三,APPLICATION_OCTET_STREAM是二进制流类型,浏览器收到之后默认会走下载而不是打开,这个类型对绝大多数场景都适用,它相当于告诉浏览器“别猜这个文件是什么了,直接保存”。
写到这里有人会问,为什么不用return new File(...)或者直接返回File对象?Spring MVC 确实支持这么做,比如方法返回File类型,框架会自动写响应。但我实测下来,这种方式在异常处理和自定义响应头方面不够灵活,比如文件不存在时默认还会返回 200,前端拿到的是一段空内容,排查起来很被动。所以统一用ResponseEntity<Resource>作为标准写法,状态码和响应头都显式控制,行为完全可预期。
3.2 方式二:HttpServletResponse 直接写流
ResponseEntity适合绝大多数文件下载场景,但当你要对输出过程做更精细的控制时,比如写入日志、统计下载流量、或者把多个业务动作合并到一次响应里,直接用HttpServletResponse更顺手。下面是我常用的模板:
@GetMapping("/download/stream") public void streamDownload(@RequestParam String fileName, HttpServletResponse response) throws IOException { File file = new File("/data/private", fileName); if (!file.exists()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); response.setContentLengthLong(file.length()); try (InputStream is = new FileInputStream(file); OutputStream os = response.getOutputStream()) { byte[] buffer = new byte[4096]; int bytesRead; while ((bytesRead = is.read(buffer)) != -1) { os.write(buffer, 0, bytesRead); } os.flush(); } }这里有个细节我希望大家能注意到:通过response.getOutputStream()拿到的输出流,写完之后在try-with-resources里会被自动关闭,但有些版本的 Servlet 容器在关闭输出流之后还会尝试 commit response,如果你在代码里又去设置响应头,就会抛出IllegalStateException。所以我的习惯是先把所有响应头都设置好,再打开输出流,之后就不动响应对象了。
buffer的大小也值得讲一下。缓冲区太小会导致任务线程频繁执行 IO 读写,CPU 空转严重;缓冲区太大则占用内存,多用户同时下载时容易把堆撑爆。在我这边压测过的项目里,4KB 到 16KB 是最稳定的区间,超过 16KB 之后性能提升非常有限,反而增加了内存压力。你可以把缓冲区大小做成配置项,方便上线后根据实际并发调整。
3.3 方式三:InputStreamResource 与流式返回
如果要下载的文件不是磁盘上的真实文件,而是动态生成的内容,比如把数据库查询结果生成 CSV、把报表模板渲染成 Excel,那FileSystemResource就用不了了。这时候推荐InputStreamResource配合ResponseEntity返回:
@GetMapping("/export") public ResponseEntity<Resource> exportCsv() { StringBuilder sb = new StringBuilder(); sb.append("姓名,部门,工号\n"); sb.append("张三,研发部,1001\n"); byte[] data = sb.toString().getBytes(StandardCharsets.UTF_8); ByteArrayInputStream in = new ByteArrayInputStream(data); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=employees.csv") .contentType(MediaType.parseMediaType("text/csv")) .body(new InputStreamResource(in)); }需要留意的是,InputStreamResource虽然叫 Resource,但它的contentLength()方法通常不知道流里面有多少数据,因为需要预先读取整个流才能算出长度。如果下载过程中进度条没有显示总大小,大概率就是这个问题。解决办法有两个:如果你知道数据大小,手动设置contentLength;如果你的流本身是从文件来的,优先使用FileSystemResource或者下面的流式响应方案,让 Tomcat 自己用零拷贝方式传输。
对于异步响应式下载,Spring Boot 还提供了StreamingResponseBody,它允许你在后台线程中写输出流,不占用请求线程,对需要长时间执行的下载任务很有帮助:
@GetMapping("/download/async") public StreamingResponseBody downloadAsync() { return outputStream -> { try (InputStream is = new FileInputStream("/data/private/big.iso")) { byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { outputStream.write(buffer, 0, len); } } }; }这种方式的优点是不会长时间占用 Tomcat 的线程池,适合那种“启动任务然后慢慢生成文件”的场景。但它有个隐藏风险:连接可能在流还没写完的时候被客户端断开,此时你继续往 outputStream 里写数据,会抛出异常。所以使用流式响应时要捕获客户端的断开异常,做合理的日志记录,避免产生大量无意义的堆栈信息。
4. 中文文件名与下载头:最容易翻车的细节
4.1 不同浏览器的文件名编码策略
如果说 IO 流处理是文件下载的骨架,那响应头的内容就是它的神经。很多项目上线后运营反馈“下载的合同附件在 Chrome 里变成乱码”“iPhone 上下载的 PDF 文件名变成一串 % 号”,其实就是Content-Disposition这个响应头没有正确处理中文导致的。
早期规范里,Content-Disposition的文件名参数只支持 ASCII 字符,所以中文需要做 URL 编码,写法通常是:
response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode("项目合同.pdf", StandardCharsets.UTF_8));这样设置之后,Chrome 和 Firefox 都能正确识别并解码,但 IE 和部分老内核浏览器不认这种格式,它们需要你额外提供一个filename*参数,格式是filename*=UTF-8''%E9%A1%B9%E7%9B%AE...。老实说,大部分现代项目已经不需要再兼容 IE 了,但如果你的用户群体里有企业客户,还是建议把兼容代码写上。一个比较通用的写法:
private String buildContentDisposition(String fileName) throws UnsupportedEncodingException { String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20"); return "attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded; }这里有个小坑我踩过:URLEncoder.encode()会把空格编码成加号(+),但在 HTTP 头的文件名参数里,加号是不会被解析成空格的,所以必须把+手动替换成%20。如果你不替换,中文文件名里带空格的场景(比如“第一季度 收入报表.pdf”)下载下来就变成了Quarterly+Income+Report.pdf,非常难看。
4.2 Content-Type 与缓存控制
Content-Type的选型也需要细心。很多人习惯全部设置成application/octet-stream,这确实最安全,但会牺牲掉部分用户体验:比如下载一个.pdf,明明浏览器插件可以内联预览,结果被强制下载了。反过来,如果不设置Content-Type,又会导致一些浏览器对未知文件直接当作纯文本打开,页面上一堆乱码。
我的建议是:通用的二进制文件统一用application/octet-stream;有明确展示需求的文件,比如图片、PDF、视频,使用MediaType里对应的类型,同时搭配inline或attachment的 disposition 来控制展示还是下载。还有一点容易忽略的是缓存头。文件下载接口默认每次都会从磁盘读取,这对大文件或者高并发场景压力很大。如果文件本身不会变化,可以在响应里加上缓存信息:
response.setHeader("Cache-Control", "public, max-age=3600");但要注意,一旦带了缓存头,用户修改了文件但文件名没变时,客户端拿到的还是旧内容。所以实践中更常见的做法是对静态文件加缓存、对动态权限文件禁用缓存。禁用缓存可以这样设置:
response.setHeader("Cache-Control", "no-store"); response.setHeader("Pragma", "no-cache");5. 大文件下载与性能优化
5.1 零拷贝与底层优化
大文件下载,比如 1GB 以上的视频、安装包,如果还用InputStream逐字节复制,性能是很大的浪费。因为数据要从磁盘读到内核空间,再复制到用户空间,然后再写回内核空间通过 Socket 发出去,中间多了一次内存拷贝。Linux 系统有一种优化叫零拷贝(zero-copy),通过sendfile系统调用让内核直接把文件数据从磁盘发送到网络设备,完全绕过用户空间。Java 里的FileChannel.transferTo()底层就利用了这种机制。
在 Spring Boot 中,如果你的下载接口直接返回FileSystemResource,当 Tomcat 配置了合适的连接器时,它可以自动走零拷贝路径。但如果你手动用InputStream去读文件再写输出流,就会强制走用户空间,大文件场景下性能差距会非常明显。所以我的经验是:只要文件在本地磁盘上,就不要自己手动复制流,直接交给 Spring 的Resource和响应机制,让框架和 Servlet 容器帮你做优化。
还有一个相关的点:文件下载接口和普通接口尽量隔离,尤其是大文件服务,最好使用单独的连接器线程池配置,并且给下载请求设置合理的读写超时。默认情况下 Tomcat 的连接超时可能只有几十秒,下载一个大文件时连接被判定超时,客户端就会频繁中断重连。我一般会把下载专用的 Servlet 服务超时时间调大,或者用StreamingResponseBody配合异步线程池来处理。
5.2 限速与并发控制
大文件下载的另一个问题是带宽占用。公司内部服务如果不对下载做任何限制,几个人同时拉一个 5GB 的安装包,整条出口带宽可能就被塞满了,其他业务的请求响应速度就会明显下降。我们曾经在文件服务上做过限速,核心思路是控速写:
@GetMapping("/download") public void throttledDownload(HttpServletResponse response) throws IOException { try (InputStream is = new FileInputStream("/data/files/big.iso"); OutputStream os = response.getOutputStream()) { byte[] buffer = new byte[8192]; int len; long startTime = System.currentTimeMillis(); long bytesWritten = 0; long maxBytesPerSecond = 1024 * 1024 * 2; // 每秒2MB while ((len = is.read(buffer)) != -1) { os.write(buffer, 0, len); bytesWritten += len; long elapsed = System.currentTimeMillis() - startTime; long expectedElapsed = bytesWritten * 1000 / maxBytesPerSecond; if (expectedElapsed > elapsed) { Thread.sleep(expectedElapsed - elapsed); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }这样实现比较粗糙,但对于内部系统够用。更优雅的方式是使用 Guava 的RateLimiter,或者干脆用 Nginx 的限速模块,在反向代理层做流量控制,让 Spring Boot 不用关心带宽问题。如果你只是做一个中小型内部系统,我建议优先用 Nginx 层限速,毕竟应用层做限速会牺牲吞吐量,而且调试起来也不够直观。
5.3 Java 21 虚拟线程下的下载
顺手提一个比较新的内容,最近很多项目开始升级 JDK 21,Spring Boot 3.2 及以上版本支持虚拟线程。虚拟线程对文件下载这类 IO 密集型任务有天然优势,因为下载逻辑的大部分时间都阻塞在 IO 上,传统线路程池一个线程往往只能处理一个下载请求,而虚拟线程可以创建成千上万个,极大提升并发能力。开启方式非常简单,在application.yml里:
spring: threads: virtual: enabled: true开启后,Spring MVC 的请求处理会自动切换到虚拟线程,实测在我的机器上并发下载的吞吐量提升非常明显。不过要注意,虚拟线程不适合跑 CPU 密集型任务,比如下载时顺带做文件加密、压缩这种耗 CPU 的操作,建议把这些逻辑放到线程池里隔离,避免影响下载主流程。还有一点,虚拟线程下如果代码里有synchronized锁或者Thread.sleep这种阻塞操作,要特别小心,JDK 的虚拟线程调度器会在阻塞点释放载体线程,但像 synchronized 这种锁,JDK 21 之后也会做锁的重新调度,不过为了保险起见,下载场景里尽量不要写持锁操作。
6. 断点续传与 Range 请求
6.1 Range 请求的原理
现在支持断点续传的下载工具,本质上都在发 HTTP Range 请求。客户端拿不到完整文件时会携带一个Range: bytes=0-1023的请求头,表示“我只想要文件的这个片段”,服务器正确响应之后返回206 Partial Content状态码,并把Content-Range响应头写清楚。这也是视频播放拖进度条、下载工具多线程分段下载的基础。
Spring Boot 的静态资源处理器本身支持 Range 请求,所以走静态映射的文件自动就能断点续传。但自定义的 Controller 下载接口不会自动支持,需要自己写。我一次做视频点播项目时,前端拖进度条频繁失败,排查之后发现就是后端没有处理Range头,整个请求被当成从头读取的完整下载,每次拖拽都要重新拉整个视频,自然卡得不行。
6.2 一个简单的 206 实现思路
手动支持 Range 请求的核心逻辑如下:先读取请求头Range,解析出开始和结束位置;然后设置响应状态为 206,并写好Accept-Ranges、Content-Range、Content-Length头;最后用RandomAccessFile.seek()定位到起点开始输出。核心代码:
@GetMapping("/play/{fileName}") public void play(@RequestHeader(value = "Range", required = false) String range, @PathVariable String fileName, HttpServletResponse response) throws IOException { Path file = Path.of("/data/video", fileName); long fileSize = Files.size(file); long start = 0; long end = fileSize - 1; if (range != null && range.startsWith("bytes=")) { String[] parts = range.substring(6).split("-"); start = Long.parseLong(parts[0]); if (parts.length > 1 && !parts[1].isEmpty()) { end = Math.min(Long.parseLong(parts[1]), end); } } if (start > fileSize || start > end) { response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); response.setHeader("Content-Range", "bytes */" + fileSize); return; } response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader("Accept-Ranges", "bytes"); response.setHeader("Content-Range", String.format("bytes %d-%d/%d", start, end, fileSize)); response.setContentLengthLong(end - start + 1); response.setContentType("video/mp4"); try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r"); OutputStream os = response.getOutputStream()) { raf.seek(start); byte[] buffer = new byte[4096]; long remaining = end - start + 1; int len; while (remaining > 0 && (len = raf.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) { os.write(buffer, 0, len); remaining -= len; } } }这个实现虽然简单,但能解决视频拖动、下载工具断点续传的绝大多数问题。更完整的处理还应该考虑If-Range、多 Range 段、ETag 等细节,但对于普通业务系统,一个单 Range 的 206 响应已经能带来很大的体验提升。
7. 整合 MinIO:把文件服务迁移到对象存储
7.1 MinIO 与 Spring Boot 整合步骤
很多项目的文件最终没有放在服务器本地,而是放到了 MinIO 这类兼容 S3 协议的对象存储里。MinIO 最典型的部署方式是 Docker 单机或集群,服务端负责管理分片、元数据和生命周期,应用侧只是调用 SDK。把 MinIO 集成进 Spring Boot 用的依赖是io.minio:minio,最新稳定版在 Maven 上可以直接搜到。初始化客户端时可以这样写:
@Configuration public class MinioConfig { @Value("${minio.endpoint}") private String endpoint; @Value("${minio.access-key}") private String accessKey; @Value("${minio.secret-key}") private String secretKey; @Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }下载文件一般有两种方式。第一种是生成预签名 URL,客户端拿到 URL 后直接向 MinIO 发起请求,应用服务器不经过文件内容,压力最小:
@GetMapping("/minio/url") public String presignedUrl(@RequestParam String objectName) throws Exception { return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucketName) .object(objectName) .expiry(600) .build() ); }第二种是服务端拉取再转给客户端,这种方式适合需要做权限校验、水印、或者统一计数的场景:
@GetMapping("/minio/download") public void downloadFromMinio(@RequestParam String objectName, HttpServletResponse response) throws Exception { GetObjectArgs args = GetObjectArgs.builder() .bucket(bucketName) .object(objectName) .build(); try (InputStream is = minioClient.getObject(args); OutputStream os = response.getOutputStream()) { response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(objectName, StandardCharsets.UTF_8)); byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { os.write(buffer, 0, len); } } }两种方式我都在生产环境用过,如果是私有文件或者对访问有时效控制需求,预签名 URL 更合适,因为可以不暴露 Bucket 的读写权限;如果文件需要经过应用层做审计、权限控制、或者给用户显示下载次数,就走服务端转发。
7.2 预签名 URL 与流式转发怎么选
这里说说选型时的判断依据。预签名 URL 的优点很明显:下载不占应用服务器带宽和连接,MinIO 自己处理高并发;缺点是 URL 会过期,默认我一般设置为 10 分钟,超时后需要重新生成,而且一旦 URL 泄露,在过期之前谁都可以下载,所以敏感文件不建议用长时效的预签名地址。
服务端流式转发的优点是可以叠加业务逻辑,比如记录日志、检查积分、控制并发;缺点是所有文件流量都要经过 Spring Boot 应用,应用的出口带宽、线程池、Socket 连接数很快就会成为瓶颈。如果项目文件平均不到 50MB,用服务端转发没什么问题;如果是视频平台或者镜像站,我只推荐预签名 URL,或者用 Nginx 的反向代理作为中转层,不要让 Java 应用直接扛大流量。
8. 安全校验与权限控制
8.1 校验登录态与路径穿越
文件下载接口是安全攻击的重灾区,主要原因是它让外部请求直接映射到了服务器文件路径。最基本的两条防线是:登录态校验和路径穿越防护。登录态校验通常用 Spring Security 或者自定义拦截器完成,确保只有通过认证的用户才能访问下载接口。路径穿越防护则需要特别小心,我上面代码里已经演示了用Paths.normalize()和startsWith()双重验证,实际项目中还需要限制文件后缀,比如只允许.pdf,.docx,.png,其他一律拒绝。
不要以为接口路径上带着随机数就安全了,很多系统生成的文件名是纯数字 ID 或者简单的日期拼接,攻击者完全可以遍历。更稳妥的做法是数据库中的文件记录使用随机生成的存储名,对外展示的下载文件名单独存储,用户传给后端的只是一个 UUID 关联 ID,服务端根据 ID 去数据库查真实路径。这样即使接口暴露了,也无法直接猜出其他文件的路径。
8.2 防刷与限流
文件下载接口一般比普通接口更容易被恶意刷取,尤其是大文件下载,每次都会消耗大量带宽和 IO。实践中我一般叠加三层防护:第一层是登录状态校验,未登录用户直接拒绝;第二层是下载频率控制,用 Redis 记录每个用户每小时的下载次数,超过阈值就返回 429;第三层是根据文件大小动态计算带宽占用,如果同一个用户长时间占用高带宽,就自动降级或者断开连接。
还可以考虑给下载接口加上签名参数,比如给前端发放一个带时效的 token,只有拿到 token 之后才能下载指定文件,防止接口被直接外链。这种方案在小程序、开放平台场景下很常见。我见过一个教育类项目,课件的下载地址直接硬编码在前端,被别站盗链之后损失很大,后来就是改成签名 URL 解决的。
9. 常见问题与排查技巧实录
9.1 问题速查表
我把实际开发中遇到的高频问题整理成一张速查表,方便大家对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 下载文件名中文乱码 | Content-Disposition 未做编码处理 | 使用 URLEncoder 编码并且配置 filename* 参数 |
| 下载文件大小不对或文件损坏 | 响应头 Content-Length 与实际写入长度不一致 | 先设置 Content-LengthLong 再写流,或让框架自动计算 |
| 浏览器直接打开而不是下载 | Content-Disposition 没有设置为 attachment | 检查响应头,确保 disposition 类型正确 |
| 大文件下载时内存溢出 | 用了 Files.readAllBytes 或 byte[] 全量读取 | 改为流式传输、Resource 或 StreamingResponseBody |
| 下载到一半断掉 | 连接超时设置过短 | 调大服务器读写超时,或使用异步流式响应 |
| 前端拿到 blob 后保存格式不对 | ResponseType 设置错误或 MIME 类型不匹配 | 检查前端请求 responseType 和后端 Content-Type |
| 静态资源更新后访问旧文件 | 浏览器缓存 | 设置 Cache-Control 或者文件名带版本号 |
| 动态生成的 CSV 下载后乱码 | 字节流没有 BOM 头 | 在写 CSV 之前写入 UTF-8 BOM 字节 |
| 下载接口高并发下响应慢 | 未使用零拷贝或线程阻塞 | 改用 Resource、虚拟线程,或前置 CDN/Nginx |
9.2 我实际踩过的几个坑
第一个坑是 IDEA 里开发时文件下载正常,部署到 Docker 就 404。排查之后发现是因为 Docker 容器的/tmp目录被清理了,而我用File.createTempFile()生成的临时文件就放在/tmp下面。这个问题在长期运行的容器里非常隐蔽,因为不是启动就挂,而是跑几天之后突然报错。后来我的做法是给应用配置一个独立的临时目录,通过-Djava.io.tmpdir=/data/tmp启动参数指定,并且用 volume 持久化挂载,这样既不会丢数据,也不会被系统清理。
第二个坑是前端用 fetch 下载文件时,如果后端返回的是 302 重定向,fetch 默认会跟随,并且拿不到真正的下载地址,导致流式下载失败。这个问题我在接预签名 URL 时遇到过。解决方案是让前端先请求一个接口获取预签名地址,然后使用window.location.href直接跳转下载,或者使用axios的responseType: 'blob'配合拦截器处理,不要依赖 fetch 的 follow 行为。
第三个坑是日志打印问题。下载接口如果打印日志太频繁,比如每个 buffer 都打一行,一个 2GB 文件就能打满磁盘。我自己的规范是下载接口只打印请求开始、响应状态、总字节数,不打印每个数据块,这样出现问题也能定位,同时不会刷爆日志。
还有一点想特别提一下:很多人开发时用 Postman 测下载接口,看到响应体是二进制就以为成功了,但 Postman 对二进制内容的显示不一定准确,推荐测试时用curl -O下载到本地再对比文件大小,或者写一个自动校验的脚本。我自己习惯用如下命令快速验证:
curl -O -L "http://localhost:8080/download/xxx.pdf" md5sum xxx.pdf拿到文件之后和源文件比对 MD5,一致才算接口真正通过。这个习惯帮我避免过很多次“接口返回 200 但文件其实损坏”的问题。
写在最后的实操心得
文件下载这个功能,单独看很简单,但和 Spring Boot 的静态资源、IO 流、响应协议、安全控制一结合,就藏了不少门道。我个人在实际操作中的体会是:先分清楚你的文件是公开静态文件、业务私有文件,还是大文件、对象存储文件,再决定用哪种下载方式,不要一上来就写 Controller。如果只是公开附件,静态资源配置解决;如果是业务文件,用ResponseEntity<Resource>配合路径校验;如果文件可能超过 500MB,直接考虑 Range 请求和零拷贝;如果文件压根不在服务器上,尽早用预签名 URL。
另外还有一个习惯值得推荐:把文件下载相关的公共能力封装成一个工具或基类,比如统一设置响应头、统一处理中英文文件名、统一记录下载日志、统一做限流判断。这样后续每个项目只需要关注自己的业务逻辑,不用反复踩同样的坑。这个功能虽然不起眼,但做得好的话,对系统稳定性和用户体感的提升是实打实的。