下载一个数 GB 的附件,网络中断后重新传输全部内容,不仅浪费带宽,还影响用户体验。HTTP Range 允许客户端只请求文件的一段,但实现时必须正确处理区间边界、响应状态、文件版本和权限。
技术栈:Java 17、Spring Boot 3.5、Spring MVC、HTTP Range
一、先弄懂协议:200、206 和 416
首次请求通常是:
http
GET /api/files/42 HTTP/1.1
服务端返回 200 OK,并可通过 Accept-Ranges: bytes 声明支持字节范围请求。假设文件长度 1024 字节,客户端续传时发送:
http
GET /api/files/42 HTTP/1.1
Range: bytes=512-
服务器若接受范围请求,应返回:
http
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Range: bytes 512-1023/1024
Content-Length: 512
HTTP 字节区间是闭区间 ,bytes=0-99 表示 100 字节。若请求 bytes=2048-,起始位置超出文件长度,响应通常为 416 Range Not Satisfiable,并带 Content-Range: bytes */1024。
| Range | 含义 | 1024 字节文件的结果 |
|---|---|---|
bytes=0-99 |
前 100 字节 | 206 |
bytes=512- |
从 512 到末尾 | 206 |
bytes=-100 |
最后 100 字节 | 206 |
bytes=2048- |
起点越界 | 416 |
bytes=0-99,200-299 |
多个区间 | 本文示例不支持,回退 200 |
Range 不是分页参数,不能直接作为数组下标而不做边界校验。
二、从真实下载失败场景倒推设计
假设一份 2 GiB 的离线数据包下载到 1.4 GiB 时网络断开。客户端本地已有的字节数为 1,503,238,554,下一次请求应从该偏移开始,而不是从 0 开始。这里有三个必须同时成立的条件:
- 服务端提供的是同一份不可变字节表示,而不只是同一个业务文件 ID。
- 客户端发出
Range: bytes=1503238554-,并校验返回确实为符合预期的 206。 - 本地追加之前确认
Content-Range的起始偏移等于现有文件大小;否则应重新下载或中止。
如果文件内容被覆盖、反向代理压缩了响应、或服务端返回 200 而客户端继续追加,最终文件可能大小看似正常但内容已损坏。因此断点续传是客户端与服务端共同遵守的协议,不能只靠服务端增加一个 Range 参数完成。
一次完整的交互
http
GET /api/files/42 HTTP/1.1
HTTP/1.1 200 OK
Accept-Ranges: bytes
ETag: "sha256-abc..."
Content-Length: 2147483648
首次下载中断后,客户端保留部分文件及强 ETag:
http
GET /api/files/42 HTTP/1.1
Range: bytes=1503238554-
If-Range: "sha256-abc..."
HTTP/1.1 206 Partial Content
Content-Range: bytes 1503238554-2147483647/2147483648
Content-Length: 644245094
ETag: "sha256-abc..."
上面的响应仅为协议示意,未实际发起网络请求。若版本不匹配,服务器应忽略 Range 并返回完整 200;客户端不能把完整响应追加到旧片段。
三、业务下载先解决权限与资源身份
下载链接最好使用业务 ID,例如 /api/files/42,而不是接收任意服务器路径。服务端根据 ID 查询文件记录、检查用户权限,再定位受控目录或对象存储。
还要定义文件版本:客户端续传前后,如果文件已经被覆盖,两个片段拼在一起会损坏文件。可为每个不可变文件版本提供稳定的 ETag,并在客户端使用 If-Range。示例只处理强 ETag 字符串,不涵盖日期型 If-Range。
3.1 为什么不能用文件修改时间直接充当强 ETag?
强 ETag 需要在字节级表示改变时改变。生产系统可以在上传完成时计算 SHA-256 并记录在不可变版本表,也可以使用经过确认的对象存储版本标识;不能把可能重复的秒级更新时间当作内容强校验。
一种合理的数据模型:
sql
CREATE TABLE file_version (
id BIGINT PRIMARY KEY,
file_id BIGINT NOT NULL,
object_key VARCHAR(512) NOT NULL,
content_sha256 CHAR(64) NOT NULL,
content_length BIGINT NOT NULL,
status VARCHAR(20) NOT NULL
);
该表只是结构示意:实际还需结合业务数据库设计租户字段、索引、唯一约束及审计信息。下载前必须校验租户与文件授权,且版本指向的底层对象应不可变。不能一边覆盖同一路径,一边让旧请求继续读它。
3.2 If-Range 与 If-None-Match 不是一回事
If-Range用于"文件没变就给我剩余片段,变了就给完整内容"。If-None-Match常用于缓存验证,条件满足时可能得到 304。- 按 RFC 9110,Range 在普通条件请求之后判断;如果不带 Range 的请求本来会得到 304,则不应继续按 Range 处理。
If-Range不能使用弱 ETag(W/"...")作为可靠续传依据。日期型 If-Range 还有强验证器要求,本文只实现强 ETag 路径。
因此完整的 HTTP 条件请求处理不应由简单的字符串比较替代。本文后续示例聚焦受控的单区间下载端点,并明确其不覆盖全部 HTTP 缓存语义。
四、实现单区间解析器
为了控制复杂度,先支持一个 bytes 区间。多区间需要 multipart/byteranges,不能随意拼接数据冒充一个 206。
java
record ByteRange(long start, long end) {
long length() {
return end - start + 1;
}
}
final class RangeNotSatisfiable extends RuntimeException {}
final class ByteRangeParser {
static ByteRange parse(String header, long size) {
if (size <= 0 || header == null || !header.startsWith("bytes=")) {
throw new IllegalArgumentException("invalid range");
}
String spec = header.substring(6).trim();
if (spec.contains(",")) {
throw new IllegalArgumentException("multiple ranges unsupported");
}
int dash = spec.indexOf('-');
if (dash < 0 || dash != spec.lastIndexOf('-')) {
throw new IllegalArgumentException("invalid syntax");
}
String left = spec.substring(0, dash).trim();
String right = spec.substring(dash + 1).trim();
if (left.isEmpty() && right.isEmpty()) {
throw new IllegalArgumentException("empty range");
}
try {
if (left.isEmpty()) {
long suffix = Long.parseLong(right);
if (suffix <= 0) {
throw new IllegalArgumentException("invalid suffix");
}
return new ByteRange(suffix >= size ? 0 : size - suffix, size - 1);
}
long start = Long.parseLong(left);
if (start < 0 || start >= size) {
throw new RangeNotSatisfiable();
}
long end = right.isEmpty() ? size - 1 :
Math.min(Long.parseLong(right), size - 1);
if (end < start) {
throw new RangeNotSatisfiable();
}
return new ByteRange(start, end);
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("invalid number", ex);
}
}
}
这里明确区分两种情况:语法错误 可忽略 Range 返回完整文件;语法正确但无法满足应返回 416。多区间在本例被视为不支持的语法形式,回退 200。
区间计算最容易出错的地方
假设文件长度为 1024:
| 请求 | 起始位置 | 结束位置 | 响应体长度 |
|---|---|---|---|
bytes=0-0 |
0 | 0 | 1 |
bytes=0-99 |
0 | 99 | 100 |
bytes=1000- |
1000 | 1023 | 24 |
bytes=-100 |
924 | 1023 | 100 |
bytes=-9999 |
0 | 1023 | 1024 |
bytes=1024- |
--- | --- | 416 |
bytes=500-400 |
--- | --- | 416 |
结束位置包含在区间内,因此长度必须是 end - start + 1。对 bytes=-N,当 N 大于文件长度时返回整个文件的 206 是合理的;当 N 为 0 则不应返回负数索引。
需要特别处理的边界: 空文件不适合产生非空 206;超过 long 范围的数字、负数、重复短横线、不认识的单位、多个区间和恶意超长请求头都不能直接进入数组或文件通道操作。多区间不是把两个区间拼在一起就行,它需要 multipart/byteranges 的独立编码与边界。
原稿中的解析器用于说明基本原理,但不是通用 RFC 9110 解析器。它把多区间回退为 200,也没有限制请求头长度。生产环境还应在网关层限制请求头大小,并对异常请求做日志采样与频率限制,避免恶意请求反复消耗 CPU、磁盘与连接资源。
五、Spring MVC 流式输出指定区间
下面是控制器的核心方法。示例依赖自行实现的 fileService 和 permissionService,以及 FileRecord(Path path, String version);需要补齐 import 和 Bean 才能组成完整项目。
java
@GetMapping("/api/files/{id}")
public ResponseEntity<StreamingResponseBody> download(
@PathVariable long id,
@RequestHeader(value = "Range", required = false) String range,
@RequestHeader(value = "If-Range", required = false) String ifRange
) throws IOException {
permissionService.assertCanDownload(id);
FileRecord file = fileService.findPublished(id);
Path path = file.path();
long size = Files.size(path);
String etag = "\"" + file.version() + "\"";
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.ACCEPT_RANGES, "bytes");
headers.setETag(etag);
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.set(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"download.bin\"");
ByteRange selected = null;
if (range != null && (ifRange == null || ifRange.equals(etag))) {
try {
selected = ByteRangeParser.parse(range, size);
} catch (RangeNotSatisfiable ex) {
headers.set(HttpHeaders.CONTENT_RANGE, "bytes */" + size);
return new ResponseEntity<>(null, headers,
HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
} catch (IllegalArgumentException ignored) {
// 不支持的 Range 退回完整响应
}
}
long start = selected == null ? 0 : selected.start();
long length = selected == null ? size : selected.length();
headers.setContentLength(length);
if (selected != null) {
headers.set(HttpHeaders.CONTENT_RANGE,
"bytes " + start + "-" + selected.end() + "/" + size);
}
StreamingResponseBody body = out -> {
try (SeekableByteChannel channel = Files.newByteChannel(
path, StandardOpenOption.READ)) {
channel.position(start);
ByteBuffer buffer = ByteBuffer.allocate(64 * 1024);
long remaining = length;
while (remaining > 0) {
buffer.clear();
buffer.limit((int) Math.min(remaining, buffer.capacity()));
int n = channel.read(buffer);
if (n < 0) {
throw new EOFException("file changed");
}
if (n == 0) continue;
out.write(buffer.array(), 0, n);
remaining -= n;
}
}
};
return new ResponseEntity<>(body, headers,
selected == null ? HttpStatus.OK : HttpStatus.PARTIAL_CONTENT);
}
这里使用固定缓冲区,避免 Files.readAllBytes 一次将整个大文件读入堆内存。但流式下载依然消耗网络连接和异步任务资源,需要配置执行器、并发限制和超时。
示例边界: 本文未编译运行上述代码。空文件、条件 GET、HEAD、日期型 If-Range、完整多区间语义、文件并发替换等生产边界仍需补齐;如无特殊业务需求,优先使用 Spring 的 Resource 能力或交由对象存储、Nginx 处理范围请求。
5.1 StreamingResponseBody 不等于完全非阻塞
StreamingResponseBody 把响应体写入交给异步任务执行,但底层输出流写入仍可能阻塞。一个慢速客户端会持续占用线程、连接及文件句柄;不能因为使用了"Streaming"就认为高并发没有成本。
可以显式为 Spring MVC 配置受限的异步执行器:
java
@Configuration
class DownloadAsyncConfig implements WebMvcConfigurer {
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(64);
executor.setThreadNamePrefix("file-download-");
executor.initialize();
configurer.setTaskExecutor(executor);
configurer.setDefaultTimeout(120_000L);
}
}
这是教学配置,不是通用生产容量推荐。实际线程数和队列容量要依据平均下载时长、并发连接数、磁盘吞吐及网关超时确定;拒绝策略、监控指标和优雅停机也应纳入设计。Spring 官方文档明确建议为 MVC 异步处理显式配置合适的执行器。
5.2 为什么文件读取前要固定版本?
控制器先调用 Files.size(path),异步任务稍后才 Files.newByteChannel(path)。如果这段时间内文件被覆盖,Content-Length 和真实字节可能不一致,造成截断或混合内容。
较可靠的做法是先发布不可变文件版本,通过业务 ID 找到唯一对象键。上传写临时对象,校验摘要与长度后再原子发布元数据;旧版本在活跃下载结束前保留。对于本地文件系统,还应考虑符号链接、硬链接、目录访问控制和对象清理策略。对象存储通常更适合长期的大文件分发。
5.3 传输失败后还可以改状态码吗?
响应头和部分字节发送后,客户端断线、磁盘读取失败或网关超时,服务端一般不能再可靠地把已经开始的 206 改成 500。此时要依赖日志与指标定位,客户端通过重新请求未完成区间恢复。
建议记录文件版本 ID、请求区间、计划传输字节数、实际发送字节数、耗时和失败原因。不要把敏感文件路径、下载令牌或个人信息写入公共日志。
六、如何验证断点续传没有写错?
准备一个长度为 1024 字节的测试文件。以下命令及结果均为预期验证方法,不是已执行的测试记录。
bash
# 保存响应头和响应体,避免 -i 将响应头混入输出文件
curl -sS -D headers.txt -H 'Range: bytes=0-99' \
http://localhost:8080/api/files/42 -o part.bin
wc -c part.bin
cat headers.txt
预期 part.bin 为 100 字节,响应状态为 206,Content-Range: bytes 0-99/1024。
bash
curl -sS -D headers.txt -H 'Range: bytes=2048-' \
http://localhost:8080/api/files/42 -o /dev/null
预期状态为 416,响应包含 Content-Range: bytes */1024。
还要验证:bytes=-100、bytes=0-、错误语法、并发下载、权限拒绝、文件版本改变时的 If-Range 回退行为。客户端在续传时必须校验响应状态,不能把 200 完整响应直接追加到旧文件末尾。
6.1 真正的客户端续传流程:不能只会发 Range
下面用 curl 展示一个可人工复现的流程。假设服务端提供不可变文件,下载地址需要的鉴权由测试环境自行配置:
bash
URL='http://localhost:8080/api/files/42'
# 首次获取一段,模拟下载中断
curl -fsS -D first.headers -H 'Range: bytes=0-99999' \
"$URL" -o demo.part
# 确认确实返回 206、Content-Range 从 0 开始
cat first.headers
wc -c demo.part
# 读取首次响应的强 ETag,手动填写下面的变量
ETAG='"example-content-version"'
OFFSET=$(wc -c < demo.part)
# 续传:保存到单独文件,检查响应头后再拼接
curl -fsS -D resume.headers \
-H "Range: bytes=$OFFSET-" \
-H "If-Range: $ETAG" \
"$URL" -o remainder.part
cat resume.headers
关键: 上述命令没有自动校验响应头,也不会自动合并。只有确认状态是 206、Content-Range 起点恰好等于 OFFSET、ETag 与版本一致后,才能执行 cat remainder.part >> demo.part。如果得到 200,说明服务端返回了完整内容,应丢弃旧片段并以完整响应重新开始;不能直接追加。
还可以使用 curl 自带的续传选项进行端到端验证:
bash
curl -fL -C - -o large-file.bin "$URL"
但生产客户端仍需自己管理授权、版本校验、完整性摘要和重试次数。仅凭下载命令返回成功,不能证明跨版本拼接安全。
6.2 自动化测试应该覆盖哪些协议行为?
建议至少写下列测试;表中的结果为预期断言,不是本次运行记录:
| 场景 | 预期状态 | 关键断言 |
|---|---|---|
| 无 Range | 200 | 返回完整长度 |
bytes=0-0 |
206 | 1 字节,起点 0 |
bytes=100- |
206 | 返回到 EOF |
bytes=-100 |
206 | 返回最后 100 字节 |
| 起点等于文件长度 | 416 | Content-Range: bytes */N |
| 起点大于终点 | 416 | 不返回部分文件 |
| 不支持的多区间 | 200(本例策略) | 返回完整文件 |
| If-Range 强 ETag 匹配 | 206 | 按请求区间返回 |
| If-Range 强 ETag 不匹配 | 200 | 不拼接旧文件 |
| 文件无访问权限 | 403 或 404 | 不泄漏文件内容 |
| 文件被删除 | 404 或业务错误 | 不发送虚假 206 |
| 空文件 | 200(本例策略) | Content-Length 为 0 |
对文件大小 0、1、1024、超过 2 GiB 的情况都应覆盖,特别注意 Java int 与 long 混用。MockMvc 可以验证响应头与状态码;对于异步流式输出的真实字节数,最好补充随机端口的 HTTP 集成测试,避免只测控制器却没测到真实 Servlet 写入链路。
6.3 一次完整性验证
用固定随机种子生成测试数据,或准备一个已知 SHA-256 的二进制样本。分别通过完整下载与多个连续 Range 下载获得两份文件,最后对比摘要:
bash
sha256sum full.bin reconstructed.bin
cmp full.bin reconstructed.bin
预期两份文件摘要一致,cmp 无差异。为了证明"确实发生续传",还应检查第二次请求的 Range 起点、服务器返回 206,以及第二次响应体字节数,而不仅是最终文件一致。
七、上线前别忽略四个隐患
文件路径与权限: ID 查询到的文件必须位于受控存储范围,不能拼接用户提供的路径;注意符号链接和并发替换风险。每次 Range 请求都必须重新鉴权。
文件名与响应头: Content-Disposition 不能直接拼接用户输入的文件名,需防止换行注入、编码不兼容等问题。示例固定 download.bin。
压缩与代理: Range 是对所选 HTTP 表示的字节范围。如果代理自动 gzip 或改写响应,字节位置可能与原始文件不一致,需要验证真实部署链路。
版本一致性: ETag 应绑定实际不可变内容版本;仅靠秒级修改时间可能不足。对象存储版本号或内容摘要更可靠。
八、生产排查:为什么明明发了 Range 却没有续传?
现象一:请求带 Range,响应却是 200。 先检查 If-Range 是否匹配、请求是否被代理改写、文件服务是否支持 Range。RFC 9110 允许服务端忽略部分 Range 请求;客户端必须兼容 200 回退,而不是认定服务端一定出错。
现象二:下载进度到 100%,解压却报文件损坏。 检查客户端是否把 200 追加到已有文件,是否使用了弱 ETag 或会变的文件路径,是否存在 CDN 内容编码转换。对比完整文件 SHA-256,而不只比较文件长度。
现象三:下载量上来后 JVM 线程数和内存上涨。 检查异步执行器的活动线程、排队长度、平均响应时间、慢客户端连接、网关读写超时,以及是否有人误用 readAllBytes。固定 64 KiB 缓冲区不代表每个连接只消耗 64 KiB 内存,容器缓冲和网络队列也会占资源。
现象四:偶发 206 但实际传输字节不足。 检查文件是否在传输期间被替换、网络是否中断、服务端是否在已提交响应后抛异常,以及 Content-Length 是否与发送字节数一致。用不可变对象键、日志中的版本 ID 和请求区间关联排查。
应用自己传文件,还是交给对象存储?
| 方案 | 适合场景 | 需要关注 |
|---|---|---|
| Spring MVC StreamingResponseBody | 小规模、强业务鉴权、内网文件服务 | 线程、连接、异步超时、磁盘 I/O |
| Nginx 内部重定向 | 本地受控文件、大量静态下载 | 鉴权后路径映射、内部 location 配置 |
| 对象存储预签名 URL | 大文件、高并发、跨地域下载 | URL 有效期、权限范围、版本绑定、CDN |
| CDN + 对象存储 | 热门大文件分发 | 缓存键、内容编码、版本更新与回源 |
如果业务只需要"用户有权限才可下载",通常让应用完成鉴权,再交给成熟文件服务执行范围传输,比自己实现 RFC 的全部细节更容易维护。不要在没有实测的情况下声称某方案一定能提升多少倍性能。
结论
断点续传的核心是 Range 解析、206/416 响应、精确长度、文件版本和权限。对于大规模文件服务,不建议重新实现所有 HTTP 条件请求细节;应用层负责授权,让成熟文件服务或对象存储负责字节传输通常更稳妥。