Spring Boot文件下载全解析:从基础接口到断点续传与安全优化 文件下载这件事放在需求清单里经常就三个字加个导出。可真做起来能扯出一长串问题文件名中文乱码、大文件把内存撑爆、断点续传没做导致下载一半进度条归零、zip 打包踩了路径穿越漏洞、前后端分离后浏览器直链又带不上 token……我在 Spring Boot 项目里写下载接口没有十次也有八次每次以为很简单结果总有几个新坑在等着。这篇就把常见的下载场景拆开来看把基础接口、断点续传、批量打包、权限校验、问题排查和更优替代方案一起捋顺适合刚把 Spring Boot 跑通的新手照着抄也适合老后端回头查漏补缺。1. 先把需求拆明白你到底是哪种下载1.1 四类下载场景对应完全不同的技术选型下载这个动作听起来都一样但背后的文件来源、文件大小、访问频率决定了你该用哪种实现。我把实际项目中遇到的下载需求粗略分成四类场景类型典型例子特点推荐做法静态资源文件产品图片、帮助手册文件固定、路径固定、不常变静态资源映射或 Nginx 托管动态生成文件Excel 报表、PDF 对账单请求时临时生成、大小可控应用层流式输出业务附件合同、简历、扫描件需要权限校验、可能很大应用层鉴权 流式下载超大文件/音视频日志包、培训视频几个 GB 起步、可能被拖动播放支持 Range 断点续传很多人一上来就写一个response.getOutputStream()循环读文件这在第二类场景没问题但放到第一类场景就是浪费应用性能放到第四类场景则大概率翻车。先搞清楚属于哪一类后面每一步选择都是水到渠成。1.2 三种主流实现先对比再下手Spring Boot 里实现文件下载最常见的就是下面三种方式实现方式核心 API适合场景主要坑点直接写 HttpServletResponse 输出流response.getOutputStream()大文件、动态生成、需要精细控制响应头响应头、异常处理都要自己兜返回ResponseEntitybyte[]ResponseEntity.ok(contentType).body(bytes)中小文件、模板文件整个文件载入内存大文件易 OOM返回 Resource 或 InputStreamResourceResponseEntityResource中等文件、需要附带 Resource 语义生命周期管理要留意静态资源映射 / 重定向addResourceHandlers、Nginx公开文件、固定资源权限控制弱不能随意暴露私密文件我的习惯是小于 10MB 的文件无所谓用ResponseEntitybyte[]怎么方便怎么来超过 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是必须的保证输入流和输出流在结束后都被关闭。输出流不 closeTCP 连接可能不释放小流量请求下虽然看不出问题一旦并发上来就是连接泄漏。flush 也不能省尤其在做 zip 打包时不然最后一段数据可能卡在缓冲区里没落盘。2.3 中文文件名乱码的完整解法中文文件名是下载功能里最经典的坑。HTTP 头部本身只允许 ASCII 字符Content-Disposition里的中文必须经过编码。网上很多老代码直接拼字符串比如response.setHeader(Content-Disposition, attachment; filename fileName);这在 Firefox 早期版本里能显示但 Chrome、Edge 并不认这种写法。现在的主流做法是使用 RFC 5987 规范的filename*参数用 UTF-8 百分号编码再加一个降级用的filenamepublic 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);这个写法在小演示项目里跑得欢但文件一上 GBOOM就在不远处招手。原因很简单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: bytes0-1023意思是我只想要文件的第 0 到 1023 字节。如果服务端支持就返回206 Partial Content只输出这一部分不支持就忽略这个头返回 200 整个文件。视频播放器拖动进度条、迅雷这类工具开多线程分段下载靠的都是 Range。你的下载接口如果不支持那些场景要么失败要么用户体验极差。3.2 协议要点请求头、响应头、状态码支持 Range 的请求会带一个Range请求头常见的格式有请求头格式含义Range: bytes0-1023请求第 0 到 1023 字节共 1024 字节Range: bytes1024-请求从第 1024 字节到文件末尾Range: bytes-1024请求文件最后 1024 字节Range: bytes0-0只请求第一个字节服务端对应的响应头是Accept-Ranges: bytes声明本接口支持 RangeContent-Range: bytes start-end/total标识本次返回的是哪一段以及文件总大小Content-Length: end-start1此时长度表示的是返回片段的大小不是整个文件大小状态码为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 ListString 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 或拦截器校验请求是否携带有效会话先判断用户是否登录再判断用户对当前资源是否有权限最后使用文件的绝对路径和用户信息确认归属避免用户可以传任意路径下载他人文件。更细一点的方案是临时签名链接。用户先请求一次业务接口后端校验通过后生成一个带签名和过期时间的 URLhttps://example.com/file/download?fileIdxxxexpires1712345678signmd5(fileIdexpiressecret)下载接口只要校验时间和签名全部通过后才真正投递文件。这种方式的好处是下载请求本身不依赖登录态可以被 CDN、Nginx 缓存同时即使链接泄露过期时间一到就自动失效。缺点是签名算法要自己维护密钥一旦泄露等于白搭。4.4 鉴权与浏览器直链的矛盾怎么解前后端分离的项目有个经典痛点带有权限的下载不能直接用window.location.href或者a href打开因为浏览器直链没法塞Authorization请求头后端一鉴权就 401用户下载永远失败。标准解法是前端用 fetch 或者 axios 以responseType: blob的方式请求接口把二进制文件流先拿到内存再通过 Blob 本地生成一个临时 URL 交给浏览器保存fetch(/file/download?fileIdxxx, { 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把接口返回值统一包成RT下载接口的void方法或者ResponseEntity也被意外套了一层或者是业务层抛了异常全局异常处理器把错误信息包装成 JSON 写进响应但响应状态码还是 200浏览器就傻乎乎地把 JSON 当文件存了下来。排查思路分两步。第一步看浏览器开发者工具里的原始响应。如果响应体是 JSON 但Content-Type是text/html或application/json说明有过滤器或注解处理器提前写了内容第二步检查下载接口是否在ControllerAdvice的ResponseBodyAdvice白名单范围内。通常做法是把路径前缀/file/download/排除出包装器或者让下载接口返回ResponseEntityStreamingResponseBody包装器对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 里一行配置就能把本地目录映射成外部 URLConfiguration 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; filenamedownload.xlsx; }这个方案的优点非常明显文件字节从磁盘到网络全程走的是操作系统零拷贝Tomcat 线程在X-Accel-Redirect设置完就释放了同时下载接口照样可以做权限控制。缺点是对 Nginx 配置有要求内部路径不能对公网直接暴露一旦internal没配好文件可能被绕过鉴权直接访问。我建议内部路径一律用随机不可猜测的目录名并且监控访问日志。6.3 用 StreamingResponseBody 做异步下载如果不想引入 Nginx 之类的额外组件又想避免长时间占用 Tomcat 请求线程Spring MVC 给了一个久经考验的方案StreamingResponseBody。它告诉 Spring把响应交给异步线程池去写请求线程先去处理别的活等文件写完再由容器提交响应。GetMapping(/download/async) public ResponseEntityStreamingResponseBody 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); } }); }这里有个对比很有意思普通ResponseEntitybyte[]是把全部字节提前放进内存再交给 Spring 去写ResponseEntityStreamingResponseBody是返回一个怎么写的函数Spring 在异步线程里执行它写入动作完全脱离请求线程。缺点是失去了提前确定部分响应头的能力而且异常处理要在 lambda 内部自己兜写崩了要记得打日志否则排查问题像大海捞针。6.4 对象存储直传把流量挡在系统外如果项目里已经用了对象存储比如自己部署的 MinIO 或者云上的 S3 兼容服务那最省心的下载方案不是让应用去拉文件再转给用户而是应用生成一个带签名的预授权 URL前端拿到 URL 后直接跳转。文件下载流量完全不经过应用服务器应用只负责鉴权和签名。这个方案的优点是应用服务器带宽压力骤降大文件下载再多也不怕缺点是预签名 URL 通常有有效期上限客户下载时间过长会面临链接过期另外对象存储本身会产生费用。对既有系统来说把下载链路改造成对象存储直传是性能和稳定性收益最明显的重构之一。最后说几句体己话文件下载在 Spring Boot 里永远不是会写一个接口就算完的。我实际项目里最常用的一套组合是中小文件走ResponseEntitybyte[]大文件走StreamingResponseBody文件固定且公开走静态资源映射私密文件用 Nginx 内部重定向。同时响应头里Content-Disposition、Content-Length、Accept-Ranges一定不能省Content-Type要按实际文件微调。最后再提醒一个容易被忽略的小设计下载接口对所有文件路径都要做normalize和前缀校验无论代码看着多干净都要加。文件路径校验和 zip 解压校验一样属于那种平时没存在感、出事就是安全事故的防守点。把上面这些细节提前落到位下载功能才算真正从能跑走到了能用和扛得住。