⏯️ HTTP 断点续传与文件下载原理

图解 Range 请求、206 Partial Content、多线程分片下载

一句话总结

HTTP 文件下载本质上就是一次 GET 请求 → 200 OK + 文件 Body;而断点续传则是把"一次取整个文件"拆成"多次取其中一段",核心靠三个东西:

📏

Range 请求头

告诉服务器"我想要哪一段字节"。

🧩

206 Partial Content

服务器告诉客户端"我只给你这一段"。

🔁

If-Range / ETag

续传前先校验"文件还是原来那个吗"。

读懂这三件事,再加上 Accept-RangesContent-Range 这两个辅助头,断点续传和多线程下载就完全透明了。

一、普通文件下载原理

1.1 最朴素的过程:GET → 200 OK + Body

浏览器、curl、wget 下载一个文件,最朴素的流程如下图所示:客户端发 GET 请求,服务器返回 200 OK 状态码,并把文件内容放在响应体里一次性发完。

客户端 Browser / curl / wget 本地文件 xxx.zip.part 保存到磁盘 边收边写 服务器 nginx / tomcat 磁盘上的 xxx.zip 读取文件 塞进 Body ① GET /xxx.zip HTTP/1.1 (不带 Range) ② 200 OK + 整个文件 Body [ byte 0 ........ byte N-1 ]
关键点: 普通下载里响应体一次性把文件全部发出。客户端要么全部收到,要么全部丢失——中途断网就得重新开始。

1.2 真实报文:客户端到底发了什么、收到了什么?

下面是一段典型的"下载一个 zip 包"的完整 HTTP 交互:

# ============ 客户端发出的请求 ============ GET /files/xxx.zip HTTP/1.1 Host: cdn.example.com User-Agent: Mozilla/5.0 Accept: */* # ============ 服务器返回的响应 ============ HTTP/1.1 200 OK Content-Type: application/zip Content-Length: 104857600 # 100MB,告诉客户端要收多少字节 Content-Disposition: attachment; filename="xxx.zip" # 让浏览器弹下载框 Accept-Ranges: bytes # 服务器声明:支持范围请求 Last-Modified: Wed, 03 Sep 2025 12:00:00 GMT ETag: "abc123-5f8a9c" # 下面接着 100MB 的二进制文件本体 # 4D 50 47 12 34 ... (ZIP 文件的二进制内容)

1.3 关键响应头逐个拆解

响应头 作用 为什么重要
Content-Type 资源的 MIME 类型,如 application/zip、image/png 浏览器据此选择"显示"还是"下载"。
Content-Length 整个响应体的字节数 客户端用它算进度条;也是校验文件是否完整的关键。
Content-Disposition inline 表示直接展示;attachment 表示下载 attachment; filename="x.zip" 是触发浏览器下载框的关键。
Accept-Ranges 服务器声明支持的范围单位(bytes / none) 值为 bytes 才支持断点续传;none 表示不支持。
ETag / Last-Modified 资源的"指纹"与最后修改时间 续传前用它校验"服务器上的文件没被换过"。

1.4 Content-Disposition:浏览器是"打开"还是"保存"?

同一个 URL,两种响应头,浏览器表现天差地别:

📄 Content-Type: image/png
(不设 Content-Disposition)

浏览器默认 inline:直接把图片渲染到页面上,不会弹下载框。

💾 Content-Disposition: attachment; filename="pic.png"

浏览器强制 下载:弹出"保存到..."对话框,文件名取自 filename。

二、断点续传原理(HTTP Range)

2.1 一句话解释 Range

普通 GET 拉的是"整个文件";如果请求里多了一行 Range: bytes=start-end,服务器就只会返回这一段字节,其余丢弃——这就是断点续传的全部秘密。

文件总长 1000 字节(示意) byte 0 byte 999 Range: bytes=200-499 200 499 Range: bytes=500-(从 500 取到末尾) 500 999

2.2 Range 请求头的几种写法

写法 含义 典型用途
Range: bytes=0-499 取前 500 个字节(0~499) 下载器取第 1 片
Range: bytes=500-999 取 500~999 这一段 下载器取第 2 片
Range: bytes=500- 从 500 取到文件末尾 断点续传最常用
Range: bytes=-100 取最后 100 个字节 快速探测文件大小 / 尾部哈希

2.3 正常返回:206 Partial Content

服务器如果支持 Range,就会把状态码从 200 OK 换成 206 Partial Content,并用 Content-Range 告诉你"这段在整个文件里的位置"。

# ============ 客户端续传请求 ============ GET /files/xxx.zip HTTP/1.1 Host: cdn.example.com Range: bytes=52428800- # 从 50MB 处开始要 If-Range: "abc123-5f8a9c" # 顺便校验文件没变 # ============ 服务器返回 206 ============ HTTP/1.1 206 Partial Content # 注意:状态码变了! Content-Type: application/zip Content-Length: 52428800 # 本次响应体的字节数 Content-Range: bytes 52428800-104857599/104857600 # 范围/总大小 Accept-Ranges: bytes ETag: "abc123-5f8a9c" # 下面是 50MB 的二进制内容 # 客户端把它追加写到 xxx.zip.part 的 offset 52428800 处
Content-Range 的格式: bytes {start}-{end}/{total}。其中 total 是文件的完整大小,end 是这一段的最后一个字节下标(闭区间)。例如 52428800-104857599/104857600 表示从第 50MB 字节到第 100MB-1 字节,共 50MB,整文件 100MB。

2.4 服务器不支持 Range:416 Range Not Satisfiable

如果服务器不返回 Accept-Ranges: bytes(或者返回 none),客户端发了 Range 头之后,服务器会直接回 200 OK + 完整文件,或者 416 Range Not Satisfiable。这时断点续传就无法工作。

所以下载器一般的做法是:先用 HEAD 请求探一下,看到 Accept-Ranges: bytes 才会启用多线程下载或断点续传。

三、多线程分片下载:Range 的进阶玩法

3.1 迅雷 / IDM / Aria2 的核心套路

迅雷、IDM 这类下载器之所以比浏览器单线程快,本质就是把文件分成 N 段,开 N 个连接并发拉,最后拼起来:

100MB 文件 Piece 1: 0-25MB Piece 2: 25-50MB Piece 3: 50-75MB Piece 4: 75-100MB 下载器开启 4 条 HTTP 连接并发拉取 Range: bytes=0-26214399 Range: bytes=26214400-52428799 Range: bytes=52428800-78643199 Range: bytes=78643200-104857599 服务器对每条连接返回 206 Partial Content 连接1 → 206, Content-Range: bytes 0-26214399/104857600 连接2 → 206, Content-Range: bytes 26214400-52428799/104857600 连接3 → 206, Content-Range: bytes 52428800-78643199/104857600 连接4 → 206, Content-Range: bytes 78643200-104857599/104857600

3.2 分片下载的客户端拼装逻辑

  1. 探测文件大小与是否支持 Range。HEAD 请求读 Content-LengthAccept-Ranges。如果不支持 Range,就只能单线程下。
  2. 把文件按 N 等分切片。100MB 文件,4 线程就每片 25MB;8 线程就每片 12.5MB。
  3. 开 N 个并发连接。每条连接带不同的 Range: bytes=A-B 请求对应切片。
  4. 每片单独写到一个临时文件。通常命名为 xxx.zip.0xxx.zip.1… 这样某一片失败重下不会影响其它片。
  5. 每一片边下边校验长度。如果收到字节数不等于预期 (B-A+1),就当失败重试。
  6. 所有片下完之后按顺序拼接。cat xxx.zip.* > xxx.zip 或在内存里流式拼接,最后做整体哈希校验。
为什么多线程能加速?单线程下载往往被单连接带宽服务器单连接限速卡住。N 个并发连接能把瓶颈从"一条 TCP 的速度"提升到"N 条 TCP 的速度之和",同时还常常能躲开服务器端的限速策略(很多 CDN 是按连接限速的)。

四、续传前如何校验文件没变:If-Range

4.1 问题:服务器的文件换了怎么办?

断点续传有一个隐藏陷阱:你在客户端下到一半时,服务器上的文件可能被替换了(比如用户重新上传了一个同名文件,或者 CDN 源站更新)。此时如果还按偏移去取,可能拿到错位的字节,拼出来的东西会损坏。

4.2 If-Range 的两种语义

If-Range 值 比对依据 命中(文件未变) 未命中(文件已变)
If-Range: "abc123" ETag 字符串 返回 206 Partial Content 返回 200 OK + 完整新文件
If-Range: Wed, 03 Sep 2025 12:00:00 GMT Last-Modified 时间 返回 206 Partial Content 返回 200 OK + 完整新文件
关键设计:服务器"未命中"时不会报错,而是直接返回完整文件。客户端看到 200 + Content-Length 跟之前不一样,就应该丢弃本地缓存,从头开始下。这样协议天然就能兼容"文件被替换"的情形。

4.3 完整续传流程(含校验)

客户端 服务器 ① 启动下载 检测到本地已有 .part GET /xxx.zip Range: bytes=52428800- If-Range: "abc123" ② 比对 ETag 文件未变 → 206 Partial Content 文件变了 → 200 OK + 完整文件 ③ 收到 206 追加写到 .part 末尾 ④ 收到 200 长度变了 / 头不一样 → 删掉 .part → 重新下整个文件 ⑤ 收完所有片段 改名 .part → xxx.zip 下载完成 ✓

五、实战案例:断网后如何恢复下载

把这套机制串起来,看一次完整的"网络抖动 → 自动续传"的时序:

下载器 (本地) 服务器 (CDN) 开始下载 GET /xxx.zip 200 OK + Accept-Ranges: bytes 收下 50MB... Body: 50MB 数据流 ⚠️ 网络中断 连接断开 本地文件 xxx.zip.part 已写入 50MB / 100MB 网络恢复,自动重试 GET /xxx.zip Range: bytes=52428800- If-Range: "abc123" 206 Partial Content Content-Range: 52428800-104857599/104857600 把后半段追加写到 .part 末尾 后 50MB 数据流 合并 → xxx.zip ✓
⚠️ 注意:下载器必须先把收到的字节实时写入磁盘的临时文件(通常用 O_APPEND 或 seek 到偏移位置写)。如果只是攒在内存里,断电时数据全部丢失,没法续传。

六、常见问题与最佳实践

Q1:为什么我用浏览器下载大文件失败率高?

浏览器默认是单连接、不支持断点续传的。一旦 TCP 连接中途断开,整个下载任务就被浏览器当作"失败"丢掉,本地的缓存也清掉。专业下载器(IDM、迅雷、Aria2)则在收到每一个 chunk 后立刻 fsync 到磁盘,所以即使断网,下次启动还能从断点续上。

Q2:服务器如何启用 Range 支持?

主流 HTTP 服务器默认就支持:

服务器 配置方式
Nginx默认支持,关闭用 max_ranges 0;
Apache默认支持,模块 mod_range
Tomcat默认支持,需 servlet 设置 Content-Length
CDN (CloudFront/Cloudflare)默认透传 Range
S3 / OSS默认支持 Range 请求

Q3:客户端最简单的实现示例(Python)

import os import requests url = "https://example.com/large.zip" # 1. 探测大小与是否支持 Range head = requests.head(url, allow_redirects=True) total = int(head.headers["Content-Length"]) accept_ranges = head.headers.get("Accept-Ranges", "none") print(f"total={total}, Accept-Ranges={accept_ranges}") # 2. 假如下载中断,记下已经收到的字节数 downloaded = os.path.getsize("large.zip.part") if os.path.exists("large.zip.part") else 0 headers = {} if accept_ranges == "bytes" and downloaded > 0: headers["Range"] = f"bytes={downloaded}-" # 3. 发起请求,把剩余部分以追加模式写到本地文件 mode = "ab" if downloaded > 0 else "wb" with requests.get(url, headers=headers, stream=True) as r: with open("large.zip.part", mode) as f: for chunk in r.iter_content(chunk_size=64 * 1024): f.write(chunk) f.flush() os.fsync(f.fileno()) # 关键:每个 chunk 都刷盘 # 4. 完成后改名 os.rename("large.zip.part", "large.zip")

Q4:分片越多越快吗?

不是。一般 4~16 路并发是性价比最高的甜蜜点:

  • 连接太少 → 拼不满带宽;
  • 连接太多 → 服务器端并发压力大、TCP 握手/慢启动开销变大、磁盘 IO 反而成为瓶颈。

很多 CDN 还会限制单 IP 的最大并发连接数(比如 16),开多了会被 429 拒绝。

Q5:断点续传和"分片上传"是一回事吗?

完全相反:

📥 断点续传(下载)

客户端用 Range 请求文件的一部分;服务器返回 206。本地拼装。

📤 分片上传(上传)

客户端把文件切成多片,多次 POST/PUT 传给服务器,服务器合并。一般配合预签名 URL + ETag 实现。

Q6:HTTPS 下 Range 还能用吗?

完全能用。TLS 只加密传输层,对 HTTP 头里的 Range 一无所知;服务器依然会根据 Range 决定返回 206 还是 200。一些 CDN 还会在 TLS 握手前就检查 Range 头是否合法。

七、一张图总结

① 普通下载 GET /file 200 OK + 完整 Body Content-Length: 全长 写到磁盘 不支持断点续传 ② 断点续传 / 多线程下载 GET /file Range: bytes=A-B If-Range: ETag 206 Partial Content Content-Range: A-B/total 写偏移处 seek A ✓ 中断后从 A 字节继续,支持并发分片下载 ✓ If-Range 校验:文件变了自动重下整文件
核心公式:
普通下载 = GET /file200 OK + 完整 Body
断点续传 = GET /file + Range: bytes=A- + If-Range: ETag206 Partial Content + 切片 Body
多线程下载 = 同时发 N 个 Range 请求,N 个 206 响应,本地拼接。