⚡️前端多線程大文件下載實踐,提速10倍,拿捏百度雲盤

背景

沒錯,你沒有看錯,是前端多線程,而不是Node。這一次的探索起源於最近開發中,有遇到視頻流相關的開發需求發現了一個特殊的狀態碼,他的名字叫作 206~css

屏幕快照 2020-09-21 23.21.05

爲了防止本文的枯燥,先上效果圖鎮文。(以一張3.7M 大小的圖片爲例)。html

動畫效果對比(單線程-左 VS 10個線程-右)前端

single-vs-multiple-donwload

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

image-20200915235421355

看到這裏是否是有點心動,那麼請你繼續聽我道來,那咱們先抓個包來看看整個過程是怎麼發生的。ios

`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,以及返回的 狀態碼爲 206.nginx

那麼Range是什麼呢?還記得前幾天寫過一篇文章,是關於文件下載的,其中有提到大文件的下載方式,有個叫 Range的東西,可是上一篇做爲系統性地介紹文件下載的概覽,所以沒有對range 進行詳細介紹。c++

如下全部代碼均在 https://github.com/hua1995116/node-demo/tree/master/file-download/example/download-multiple

Range 基本介紹

Range的起源

Range是在 HTTP/1.1 中新增的一個字段,這個特性也是咱們使用的迅雷等支持多線程下載以及斷點下載的核心機制。(介紹性的文案,摘錄了一下)git

首先客戶端會發起一個帶有Range: bytes=0-xxx的請求,若是服務端支持 Range,則會在響應頭中添加Accept-Ranges: bytes來表示支持 Range 的請求,以後客戶端纔可能發起帶 Range 的請求。github

服務端經過請求頭中的Range: bytes=0-xxx 來判斷是不是進行 Range 處理,若是這個值存在並且有效,則只發回請求的那部分文件內容,響應的狀態碼變成206,表示Partial Content,並設置Content-Range。若是無效,則返回416狀態碼,代表Request Range Not Satisfiable。若是請求頭中不帶 Range,那麼服務端則正常響應,也不會設置 Content-Range 等。chrome

image.png

Range的格式爲:

Range:(unit=first byte pos)-[last byte pos]

Range: 單位(如bytes)= 開始字節位置-結束字節位置

咱們來舉個例子,假設咱們開啓了多線程下載,須要把一個5000byte的文件分爲4個線程進行下載。

  • Range: bytes=0-1199 頭1200個字節
  • Range: bytes=1200-2399 第二個1200字節
  • Range: bytes=2400-3599 第三個1200字節
  • Range: bytes=3600-5000 最後的1400字節

服務器給出響應:

第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 用於響應頭,告訴了客戶端發送了多少數據,它描述了響應覆蓋的範圍和整個實體長度。通常格式:

Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity length]Content-Range:字節 開始字節位置-結束字節位置/文件大小

瀏覽器支持狀況

主流瀏覽器目前都支持這個特性。

image-20200916002624861

服務器支持

Nginx

在版本nginx版本 1.9.8 後,(加上 ngx_http_slice_module)默認自動支持,能夠將 max_ranges 設置爲 0的來取消這個設置。

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實踐

架構總覽

咱們先來看下流程架構圖總覽。單線程很簡單,正常下載就能夠了,不懂的能夠參看我上一篇文章。多線程的話,會比較麻煩一些,須要按片去下載,下載好後,須要進行合併再進行下載。(關於blob等下載方式依舊能夠參看上一篇

1600705973008

服務端代碼

很簡單,就是對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 ,這沒有什麼好說的,寫兩個按鈕來展現。

<!-- html -->
<button id="download1">串行下載</button>
<button id="download2">多線程下載</button>
<script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>

js公共參數

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);
}

多線程部分

首先發送一個 head 請求,來獲取文件的大小,而後根據 length 以及設置的分片大小,來計算每一個分片是滑動距離。經過Promise.all的回調中,用concatenate函數對分片 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("併發下載");
        const size = Number(res.headers['content-length']);
        const length = parseInt(size / m);
        const arr = []
        for (let i = 0; i < length; 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("併發下載");
        })
    })
}

完整示例

https://github.com/hua1995116/node-demo
`// 進入目錄
cd file-download
// 啓動
node server.js
// 打開 
http://localhost:8888/example/download-multiple/index.html`

因爲谷歌瀏覽器在 HTTP/1.1 對於單個域名有所限制,單個域名最大的併發量是 6.

這一點能夠在源碼以及官方人員的討論中體現。

討論地址

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 (沒錯,寫個代碼都要搞個愛你的數字),即開啓6個線程進行下載。

我用單個線程和多個線程進行分別下載了6次,看上去速度是差很少的。那麼爲何和咱們預期的不同呢?

image-20200919165242745

探索失敗的緣由

我開始仔細對比兩個請求,觀察這兩個請求的速度。

6個線程併發

image-20200919170313455

單個線程

image-20200919170512650

咱們按照3.7M 82ms 的速度來算的話,大約爲 1ms 下載 46kb,而實際狀況能夠看到,533kb ,平均就要下載 20ms 左右(已經刨去了鏈接時間,純 content 下載時間)。

我就去查找了一些資料,明白了有個叫作下行速度和上行速度的東西。

網絡的實際傳輸速度要分上行速度和下行速度, 上行速率就是發送出去數據的速度,下行就是收到數據的速度。ADSL是根據咱們平時上網,發出數據的要求相對下載數據的較小這種習慣來實現的一種傳輸方式。咱們說對於4M的 寬帶,那麼咱們的l理論最高下載速度就是512K/S,這就是所說的下行速度。 --百度百科

那咱們如今的狀況是怎麼樣的呢?

把服務器比做一根大水管,我來用圖模擬一下咱們單個線程和多個線程下載的狀況。左側爲服務器端,右側爲客戶端。(如下全部狀況都是考慮理想狀況下,只是爲了模擬過程,不考慮其餘一些程序的競態影響。)

單線程

IMG_01

多線程

IMG_02

沒錯,因爲咱們的服務器是一根大水管,流速是必定的,而且咱們客戶端沒有限制。若是是單線程跑的話,那麼會跑滿用戶的最大的速度。若是是多線程呢,以3個線程爲例子的話,至關於每一個線程都跑了原先線程三分之一的速度。合起來的速度和單個線程是沒有差異的。

下面我就分幾種狀況來說解一下,什麼樣的狀況才咱們的多線程纔會生效呢?

服務器帶寬大於用戶帶寬,不作任何限制

這種狀況其實咱們遇到的狀況差很少的。

服務器帶寬遠大於用戶帶寬,限制單鏈接網速

IMG_03

若是服務器限制了單個寬帶的下載速度,大部分也是這種狀況,例如百度雲就是這樣,例如明明你是 10M 的寬帶,可是實際下載速度只有 100kb/s ,這種狀況下,咱們就能夠開啓多線程去下載,由於它每每限制的是單個TCP的下載,固然在線上環境不是說可讓用戶開啓無限多個線程,仍是會有限制的,會限制你當前IP的最大TCP。這種狀況下下載的上限每每是你的用戶最大速度。按照上面的例子,若是你開10個線程已經達到了最大速度,由於再大,你的入口已經被限制死了,那麼各個線程之間就會搶佔速度,再多開線程也沒有用了。

改進方案

因爲 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 你的靜態目錄;
        index index.html;
    }
}

2.配置本地 host

`127.0.0.1 limit.qiufeng.com`

查看效果,這下基本上速度已是正常了,多線程下載比單線程快了速度。基本是 5-6 : 1 的速度,可是發現若是下載過程當中快速點擊數次後,使用Range下載會愈來愈快(此處懷疑是 Nginx 作了什麼緩存,暫時沒有深刻研究)。

修改代碼中的下載地址
const url = 'http://localhost:8888/api/rangeFile?filename=360_0388.jpg';
變成
const url = 'http://limit.qiufeng.com/360_0388.jpg';

測試下載速度

image-20200919201613507

還記得上面說的嗎,關於 HTTP/1.1 同一站點只能併發 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;`

image-20200919200203877

12個線程

image-20200919202302096

24個線程

image-20200919202138838

固然線程不是越多越好,通過測試,發現線程達到必定數量的時候,反而速度會更加緩慢。如下是 36個併發請求的效果圖。

image-20200919202427985

實際應用探索

那麼多進程下載到底有啥用呢?沒錯,開頭也說了,這個分片機制是迅雷等下載軟件的核心機制。

網易雲課堂

https://study.163.com/course/courseLearn.htm?courseId=1004500008#/learn/video?lessonId=1048954063&courseId=1004500008

咱們打開控制檯,很容易地發現這個下載 url,直接一個裸奔的 mp4 下載地址。

image-20200920222053726

把咱們的測試腳本從控制檯輸入進行。

// 測試腳本,因爲太長了,並且若是仔細看了上面的文章也應該能寫出代碼。實在寫不出能夠看如下代碼。
https://github.com/hua1995116/node-demo/blob/master/file-download/example/download-multiple/script.js

直接下載

image-20200920221657541

多線程下載

image-20200920221853959

能夠看到因爲網易雲課堂對單個TCP的下載速度並無什麼限制沒有那麼嚴格,提高的速度不是那麼明顯。

百度雲

咱們就來測試一下網頁版的百度雲。

image-20200919210106839

以一個 16.6M的文件爲例。

打開網頁版百度雲盤的界面,點擊下載

image-20200920222309345

這個時候點擊暫停, 打開 chrome -> 更多 -> 下載內容 -> 右鍵複製下載連接

image-20200922004619680

依舊用上述的網易雲課程下載課程的腳本。只不過你須要改一下參數。

`url 改爲對應百度雲下載連接
m 改爲 1024 * 1024 * 2 合適的分片大小~`

直接下載

百度雲多單個TCP鏈接的限速,真的是慘無人道,足足花了217秒!!!就一個17M的文件,平時咱們飽受了它多少的折磨。(除了VIP玩家)

image-20200919211105023

多線程下載

image-20200919210516632

因爲是HTTP/1.1 所以咱們只要開啓6個以及以上的線程下載就行了。如下是多線程下載的速度,約用時 46 秒。

image-20200919210550840

咱們經過這個圖再來切身感覺一下速度差別。

image-20200922010911389

真香,免費且只靠咱們前端本身實現了這個功能,太tm香了,你還不趕忙來試試??

方案缺陷

1.對於大文件的上限有必定的限制

因爲 blob 在 各大瀏覽器有上限大小的限制,所以該方法仍是存在必定的缺陷。

image.png

2. 服務器對單個TCP速度有所限制

通常狀況下都會有限制,那麼這個時候就看用戶的寬度速度了。

結尾

文章寫的比較倉促,表達可能不是特別精準,若有錯誤之處,歡迎各位大佬指出。

回頭調研下,有沒有網頁版百度雲加速的插件,若是沒有就造一個網頁版百度雲下載的插件~。

系列文章

參考文獻

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/聊一聊HTTP的Range, Content-Range/

最後

若是個人文章有幫助到你,但願你也能幫助我,歡迎關注個人微信公衆號 秋風的筆記,回覆好友 二次,可加微信而且加入交流羣,秋風的筆記 將一直陪伴你的左右。

image

相關文章
相關標籤/搜索