Spring Boot 大文件下载如何实现断点续传?HTTP Range、206 与 416 实战

下载一个数 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 开始。这里有三个必须同时成立的条件:

  1. 服务端提供的是同一份不可变字节表示,而不只是同一个业务文件 ID。
  2. 客户端发出 Range: bytes=1503238554-,并校验返回确实为符合预期的 206。
  3. 本地追加之前确认 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 条件请求细节;应用层负责授权,让成熟文件服务或对象存储负责字节传输通常更稳妥。

官方参考

相关推荐
量化分析码农17 分钟前
股票实时行情接口返回字段说明:单次请求的时间字段与数值类型
后端
脚本自动化开发7 小时前
柔光屏平板怎么选?华为 MatePad Air Z 柔光版学
后端
程序猿DD8 小时前
OctaFuse 2.19.0:重做管理后台界面与交互,模型目录同步更新
后端
金銀銅鐵9 小时前
带您看懂字节码指令(一):一帧里有什么
java·后端·ai编程
码事漫谈10 小时前
Agent 测评是什么?大白话讲一讲
后端
皮皮林55110 小时前
在 IDEA 里斗个地主不过分吧!
后端
nice_lcj52011 小时前
【从零理解 Spring AI · 14】RAG 的四个环节:加载、切分、向量化、检索
java·spring boot·embedding·向量数据库·rag·spring ai·ai应用开发
SomeOtherTime11 小时前
Rust所有权(AI回答)
开发语言·后端·rust
程序员cxuan12 小时前
离谱,AI 大佬顺手搓了个 YouTube ,而我顺手给他搓了个中文插件
人工智能·后端·程序员