关于前端:⚡️前端多线程大文件下载实践提速10倍拿捏百度云盘

25次阅读

共计 10805 个字符,预计需要花费 28 分钟才能阅读完成。

背景

没错,你没有看错,是前端多线程,而不是Node。这一次的摸索起源于最近开发中,有遇到视频流相干的开发需要发现了一个非凡的状态码,他的名字叫做 206~

为了避免本文的干燥,先上效果图镇文。(以一张3.7M 大小的图片为例)。

动画成果比照(单线程 - 左 VS 10 个线程 - 右)

工夫比照(单线程 VS 10 个线程)

看到这里是不是有点心动,那么请你持续听我道来,那咱们先抓个包来看看整个过程是怎么产生的。

`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.

那么 Range 是什么呢?还记得前几天写过一篇文章,是对于文件下载的,其中有提到大文件的下载方式,有个叫 Range的货色,然而上一篇作为系统性地介绍文件下载的概览,因而没有对range 进行具体介绍。

以下所有代码均在 https://github.com/hua1995116/node-demo/tree/master/file-download/example/download-multiple

Range 根本介绍

Range 的起源

Range是在 HTTP/1.1 中新增的一个字段,这个个性也是咱们应用的迅雷等反对多线程下载以及断点下载的外围机制。(介绍性的文案,摘录了一下)

首先客户端会发动一个带有 Range: bytes=0-xxx 的申请,如果服务端反对 Range,则会在响应头中增加 Accept-Ranges: bytes 来示意反对 Range 的申请,之后客户端才可能发动带 Range 的申请。

服务端通过申请头中的Range: bytes=0-xxx 来判断是否是进行 Range 解决,如果这个值存在而且无效,则只发回申请的那局部文件内容,响应的状态码变成 206,示意 Partial Content,并设置 Content-Range。如果有效,则返回 416 状态码,表明 Request Range Not Satisfiable。如果申请头中不带 Range,那么服务端则失常响应,也不会设置 Content-Range 等。

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:字节 开始字节地位 - 完结字节地位/文件大小

浏览器反对状况

支流浏览器目前都反对这个个性。

服务器反对

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 等下载方式仍旧能够参看上一篇)

服务端代码

很简略,就是对 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 次,看上去速度是差不多的。那么为什么和咱们预期的不一样呢?

摸索失败的起因

我开始认真比照两个申请,察看这两个申请的速度。

6 个线程并发

单个线程

咱们依照 3.7M 82ms 的速度来算的话,大概为 1ms 下载 46kb,而理论状况能够看到,533kb,均匀就要下载 20ms 左右(曾经刨去了连接时间,纯 content 下载工夫)。

我就去查找了一些材料,明确了有个叫做上行速度和上行速度的货色。

网络的理论传输速度要分上行速度和上行速度,上行速率就是发送进来数据的速度,上行就是收到数据的速度。ADSL 是依据咱们平时上网,收回数据的要求绝对下载数据的较小这种习惯来实现的一种传输方式。咱们说对于 4M 的宽带,那么咱们的 l 实践最高下载速度就是 512K/S,这就是所说的上行速度。– 百度百科

那咱们当初的状况是怎么样的呢?

把服务器比作一根大水管,我来用图模仿一下咱们单个线程和多个线程下载的状况。左侧为服务器端,右侧为客户端。(以下所有状况都是思考现实状况下,只是为了模仿过程,不思考其余一些程序的竞态影响。)

单线程

多线程

没错,因为咱们的服务器是一根大水管,流速是肯定的,并且咱们客户端没有限度。如果是单线程跑的话,那么会跑满用户的最大的速度。如果是多线程呢,以 3 个线程为例子的话,相当于每个线程都跑了原先线程三分之一的速度。合起来的速度和单个线程是没有差异的。

上面我就分几种状况来解说一下,什么样的状况才咱们的多线程才会失效呢?

服务器带宽大于用户带宽,不做任何限度

这种状况其实咱们遇到的状况差不多的。

服务器带宽远大于用户带宽,限度单连贯网速

如果服务器限度了单个宽带的下载速度,大部分也是这种状况,例如百度云就是这样,例如明明你是 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'; 

测试下载速度

还记得下面说的吗,对于 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;` 

12 个线程

24 个线程

当然线程不是越多越好,通过测试,发现线程达到肯定数量的时候,反而速度会更加迟缓。以下是 36 个并发申请的效果图。

理论利用摸索

那么多过程下载到底有啥用呢?没错,结尾也说了,这个分片机制是迅雷等下载软件的外围机制。

网易云课堂

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

咱们关上控制台,很容易地发现这个下载 url,间接一个裸奔的 mp4 下载地址。

把咱们的测试脚本从控制台输出进行。

// 测试脚本,因为太长了,而且如果认真看了下面的文章也应该能写出代码。切实写不出能够看以下代码。https://github.com/hua1995116/node-demo/blob/master/file-download/example/download-multiple/script.js 

间接下载

多线程下载

能够看到因为网易云课堂对单个 TCP 的下载速度并没有什么限度没有那么严格,晋升的速度不是那么显著。

百度云

咱们就来测试一下网页版的百度云。

以一个 16.6M 的文件为例。

关上网页版百度云盘的界面,点击下载

这个时候点击暂停, 关上 chrome -> 更多 -> 下载内容 -> 右键复制下载链接

仍旧用上述的网易云课程下载课程的脚本。只不过你须要改一下参数。

`url 改成对应百度云下载链接
m 改成 1024 * 1024 * 2 适合的分片大小~` 

间接下载

百度云多单个 TCP 连贯的限速,真的是惨无人道,足足花了 217 秒!!!就一个 17M 的文件,平时咱们饱受了它多少的折磨。(除了 VIP 玩家)

多线程下载

因为是 HTTP/1.1 因而咱们只有开启 6 个以及以上的线程下载就好了。以下是多线程下载的速度,约用时 46 秒。

咱们通过这个图再来切身感受一下速度差别。

真香,收费且只靠咱们前端本人实现了这个性能,太 tm 香了,你还不连忙来试试??

计划缺点

1. 对于大文件的下限有肯定的限度

因为 blob 在 各大浏览器有下限大小的限度,因而该办法还是存在肯定的缺点。

2. 服务器对单个 TCP 速度有所限度

个别状况下都会有限度,那么这个时候就看用户的宽度速度了。

结尾

文章写的比拟仓促,表白可能不是特地精准,如有谬误之处,欢送各位大佬指出。

回头调研下,有没有网页版百度云减速的插件,如果没有就造一个网页版百度云下载的插件~。

系列文章

  • 一文带你层层解锁「文件下载」的神秘
  • 一文理解文件上传全过程(1.8w 字深度解析,进阶必备)

参考文献

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/

最初

如果我的文章有帮忙到你,心愿你也能帮忙我,欢送关注我的微信公众号 秋风的笔记 ,回复 好友 二次,可加微信并且退出交换群, 秋风的笔记 将始终陪伴你的左右。

正文完
 0