前端多線程大文件下載實踐,提速10倍
背景
沒錯,你沒有看錯,是前端多線程,而不是Node。這一次的探索起源于最近開發(fā)中,有遇到視頻流相關(guān)的開發(fā)需求發(fā)現(xiàn)了一個特殊的狀態(tài)碼,他的名字叫做 206~

為了防止本文的枯燥,先上效果圖鎮(zhèn)文。(以一張3.7M 大小的圖片為例)。
動畫效果對比(單線程-左 VS 10個線程-右)

時間對比(單線程 VS 10個線程)

看到這里是不是有點心動,那么請你繼續(xù)聽我道來,那我們先抓個包來看看整個過程是怎么發(fā)生的。
GET?/360_0388.jpg?HTTP/1.1
Host:?limit.qiufeng.com
Connection:?keep-alive
...
Range:?bytes=0-102399
HTTP/1.1?206?Partial?Content
Server:?openresty/1.13.6.2
Date:?Sat,?19?Sep?2020?06:31:11?GMT
Content-Type:?image/jpeg
Content-Length:?102400
....
Content-Range:?bytes?0-102399/3670627
...(這里是文件流)
可以看到請求這里多出一個字段 Range: bytes=0-102399 ,服務端也多出一個字段Content-Range: bytes 0-102399/3670627,以及返回的 狀態(tài)碼為 206.
那么Range是什么呢?還記得前幾天寫過一篇文章,是關(guān)于文件下載的,其中有提到大文件的下載方式,有個叫 Range的東西,但是上一篇作為系統(tǒng)性地介紹文件下載的概覽,因此沒有對range 進行詳細介紹。
以下所有代碼均在 https://github.com/hua1995116/node-demo/tree/master/file-download/example/download-multiple
Range 基本介紹
Range的起源
Range是在 HTTP/1.1 中新增的一個字段,這個特性也是我們使用的迅雷等支持多線程下載以及斷點下載的核心機制。(介紹性的文案,摘錄了一下)
首先客戶端會發(fā)起一個帶有Range: bytes=0-xxx的請求,如果服務端支持 Range,則會在響應頭中添加Accept-Ranges: bytes來表示支持 Range 的請求,之后客戶端才可能發(fā)起帶 Range 的請求。
服務端通過請求頭中的Range: bytes=0-xxx來判斷是否是進行 Range 處理,如果這個值存在而且有效,則只發(fā)回請求的那部分文件內(nèi)容,響應的狀態(tài)碼變成206,表示Partial Content,并設(shè)置Content-Range。如果無效,則返回416狀態(tài)碼,表明Request Range Not Satisfiable。如果請求頭中不帶 Range,那么服務端則正常響應,也不會設(shè)置 Content-Range 等。
| Value | Description |
|---|---|
| 206 | Partial Content |
| 416 | Range Not Satisfiable |
Range的格式為:
Range:(unit=first byte pos)-[last byte pos]
即Range: 單位(如bytes)= 開始字節(jié)位置-結(jié)束字節(jié)位置。
我們來舉個例子,假設(shè)我們開啟了多線程下載,需要把一個5000byte的文件分為4個線程進行下載。
Range: bytes=0-1199 頭1200個字節(jié) Range: bytes=1200-2399 第二個1200字節(jié) Range: bytes=2400-3599 第三個1200字節(jié) Range: bytes=3600-5000 最后的1400字節(jié)
服務器給出響應:
第1個響應
Content-Length:1200 Content-Range:bytes 0-1199/5000
第2個響應
Content-Length:1200 Content-Range:bytes 1200-2399/5000
第3個響應
Content-Length:1200 Content-Range:bytes 2400-3599/5000
第4個響應
Content-Length:1400 Content-Range:bytes 3600-5000/5000
如果每個請求都成功了,服務端返回的response頭中有一個 Content-Range 的字段域,Content-Range 用于響應頭,告訴了客戶端發(fā)送了多少數(shù)據(jù),它描述了響應覆蓋的范圍和整個實體長度。一般格式:
Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity length]即Content-Range:字節(jié) 開始字節(jié)位置-結(jié)束字節(jié)位置/文件大小。
瀏覽器支持情況
主流瀏覽器目前都支持這個特性。

服務器支持
Nginx
在版本nginx版本 1.9.8 后,(加上 ngx_http_slice_module)默認自動支持,可以將 max_ranges 設(shè)置為 0的來取消這個設(shè)置。
Node
Node 默認不提供 對 Range方法的處理,需要自己寫代碼進行處理。
router.get('/api/rangeFile',?async(ctx)?=>?{
????const?{?filename?}?=?ctx.query;
????const?{?size?}?=?fs.statSync(path.join(__dirname,?'./static/',?filename));
????const?range?=?ctx.headers['range'];
????if?(!range)?{
????????ctx.set('Accept-Ranges',?'bytes');
????????ctx.body?=?fs.readFileSync(path.join(__dirname,?'./static/',?filename));
????????return;
????}
????const?{?start,?end?}?=?getRange(range);
????if?(start?>=?size?||?end?>=?size)?{
????????ctx.response.status?=?416;
????????ctx.body?=?'';
????????return;
????}
????ctx.response.status?=?206;
????ctx.set('Accept-Ranges',?'bytes');
????ctx.set('Content-Range',?`bytes?${start}-${end???end?:?size?-?1}/${size}`);
????ctx.body?=?fs.createReadStream(path.join(__dirname,?'./static/',?filename),?{?start,?end?});
})
或者你可以使用 koa-send 這個庫。
https://github.com/pillarjs/send/blob/0.17.1/index.js#L680
Range實踐
架構(gòu)總覽
我們先來看下流程架構(gòu)圖總覽。單線程很簡單,正常下載就可以了,不懂的可以參看我上一篇文章。多線程的話,會比較麻煩一些,需要按片去下載,下載好后,需要進行合并再進行下載。(關(guān)于blob等下載方式依舊可以參看上一篇)

服務端代碼
很簡單,就是對Range做了兼容。
router.get('/api/rangeFile',?async(ctx)?=>?{
????const?{?filename?}?=?ctx.query;
????const?{?size?}?=?fs.statSync(path.join(__dirname,?'./static/',?filename));
????const?range?=?ctx.headers['range'];
????if?(!range)?{
????????ctx.set('Accept-Ranges',?'bytes');
????????ctx.body?=?fs.readFileSync(path.join(__dirname,?'./static/',?filename));
????????return;
????}
????const?{?start,?end?}?=?getRange(range);
????if?(start?>=?size?||?end?>=?size)?{
????????ctx.response.status?=?416;
????????ctx.body?=?'';
????????return;
????}
????ctx.response.status?=?206;
????ctx.set('Accept-Ranges',?'bytes');
????ctx.set('Content-Range',?`bytes?${start}-${end???end?:?size?-?1}/${size}`);
????ctx.body?=?fs.createReadStream(path.join(__dirname,?'./static/',?filename),?{?start,?end?});
})
html
然后來編寫 html ,這沒有什么好說的,寫兩個按鈕來展示。
<button?id="download1">串行下載button>
<button?id="download2">多線程下載button>
<script?src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js">script>
js公共參數(shù)
const?m?=?1024?*?520;??//?分片的大小
const?url?=?'http://localhost:8888/api/rangeFile?filename=360_0388.jpg';?//?要下載的地址
單線程部分
單線程下載代碼,直接去請求以blob方式獲取,然后用blobURL 的方式下載。
download1.onclick?=?()?=>?{
????console.time("直接下載");
????function?download(url)?{
????????const?req?=?new?XMLHttpRequest();
????????req.open("GET",?url,?true);
????????req.responseType?=?"blob";
????????req.onload?=?function?(oEvent)?{
????????????const?content?=?req.response;
????????????const?aTag?=?document.createElement('a');
????????????aTag.download?=?'360_0388.jpg';
????????????const?blob?=?new?Blob([content])
????????????const?blobUrl?=?URL.createObjectURL(blob);
????????????aTag.href?=?blobUrl;
????????????aTag.click();
????????????URL.revokeObjectURL(blob);
????????????console.timeEnd("直接下載");
????????};
????????req.send();
????}
????download(url);
}
多線程部分
首先發(fā)送一個 head 請求,來獲取文件的大小,然后根據(jù) length 以及設(shè)置的分片大小,來計算每個分片是滑動距離。通過Promise.all的回調(diào)中,用concatenate函數(shù)對分片 buffer 進行一個合并成一個 blob,然后用blobURL 的方式下載。
//?script
function?downloadRange(url,?start,?end,?i)?{
????return?new?Promise((resolve,?reject)?=>?{
????????const?req?=?new?XMLHttpRequest();
????????req.open("GET",?url,?true);
????????req.setRequestHeader('range',?`bytes=${start}-${end}`)
????????req.responseType?=?"blob";
????????req.onload?=?function?(oEvent)?{
????????????req.response.arrayBuffer().then(res?=>?{
????????????????resolve({
????????????????????i,
????????????????????buffer:?res
????????????????});
????????????})
????????};
????????req.send();
????})
}
//?合并buffer
function?concatenate(resultConstructor,?arrays)?{
????let?totalLength?=?0;
????for?(let?arr?of?arrays)?{
????????totalLength?+=?arr.length;
????}
????let?result?=?new?resultConstructor(totalLength);
????let?offset?=?0;
????for?(let?arr?of?arrays)?{
????????result.set(arr,?offset);
????????offset?+=?arr.length;
????}
????return?result;
}
download2.onclick?=?()?=>?{
????axios({
????????url,
????????method:?'head',
????}).then((res)?=>?{
????????//?獲取長度來進行分割塊
????????console.time("并發(fā)下載");
????????const?size?=?Number(res.headers['content-length']);
????????const?length?=?parseInt(size?/?m);
????????const?arr?=?[]
????????for?(let?i?=?0;?i?????????????let?start?=?i?*?m;
????????????let?end?=?(i?==?length?-?1)????size?-?1??:?(i?+?1)?*?m?-?1;
????????????arr.push(downloadRange(url,?start,?end,?i))
????????}
????????Promise.all(arr).then(res?=>?{
????????????const?arrBufferList?=?res.sort(item?=>?item.i?-?item.i).map(item?=>?new?Uint8Array(item.buffer));
????????????const?allBuffer?=?concatenate(Uint8Array,?arrBufferList);
????????????const?blob?=?new?Blob([allBuffer],?{type:?'image/jpeg'});
????????????const?blobUrl?=?URL.createObjectURL(blob);
????????????const?aTag?=?document.createElement('a');
????????????aTag.download?=?'360_0388.jpg';
????????????aTag.href?=?blobUrl;
????????????aTag.click();
????????????URL.revokeObjectURL(blob);
????????????console.timeEnd("并發(fā)下載");
????????})
????})
}
完整示例
https://github.com/hua1995116/node-demo
//?進入目錄
cd?file-download
//?啟動
node?server.js
//?打開?
http://localhost:8888/example/download-multiple/index.html
由于谷歌瀏覽器在 HTTP/1.1 對于單個域名有所限制,單個域名最大的并發(fā)量是 6.
這一點可以在源碼以及官方人員的討論中體現(xiàn)。
討論地址
https://bugs.chromium.org/p/chromium/issues/detail?id=12066
Chromium 源碼
//?https://source.chromium.org/chromium/chromium/src/+/refs/tags/87.0.4268.1:net/socket/client_socket_pool_manager.cc;l=47
//?Default?to?allow?up?to?6?connections?per?host.?Experiment?and?tuning?may
//?try?other?values?(greater?than?0).??Too?large?may?cause?many?problems,?such
//?as?home?routers?blocking?the?connections!?!???See?http://crbug.com/12066.
//
//?WebSocket?connections?are?long-lived,?and?should?be?treated?differently
//?than?normal?other?connections.?Use?a?limit?of?255,?so?the?limit?for?wss?will
//?be?the?same?as?the?limit?for?ws.?Also?note?that?Firefox?uses?a?limit?of?200.
//?See?http://crbug.com/486800
int?g_max_sockets_per_group[]?=?{
????6,???//?NORMAL_SOCKET_POOL
????255??//?WEBSOCKET_SOCKET_POOL
};
因此為了配合這個特性我將文件分成6個片段,每個片段為520kb (沒錯,寫個代碼都要搞個愛你的數(shù)字),即開啟6個線程進行下載。
我用單個線程和多個線程進行分別下載了6次,看上去速度是差不多的。那么為什么和我們預期的不一樣呢?

探索失敗的原因
我開始仔細對比兩個請求,觀察這兩個請求的速度。
6個線程并發(fā)

單個線程

我們按照3.7M 82ms 的速度來算的話,大約為 1ms 下載 46kb,而實際情況可以看到,533kb ,平均就要下載 20ms 左右(已經(jīng)刨去了連接時間,純 content 下載時間)。
我就去查找了一些資料,明白了有個叫做下行速度和上行速度的東西。
網(wǎng)絡的實際傳輸速度要分上行速度和下行速度,上行速率就是發(fā)送出去數(shù)據(jù)的速度,下行就是收到數(shù)據(jù)的速度。ADSL是根據(jù)我們平時上網(wǎng),發(fā)出數(shù)據(jù)的要求相對下載數(shù)據(jù)的較小這種習慣來實現(xiàn)的一種傳輸方式。我們說對于4M的寬帶,那么我們的l理論最高下載速度就是512K/S,這就是所說的下行速度。?--百度百科
那我們現(xiàn)在的情況是怎么樣的呢?
把服務器比作一根大水管,我來用圖模擬一下我們單個線程和多個線程下載的情況。左側(cè)為服務器端,右側(cè)為客戶端。(以下所有情況都是考慮理想情況下,只是為了模擬過程,不考慮其他一些程序的競態(tài)影響。)
單線程

多線程

沒錯,由于我們的服務器是一根大水管,流速是一定的,并且我們客戶端沒有限制。如果是單線程跑的話,那么會跑滿用戶的最大的速度。如果是多線程呢,以3個線程為例子的話,相當于每個線程都跑了原先線程三分之一的速度。合起來的速度和單個線程是沒有差別的。
下面我就分幾種情況來講解一下,什么樣的情況才我們的多線程才會生效呢?
服務器帶寬大于用戶帶寬,不做任何限制
這種情況其實我們遇到的情況差不多的。
服務器帶寬遠大于用戶帶寬,限制單連接網(wǎng)速

如果服務器限制了單個寬帶的下載速度,大部分也是這種情況,例如百度云就是這樣,例如明明你是 10M 的寬帶,但是實際下載速度只有 100kb/s ,這種情況下,我們就可以開啟多線程去下載,因為它往往限制的是單個TCP的下載,當然在線上環(huán)境不是說可以讓用戶開啟無限多個線程,還是會有限制的,會限制你當前IP的最大TCP。這種情況下下載的上限往往是你的用戶最大速度。按照上面的例子,如果你開10個線程已經(jīng)達到了最大速度,因為再大,你的入口已經(jīng)被限制死了,那么各個線程之間就會搶占速度,再多開線程也沒有用了。
改進方案
由于 Node 我暫時沒有找到比較簡單地控制下載速度的方法,因此我就引入了 Nginx。
我們將每個TCP連接的速度控制在 1M/s。
加入配置 limit_rate 1M;
準備工作
1.nginx_conf
server {
listen 80;
server_name limit.qiufeng.com;
access_log /opt/logs/wwwlogs/limitqiufeng.access.log;
error_log /opt/logs/wwwlogs/limitqiufeng.error.log;
add_header Cache-Control max-age=60;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,range,If-Range';
if ($request_method = 'OPTIONS') {
return 204;
}
limit_rate 1M;
location / {
root 你的靜態(tài)目錄;
index index.html;
}
}
2.配置本地 host
127.0.0.1?limit.qiufeng.com
查看效果,這下基本上速度已經(jīng)是正常了,多線程下載比單線程快了速度。基本是 5-6 : 1 的速度,但是發(fā)現(xiàn)如果下載過程中快速點擊數(shù)次后,使用Range下載會越來越快(此處懷疑是 Nginx 做了什么緩存,暫時沒有深入研究)。
修改代碼中的下載地址
const?url?=?'http://localhost:8888/api/rangeFile?filename=360_0388.jpg';
變成
const?url?=?'http://limit.qiufeng.com/360_0388.jpg';
測試下載速度

還記得上面說的嗎,關(guān)于 HTTP/1.1 同一站點只能并發(fā) 6 個請求,多余的請求會放到下一個批次。但是 HTTP/2.0 不受這個限制,多路復用代替了 HTTP/1.x 的序列和阻塞機制。讓我們來升級 HTTP/2.0 來測試一下。
需要本地生成一個證書。(生成證書方法: https://juejin.im/post/6844903556722475021)
server {
listen 443 ssl http2;
ssl on;
ssl_certificate /usr/local/openresty/nginx/conf/ssl/server.crt;
ssl_certificate_key /usr/local/openresty/nginx/conf/ssl/server.key;
ssl_session_cache shared:le_nginx_SSL:1m;
ssl_session_timeout 1440m;
ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers RC4:HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
server_name limit.qiufeng.com;
access_log /opt/logs/wwwlogs/limitqiufeng2.access.log;
error_log /opt/logs/wwwlogs/limitqiufeng2.error.log;
add_header Cache-Control max-age=60;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,range,If-Range';
if ($request_method = 'OPTIONS') {
return 204;
}
limit_rate 1M;
location / {
root 你存放項目的前綴路徑/node-demo/file-download/;
index index.html;
}
}
10個線程
將單個下載大小進行修改
const?m?=?1024?*?400;

12個線程

24個線程

當然線程不是越多越好,經(jīng)過測試,發(fā)現(xiàn)線程達到一定數(shù)量的時候,反而速度會更加緩慢。以下是 36個并發(fā)請求的效果圖。

實際應用探索
那么多進程下載到底有啥用呢?沒錯,開頭也說了,這個分片機制是迅雷等下載軟件的核心機制。
網(wǎng)易云課堂
https://study.163.com/course/courseLearn.htm?courseId=1004500008#/learn/video?lessonId=1048954063&courseId=1004500008
我們打開控制臺,很容易地發(fā)現(xiàn)這個下載 url,直接一個裸奔的 mp4 下載地址。

把我們的測試腳本從控制臺輸入進行。
//?測試腳本,由于太長了,而且如果仔細看了上面的文章也應該能寫出代碼。實在寫不出可以看以下代碼。
https://github.com/hua1995116/node-demo/blob/master/file-download/example/download-multiple/script.js
直接下載

多線程下載

可以看到由于網(wǎng)易云課堂對單個TCP的下載速度并沒有什么限制沒有那么嚴格,提升的速度不是那么明顯。
百度云
我們就來測試一下網(wǎng)頁版的百度云。

以一個 16.6M的文件為例。
打開網(wǎng)頁版百度云盤的界面,點擊下載

這個時候點擊暫停, 打開 chrome -> 更多 -> 下載內(nèi)容 -> 右鍵復制下載鏈接

依舊用上述的網(wǎng)易云課程下載課程的腳本。只不過你需要改一下參數(shù)。
url?改成對應百度云下載鏈接
m?改成?1024?*?1024?*?2?合適的分片大小~
直接下載
百度云多單個TCP連接的限速,真的是慘無人道,足足花了217秒?。?!就一個17M的文件,平時我們飽受了它多少的折磨。(除了VIP玩家)

多線程下載

由于是HTTP/1.1 因此我們只要開啟6個以及以上的線程下載就好了。以下是多線程下載的速度,約用時 46 秒。

我們通過這個圖再來切身感受一下速度差異。

真香,免費且只靠我們前端自己實現(xiàn)了這個功能,太tm香了,你還不趕緊來試試??
方案缺陷
1.對于大文件的上限有一定的限制
由于 blob 在 各大瀏覽器有上限大小的限制,因此該方法還是存在一定的缺陷。
| Browser | Constructs as | Filenames | Max Blob Size | Dependencies |
|---|---|---|---|---|
| Firefox 20+ | Blob | Yes | 800 MiB | None |
| Firefox < 20 | data: URI | No | n/a | Blob.js |
| Chrome | Blob | Yes | 2GB | None |
| Chrome for Android | Blob | Yes | RAM/5 | None |
| Edge | Blob | Yes | ? | None |
| IE 10+ | Blob | Yes | 600 MiB | None |
| Opera 15+ | Blob | Yes | 500 MiB | None |
| Opera < 15 | data: URI | No | n/a | Blob.js |
| Safari 6.1+* | Blob | No | ? | None |
| Safari < 6 | data: URI | No | n/a | Blob.js |
| Safari 10.1+ | Blob | Yes | n/a | None |
2. 服務器對單個TCP速度有所限制
一般情況下都會有限制,那么這個時候就看用戶的寬度速度了。
結(jié)尾
文章寫的比較倉促,表達可能不是特別精準,如有錯誤之處,歡迎各位大佬指出。
回頭調(diào)研下,有沒有網(wǎng)頁版百度云加速的插件,如果沒有就造一個網(wǎng)頁版百度云下載的插件~。
參考文獻
Nginx帶寬控制 : https://blog.huoding.com/2015/03/20/423
openresty 部署 https 并開啟 http2 支持 ?: https://www.gryen.com/articles/show/5.html
聊一聊HTTP的Range : https://dabing1022.github.io/2016/12/24/%E8%81%8A%E4%B8%80%E8%81%8AHTTP%E7%9A%84Range,%20Content-Range/
