16 把大象装进冰箱:HTTP传输大文件的方法
上次我们谈到了HTTP报文里的body,知道了HTTP可以传输很多种类的数据,不仅是文本,也能传输图片、音频和视频。
早期互联网上传输的基本上都是只有几K大小的文本和小图片,现在的情况则大有不同。网页里包含的信息实在是太多了,随随便便一个主页HTML就有可能上百K,高质量的图片都以M论,更不要说那些电影、电视剧了,几G、几十G都有可能。
相比之下,100M的光纤固网或者4G移动网络在这些大文件的压力下都变成了“小水管”,无论是上传还是下载,都会把网络传输链路挤的“满满当当”。
所以,如何在有限的带宽下高效快捷地传输这些大文件就成了一个重要的课题。这就好比是已经打开了冰箱门(建立连接),该怎么把大象(文件)塞进去再关上门(完成传输)呢?
今天我们就一起看看HTTP协议里有哪些手段能解决这个问题。
数据压缩
还记得上一讲中说到的“数据类型与编码”吗?如果你还有印象的话,肯定能够想到一个最基本的解决方案,那就是“数据压缩”,把大象变成小猪佩奇,再放进冰箱。
通常浏览器在发送请求时都会带着“Accept-Encoding”头字段,里面是浏览器支持的压缩格式列表,例如gzip、deflate、br等,这样服务器就可以从中选择一种压缩算法,放进“Content-Encoding”响应头里,再把原数据压缩后发给浏览器。
如果压缩率能有50%,也就是说100K的数据能够压缩成50K的大小,那么就相当于在带宽不变的情况下网速提升了一倍,加速的效果是非常明显的。
不过这个解决方法也有个缺点,gzip等压缩算法通常只对文本文件有较好的压缩率,而图片、音频视频等多媒体数据本身就已经是高度压缩的,再用gzip处理也不会变小(甚至还有可能会增大一点),所以它就失效了。
不过数据压缩在处理文本的时候效果还是很好的,所以各大网站的服务器都会使用这个手段作为“保底”。例如,在Nginx里就会使用“gzip on”指令,启用对“text/html”的压缩。
分块传输
在数据压缩之外,还能有什么办法来解决大文件的问题呢?
压缩是把大文件整体变小,我们可以反过来思考,如果大文件整体不能变小,那就把它“拆开”,分解成多个小块,把这些小块分批发给浏览器,浏览器收到后再组装复原。
这样浏览器和服务器都不用在内存里保存文件的全部,每次只收发一小部分,网络也不会被大文件长时间占用,内存、带宽等资源也就节省下来了。
这种“化整为零”的思路在HTTP协议里就是“chunked”分块传输编码,在响应报文里用头字段“Transfer-Encoding: chunked”来表示,意思是报文里的body部分不是一次性发过来的,而是分成了许多的块(chunk)逐个发送。
这就好比是用魔法把大象变成“乐高积木”,拆散了逐个装进冰箱,到达目的地后再施法拼起来“满血复活”。
分块传输也可以用于“流式数据”,例如由数据库动态生成的表单页面,这种情况下body数据的长度是未知的,无法在头字段“Content-Length”里给出确切的长度,所以也只能用chunked方式分块发送。
“Transfer-Encoding: chunked”和“Content-Length”这两个字段是互斥的,也就是说响应报文里这两个字段不能同时出现,一个响应报文的传输要么是长度已知,要么是长度未知(chunked),这一点你一定要记住。
下面我们来看一下分块传输的编码规则,其实也很简单,同样采用了明文的方式,很类似响应头。
- 每个分块包含两个部分,长度头和数据块;
- 长度头是以CRLF(回车换行,即\r\n)结尾的一行明文,用16进制数字表示长度;
- 数据块紧跟在长度头后,最后也用CRLF结尾,但数据不包含CRLF;
- 最后用一个长度为0的块表示结束,即“0\r\n\r\n”。
听起来好像有点难懂,看一下图就好理解了:

实验环境里的URI“/16-1”简单地模拟了分块传输,可以用Chrome访问这个地址看一下效果:

不过浏览器在收到分块传输的数据后会自动按照规则去掉分块编码,重新组装出内容,所以想要看到服务器发出的原始报文形态就得用Telnet手工发送请求(或者用Wireshark抓包):
因为Telnet只是收到响应报文就完事了,不会解析分块数据,所以可以很清楚地看到响应报文里的chunked数据格式:先是一行16进制长度,然后是数据,然后再是16进制长度和数据,如此重复,最后是0长度分块结束。

范围请求
有了分块传输编码,服务器就可以轻松地收发大文件了,但对于上G的超大文件,还有一些问题需要考虑。
比如,你在看当下正热播的某穿越剧,想跳过片头,直接看正片,或者有段剧情很无聊,想拖动进度条快进几分钟,这实际上是想获取一个大文件其中的片段数据,而分块传输并没有这个能力。
HTTP协议为了满足这样的需求,提出了“范围请求”(range requests)的概念,允许客户端在请求头里使用专用字段来表示只获取文件的一部分,相当于是客户端的“化整为零”。
范围请求不是Web服务器必备的功能,可以实现也可以不实现,所以服务器必须在响应头里使用字段“Accept-Ranges: bytes”明确告知客户端:“我是支持范围请求的”。
如果不支持的话该怎么办呢?服务器可以发送“Accept-Ranges: none”,或者干脆不发送“Accept-Ranges”字段,这样客户端就认为服务器没有实现范围请求功能,只能老老实实地收发整块文件了。
请求头Range是HTTP范围请求的专用字段,格式是“bytes=x-y”,其中的x和y是以字节为单位的数据范围。
要注意x、y表示的是“偏移量”,范围必须从0计数,例如前10个字节表示为“0-9”,第二个10字节表示为“10-19”,而“0-10”实际上是前11个字节。
Range的格式也很灵活,起点x和终点y可以省略,能够很方便地表示正数或者倒数的范围。假设文件是100个字节,那么:
- “0-”表示从文档起点到文档终点,相当于“0-99”,即整个文件;
- “10-”是从第10个字节开始到文档末尾,相当于“10-99”;
- “-1”是文档的最后一个字节,相当于“99-99”;
- “-10”是从文档末尾倒数10个字节,相当于“90-99”。
服务器收到Range字段后,需要做四件事。
第一,它必须检查范围是否合法,比如文件只有100个字节,但请求“200-300”,这就是范围越界了。服务器就会返回状态码416,意思是“你的范围请求有误,我无法处理,请再检查一下”。
第二,如果范围正确,服务器就可以根据Range头计算偏移量,读取文件的片段了,返回状态码“206 Partial Content”,和200的意思差不多,但表示body只是原数据的一部分。
第三,服务器要添加一个响应头字段Content-Range,告诉片段的实际偏移量和资源的总大小,格式是“bytes x-y/length”,与Range头区别在没有“=”,范围后多了总长度。例如,对于“0-10”的范围请求,值就是“bytes 0-10/100”。
最后剩下的就是发送数据了,直接把片段用TCP发给客户端,一个范围请求就算是处理完了。
你可以用实验环境的URI“/16-2”来测试范围请求,它处理的对象是“/mime/a.txt”。不过我们不能用Chrome浏览器,因为它没有编辑HTTP请求头的功能(这点上不如Firefox方便),所以还是要用Telnet。
例如下面的这个请求使用Range字段获取了文件的前32个字节:
返回的数据是(去掉了几个无关字段):
HTTP/1.1 206 Partial Content
Content-Length: 32
Accept-Ranges: bytes
Content-Range: bytes 0-31/96
// this is a plain text json doc
有了范围请求之后,HTTP处理大文件就更加轻松了,看视频时可以根据时间点计算出文件的Range,不用下载整个文件,直接精确获取片段所在的数据内容。
不仅看视频的拖拽进度需要范围请求,常用的下载工具里的多段下载、断点续传也是基于它实现的,要点是:
- 先发个HEAD,看服务器是否支持范围请求,同时获取文件的大小;
- 开N个线程,每个线程使用Range字段划分出各自负责下载的片段,发请求传输数据;
- 下载意外中断也不怕,不必重头再来一遍,只要根据上次的下载记录,用Range请求剩下的那一部分就可以了。
多段数据
刚才说的范围请求一次只获取一个片段,其实它还支持在Range头里使用多个“x-y”,一次性获取多个片段数据。
这种情况需要使用一种特殊的MIME类型:“multipart/byteranges”,表示报文的body是由多段字节序列组成的,并且还要用一个参数“boundary=xxx”给出段之间的分隔标记。
多段数据的格式与分块传输也比较类似,但它需要用分隔标记boundary来区分不同的片段,可以通过图来对比一下。

每一个分段必须以“- -boundary”开始(前面加两个“-”),之后要用“Content-Type”和“Content-Range”标记这段数据的类型和所在范围,然后就像普通的响应头一样以回车换行结束,再加上分段数据,最后用一个“- -boundary- -”(前后各有两个“-”)表示所有的分段结束。
例如,我们在实验环境里用Telnet发出有两个范围的请求:
得到的就会是下面这样:
HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=00000000001
Content-Length: 189
Connection: keep-alive
Accept-Ranges: bytes
--00000000001
Content-Type: text/plain
Content-Range: bytes 0-9/96
// this is
--00000000001
Content-Type: text/plain
Content-Range: bytes 20-29/96
ext json d
--00000000001--
报文里的“- -00000000001”就是多段的分隔符,使用它客户端就可以很容易地区分出多段Range 数据。
小结
今天我们学习了HTTP传输大文件相关的知识,在这里做一下简单小结:
- 压缩HTML等文本文件是传输大文件最基本的方法;
- 分块传输可以流式收发数据,节约内存和带宽,使用响应头字段“Transfer-Encoding: chunked”来表示,分块的格式是16进制长度头+数据块;
- 范围请求可以只获取部分数据,即“分块请求”,实现视频拖拽或者断点续传,使用请求头字段“Range”和响应头字段“Content-Range”,响应状态码必须是206;
- 也可以一次请求多个范围,这时候响应报文的数据类型是“multipart/byteranges”,body里的多个部分会用boundary字符串分隔。
要注意这四种方法不是互斥的,而是可以混合起来使用,例如压缩后再分块传输,或者分段后再分块,实验环境的URI“/16-3”就模拟了后一种的情形,你可以自己用Telnet试一下。
课下作业
- 分块传输数据的时候,如果数据里含有回车换行(\r\n)是否会影响分块的处理呢?
- 如果对一个被gzip的文件执行范围请求,比如“Range: bytes=10-19”,那么这个范围是应用于原文件还是压缩后的文件呢?
欢迎你把自己的学习体会写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

- Aaaaaaaaaaayou 👍(86) 💬(16)
老师,有个问题:http交给tcp进行传输的时候本来就会分块,那http分块的意义是什么呢?
2019-07-03 - chengzise 👍(78) 💬(3)
1. 分块传输中数据里含有回车换行(\r\n)不影响分块处理,因为分块前有数据长度说明 2. 范围是应用于压缩后的文件
2019-07-03 - 赵健 👍(60) 💬(4)
“Transfer-Encoding: chunked”和“Content-Length”这两个字段是互斥的,也就是说响应报文里这两个字段不能同时出现,一个响应报文的传输要么是长度已知,要么是长度未知(chunked),这一点你一定要记住。老师请问下,为啥分块意味着长度未知,后面不是提到块里面有个长度头嘛?而且单个块应该是一次http传输的内容,既然块里有长度头,那这次传输的内容长度也就能算出来,这次http的Content-Length 也就知道啊!是我理解错了吗
2019-07-04 - 秋水共长天一色🌄 👍(55) 💬(2)
老师,我有些问题需要问问您。 1.比如我在视频网上看电影,我们经常能看到进度条里面有一条灰色的缓存进度,我是否能理解成这个进度就是分块传输的一个进度显示吗? 2.刚刚我有看到评论说过一个问题就是分块传输的时候是由一个请求和一个响应完成的,如果我们在抓一个需要10分钟才能完成分块传输的请求时,我是不是就会看到这个请求在这10分钟内都是一个正在响应的状态吗? 3.为什么我们在对一些视频网站看视频抓包的时候却无法捕抓到这个请求呢? 4.如果我们在看完视频后在浏览器缓存里发现一些片段式的视频文件,能否就说明这个是用分块传输呢? 5.如果我们在看视频拖动进度条到10分30秒,到最后视频会从10分20秒开始播放,能否说明10分30秒的这个分块的头是在10分20秒呢? 6.请问多段数据能理解成一次性获取分块传输里多个连续的分块的数据的意思吗? 还有就是非常感谢老师把这些知识点讲的那么细,我近期多个面试里都有被问到相关的知识,多亏老师的讲解我才能顺利应付,谢谢老师!!!
2019-07-29 - 小桶 👍(44) 💬(12)
分块传输,客户端只需要发一次请求,还是发多次请求呢?使用分块传输时,客户端与服务器是怎样工作的呢
2019-07-20 - 奕 👍(24) 💬(1)
对于问题2,range是针对原文的还是压缩后的,可以想象一下看视频的时候,我们拖拽进度条请求的range范围是针对原视频长度的,如果针对压缩后的,那么我们实际拖拽的范围和响应的数据范围就不一致了
2019-07-04 - darren 👍(20) 💬(1)
不分块:http把客户端需要的东西整个交给tcp,由tcp切块后发送给客户端,客户端接受后在tcp层组装完整发给浏览器使用。 分块:http把客户端需要的东西切分成1、2、3到n块,然后将1块发给tcp,tcp将块1再次切分后发给客户端,客户端接受后在tcp组装成块1发给http层。然后服务器与客户端用同样的方式发送块2、块3到块n。客户端的http在接收完所有块后组装成一个完整的响应。整个过程使用同一个tcp连接,块1到块n如上是挨个发送的。如果是http2,则基于多路复用技术块1到块n可以同时发送。所以分块抓包http只能抓到一个包,如果抓tcp的包,分不分块,都会抓到很多包。 分段:分段就是对某个资源的一部分进行请求(类似于把一个大文件切分成很多小文件,类似压缩中的分卷功能,然后客户端只对这些小文件中的一部分进行请求) 分段是对需要哪些资源进行一种说明,分块是一种传输机制,完全不同的两个东西,只是名字比较像。 请老师指教理解不正确的地方,另外想问一下老师分块的时候每个块都会复制一次响应头吗,还是只有块1带有请求头。
2021-04-07 - -W.LI- 👍(15) 💬(1)
老师好!在带宽固定的情况下,范围请求没发提高下载速度。如果服务器对客户端每个累链接限速的情况下,可通过多线程并发下载,提高下载速度是么?还有几个问题 分块传输:顺序传一次一小块 范围请求:支持跳跃式传输,还可以并发获取不同的range最后合并。 多段数据:一次请求多个范围,范围可以不连续是么?如果必须联系的话和请求一个大范围没差别了。 这几个拒的例子都是服务端这么返回的。 客户端上传的时候怎么使用呢?老师后面会讲么。 只读到了这么点,希望老师补充下每个的作用,和解决的问题,谢谢老师。
2019-07-03 - 一只鱼 👍(11) 💬(1)
针对课下作业2: 情况一:如果服务器上只有 gzip 之后的文件,没有原文件,那范围请求针对的就是 gizp 之后的文件; 情况二:如果服务器上有原文件(未压缩),只是在传输过程中被 gizp , 那范围请求针对的就是未压缩的原文件。 这里拓展一下,假如在服务器和客户端之间有一个 cdn , 那么 cdn 缓存的是文件的某个范围吗?cdn 会根据请求头判断缓存里面有没有这个范围的结果,如果有就直接返回,并没有再根据bytes进行计算?
2020-04-10 - wheat7 👍(8) 💬(6)
chunk的核心问题并不是所谓把大象装进冰箱,是为了解决应用层在没有content-length的时候知道数据在哪里结束,chunk和普通传输方式都是在一个http报文里传输的,只是在body里相当于又加了一层协议或者是编码,数据无论如何是在一头大象里,在一个http报文中传输,大的数据传输使用chunk和不使用传输方式并没有什么区别。
2019-07-12 - lesserror 👍(6) 💬(1)
老师,请问一下:“分块传输也可以用于“流式数据””。该怎么理解这个“流式数据”这句话呢?
2019-12-04 - ly 👍(6) 💬(2)
有几个小疑问: 分块传输: 对于一个500Mb的数据,客户端应该是发送N次http请求,每次http请求只传输其中一部分,每次都是采用了分块传输的body格式,那么每次都会重新建立TCP连接吗(三次握手)? 另外文章提到分块传输中的“流式数据”,这个流式数据怎么理解呢? 对于多段数据: 服务端在响应body里面的每一段都会指定Content-Type和Content-Range,总感觉其中的Content-Type字段是多余的,难道body里面的不同分段,Content-Type可能不一样?
2019-08-18 - Gopher 👍(6) 💬(1)
这个专栏质量很棒,老师很负责,知识讲解很通透,很容易就get、解惑了。 哈哈哈,特此留言就是想说,老师,你认真做事的样子真帅(*•̀ᴗ•́*)و ̑̑
2019-07-03 - 四月长安 👍(4) 💬(1)
http数据包封装好交给下层tcp协议的时候,应该是作为tcp数据部分所要传输的内容吧,ip协议数据报最大传输65535字节的数据,这65535的数据减去tcp的首部,应该就是tcp所能容纳的负载极限了吧,所以如果是这样的话http数据的分块应该粒度更小才是吧,或许一个tcp负载里边就有好多http分块?请老师指正,感谢🙏
2019-08-16 - djfhchdh 👍(4) 💬(1)
1、因为分块数据是明文传输,如果数据里有\r\n,是会影响分块处理的 2、个人感觉应该是应用于原文件
2019-07-03