每次我在浏览器里把一个 4 GB 的 Linux ISO 暂停、合上笔记本、第二天再继续,我信任的其实是一小块 HTTP 协议:HTTP 范围请求。每次我在视频里拖动进度条,它立刻从文件中段接着播放、而不是重新下载一遍,靠的也是同一个机制。这是协议里最不起眼、却又承担最多重量的部分之一——CDN、包管理器、备份工具和视频播放器全都依赖它。下面讲这些响应头各自是什么意思、一次 断点续传 真正发出了什么,以及为什么把 URL 贴进一个“只会流式转发”的工具里,会悄悄弄坏拖动播放。
下面所有内容都是对着一个临时 nginx 实测的,它在 127.0.0.1:18080 上提供一个 2120 字节的文本文件,另有一个生成式响应服务器跑在 127.0.0.1:18081,所以响应头和状态码都是真实的。如果你想看与它相邻的响应侧缓存故事,请读 HTTP 缓存。
一口气讲清 Accept-Ranges、Range、Content-Range、206 与 416
干活的只有五样东西:
Accept-Ranges: bytes—— 服务器在宣告:我能提供文件的一部分。Range: bytes=100-199—— 客户端在请求第 100 到第 199 字节。206 Partial Content—— 状态码,意思是“这是那一小片,不是整个文件”。Content-Range: bytes 100-199/2120—— 这一片在整份文件里的位置:起点、终点、总大小。416 Requested Range Not Satisfiable—— “你想要的字节这里不存在。”
一个普通的 GET 就会宣告这个能力。nginx 对静态文件默认开启字节范围,所以 Accept-Ranges: bytes 直接就会出现:
$ curl -si http://127.0.0.1:18080/report.txt
HTTP/1.1 200 OK
Server: nginx/1.31.6
Content-Type: text/plain
Content-Length: 2120
Last-Modified: Mon, 12 Oct 2026 10:31:45 GMT
ETag: "6accb711-848"
Accept-Ranges: bytes
line 01: the quick brown fox jumps over the lazy dog
这一个响应头就是全部邀请。看到它的客户端如果只想要文件的一部分,就可以精确地索取那一段。
一个字节范围就是一个偏移量,回来的是一份 206
发出明确的 Range,服务器就回 206 Partial Content,响应体是精确的 100 字节,而不是 2120 字节:
$ curl -si -H 'Range: bytes=100-199' http://127.0.0.1:18080/report.txt
HTTP/1.1 206 Partial Content
Server: nginx/1.31.6
Content-Type: text/plain
Content-Length: 100
Last-Modified: Mon, 12 Oct 2026 10:31:45 GMT
ETag: "6accb711-848"
Content-Range: bytes 100-199/2120
y dog
line 03: the quick brown fox jumps over the lazy dog
line 04: the quick brown fox jumps over t
两个细节很重要。Content-Range 回显了这片数据的来处和整份文件的大小——100-199/2120。另外偏移量是从 0 开始的,所以 bytes=100-199 并不是从你能看到的第 100 个字符开始;它从字节 100 开始,而偏移 100 上的那个字节正是 lazy 里的 y。手写下载代码时在这个地方差一位,是经典 bug。
<rect x="10" y="46" width="680" height="46" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.55"/>
<text x="16" y="74" font-size="12" fill-opacity="0.7">字节 0</text>
<text x="636" y="74" font-size="12" fill-opacity="0.7">字节 2120</text>
<rect x="42" y="46" width="32" height="46" fill="#0f766e" fill-opacity="0.85"/>
<text x="34" y="120" font-size="12" fill-opacity="0.85">100</text>
<text x="66" y="120" font-size="12" fill-opacity="0.85">199</text>
<text x="34" y="140" fill="#0f766e" font-weight="bold">Range: bytes=100-199</text>
<line x1="58" y1="146" x2="58" y2="176" stroke="#0f766e" stroke-width="1.6"/>
<path d="M52 170 L58 180 L64 170" fill="none" stroke="#0f766e" stroke-width="1.6"/>
<rect x="120" y="184" width="470" height="56" rx="8" fill="none" stroke="currentColor" stroke-opacity="0.6"/>
<text x="134" y="206" fill="#0f766e" font-weight="bold">206 Partial Content</text>
<text x="134" y="226" fill-opacity="0.85">Content-Range: bytes 100-199/2120 · Content-Length: 100</text>
206 响应。Content-Range 响应头让客户端知道这一片属于哪里。下载工具和视频播放器为什么离不开它
两个日常功能直接出自范围请求。
断点续传。 一个正在下载 4 GB 文件的下载工具,在连接断开时不必从头再来。它保留已经写入的部分,然后只索取剩下的。curl -C - 正是这么做的——它读取部分文件的大小,把它变成一个 Range 请求头:
$ curl -s -r 0-399 -o partial.bin http://127.0.0.1:18080/report.txt
$ curl -sv -C - -o partial.bin http://127.0.0.1:18080/report.txt
> GET /report.txt HTTP/1.1
> Host: 127.0.0.1:18080
> Range: bytes=400-
> User-Agent: curl/8.22.0
> Accept: */*
< HTTP/1.1 206 Partial Content
< Content-Type: text/plain
< Content-Length: 1771
< ETag: "6accb735-87b"
Range: bytes=400- 没有结束偏移,意思是“从字节 400 一直到结尾”。两段拼回去要逐字节一致,这才是真正的检验:续传之后,cmp partial.bin report.txt 的结果是 identical。
视频拖动。 视频播放器不会先下完整个文件才播放。它先请求开头,开始播放;当你往前拖时,它按新的偏移量再发一次范围请求——这就是“拖动”的本质。没有 Accept-Ranges,播放器只能线性下载,拖进度条要么没反应,要么强制重新拉取整个文件。这也是 MP4 的 “faststart” 很重要的原因:播放器希望索引(moov 原子)靠近字节 0,这样它才能判断该请求哪一段字节范围。
If-Range:让断点续传在文件变化时依然安全
续传有一个很恶心的失败模式。你下载了 app.tar.gz 的一半,服务器在同一个 URL 上发布了新构建,而你的下载工具兴高采烈地把新文件从 500 MB 处往后的字节下载下来,拼到旧文件的前半段上。于是你得到一个校验失败、也毫无意义的损坏压缩包。
If-Range 堵住了这个洞。你把当初开始下载时缓存下来的校验器——ETag 或 Last-Modified——随范围一起发过去。如果校验器仍然匹配,你得到 206。如果文件已经变了,服务器会忽略范围,直接回 200 加上整份新文件,于是你被迫干净地重来,而不是把数据拼坏。
下面是匹配的情况,用的是原始响应里的 ETag:
$ curl -si -H 'Range: bytes=0-9' -H 'If-Range: "6accb711-848"' http://127.0.0.1:18080/report.txt
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-9/2120
Content-Length: 10
ETag: "6accb711-848"
下面是当我在文件末尾追加一行、并发送现在已经过期的 ETag 时的结果——注意状态码翻成了 200,响应体是全新的 2171 字节整份文件:
$ curl -si -H 'Range: bytes=0-9' -H 'If-Range: "6accb711-848"' http://127.0.0.1:18080/report.txt
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 2171
ETag: "6accb735-87b"
这就是那份契约:If-Range 是“盲目续传”和“只有在仍是同一个文件时才续传”之间的区别。如果你要写下载器,永远把校验器和那些部分字节存在一起。
为什么生成的响应通常做不了范围请求
不是每个 200 都支持范围,通常是因为响应是即时生成的。一个用 Python 流式产出、没有存下来的长度的动态处理器:
$ curl -si -H 'Range: bytes=100-199' http://127.0.0.1:18081/
HTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.14.7
Content-Type: text/plain
Transfer-Encoding: chunked
X-Generator: dynamic-no-ranges
line 01: the quick brown fox jumps over the lazy dog
没有 Content-Length,没有 Accept-Ranges,响应体是 Transfer-Encoding: chunked——而 curl -r 100-199 报告 http=200 size_download=2120,也就是服务器把整个响应体都给了你,完全无视了范围。这是合法的:服务器可以忽略 Range 并直接回 200。但这样一来,你的下载工具没法续传,视频播放器也没法拖动。
要在生成式内容上支持范围,你得事先知道总长度、能够确定性地重建任意偏移处的数据,然后自己实现切片(或者交给框架里类似 send_file 的辅助函数)。如果内容是真正开放式的——事件流、LLM token 流、实时日志 tail——范围请求根本不适用,流式传输才是正确答案,而不是退而求其次的方案。流说的是“内容产生多少就给你多少”;范围说的是“给我那个我已经知道大小的东西的某个特定窗口”。
在 nginx 里开启它,以及什么会悄悄把它弄坏
对静态文件,nginx 开箱即用——没什么需要开启的。Accept-Ranges: bytes 会出现,Range 会被尊重,而一个格式错误或越界的范围会得到 416:
$ curl -si -H 'Range: bytes=5000-6000' http://127.0.0.1:18080/report.txt | head -6
HTTP/1.1 416 Requested Range Not Satisfiable
Server: nginx/1.31.6
Content-Type: text/html
Content-Length: 197
Connection: keep-alive
Content-Range: bytes */2120
416 上的 Content-Range: bytes */2120 才是有用的部分——它告诉一头雾水的客户端文件真正的大小,好让它正确地重试。
范围请求失效的方式几乎总是下面这几种,值得了解,因为每一种看起来都像“服务器不支持范围”,而真正的原因却在它前面的某一层:
-
压缩。 一旦应用了 gzip,线上的字节就不再是磁盘上的字节,偏移量对不上了。在一个强制
gzip on的 location 上带Accept-Encoding: gzip请求同一个文件,206就蒸发了:$ curl -si -H 'Accept-Encoding: gzip' -H 'Range: bytes=100-199' http://127.0.0.1:18080/report.txt.gz | head -8 HTTP/1.1 200 OK Content-Type: text/plain Transfer-Encoding: chunked ETag: W/"6accb711-848" Content-Encoding: gzip你得到的是
200、一个分块响应体、一个弱ETag,而且没有Accept-Ranges——nginx 在压缩时会刻意放弃范围支持,因为压缩流上的字节范围对客户端毫无意义。实际教训:永远不要对大媒体文件或压缩包做 gzip(反正也没用),也别指望经过一个会压缩的层还能断点续传。 -
分块响应 / 长度未知。 就像 Python 那个例子展示的,没有
Content-Length就没有东西可以作为范围的基准。 -
代理和缓存。 一个把响应缓冲后以分块方式转发、剥掉
Accept-Ranges、或者丢掉Range请求头的反向代理或 CDN,会让它背后所有人都无法续传。当一个下载直连时能正常续传、经过 CDN 就不行时,用curl -I检查边缘上的响应头并做对比。 -
为拍脑袋的目标选错了文件类型。 范围续的是字节,不是道歉。文件变了,只有
If-Range能救你。
至于一套通用的 nginx 配置,这篇 nginx cookbook 里有它能嵌进去的周边配置。
多范围请求:一次响应里要好几片
客户端可以一次索取多个范围,用逗号分隔。这就是 multipart/byteranges——一个 multipart 响应体,其中每一部分都带着自己的 Content-Range:
$ curl -si -H 'Range: bytes=0-9,20-29' http://127.0.0.1:18080/report.txt
HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=00000000000000000001
Content-Length: 220
--00000000000000000001
Content-Type: text/plain
Content-Range: bytes 0-9/2120
line 01: t
--00000000000000000001
Content-Type: text/plain
Content-Range: bytes 20-29/2120
rown fox j
--00000000000000000001--
注意顶层已经没有 Content-Range 了——它移到了每一部分的内部,而 Content-Type 里的 boundary 告诉客户端怎么把它们拆开。实践中多范围请求很少见:播放器通常一次只取一个范围,浏览器则可能拒绝或忽略它。认识它,是为了当某个工具真的用到时,不至于把它当成一个损坏的响应。
用 curl -r 做测试
检查一台服务器是否支持续传,最快的办法是 curl -r,它本质上就是打起来更方便的 Range:
curl -r 100-199 URL→ 字节范围。确认你看到206和那行Content-Range。curl -r 100- URL→ 开放式:字节 100 到结尾。这正是续传时发出的形式。curl -r -100 URL→ 最后 100 字节。检查一个文件的尾部时很好用。curl -sI URL→ 在还没下载任何东西之前,先看HEAD里有没有Accept-Ranges: bytes。curl -C - -o file URL→ 续传到已有的部分文件里;检查它发的是Range: bytes=<size>-。
对源站做一遍,再对源站前面的代理/CDN 做一遍。如果源站说 Accept-Ranges: bytes 而边缘没有,你就找到了下载在 CDN 后面总是重头开始的原因。范围请求就是这样一组干着巨大工作量的小小响应头,而它失败的地方几乎从来不在范围本身——而在响应往外走时经过的每一层东西。