☰
Spring Boot文件下载全解析:从基础接口到断点续传与安全优化
2026/10/16 10:29:35 网站建设 项目流程

文件下载这件事,放在需求清单里经常就三个字:加个导出。可真做起来,能扯出一长串问题:文件名中文乱码、大文件把内存撑爆、断点续传没做导致下载一半进度条归零、zip 打包踩了路径穿越漏洞、前后端分离后浏览器直链又带不上 token……我在 Spring Boot 项目里写下载接口没有十次也有八次,每次以为很简单,结果总有几个新坑在等着。这篇就把常见的下载场景拆开来看,把基础接口、断点续传、批量打包、权限校验、问题排查和更优替代方案一起捋顺,适合刚把 Spring Boot 跑通的新手照着抄,也适合老后端回头查漏补缺。

1. 先把需求拆明白:你到底是哪种下载

1.1 四类下载场景,对应完全不同的技术选型

下载这个动作听起来都一样,但背后的文件来源、文件大小、访问频率决定了你该用哪种实现。我把实际项目中遇到的下载需求粗略分成四类:

场景类型典型例子特点推荐做法
静态资源文件产品图片、帮助手册文件固定、路径固定、不常变静态资源映射或 Nginx 托管
动态生成文件Excel 报表、PDF 对账单请求时临时生成、大小可控应用层流式输出
业务附件合同、简历、扫描件需要权限校验、可能很大应用层鉴权 + 流式下载
超大文件/音视频日志包、培训视频几个 GB 起步、可能被拖动播放支持 Range 断点续传

很多人一上来就写一个response.getOutputStream()循环读文件,这在第二类场景没问题,但放到第一类场景就是浪费应用性能,放到第四类场景则大概率翻车。先搞清楚属于哪一类,后面每一步选择都是水到渠成。

1.2 三种主流实现,先对比再下手

Spring Boot 里实现文件下载,最常见的就是下面三种方式:

实现方式核心 API适合场景主要坑点
直接写 HttpServletResponse 输出流response.getOutputStream()大文件、动态生成、需要精细控制响应头响应头、异常处理都要自己兜
返回ResponseEntity<byte[]>ResponseEntity.ok(contentType).body(bytes)中小文件、模板文件整个文件载入内存,大文件易 OOM
返回 Resource 或 InputStreamResourceResponseEntity<Resource>中等文件、需要附带 Resource 语义生命周期管理要留意
静态资源映射 / 重定向addResourceHandlers、Nginx公开文件、固定资源权限控制弱,不能随意暴露私密文件

我的习惯是:小于 10MB 的文件无所谓,用ResponseEntity<byte[]>怎么方便怎么来;超过 10MB 或者不能确定文件大小,一律走流式输出;涉密文件绝不走静态映射,必须由应用层鉴权后再下载。

1.3 选型背后不能忽略的三个变量

第一个变量是内存。byte[]方式本质上是把整个文件读进 JVM 堆内存,再复制出一份作为 HTTP 响应体。一台服务器假设堆内存 512MB,并发 10 个用户同时下载 100MB 的文件,堆直接被干穿,GC 频繁触发,系统整体卡死。流式输出是边读边写,单个连接在任意时刻只占一个 8KB 缓冲区,这才是大下载的正确姿势。

第二个变量是并发。文件下载是 IO 密集型操作,一个长时间占用的连接会一直占着 Tomcat 的工作线程。如果所有下载请求都走应用层同步转发,并发一高,线程池被打满,其他普通接口也跟着超时。后面我会讲 Nginx 内部重定向和异步流式响应,目的都是把这种长连接从 Tomcat 请求线程上剥离出去。

第三个变量是用户体验。没有Content-Length,浏览器就没有下载进度条;没有Accept-Ranges和 Range 处理,下载工具抬不起多线程,视频播放器拖动不了进度;没有正确的Content-Disposition,中文文件名显示成一串乱码。这三个响应头,是让文件下载"能用"到"好用"的分界线。

2. 从零写一个能用的下载接口

2.1 准备工作:其实只需要一个启动项目

基础下载接口不需要引入任何额外依赖,Spring Boot 自带的spring-boot-starter-web已经包含 Servlet 容器和HttpServletResponse。如果你要做 Excel 导出,后面再加上对应办公库;要做 zip 打包,JDK 自带的ZipOutputStream就够用了。

我下面所有代码都会基于一个虚拟的文件目录:/data/files/。实际项目里这个目录应该通过配置文件暴露,比如file.store-dir=${STORE_DIR:/data/files},不要在代码里写死路径,后期换服务器或者上容器都会省很多事。

2.2 最小可运行代码:逐行说清楚

先来一段最基础、不掺花活的下载接口:

@GetMapping("/download/basic") public void downloadBasic(@RequestParam String fileName, HttpServletResponse response) throws IOException { File file = new File("/data/files/", fileName); if (!file.exists()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } response.setContentType("application/octet-stream"); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\""); response.setContentLengthLong(file.length()); try (InputStream is = new FileInputStream(file); OutputStream os = response.getOutputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { os.write(buffer, 0, len); } os.flush(); } }

这段代码里的每个设置都别删,删了就会在某一步踩坑:

setContentType决定浏览器怎么处理这个响应。application/octet-stream是通用二进制流,浏览器收到后会走下载而不是直接打开。如果你下载的是 PDF 并希望浏览器直接预览,可以改成application/pdf;如果下载 Excel,可以用对应的 MIME 类型,比如application/vnd.openxmlformats-officedocument.spreadsheetml.sheet。

Content-Disposition头里的attachment告诉浏览器"别尝试展示我,直接另存为"。filename参数是给用户看到的建议文件名。注意这里是英文文件名的写法,中文文件名如果直接写进去,后台管理系统常遇到乱码,下一节单独说。

setContentLengthLong让浏览器提前知道文件总大小,下载时才能显示进度条。别小看这一行,很多下载工具会直接读这个长度做文件完整性校验,长度对不上,下载完就会提示文件损坏。

try-with-resources是必须的,保证输入流和输出流在结束后都被关闭。输出流不 close,TCP 连接可能不释放,小流量请求下虽然看不出问题,一旦并发上来就是连接泄漏。flush 也不能省,尤其在做 zip 打包时,不然最后一段数据可能卡在缓冲区里没落盘。

2.3 中文文件名乱码的完整解法

中文文件名是下载功能里最经典的坑。HTTP 头部本身只允许 ASCII 字符,Content-Disposition里的中文必须经过编码。网上很多老代码直接拼字符串,比如:

response.setHeader("Content-Disposition", "attachment; filename=" + fileName);

这在 Firefox 早期版本里能显示,但 Chrome、Edge 并不认这种写法。现在的主流做法是使用 RFC 5987 规范的filename*参数,用 UTF-8 百分号编码,再加一个降级用的filename:

public static String buildAttachmentHeader(String fileName) { String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8) .replaceAll("\\+", "%20"); return "attachment; filename=\"download.txt\"; filename*=UTF-8''" + encoded; }

URLEncoder.encode会把空格变成加号,HTTP 头里加号会被解释成空格,所以要替换成%20。UTF-8''是固定写法,表示编码格式。旧版 IE 不认filename*,但毕竟手里下载业务总要兼容它,所以保留普通filename作为降级。

实际使用中可以更聪明一点,文件名不要直接用用户传进来的原始名字,而是先做一次过滤。把路径分隔符、控制字符、..这些符号洗一遍,既能防路径穿越,又能避免浏览器解析响应头时出问题。文件名建议用服务端自己生成的下载名,而不是用户上传时的原始文件名。

2.4 大文件下载如何避免内存爆炸

前文已经强调过:大文件不要整个读进byte[]。有一种危险写法长这样:

byte[] data = FileUtils.readFileToByteArray(file); return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).body(data);

这个写法在小演示项目里跑得欢,但文件一上 GB,OOM就在不远处招手。原因很简单,readFileToByteArray会把全部字节放到堆内存,byte[] data本身就是一份拷贝,HTTP 响应体序列化时大概率还要再来一份,峰值内存是文件大小的两三倍。

流式输出的本质是"边读边写",把磁盘文件分成小份塞给网络:

try (InputStream is = new FileInputStream(file); OutputStream os = response.getOutputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { os.write(buffer, 0, len); } }

8KB 缓冲区是一个比较中庸的数值。太小,比如 1KB,系统调用次数变多,CPU 占用升高;太大,比如 1MB,占用内存就大了。生产环境如果追求极致吞吐,可以测一下 16KB、32KB 的表现。其实InputStream#transferTo(OutputStream)在 JDK 9+ 里就是帮你做同样的事,内部用的也是 8192 字节缓冲,代码可以更短:

try (InputStream is = new FileInputStream(file); OutputStream os = response.getOutputStream()) { is.transferTo(os); }

另外,超大文件还要考虑 Tomcat 的maxSwallowSize。如果客户端中途断开,Tomcat 会尝试把剩余请求体"吞掉",大文件场景下这个行为会拖累服务器。可以在 Tomcat 配置里调整参数,或者改用异步 Servlet,这块在后面异步下载部分再展开。

3. 进阶:支持断点续传的下载接口

3.1 为什么要跟 Range 打交道

基础下载接口能把文件完整给出去,但它有个致命弱点:不支持断点续传。网络抖一下,下载到一半的连接断了,用户只能重新点一次,从头再来一遍。

浏览器和下载工具解决这个问题靠的是 HTTP 的 Range 机制。客户端发起请求时主动带Range: bytes=0-1023,意思是"我只想要文件的第 0 到 1023 字节"。如果服务端支持,就返回206 Partial Content,只输出这一部分;不支持,就忽略这个头,返回 200 整个文件。

视频播放器拖动进度条、迅雷这类工具开多线程分段下载,靠的都是 Range。你的下载接口如果不支持,那些场景要么失败,要么用户体验极差。

3.2 协议要点:请求头、响应头、状态码

支持 Range 的请求会带一个Range请求头,常见的格式有:

请求头格式含义
Range: bytes=0-1023请求第 0 到 1023 字节,共 1024 字节
Range: bytes=1024-请求从第 1024 字节到文件末尾
Range: bytes=-1024请求文件最后 1024 字节
Range: bytes=0-0只请求第一个字节

服务端对应的响应头是:

  • Accept-Ranges: bytes,声明本接口支持 Range;
  • Content-Range: bytes start-end/total,标识本次返回的是哪一段以及文件总大小;
  • Content-Length: end-start+1,此时长度表示的是返回片段的大小,不是整个文件大小;
  • 状态码为206 Partial Content,而不是平时的 200。

如果请求的 range 超出了文件实际大小,要返回416 Range Not Satisfiable,并带上Content-Range: bytes */total,告诉客户端文件总共多大。

3.3 代码实现:从 200 到 206

下面的代码基于RandomAccessFile做分段读取,支持单段 Range。多段 Range(客户端一次要好几块)在真实场景里用得极少,这里只做单段处理,多段就留给极客研究。

@GetMapping("/download/range") public void downloadWithRange(@RequestParam String fileName, @RequestHeader(value = "Range", required = false) String range, HttpServletResponse response) throws IOException { File file = new File("/data/files/", fileName); long fileLength = file.length(); String contentType = "application/octet-stream"; // 默认返回整个文件 long start = 0; long end = fileLength - 1; if (range != null) { // 只处理 bytes= 开头的单段范围 if (range.startsWith("bytes=")) { String part = range.substring("bytes=".length()); String[] rangeArr = part.split("-", 2); try { if (rangeArr[0].isEmpty()) { // bytes=-1024,表示最后 1024 字节 long suffix = Long.parseLong(rangeArr[1]); start = Math.max(fileLength - suffix, 0); end = fileLength - 1; } else { start = Long.parseLong(rangeArr[0]); if (rangeArr.length > 1 && !rangeArr[1].isEmpty()) { end = Long.parseLong(rangeArr[1]); } } } catch (NumberFormatException e) { response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return; } } } if (start > end || start >= fileLength) { response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); response.setHeader("Content-Range", "bytes */" + fileLength); return; } end = Math.min(end, fileLength - 1); long contentLength = end - start + 1; response.setStatus(range != null ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK); response.setHeader("Accept-Ranges", "bytes"); response.setContentType(contentType); response.setHeader("Content-Disposition", buildAttachmentHeader(fileName)); if (range != null) { response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength); } response.setContentLengthLong(contentLength); try (RandomAccessFile raf = new RandomAccessFile(file, "r"); OutputStream os = response.getOutputStream()) { raf.seek(start); long remaining = contentLength; byte[] buffer = new byte[8192]; int len; while (remaining > 0 && (len = raf.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) { os.write(buffer, 0, len); remaining -= len; } os.flush(); } }

使用RandomAccessFile而不是FileInputStream是因为它能直接seek到指定位置,不需要把前面的字节全部跳过。RandomAccessFile读文件是独占锁,不过这只影响打开同一个文件的多个随机访问,不阻塞其他进程正常读,实际下载场景没问题。

代码里对异常范围做了兜底:如果客户端要到超出文件大小的位置,直接回 416,不要硬着头皮强读,不然会产生空响应体导致客户端判断文件大小对不上。

3.4 容易忽略的边界情况

抛开 Range 本身,把这段代码放到生产环境还要补几个东西。

第一是If-Range请求头。一个视频文件如果已经被替换了,但客户端拿着旧的 ETag 和旧 Range 来请求,服务端直接返回 206,会造成内容前后不一致,甚至损坏。正确处理是:客户端带If-Range时,服务器校验值与当前 ETag / Last-Modified 是否匹配,不匹配就返回完整的 200 文件,让客户端重新加载。

第二是 HEAD 请求。健康检查、下载工具探测文件信息都会发 HEAD。此时不能输出文件体,但要保留响应头,尤其是Content-Length和Accept-Ranges。在方法上加@RequestMapping而不是@GetMapping,或者拦截 HEAD 方法单独处理。

第三是Content-Length必须是返回片段的长度。很多人把整个文件长度填进去,结果客户端收到 206 却说字节数不对,多数就是这个原因。计算不要用end - start,要用end - start + 1,别把两个闭区间端点的差值当成区间大小。

4. 批量下载、打包压缩与安全防护

4.1 多文件打包 zip 的正确姿势

后台管理系统里有个高频需求:用户勾选一批订单,导出打包文件。实现方式不复杂,用 JDK 自带的ZipOutputStream把多个文件写进一个 zip 流,边生成边往响应里输出:

@GetMapping("/download/zip") public void downloadZip(@RequestParam List<String> fileNames, HttpServletResponse response) throws IOException { response.setContentType("application/zip"); response.setHeader("Content-Disposition", "attachment; filename=\"batch.zip\""); try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) { for (String fileName : fileNames) { File file = new File("/data/files/", fileName); if (!file.exists()) { continue; } try (InputStream is = new FileInputStream(file)) { zos.putNextEntry(new ZipEntry(file.getName())); byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { zos.write(buffer, 0, len); } zos.closeEntry(); } } zos.finish(); } }

几个细节要特别注意。putNextEntry必须在写入该文件内容之前调用,closeEntry必须在文件内容写完、下一个条目开始之前调用,否则 zip 结构会错乱,解压时提示文件损坏。每个条目的名字不要直接从用户输入拿,而是只取文件名,过滤掉路径,既避免跨目录问题,也防止用户在 zip 里看到奇怪的绝对路径。

中文文件名在 zip 里能不能正确显示,取决于创建ZipEntry时名字的编码。JDK 7 之后ZipOutputStream默认使用 UTF-8,多数主流解压工具都认;如果你必须兼容老系统,可以在构造时指定编码,但现代项目我建议保持默认。

4.2 Zip Slip:一个被低估的漏洞

如果项目要求把压缩包里的文件自动解压到服务器,就要小心一个叫 Zip Slip 的漏洞。恶意方构造一个条目名,比如../../evil.sh,解压时如果直接把条目名拼到目标目录后面,文件就会被写到目标目录之外,覆盖服务器其他位置的同名文件。

防御思路很简单:规范化解压目标路径,然后判断是否依然在期望目录内。

Path baseDir = Paths.get("/data/unzip/").toAbsolutePath().normalize(); Path targetPath = baseDir.resolve(entryName).normalize(); if (!targetPath.startsWith(baseDir)) { throw new SecurityException("非法解压路径: " + entryName); }

这里有两个关键动作。一是resolve再normalize,让../../被语义化解析,而不是当字符串拼接;二是startsWith(baseDir)判断最终路径的前缀,只要不在基础目录里就一律拒绝。不要信条目名里那种"看着像文件名"的直觉,攻击者最喜欢利用的就是你的直觉。

4.3 下载接口的权限校验与临时签名

下载接口一旦开放,就会成为爬虫和越权访问的重点目标。基础方案是走 Spring Security 或拦截器,校验请求是否携带有效会话:先判断用户是否登录,再判断用户对当前资源是否有权限,最后使用文件的绝对路径和用户信息确认归属,避免用户可以传任意路径下载他人文件。

更细一点的方案是"临时签名链接"。用户先请求一次业务接口,后端校验通过后生成一个带签名和过期时间的 URL:

https://example.com/file/download?fileId=xxx&expires=1712345678&sign=md5(fileId+expires+secret)

下载接口只要校验时间和签名,全部通过后才真正投递文件。这种方式的好处是:下载请求本身不依赖登录态,可以被 CDN、Nginx 缓存,同时即使链接泄露,过期时间一到就自动失效。缺点是签名算法要自己维护,密钥一旦泄露等于白搭。

4.4 鉴权与浏览器直链的矛盾怎么解

前后端分离的项目有个经典痛点:带有权限的下载,不能直接用window.location.href或者<a href>打开,因为浏览器直链没法塞Authorization请求头,后端一鉴权就 401,用户下载永远失败。

标准解法是前端用 fetch 或者 axios 以responseType: 'blob'的方式请求接口,把二进制文件流先拿到内存,再通过 Blob 本地生成一个临时 URL 交给浏览器保存:

fetch('/file/download?fileId=xxx', { headers: { 'Authorization': 'Bearer ' + token } }) .then(res => res.blob()) .then(blob => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '待定文件名'; a.click(); URL.revokeObjectURL(url); });

注意a.download属性不能忽略。这不是设置 UI 上显示的名字,而是告诉浏览器"别导航到这个 Blob URL,直接下载"。文件名最好从响应头Content-Disposition里解析出来,前端再回填到a.download。revokeObjectURL记得在点击下载后调用,否则页面长时间开着会积累一堆 Blob 内存。

5. 常见问题排查与填坑记录

5.1 下载出来是一坨乱码 HTML,而不是文件

现象是用户点下载,下来一个文件,打开一看全是 HTML 或者 JSON 字符串,里面往往还有{"code":500,...}之类的信息。这多半是项目里的全局返回包装器或全局异常处理器在捣乱。

比如使用@RestControllerAdvice把接口返回值统一包成R<T>,下载接口的void方法或者ResponseEntity也被意外套了一层;或者是业务层抛了异常,全局异常处理器把错误信息包装成 JSON 写进响应,但响应状态码还是 200,浏览器就傻乎乎地把 JSON 当文件存了下来。

排查思路分两步。第一步,看浏览器开发者工具里的原始响应。如果响应体是 JSON 但Content-Type是text/html或application/json,说明有过滤器或注解处理器提前写了内容;第二步,检查下载接口是否在@ControllerAdvice的ResponseBodyAdvice白名单范围内。通常做法是把路径前缀/file/download/排除出包装器,或者让下载接口返回ResponseEntity<StreamingResponseBody>,包装器对StreamingResponseBody类型没有接管能力,顺手也能避免这个问题。

还有一个容易漏的坑:业务异常发生时,下载接口没用return而是继续往下执行,最后输出流永远不会被写入,客户端干等着超时。这种隐性问题要靠日志和单元测试兜住,别把赌注压在"我看这代码应该没问题"上。

5.2 大文件并发下载 OOM

这里有两种典型现场。一种是我前面说的,用byte[]一下子吞下整个文件;另一种是动态生成 Excel 时,用一次性生成整个内存模型的方式导出超大列表,几万行数据全堆在内存里,再往响应里写就爆了。

解决方向有两个。一是让生成端支持流式写入,比如 EasyExcel 的write(fileName, sheetName, modelClass)方法就能边写边输出,而不是先建完一个完整Workbook再写。二是把生成动作与下载动作解耦,报表先在服务端生成到临时目录或对象存储,再返回一个下载链接给前端。这个方案对用户体验更友好,因为大报表生成本身就耗时,谁也不愿意在下载页面无限转圈。

如果服务器内存已经捉襟见肘,还可以考虑把大文件下载丢给静态文件服务器。应用层只负责鉴权和重定向,真正的字节搬运交给操作系统级别的sendfile,应用 JVM 完全不参与。

5.3 下载文件损坏或字节数不对

下载完的文件解压失败、Excel 打开提示已损坏,排查优先级最高的就是字节数。对比服务端原始文件与下载后文件的大小,如果不一致,八成是输出流提前关闭、响应头里Content-Length与实际字节数不一致,或者中间某一段写出乱序。

另一种隐蔽原因是中途flush时机不对。正常写完缓冲区后flush没问题,但如果你在ZipOutputStream的closeEntry之前多flush了几次,或者忘了finish(),zip 的中央目录段就缺失了,解压工具会毫不留情地报错。这种情况我建议直接看原始文件大小一目了然:文件少几百字节,zip 结构就不完整。

还有一种应用层'假装成功'的情况:文件不存在或者读取失败时,接口没有设置错误状态码,还是 200,同时输出流里啥都没写。客户端按Content-Length等半天发现只有 0 字节,也会判定损坏。正确做法是读取失败时要么设置非 200 状态码,要么写一段明确错误信息,不要留空白响应。

5.4 下载中断与超时问题

下载到一半断掉,原因通常不在下载代码本身,而是服务器或中间层把连接杀了。

第一个常见是代理超时。Nginx 默认proxy_read_timeout是 60 秒,大文件下载时,如果文件生成耗时超过 60 秒,Nginx 就急不可耐地断开连接。我踩过几回:服务端还在慢吞吞生成 Excel,客户端已经收到一个空响应。针对下载接口,Nginx 的 proxy 相关超时要单独调长,比如proxy_read_timeout 300s;,或者干脆把这个接口放到不走反向代理的内部路径上。

第二个常见是 Tomcat 连接超时。Spring Boot 内置 Tomcat 的连接超时主要指读取请求数据的时间,长连接下载通常不受影响。真正容易出问题的是服务端启用了异步接口,异步任务执行超时自动把响应态重置,客户端拿到不对的响应。如果用了StreamingResponseBody,要留意 Spring MVC 的 async timeout 设置,必要时通过 WebMvcConfigurer 调整。

第三个常见是用户断网。客户端断开后,服务端要写数据时才会发现Connection reset by peer,但这个异常往往被吞在日志底部。生产环境可以通过监控SOCKET_WRITE指标观察是否存在大量客户端提前断开,如果占比过高,说明下载链接过期、前端抢跑或者等待时间过长,需要从业务流程上找原因,而不是死磕写流代码。

5.5 前后端分离下文件名获取

前端用 Blob 方式下载后,a.download文件名的来源有两个:一个是前端硬编码,另一个是从响应头Content-Disposition解析。推荐第二种,因为服务端才知道文件真实名字。

但这里有个坑:跨域请求下,浏览器默认只能暴露一小撮安全响应头,Content-Disposition不在其中。后端要在响应中显式放行:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"); } }

前端fetch需要拿到这个头,还得开启Access-Control-Expose-Headers,否则res.headers.get('Content-Disposition')一直是 null。可以配合 Spring 的HttpServletResponse.setHeader("Access-Control-Expose-Headers", "Content-Disposition")或者 CORS 配置里的exposedHeaders("Content-Disposition")。

然后前端再把这个头里的filename*=部分用decodeURIComponent解码,写进a.download属性即可。这一步做不好,用户下载下来就问"这文件叫啥?",体验直接减分。

6. 更优方案:什么时候不必自己写流

6.1 静态资源映射:彻底解放应用线程

文件就是公开的、不大、没有权限控制需求的时候,完全没必要让 Java 方法去 read 文件。Spring Boot 里一行配置就能把本地目录映射成外部 URL:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:/data/files/"); } }

访问/files/2025/1/report.xlsx时,Spring 会根据 URL 寻找/data/files/2025/1/report.xlsx文件并返回。这个方案的优点是代码量几乎为零,缺点是没有权限控制和防盗链。一旦 URL 泄露,任何知道地址的人都能下载。所以它只能用于真正的公开资源,比如产品文档、示例图片,业务文件不要这样开放。

如果用的是 Nginx 反向代理,甚至可以连 Spring 这层都不走,直接让 Nginx 服务静态目录。应用只负责业务,文件下载的活儿交给 Nginx,实现真正的零侵入。

6.2 Nginx 内部重定向:鉴权和加速两不误

想要鉴权,又不想让大文件经过 Java 应用层慢慢转发,Nginx 有个利器叫X-Accel-Redirect,很多人称呼为"内部重定向"。

流程是:客户端请求到 Spring Boot,应用做完权限校验,不在响应体里返回文件,而是设一个特殊响应头:

response.setHeader("X-Accel-Redirect", "/protected/2025/1/report.xlsx"); response.setStatus(HttpServletResponse.SC_OK);

Nginx 在飘层截住这个头,不会把这个头转发给客户端,而是直接进入到internal标识的 location 去我认为静态文件并用 sendfile 方式返回。客户端感知到的是一次普通下载,全程没接触过真实路径。

Nginx 配置示意:

location /protected/ { internal; alias /data/files/; add_header Content-Disposition 'attachment; filename="download.xlsx"'; }

这个方案的优点非常明显:文件字节从磁盘到网络,全程走的是操作系统零拷贝,Tomcat 线程在X-Accel-Redirect设置完就释放了,同时下载接口照样可以做权限控制。缺点是对 Nginx 配置有要求,内部路径不能对公网直接暴露,一旦internal没配好,文件可能被绕过鉴权直接访问。我建议内部路径一律用随机不可猜测的目录名,并且监控访问日志。

6.3 用 StreamingResponseBody 做异步下载

如果不想引入 Nginx 之类的额外组件,又想避免长时间占用 Tomcat 请求线程,Spring MVC 给了一个久经考验的方案:StreamingResponseBody。它告诉 Spring,把响应交给异步线程池去写,请求线程先去处理别的活,等文件写完再由容器提交响应。

@GetMapping("/download/async") public ResponseEntity<StreamingResponseBody> downloadAsync(@RequestParam String fileName) throws IOException { File file = new File("/data/files/", fileName); if (!file.exists()) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .header(HttpHeaders.CONTENT_DISPOSITION, buildAttachmentHeader(fileName)) .body(os -> { try (InputStream is = new FileInputStream(file)) { byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { os.write(buffer, 0, len); } os.flush(); } catch (IOException e) { throw new RuntimeException("下载写入失败", e); } }); }

这里有个对比很有意思:普通ResponseEntity<byte[]>是把全部字节提前放进内存,再交给 Spring 去写;ResponseEntity<StreamingResponseBody>是返回一个"怎么写"的函数,Spring 在异步线程里执行它,写入动作完全脱离请求线程。缺点是失去了提前确定部分响应头的能力,而且异常处理要在 lambda 内部自己兜,写崩了要记得打日志,否则排查问题像大海捞针。

6.4 对象存储直传:把流量挡在系统外

如果项目里已经用了对象存储,比如自己部署的 MinIO 或者云上的 S3 兼容服务,那最省心的下载方案不是让应用去拉文件再转给用户,而是应用生成一个带签名的预授权 URL,前端拿到 URL 后直接跳转。文件下载流量完全不经过应用服务器,应用只负责鉴权和签名。

这个方案的优点是应用服务器带宽压力骤降,大文件下载再多也不怕;缺点是预签名 URL 通常有有效期上限,客户下载时间过长会面临链接过期,另外对象存储本身会产生费用。对既有系统来说,把下载链路改造成对象存储直传,是性能和稳定性收益最明显的重构之一。

最后说几句体己话

文件下载在 Spring Boot 里永远不是"会写一个接口"就算完的。我实际项目里最常用的一套组合是:中小文件走ResponseEntity<byte[]>,大文件走StreamingResponseBody,文件固定且公开走静态资源映射,私密文件用 Nginx 内部重定向。同时,响应头里Content-Disposition、Content-Length、Accept-Ranges一定不能省,Content-Type要按实际文件微调。

最后再提醒一个容易被忽略的小设计:下载接口对所有文件路径都要做normalize和前缀校验,无论代码看着多"干净"都要加。文件路径校验和 zip 解压校验一样,属于那种平时没存在感、出事就是安全事故的防守点。把上面这些细节提前落到位,下载功能才算真正从"能跑"走到了"能用"和"扛得住"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询