1. 大文件下载的核心痛点与方案选型
做过文件传输功能的人都有一个共识:小文件随便写,大文件处处是坑。一个几百KB的文件,你直接byte[]一把梭读进内存再写出去,什么问题都没有。但一旦文件变成几个GB甚至几十GB,同样的代码跑起来就是另一回事了——内存直接飙满、连接超时、下载到一半断了还得从头再来。这些问题在真实的生产环境里非常常见,尤其是做数据导出、日志归档、媒体资源分发这类场景。
所谓“大文件下载”,在Java后端语境下,通常指的是通过HTTP协议把服务端的大文件(几百MB到几十GB不等)传输给客户端。这里的“大”不只是文件体积大,还意味着传输时间长、网络不确定性高、对内存和连接管理的要求苛刻。核心要解决的问题有三个:内存占用可控、支持断点续传、传输过程稳定可恢复。
适合阅读这篇内容的读者包括:正在做文件服务模块的后端开发、需要实现数据导出功能的工程师、以及想搞清楚HTTP文件传输底层机制的技术爱好者。不管你是刚接触这块的新手,还是已经写过几版下载逻辑但总觉得不够稳的老手,下面的内容应该都能给你一些可以直接拿来用的思路和代码。
我打算从方案选型开始讲,然后逐步拆到HTTP协议层的细节、Java服务端和客户端的实现、断点续传的完整逻辑,最后把我自己踩过的坑和排查经验整理出来。整篇内容会以Spring Boot作为服务端框架来演示,客户端则分别覆盖Java原生方式和常用HTTP客户端库的用法。
1.1 为什么不能直接读进内存
先把这个最根本的问题说清楚。很多人写下载功能的第一反应是这样的:
@GetMapping("/download") public void download(HttpServletResponse response) throws IOException { File file = new File("/data/bigfile.zip"); byte[] bytes = Files.readAllBytes(file.toPath()); response.getOutputStream().write(bytes); }这段代码在小文件上跑得挺好,但文件一旦超过几百MB,Files.readAllBytes会把整个文件加载到JVM堆内存里。一个2GB的文件就意味着需要2GB的连续堆空间,就算你的机器内存够大,GC压力也会瞬间飙升,搞不好直接OOM。而且这种方式下,客户端必须等所有字节都准备好了才开始接收,首字节时间(TTFB)非常长。
正确的做法是流式传输:从磁盘读一块、往网络写一块,循环往复,内存里始终只有几KB到几十KB的缓冲区。这是大文件下载的第一原则,后面所有的优化都是在这个基础上展开的。
1.2 方案对比:几种常见的实现路径
在Java生态里,实现HTTP大文件下载有几种主流方案,各有适用场景:
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Servlet OutputStream流式写出 | 手动读文件流写入response | 简单直接、可控性强 | 需自己处理断点续传、Range解析 | 中小规模文件服务 |
| Spring ResourceHttpRequestHandler | 返回Resource对象由框架处理 | 框架自动支持Range、ETag | 定制性稍弱 | 标准静态资源分发 |
| 零拷贝(FileChannel.transferTo) | 内核态直接传输 | 性能最高、CPU占用低 | 平台相关、调试不便 | 高并发大文件场景 |
| 分片下载+合并 | 客户端分片请求后拼接 | 可并行、易恢复 | 客户端逻辑复杂 | 超大文件、弱网环境 |
选哪种?我的经验是:如果文件在本地磁盘或可通过InputStream获取,优先用流式写出配合Range支持。这是最通用、最容易控制的方案。零拷贝虽然性能好,但在需要加密、压缩或动态处理的场景下反而添乱。分片下载更适合客户端有强控制需求的场景,比如下载器类应用。
下面重点围绕流式写出+断点续传这条主线展开,这也是实际项目中最常落地的方案。
2. HTTP协议层的关键细节:Range与响应头
很多人写下载功能时只关注Java代码怎么写,忽略了HTTP协议本身的约定。实际上,大文件下载的稳定性很大程度上取决于你对HTTP协议细节的把握。这一章把几个关键点讲透。
2.1 Range请求头:断点续传的基石
断点续传的核心机制是HTTP/1.1引入的Range请求。客户端在请求头里告诉服务端“我只要文件从第X字节到第Y字节的部分”,服务端返回对应的片段和206 Partial Content状态码。
请求格式是这样的:
Range: bytes=0-1023 // 前1024字节 Range: bytes=1024- // 从1024字节到末尾 Range: bytes=-1024 // 最后1024字节 Range: bytes=0-1023,2048-3071 // 多个区间(多用于并行下载)服务端收到Range请求后,需要做几件事:
- 解析Range头,计算出实际的起止位置
- 校验范围是否合法(超出文件大小要返回
416 Range Not Satisfiable) - 设置响应状态码为
206 - 设置
Content-Range响应头,格式为bytes start-end/total - 设置
Content-Length为本次返回的字节数(不是文件总大小) - 从指定位置开始流式写出数据
如果客户端没有发Range头,服务端就返回200和完整文件,同时最好带上Accept-Ranges: bytes告诉客户端“我支持断点续传”。
注意:
Content-Length在206响应里必须是本次返回的片段长度,而不是文件总长度。这个细节搞错了,客户端会一直等数据直到超时。
2.2 关键响应头逐个拆解
除了Range相关的头,还有几个响应头对大文件下载体验影响很大:
Content-Disposition:决定浏览器是直接打开还是弹出下载框。格式为attachment; filename="xxx.zip"。文件名如果包含中文或特殊字符,需要做URL编码,否则可能乱码。我一般用URLEncoder.encode(filename, "UTF-8").replace("+", "%20")处理。
Content-Type:大文件通常设为application/octet-stream,表示二进制流,让浏览器走下载逻辑。如果知道具体类型(如video/mp4),也可以设成对应的MIME类型。
Accept-Ranges:值为bytes时表示服务端支持Range请求。这个头在完整响应(200)里就应该带上,让客户端知道可以尝试断点续传。
ETag和Last-Modified:用于缓存校验和断点续传时的文件一致性判断。如果文件在下载过程中被修改了,续传就会拿到错误的数据。ETag一般用文件大小+修改时间生成,简单可靠。
Cache-Control:对于需要频繁下载的大文件,可以设置合适的缓存策略。但如果是动态生成的文件,一般设为no-cache。
2.3 206状态码与416错误处理
206 Partial Content是Range请求成功时的标准响应。但实际开发中,边界情况不少:
- 客户端请求的范围完全超出文件大小 → 返回
416,并带Content-Range: bytes */totalSize - 客户端请求的范围部分超出 → 按实际可用范围返回,比如请求
bytes=100-99999但文件只有5000字节,则返回bytes=100-4999/5000 - Range头格式非法 → 可以忽略Range,返回完整文件(200),或者返回400
我见过不少实现直接对非法Range抛异常返回500,这会让客户端很困惑。稳妥的做法是:能解析就按规则处理,解析不了就退化成完整下载。
3. 服务端实现:从零搭建支持断点续传的下载接口
理论讲完了,直接上代码。这一章用Spring Boot演示一个完整的、生产可用的大文件下载接口。我会把每个关键步骤拆开讲,包括参数计算和边界处理。
3.1 基础流式写出:先把内存控制住
先看最基础的版本,不带断点续传,但已经解决了内存问题:
@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.setContentLengthLong(file.length()); response.setHeader("Content-Disposition", "attachment; filename=\"" + encodeFilename(file.getName()) + "\""); try (InputStream in = new FileInputStream(file); OutputStream out = response.getOutputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } out.flush(); } }这段代码的关键点:
setContentLengthLong用long类型,避免超过2GB的文件长度溢出(setContentLength是int类型,最大约2GB)- 缓冲区用8KB,这是磁盘IO和网络IO比较平衡的大小。太小会增加系统调用次数,太大对内存和延迟都不友好
- 用try-with-resources确保流关闭
flush()在最后调用一次即可,循环里不需要频繁flush
这个版本已经能处理大文件了,但客户端如果下载到一半断了,只能从头再来。下面加上Range支持。
3.2 加入Range解析:断点续传的核心逻辑
Range头的解析是这块最容易出错的地方。我把它单独抽成一个方法:
public class RangeHelper { public static class Range { public long start; public long end; public boolean valid; } /** * 解析Range头,fileLength为文件总大小 * 返回null表示无Range请求或格式非法,应返回完整文件 */ public static Range parseRange(String rangeHeader, long fileLength) { if (rangeHeader == null || !rangeHeader.startsWith("bytes=")) { return null; } Range range = new Range(); String spec = rangeHeader.substring(6).trim(); // 只处理单区间,多区间场景较复杂,实际项目少见 if (spec.contains(",")) { spec = spec.split(",")[0].trim(); } try { int dashIdx = spec.indexOf('-'); if (dashIdx < 0) return null; String startStr = spec.substring(0, dashIdx).trim(); String endStr = spec.substring(dashIdx + 1).trim(); if (startStr.isEmpty()) { // 形式:bytes=-500,表示最后500字节 long suffixLen = Long.parseLong(endStr); if (suffixLen <= 0) return null; range.start = Math.max(0, fileLength - suffixLen); range.end = fileLength - 1; } else { range.start = Long.parseLong(startStr); if (endStr.isEmpty()) { range.end = fileLength - 1; } else { range.end = Long.parseLong(endStr); } } // 边界修正 if (range.start < 0) range.start = 0; if (range.end >= fileLength) range.end = fileLength - 1; if (range.start > range.end || range.start >= fileLength) { range.valid = false; return range; } range.valid = true; return range; } catch (NumberFormatException e) { return null; } } }这个解析器处理了几种情况:bytes=0-1023、bytes=1024-、bytes=-1024,以及越界修正。返回valid=false时表示范围不合法,应该返回416。
3.3 完整下载接口:整合Range与流式传输
把Range解析和流式写出整合起来:
@GetMapping("/download") public void download(@RequestParam String filename, HttpServletRequest request, HttpServletResponse response) throws IOException { File file = new File("/data/files/" + filename); if (!file.exists() || !file.isFile()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } long fileLength = file.length(); String etag = "\"" + fileLength + "-" + file.lastModified() + "\""; // 处理If-Range:文件未变才允许续传 String ifRange = request.getHeader("If-Range"); boolean rangeAllowed = true; if (ifRange != null && !ifRange.equals(etag)) { rangeAllowed = false; } response.setHeader("Accept-Ranges", "bytes"); response.setHeader("ETag", etag); response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename=\"" + encodeFilename(file.getName()) + "\""); RangeHelper.Range range = rangeAllowed ? RangeHelper.parseRange(request.getHeader("Range"), fileLength) : null; long start = 0; long end = fileLength - 1; if (range != null) { if (!range.valid) { response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); response.setHeader("Content-Range", "bytes */" + fileLength); return; } start = range.start; end = range.end; response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength); } long contentLength = end - start + 1; response.setContentLengthLong(contentLength); try (RandomAccessFile raf = new RandomAccessFile(file, "r"); OutputStream out = response.getOutputStream()) { raf.seek(start); byte[] buffer = new byte[8192]; long remaining = contentLength; 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; } out.flush(); } }这里用RandomAccessFile而不是FileInputStream,因为需要seek到指定位置。也可以用FileChannel.position(start)配合Channels.newInputStream,效果一样。
几个容易忽略的点:
If-Range头用于判断文件是否变化。如果客户端带了If-Range且值和服务端当前ETag不一致,说明文件已变,此时应忽略Range返回完整文件(200),否则续传会拿到错误数据Content-Range的格式必须严格是bytes start-end/total,空格和斜杠都不能错- 循环里用
remaining控制读取量,确保不会多读超出范围的字节
3.4 零拷贝优化:transferTo的正确用法
如果不需要加密、压缩等处理,可以用FileChannel.transferTo实现零拷贝,性能更好:
try (FileChannel channel = FileChannel.open(file.toPath(), StandardOpenOption.READ); WritableByteChannel out = Channels.newChannel(response.getOutputStream())) { long position = start; long remaining = contentLength; while (remaining > 0) { long transferred = channel.transferTo(position, remaining, out); if (transferred <= 0) break; position += transferred; remaining -= transferred; } }transferTo在Linux上底层用的是sendfile系统调用,数据直接从磁盘页缓存传到socket缓冲区,不经过用户态,CPU和内存开销都小很多。但要注意:单次transferTo不一定传输完所有字节,必须用循环处理返回值。我见过有人只调一次就以为完事了,结果大文件只传了一部分。
提示:transferTo在传输超过2GB时,某些JDK版本上单次调用有上限,循环处理是必须的。另外,如果中间经过了TLS加密,零拷贝的优势会打折扣,因为数据还是要经过用户态加密。
4. 客户端实现:Java侧如何稳定下载大文件
服务端搞定了,客户端这边同样有讲究。用Java做客户端下载,核心诉求是:内存可控、支持断点续传、能感知进度、异常可恢复。
4.1 HttpURLConnection基础下载
先看用JDK自带的HttpURLConnection怎么下载:
public void download(String url, File target) throws IOException { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod("GET"); conn.setConnectTimeout(10_000); conn.setReadTimeout(30_000); long existingLength = target.exists() ? target.length() : 0; if (existingLength > 0) { conn.setRequestProperty("Range", "bytes=" + existingLength + "-"); } int code = conn.getResponseCode(); if (code != 200 && code != 206) { throw new IOException("Unexpected code: " + code); } boolean append = (code == 206); try (InputStream in = conn.getInputStream(); RandomAccessFile raf = new RandomAccessFile(target, "rw")) { if (append) { raf.seek(existingLength); } else { raf.setLength(0); } byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { raf.write(buffer, 0, len); } } finally { conn.disconnect(); } }逻辑很直白:如果本地已有部分文件,就带Range头请求剩余部分;服务端返回206就追加写入,返回200就从头覆盖。RandomAccessFile的seek保证从正确位置写入。
4.2 用HttpClient做更精细的控制
JDK 11+的java.net.http.HttpClientAPI更现代,处理超时和异步更方便:
HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); long existing = Files.exists(target) ? Files.size(target) : 0; HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofMinutes(30)) .GET(); if (existing > 0) { reqBuilder.header("Range", "bytes=" + existing + "-"); } HttpResponse<InputStream> response = client.send( reqBuilder.build(), HttpResponse.BodyHandlers.ofInputStream()); int status = response.statusCode(); boolean append = (status == 206); try (InputStream in = response.body(); FileChannel channel = FileChannel.open(target.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE, append ? StandardOpenOption.APPEND : StandardOpenOption.TRUNCATE_EXISTING)) { ByteBuffer buf = ByteBuffer.allocate(8192); byte[] tmp = new byte[8192]; int len; while ((len = in.read(tmp)) != -1) { buf.clear(); buf.put(tmp, 0, len); buf.flip(); while (buf.hasRemaining()) { channel.write(buf); } } }用FileChannel写入比FileOutputStream稍快,而且能更灵活地控制位置。BodyHandlers.ofInputStream()保证响应体是流式的,不会一次性加载到内存。
4.3 断点续传的客户端状态管理
真正稳定的下载器需要记录下载状态,以便中断后恢复。核心信息包括:已下载字节数、文件总大小、ETag。我一般用一个简单的状态文件(比如.download.meta)来存:
public class DownloadState { public long downloadedBytes; public long totalBytes; public String etag; public void save(Path metaPath) throws IOException { String json = String.format( "{\"downloaded\":%d,\"total\":%d,\"etag\":\"%s\"}", downloadedBytes, totalBytes, etag); Files.writeString(metaPath, json); } public static DownloadState load(Path metaPath) throws IOException { if (!Files.exists(metaPath)) return null; String json = Files.readString(metaPath); // 简单解析,实际项目建议用Jackson DownloadState state = new DownloadState(); // ... 解析逻辑 return state; } }续传时先读状态文件,带上Range: bytes=downloadedBytes-和If-Range: etag。如果服务端返回206,说明可以续传;如果返回200,说明文件已变,需要删掉本地文件重新下载。
注意:状态文件要和目标文件放在一起,下载完成后一起删除。如果状态文件和实际文件大小对不上(比如手动删了部分数据),要以实际文件大小为准,不能盲信状态文件。
4.4 进度监控与限速
下载大文件时,用户通常想知道进度。在读取循环里加个回调即可:
public interface ProgressListener { void onProgress(long downloaded, long total); } // 在循环中 long lastReport = 0; while ((len = in.read(buffer)) != -1) { raf.write(buffer, 0, len); downloaded += len; long now = System.currentTimeMillis(); if (now - lastReport > 500) { // 每500ms报告一次 listener.onProgress(downloaded, total); lastReport = now; } }限速的话,在每次写入后计算已用时间和已传字节,如果速度超过阈值就Thread.sleep一会儿。不过限速逻辑要小心,sleep太久可能导致连接被服务端判定为空闲而断开。
5. 常见问题与排查技巧实录
这部分是我在实际项目中踩过的坑,很多问题在文档里不会写,但遇到了真的很头疼。
5.1 下载中断与超时问题排查
问题一:下载到一半连接被重置
最常见的原因是服务端或中间层(负载均衡、反向代理)有超时设置。比如Nginx默认的proxy_read_timeout是60秒,如果60秒内没有数据传输就会断开。大文件下载如果客户端读取慢,很容易触发。
解决办法:在Nginx配置里调大proxy_read_timeout和proxy_send_timeout,比如设成600秒。同时服务端要确保有数据持续写出,不要长时间阻塞。
问题二:客户端read超时
HttpURLConnection的setReadTimeout是两次数据到达之间的最大间隔,不是整个下载的总时长。如果设成30秒,而服务端因为GC或其他原因卡了31秒没发数据,就会超时。大文件下载建议把这个值设大一些,或者用带心跳的机制。
问题三:文件下载完但大小不对
这种情况通常是Content-Length和实际写出字节数不一致导致的。检查两点:一是206响应里Content-Length是否等于end - start + 1;二是循环写出时是否严格按contentLength控制,没有多写或少写。
5.2 内存与性能问题速查
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 下载时OOM | 文件被整体读入内存 | 看堆dump,找大byte[] | 改用流式传输 |
| CPU占用高 | 缓冲区太小,系统调用频繁 | 监控sys CPU占比 | 增大缓冲区到8KB-64KB |
| 下载速度慢 | 磁盘IO瓶颈或网络带宽限制 | iostat、iftop | 零拷贝、CDN分流 |
| GC频繁 | 频繁创建小对象 | GC日志分析 | 复用缓冲区,避免循环内new |
| 连接数暴涨 | 客户端未复用连接 | netstat统计 | 用连接池,设置keep-alive |
5.3 断点续传失效的几种典型场景
场景一:服务端不支持Range
有些框架或中间件默认不处理Range头,直接返回200和完整文件。客户端以为能续传,结果从头开始覆盖。排查方法:用curl发个带Range的请求,看返回码是不是206。
场景二:ETag变化导致续传被拒
如果服务端每次请求都生成不同的ETag(比如用了随机数或时间戳),客户端的If-Range永远匹配不上,续传就退化成完整下载。ETag必须基于文件内容或元数据稳定生成。
场景三:文件被修改但ETag没变
反过来,如果文件内容变了但ETag没更新(比如只用了文件名做ETag),续传会拼出损坏的文件。ETag至少要包含文件大小和修改时间。
场景四:多线程下载时的Range冲突
如果客户端用多线程分片下载,每个线程请求不同的Range,服务端必须能正确处理并发Range请求。这本身没问题,但如果服务端用了共享的RandomAccessFile且没做同步,就可能读错位置。每个请求应该独立打开自己的文件句柄。
5.4 几个实用的调试技巧
用curl快速验证服务端Range支持:
# 请求前100字节 curl -v -H "Range: bytes=0-99" http://localhost:8080/download?filename=test.zip # 期望看到: # < HTTP/1.1 206 Partial Content # < Content-Range: bytes 0-99/10485760 # < Content-Length: 100用wget测试断点续传:
# 先下载一部分然后Ctrl+C wget http://localhost:8080/download?filename=test.zip # 再次执行,wget会自动带Range头续传 wget -c http://localhost:8080/download?filename=test.zip如果wget -c能正常续传,说明服务端的Range支持没问题。
另外,抓包工具(如Wireshark或浏览器的Network面板)能看到完整的请求响应头,排查头部问题时非常有用。我一般先看请求有没有带Range,再看响应码是200还是206,最后看Content-Range和Content-Length对不对,三步基本能定位大部分问题。
6. 进阶优化与生产环境考量
基础功能跑通之后,还有一些优化点能让下载服务更稳、更快。
6.1 缓冲区大小的选择依据
缓冲区不是越大越好。8KB是磁盘块大小的常见值,也是网络MTU(约1500字节)的整数倍附近,综合表现最好。我实测过4KB、8KB、16KB、64KB几档,在千兆网络下8KB和16KB差异不大,但4KB时系统调用次数翻倍,CPU占用明显上升。64KB以上对单连接吞吐提升有限,反而增加内存占用。所以8KB到32KB是甜区,具体看场景微调。
6.2 并发下载的连接数控制
服务端能同时处理多少下载请求,取决于磁盘IO、网络带宽和线程模型。如果用传统的阻塞IO(每个请求占一个线程),线程数就是并发上限。Tomcat默认最大线程200,意味着最多200个并发下载。超过之后请求排队。
对于大文件下载这种长连接场景,建议用异步Servlet或响应式框架(如WebFlux)来降低线程占用。或者在前端加一层Nginx做缓冲,Nginx处理静态文件下载的效率比Java应用高很多。
6.3 文件校验与完整性保证
下载完成后,客户端应该校验文件完整性。常见做法是服务端提供文件的MD5或SHA-256,客户端下载完后计算比对。响应头里可以带Content-MD5(Base64编码的MD5),或者单独提供一个校验接口。
// 服务端计算MD5(大文件要流式计算,不能一次读入) public static String md5(File file) throws Exception { MessageDigest md = MessageDigest.getInstance("MD5"); try (InputStream in = new FileInputStream(file)) { byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) != -1) { md.update(buf, 0, len); } } return HexFormat.of().formatHex(md.digest()); }注意大文件算MD5本身也耗时,如果文件不常变,可以算一次缓存起来,不用每次请求都算。
6.4 日志与监控要点
生产环境的下载服务,至少要监控这几个指标:下载请求数、平均下载速度、失败率、平均耗时、当前并发数。日志里记录文件名、文件大小、客户端IP、响应码、传输字节数、耗时。这些数据能帮你快速定位是网络问题、磁盘问题还是代码问题。
我一般会在下载接口的finally块里打一条结构化日志:
log.info("download file={} size={} range={} status={} bytesSent={} cost={}ms", filename, fileLength, rangeHeader, status, bytesSent, cost);这样出问题时,grep一下日志就能还原现场。
7. 我个人的一些实操体会
这套方案我在几个项目里都用过,从几百MB的日志包到十几GB的数据集,基本都能稳定跑。有几个体会比较深:
第一,断点续传不是可选项,是必选项。只要文件超过100MB,用户网络稍微不稳定就会断,没有续传功能体验极差。而且实现成本并不高,核心就是Range解析加RandomAccessFile定位,值得一开始就做进去。
第二,ETag的稳定性比想象中重要。我早期用文件路径的hash做ETag,结果文件更新后ETag没变,客户端续传拼出了损坏文件。后来改成文件大小+修改时间,再没出过问题。
第三,测试要覆盖边界。空文件、1字节文件、刚好2GB的文件、Range超出范围、Range格式错误、并发续传……这些边界情况在真实环境里都会遇到。我写过一个测试用例列表,每次改下载逻辑都跑一遍,省了很多事。
第四,别忽视中间层。Nginx、网关、CDN都可能对Range请求做处理或拦截。有一次排查了半天,最后发现是网关把Range头吃掉了。所以服务端代码写对了,还要确认整条链路都支持。
最后分享一个小技巧:如果客户端和服务端都在你的控制下,可以在响应头里加一个自定义的X-Download-Id,客户端续传时带上,服务端用它来关联同一次下载会话,方便日志追踪和问题定位。这个在排查用户反馈的下载问题时特别好用。