1. 大文件下载为什么不能直接照搬小文件那套写法
很多人第一次写文件下载,脑子里浮现的就是那几行代码:打开一个InputStream,拿到OutputStream,然后一个while循环读一点写一点,最后关流。这套写法下载几KB的配置文件、几百KB的图片完全没问题,可一旦文件体积上到几百MB甚至几个GB,问题就会一个接一个冒出来。我见过太多项目在测试环境用几十MB的文件跑得好好的,一上线遇到真实的大文件,要么内存直接飙满触发告警,要么下载到一半连接断开,要么用户浏览器里进度条卡死不动。
大文件下载的核心矛盾,其实就一句话:文件太大,不能一次性装进内存,也不能让一次请求占用太长时间而不做任何控制。小文件下载时,我们习惯把整个文件读进byte[]再写出去,因为数据量小,内存扛得住。但一个2GB的文件如果这么干,光是这一个请求就能把堆内存撑爆,更别说同时来几个用户了。所以大文件下载的第一原则是流式处理,边读边写,内存里始终只保留一个缓冲区大小的数据。
第二个容易被忽略的点是响应头的设置。小文件下载时很多人连Content-Length都懒得设,浏览器照样能下。但大文件不一样,浏览器需要知道文件总大小才能显示进度条,用户也需要知道这文件到底多大、要下多久。如果响应头里没有正确的Content-Length,浏览器就只能显示一个转圈圈的动画,用户体验极差。而且Content-Type如果设错,浏览器可能会尝试直接打开文件而不是下载,比如把.zip识别成文本,结果页面上一堆乱码。
第三个坑是断点续传。大文件下载时间长,网络抖动、用户误操作、浏览器崩溃都可能中断下载。如果没有断点续传,用户只能从头再来,一个下了90%的2GB文件就这么废了,换谁都得骂人。断点续传依赖 HTTP 的Range请求头,服务端要能解析这个头,并且返回206 Partial Content状态码和正确的Content-Range响应头。这套机制不难,但细节特别多,稍不注意就会出现文件损坏或者下载卡住的问题。
最后一个现实问题是超时和资源占用。一个下载请求可能持续几分钟甚至几十分钟,这期间占着一个线程、一个数据库连接(如果下载前要查权限)、一个文件句柄。如果并发量上来,线程池很快就被下载请求占满,其他接口全部排队。所以大文件下载必须考虑超时设置、线程模型、以及是否要把下载任务从主业务线程里剥离出去。
提示:判断一个下载实现是否合格,最简单的标准就是拿一个超过1GB的文件,在弱网环境下测试,看内存曲线是否平稳、能否断点续传、并发几个请求时其他接口是否还正常响应。
2. 流式下载的底层机制与缓冲区大小的选择逻辑
2.1 从 InputStream 到 OutputStream 的数据流转
Java 里做流式下载,最核心的两个角色就是InputStream(读文件)和OutputStream(写响应)。文件在磁盘上是一个字节序列,FileInputStream负责按顺序把这些字节读出来,ServletOutputStream负责把字节写到网络连接上。中间需要一个缓冲区来暂存数据,因为一次读一个字节效率太低,一次读整个文件内存又扛不住。
这个缓冲区的大小选择是有讲究的。太小了,比如 1KB,读写次数太多,CPU 大部分时间花在系统调用上,下载速度上不去。太大了,比如 10MB,内存占用高,而且对于慢速客户端,大缓冲区意味着数据在服务端积压,反而增加内存压力。经过大量实践,8KB 到 64KB 是比较合理的区间,其中 16KB 和 32KB 用得最多。这个范围既能保证吞吐量,又不会给内存带来明显负担。
我个人的习惯是用 16KB 作为默认缓冲区,如果下载的是超大文件且服务端内存充裕,可以调到 32KB。但要注意,缓冲区大小并不是越大越好,超过 64KB 之后吞吐量提升非常有限,而内存占用是线性增长的。下面是一个最基础的流式下载核心代码结构:
// 设置响应头 response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename=\"large-file.zip\""); response.setContentLengthLong(file.length()); // 流式读写 byte[] buffer = new byte[16 * 1024]; try (InputStream in = new FileInputStream(file); OutputStream out = response.getOutputStream()) { int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } out.flush(); }这段代码看起来简单,但每一行都有它的道理。setContentLengthLong用的是 long 类型,因为大文件可能超过 2GB,用setContentLength(int)会溢出变成负数,浏览器会认为文件大小异常。try-with-resources保证流一定被关闭,避免文件句柄泄漏。out.flush()确保缓冲区里的数据全部推送到客户端。
2.2 为什么必须用 long 而不是 int 表示文件长度
这个问题值得单独拎出来说,因为它是大文件下载里最隐蔽的坑之一。Java 的int最大值是 2147483647,也就是大约 2GB。如果一个文件是 3GB,用int存长度会溢出成负数。File.length()返回的是long,但很多人在设置响应头时习惯性地用了response.setContentLength((int) file.length()),这一强制转换就把大文件坑死了。
浏览器收到一个负数的Content-Length,行为是不可预测的。有的浏览器直接报错,有的浏览器忽略这个头然后显示未知大小,有的浏览器会尝试下载但进度条乱跳。所以只要涉及文件长度,一律用 long,对应的 API 是setContentLengthLong。同样,在计算 Range 的起止位置时,也要用 long,因为偏移量也可能超过 2GB。
2.3 缓冲区刷新时机与网络背压
OutputStream.write并不是每次调用都会立即把数据发到网络上,数据会先进入操作系统的 socket 缓冲区。如果客户端读取速度慢,socket 缓冲区满了之后,write方法会阻塞,直到有空间为止。这个阻塞机制其实就是天然的背压(back pressure),它保证了服务端不会无限制地往网络里灌数据。
但这里有个细节:response.getOutputStream()拿到的流,在写入过程中可能会被容器(比如 Tomcat)包装过,中间可能还有一层缓冲区。所以flush()的调用时机要合理。如果每写一次就 flush,会增加网络交互次数;如果一直不 flush,数据可能积压在容器缓冲区里,用户看到的是下载进度长时间不动然后突然跳一大截。我的经验是每写完一个缓冲区就检查一次是否需要 flush,或者干脆依赖容器在响应结束时统一 flush,对于大文件下载来说,后者通常更高效。
3. 断点续传的实现细节与 Range 头解析
3.1 Range 请求头的格式与解析
断点续传的核心是 HTTP 的Range请求头。当用户暂停下载后重新开始,浏览器会发送类似这样的头:
Range: bytes=1048576-这表示“从第 1048576 个字节开始,一直到文件末尾”。也可能是:
Range: bytes=1048576-2097151表示“从 1048576 到 2097151 这个区间”。服务端需要解析这个头,然后只返回对应区间的数据,同时设置响应状态码为206 Partial Content,并带上Content-Range头:
Content-Range: bytes 1048576-2097151/3145728其中3145728是文件总大小。如果 Range 不合法,比如起始位置超过了文件大小,服务端应该返回416 Requested Range Not Satisfiable。
解析 Range 头的时候有几个边界情况要处理:多个区间(比如bytes=0-100,200-300)虽然 HTTP 规范允许,但实际下载场景中很少见,可以只支持单区间;起始位置为负数表示从末尾倒数,比如bytes=-500表示最后500字节,这个也要支持;如果 Range 格式完全不对,就忽略它,返回完整文件。
3.2 随机读取文件:RandomAccessFile 的用法
要实现断点续传,就不能用FileInputStream从头读到尾了,因为需要从指定位置开始读。这时候RandomAccessFile就派上用场了。它可以通过seek(long position)方法把文件指针移动到任意位置,然后开始读取。
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { raf.seek(startPos); byte[] buffer = new byte[16 * 1024]; long remaining = endPos - startPos + 1; while (remaining > 0) { int toRead = (int) Math.min(buffer.length, remaining); int len = raf.read(buffer, 0, toRead); if (len == -1) break; out.write(buffer, 0, len); remaining -= len; } }这里有个关键点:remaining变量控制还要读多少字节,因为断点续传时不是读到文件末尾就停,而是读到endPos就停。如果客户端请求的是bytes=0-1048575,那服务端就只返回前1MB,不能多返回。Math.min(buffer.length, remaining)保证最后一次读取不会超出范围。
3.3 响应头设置的完整清单
断点续传场景下,响应头比普通下载要多设置几个,而且每个都有明确作用:
| 响应头 | 作用 | 示例值 |
|---|---|---|
| Content-Type | 告诉浏览器文件类型 | application/octet-stream |
| Content-Disposition | 指定下载文件名 | attachment; filename="data.zip" |
| Content-Length | 本次响应返回的字节数 | 1048576 |
| Content-Range | 本次返回的区间和总大小 | bytes 0-1048575/3145728 |
| Accept-Ranges | 告诉浏览器支持断点续传 | bytes |
| Last-Modified | 文件最后修改时间,用于缓存校验 | Wed, 21 Oct 2025 07:28:00 GMT |
Accept-Ranges: bytes这个头特别重要,浏览器只有在看到这个头之后,才会在暂停后尝试断点续传。如果服务端不返回这个头,浏览器会认为不支持续传,重新下载时还是从头开始。Content-Length在 206 响应里表示的是本次返回的字节数,不是文件总大小,总大小在Content-Range里。
3.4 断点续传的边界条件与异常处理
实际跑起来之后,断点续传会遇到一些边界情况。比如客户端请求的startPos等于文件大小,这时候应该返回 416,因为已经没有数据可读了。如果startPos大于文件大小,同样返回 416。如果endPos超过了文件末尾,要把endPos修正为fileSize - 1,然后正常返回。
还有一个容易被忽略的点:文件在下载过程中被修改了。如果用户开始下载时文件是 3GB,下载到一半文件被替换成了 2GB,那断点续传时startPos可能已经超过了新文件的大小。这种情况下,服务端应该返回 416,并且客户端应该重新开始下载。为了检测这种情况,可以在响应头里带上ETag(可以用文件大小+最后修改时间生成),客户端续传时带上If-Range头,服务端比对不一致就返回完整文件。
注意:
RandomAccessFile打开文件时会占用一个文件句柄,高并发下载场景下要注意句柄数量限制。如果同时有几百个下载请求,每个都打开一个RandomAccessFile,可能会遇到“Too many open files”的错误。解决办法是控制并发下载数,或者用文件通道(FileChannel)配合直接内存做更细粒度的控制。
4. 大文件下载中的内存与连接管理实战
4.1 为什么不能把文件读进 byte 数组
我见过不少“简化版”的大文件下载实现,代码大概长这样:
byte[] data = Files.readAllBytes(file.toPath()); response.getOutputStream().write(data);这段代码在文件小于几十MB时确实能跑,但文件一大就完蛋。Files.readAllBytes会把整个文件加载到堆内存里,一个2GB的文件就需要2GB的连续堆空间。JVM 的堆内存通常也就几个GB,这一下就占满了,紧接着就是频繁 Full GC,然后OutOfMemoryError。更糟糕的是,这个数组会一直存活到响应写完,期间如果来几个并发请求,内存直接爆炸。
正确的做法永远是流式处理,内存里只保留一个固定大小的缓冲区。这样无论文件是1GB还是100GB,内存占用都是恒定的。这也是为什么我在任何下载相关的代码审查里,看到readAllBytes都会直接打回去重写。
4.2 连接超时与读写超时的设置
大文件下载的另一个现实问题是超时。一个下载请求可能持续几十分钟,如果超时设置得太短,下载到一半连接就被服务端断开了。但如果设置得太长,又会导致僵尸连接长期占用资源。
在 Spring Boot 里,可以通过配置文件调整 Tomcat 的连接超时:
server: tomcat: connection-timeout: 300000 keep-alive-timeout: 300000 max-connections: 200 accept-count: 100connection-timeout是建立连接后的等待时间,对于下载来说,这个值要设大一些,比如5分钟。keep-alive-timeout控制连接空闲多久后关闭,下载过程中连接一直有数据传输,不会触发这个超时。max-connections限制同时处理的连接数,防止下载请求把连接池占满。
但光靠容器配置还不够,因为下载请求在业务代码里执行时,可能还会经过网关、反向代理等中间层。每一层都有自己的超时设置,需要逐层确认。我遇到过一种情况:Tomcat 的超时设了10分钟,但前面的反向代理只给了60秒,结果下载到1分钟就被代理断开了,排查了半天才发现是代理层的问题。
4.3 线程池隔离:别让下载拖垮主业务
如果下载接口和其他业务接口共用同一个线程池,大量下载请求会把线程占满,导致其他接口无法响应。解决办法是给下载接口单独配置一个线程池,做线程隔离。
在 Spring Boot 里可以这样配置:
@Configuration public class DownloadThreadPoolConfig { @Bean("downloadExecutor") public ThreadPoolTaskExecutor downloadExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setThreadNamePrefix("download-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }然后在下载接口上用@Async("downloadExecutor")或者手动提交任务。这样即使下载请求暴涨,也只会影响下载线程池,主业务线程池不受影响。CallerRunsPolicy是当队列满了之后,让提交任务的线程自己执行,起到一个降级限流的作用。
4.4 文件句柄与磁盘 IO 的监控
大文件下载对磁盘 IO 的压力也不小。如果多个用户同时下载同一个大文件,磁盘需要同时读取多个流,磁头频繁寻道,效率会下降。对于机械硬盘,这个问题尤其明显。解决办法有几个:一是用 SSD 替换机械硬盘,随机读取性能好很多;二是对热门文件做缓存,比如用操作系统的 page cache,或者用内存文件系统;三是限制单个文件的并发下载数。
文件句柄方面,Linux 默认的单进程文件句柄数是1024,可以通过ulimit -n查看。如果下载并发高,很容易超过这个限制。可以在启动脚本里调大:
ulimit -n 65535同时要确保代码里所有流都被正确关闭,try-with-resources是最稳妥的方式。如果用了RandomAccessFile,也要放在 try-with-resources 里,否则异常时句柄不会释放。
5. 实测中遇到的典型问题与排查过程
5.1 下载到 2GB 位置突然中断
这个问题我印象特别深。当时测试一个 4GB 的文件下载,每次都是下到 2GB 左右就断了,浏览器显示“网络错误”。一开始怀疑是网络问题,换了几个环境都一样。后来抓包分析,发现服务端在发送到 2GB 位置时,Content-Length突然变成了负数。
原因很快就定位到了:代码里用了response.setContentLength((int) file.length()),文件是 4GB,强转成 int 后溢出成了负数。浏览器收到负的Content-Length,解析失败,直接断开连接。改成setContentLengthLong之后问题消失。这个坑的隐蔽之处在于,文件小于 2GB 时完全正常,只有超过 2GB 才会触发,很容易在测试阶段被漏掉。
5.2 断点续传后文件损坏
另一个典型问题是断点续传后文件打不开,或者校验和不匹配。排查下来发现是Content-Range头的格式写错了。正确的格式是bytes start-end/total,注意bytes和数字之间有空格,start和end之间是短横线,end是包含的。我见过有人写成bytes=0-1048575/3145728,多了个等号,浏览器解析不了,就当成完整文件处理了,结果文件内容错位。
还有一种情况是endPos计算错误。比如客户端请求bytes=1000-,服务端应该返回从 1000 到文件末尾的所有数据,Content-Range是bytes 1000-3145727/3145728。如果endPos算成了fileSize而不是fileSize - 1,就会多读一个字节,导致文件末尾多出一个字节,校验和不匹配。
5.3 并发下载时内存缓慢增长
有一次压测发现,单个下载请求内存很平稳,但并发 50 个下载请求时,内存缓慢增长,几个小时后 OOM。用 MAT 分析堆转储,发现大量byte[]对象被持有。追查代码发现,每个下载请求都创建了一个 1MB 的缓冲区,而不是我推荐的 16KB。50 个并发就是 50MB,加上其他对象,堆内存压力很大。
更关键的是,这些缓冲区在请求结束后没有立即释放,因为线程池的线程是复用的,缓冲区作为局部变量虽然会随着方法结束而可回收,但 GC 不是实时的。把缓冲区从 1MB 降到 16KB 后,内存曲线立刻平稳了。这个案例说明,缓冲区大小要根据并发量来权衡,并发高的时候,小缓冲区反而更合适。
5.4 浏览器进度条不动但文件在下载
这个问题比较诡异:用户反馈下载进度条一直显示 0%,但最后文件确实下载完整了。抓包发现,服务端在写数据时,前几MB的数据被容器缓冲了,没有立即 flush 到网络。浏览器收不到数据,自然不更新进度。
解决办法是在写入一定量数据后主动 flush。但也不能每写一次就 flush,那样网络交互太频繁。我的做法是每写入 1MB 数据 flush 一次,这样既能让浏览器及时更新进度,又不会太影响性能。代码大概是这样:
long flushed = 0; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); flushed += len; if (flushed >= 1024 * 1024) { out.flush(); flushed = 0; } } out.flush();5.5 文件名中文乱码
下载中文文件名时,浏览器保存下来的文件名是乱码,这也是高频问题。原因是Content-Disposition头里的文件名需要 URL 编码,而且不同浏览器对编码方式的支持不一样。稳妥的做法是同时提供filename和filename*两个参数:
String encodedName = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName);filename*是 RFC 5987 定义的扩展格式,现代浏览器优先识别它。filename作为降级方案,给老浏览器用。注意URLEncoder.encode会把空格编码成+,而 HTTP 头里空格应该编码成%20,所以要替换一下。
6. 从能跑到好用:几个值得加上的增强点
6.1 用 FileChannel 替代 RandomAccessFile
RandomAccessFile用起来简单,但它是阻塞式 IO,每次read都会触发一次系统调用。对于大文件下载,可以用FileChannel配合ByteBuffer做更高效的读取。FileChannel支持transferTo方法,在某些操作系统上可以直接把数据从文件通道传输到网络通道,减少数据拷贝次数。
try (FileChannel channel = FileChannel.open(file.toPath(), StandardOpenOption.READ)) { channel.position(startPos); ByteBuffer buffer = ByteBuffer.allocateDirect(16 * 1024); long remaining = endPos - startPos + 1; while (remaining > 0) { buffer.clear(); buffer.limit((int) Math.min(buffer.capacity(), remaining)); int read = channel.read(buffer); if (read == -1) break; buffer.flip(); out.write(buffer.array(), 0, buffer.limit()); remaining -= read; } }allocateDirect分配的是直接内存,不受 JVM 堆大小限制,而且和操作系统交互时少一次拷贝。但直接内存的分配和回收成本比堆内存高,所以适合长期存在的缓冲区,不适合频繁创建销毁。对于下载场景,每个请求创建一个直接缓冲区,请求结束就释放,这个开销是可以接受的。
6.2 限速:防止单个下载占满带宽
如果服务端带宽有限,一个大文件下载可能把带宽占满,影响其他用户。这时候需要做限速。限速的思路很简单:记录每秒发送的字节数,如果超过阈值就Thread.sleep等待。
long maxBytesPerSecond = 1024 * 1024; // 1MB/s long startTime = System.currentTimeMillis(); long sentBytes = 0; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); sentBytes += len; long elapsed = System.currentTimeMillis() - startTime; long expectedTime = sentBytes * 1000 / maxBytesPerSecond; if (expectedTime > elapsed) { Thread.sleep(expectedTime - elapsed); } }这种限速方式比较粗糙,但实现简单,效果也够用。更精细的做法是用令牌桶算法,平滑控制速率。限速的粒度可以是全局的,也可以是每个连接的。全局限速需要用一个共享的计数器,每个连接限速则各自独立。
6.3 下载日志与审计
大文件下载通常需要记录日志,方便排查问题和审计。日志里至少要包含:谁下载了什么文件、下载了多少字节、耗时多久、是否成功。这些信息在出问题时非常有用,比如用户说“我下载的文件不完整”,你可以查日志确认服务端是否完整发送了所有字节。
long startTime = System.currentTimeMillis(); long totalSent = 0; try { // ... 下载逻辑,累加 totalSent } finally { long cost = System.currentTimeMillis() - startTime; log.info("download file={}, user={}, bytes={}, cost={}ms, range={}", fileName, userId, totalSent, cost, rangeHeader); }注意日志里不要打印文件内容,也不要打印完整的文件路径(可能包含敏感信息),只记录文件名和大小即可。
6.4 用 Nginx 卸载静态文件下载
如果文件是静态的、不需要权限校验,最省事的方案其实是把下载交给 Nginx 处理。Nginx 处理静态文件下载的性能比 Java 应用高一个数量级,而且自带断点续传、限速、缓存等功能。Java 应用只需要做权限校验,然后返回一个内部重定向头,让 Nginx 去发送文件。
response.setHeader("X-Accel-Redirect", "/protected-files/" + fileName); response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\"");Nginx 配置里把/protected-files/映射到实际文件目录,并开启internal指令,禁止外部直接访问。这样 Java 应用只负责鉴权,文件传输完全由 Nginx 接管,既高效又稳定。这个方案我在多个项目里用过,效果非常好,推荐给所有不需要在下载过程中做动态处理的场景。
提示:用 Nginx 卸载下载时,
X-Accel-Redirect的路径必须是 Nginx 配置里internal的 location,否则会 404。另外,Content-Disposition头要在 Java 侧设置好,Nginx 会透传这个头。
7. 我个人在实际操作中的几点体会
大文件下载这个功能,写出来能跑不难,难的是在各种边界条件下都能稳定工作。我踩过的坑里,大部分都不是逻辑错误,而是对 HTTP 协议细节、Java IO 特性、容器行为理解不到位导致的。比如Content-Length溢出、Range头格式错误、缓冲区大小不合理、超时设置不匹配,这些问题在代码审查时很难发现,只有实际跑大文件、弱网、高并发才能暴露出来。
我的建议是,任何下载功能上线前,至少做三组测试:第一组用超过 2GB 的文件,验证 long 类型相关逻辑;第二组用弱网工具模拟丢包和延迟,验证断点续传和超时;第三组用并发工具同时发起几十个下载,观察内存、CPU、文件句柄和响应时间。这三组测试能覆盖 90% 以上的大文件下载问题。
另外,不要过度设计。如果文件是静态的、不需要动态鉴权,直接用 Nginx 或对象存储的预签名 URL 是最优解,没必要在 Java 里自己实现一套。Java 实现的下载更适合需要动态权限校验、动态内容生成、或者需要精细控制下载行为的场景。选对方案,比把方案写得多复杂更重要。