关于请求并发控制的思考

3,775 阅读5分钟

前言

我们需要知道,在 http1.1 的版本中 Chrome 浏览器对于同域名下的并发请求限制在 6 个,并且这样的限制不能修改,而是在源码里写死的。那么问题来了,如果同时发起多个请求,浏览器会如何处理呢?另外,为什么 http1.1 会有这样的情况? http2.0 是不是也有同样的问题呢?

其实,在 http 的不同版本过程中,一直在优化接口的传输可靠性和速度的及时性。 http 是建立在 tcp 之上的协议,我们没有很好的办法优化 tcp,只能想办法优化自己。这里不过多说这个,下面的思考都基于 http1.1 的基础上,http 2.0多路复用,不会有 6tcp 链接的情况,是复用一个 tcp 连接。

本文的目录结构如下:

Chrome浏览器对于多个请求的处理情况

在这里,我们模拟了 17 个接口几乎同时请求的情况,如下图:

上图可以发现,浏览器会几乎同时发起这些请求,全部处于 pending 状态。

但是从上面这张图我们又可以发现,接口真的开始请求响应之后,第六个接口之后的请求会被阻塞。直到前面的请求被完成的时候,才会依次执行后续的接口。前面有很长一段的等待时间。

这似乎是一个非常好的解决大量接口请求,可能导致的 tcp 链接过多或者服务器扛不住的情况。

但是这里也会遇到一个问题: 如果实际的接口响应时间很慢,同时请求大量接口,对于处于后面pending的接口而言,有没有超时的风险呢?

其实肯定是有的,那么我们还有什么可以优化的方法吗?

我的想法是,既然浏览器可以控制接口的阻塞,使其一直等待。那我们也可以控制接口的请求数量,我们在最大限制量内,分批次处理请求,请求结束后,再去请求后续的接口。

我们从下面的几个方案来大概讲解一下,遇见这种情况,前端如何更好的处理。

我们先创建一个含有多请求 url 的数组,和最大并发量的限制变量,作为后面代码的前提条件。

const pics = [
  'https://****/mock/**/getListForOpeCallPage',
  'https://****/mock/**/listPage',
  'https://****/mock/**/getCsCallList',
  'https://****/mock/**/getPhoneLog',
  'https://****/mock/**/getPhoneResult',
  'https://****/mock/**/listInboundInstanceById',
  'https://****/mock/**/inboundInstanceInfo',
  'https://****/mock/**/getHidePhone',
  'https://****/mock/**/listAllStaff',
  'https://****/mock/**/selectBindCompanyListByRobotDefId',
  'https://****/mock/**/getResultListByCall',
  'https://****/mock/**/updateResult',
  'https://****/mock/**/getInboundInstanceResult',
  'https://****/mock/**/listInboundInstanceLog',
  'https://****/mock/**/getResultList',
  'https://****/mock/**/virtual',
  'https://****/mock/**/actual',
];
const maxLoad = 5;

简单的 http 控制

http 的请求限制很简单,做到以下三个点即可:

  1. 我们需要一个 maxLoad 变量,控制实时发起请求的数量。这里临时定为 5。
  2. 还需要一个临时的下标,用于当某个接口完成的时候处理哪一个未请求的接口。
  3. httponload 回调里,递归调用请求函数。

代码如下:

function getUrlByHttp() {
  let idx = maxLoad;

  function getContention(index) {
    console.log('我在执行', index);
    const conn = new XMLHttpRequest();
    conn.open('get', pics[index]);
    conn.onload = () => {
      console.log('当前是哪个id先返回', index);
      idx++;
      if(idx < pics.length){
        getContention(idx);
      }
    };
    conn.send();
  }

  function start() {
    for (let i = 0; i < maxLoad; i++) {
      getContention(i);
    }
  }
  start();
}

我们一起看一下结果,如下图:当点击按钮的时候,会同时发起 5 个请求。并不会把所有的接口全部都发出。而这5个限制是由变量控制的,我们可以按情况更改最大并发的量。

那么当请求到后期,我们可以看见下图的加载顺序,在前面的 5 个接口请求中,任意一个接口结束后,就会马上发起下一个请求。在任意的时间段,请求数量都一定小于等于最大并发量:5

关于上面的代码,我们还可以升级一下代码,使用更简便的 api 来实现。

关于 fetch 控制

虽然是http方法的升级版本,但其实和http一样的方法,只是减少了代码量,更干净而已。

function getUrlByFetch() {
  let idx = maxLoad;

  function getContention(index) {
    fetch(pics[index]).then(() => {
      idx++;
      if(idx < pics.length){
        getContention(idx);
      }
    });
  }
  function start() {
    for (let i = 0; i < maxLoad; i++) {
      getContention(i);
    }
  }
  start();
}

这里可以注意,下一次执行不是绑定在 onload 的方法上,而是在有返回值的时候。

接口的最后完成时间会更短,因为不用等待资源下载的时间,如下图所示:

fetch 方法,其实也并不能完全满足目前项目的需求和编码习惯,我们可以尝试更高级的用法 - promise

关于 promise 的实现

这里实现的功能和上面的需求不太一样,这里的需求是:现有大量的接口,使用 pomise 执行这些接口,保证每个时间的最大请求数在 5 个,并在接口全部成功或者失败的时候返回结果。

这个需求有 3 个重点:

  1. 在接口全部成功或者失败的时候,告知成功或者失败。

    此时,我们肯定会首先想到使用 promise.all,这个api会返回请求数组全部请求成功或者失败的结果。

  2. 但是我们如何对请求的数组进行最大并发量的限制呢?

    如果最大限制为 5,那么 promise.all 的数组长度只能是 5

  3. 在长度被限制的情况下,我们如果保真所有的接口被一个接一个请求呢?

    我们可以使用 Promise.race 监控当前 Promise.all 的接口数组的请求情况,返回第一个请求成功的接口。

具体代码如下:

function getUrlByPromise() {
  /**
   * 一个执行异步逻辑的promise函数,返回成功的异步id,或者失败的id
   */
  function getFetch(url, idx) {
    return new Promise(async (resolve, reject) => {
      console.log(`发起第${idx}个请求`);
      const res = await fetch(url);
      if (!res) {
        return reject();
      }
      return resolve();
    });
  }

  function limitLoad() {
    // 限制请求数量的数组,idx是第几个位置,用于验证是第几个位置的接口请求成功,需要更换接口
    const promises = pics.slice(0, maxLoad).map((it, idx) => {
      // 这里返回结束的idx,是限制数组的下标
      return getFetch(it, idx).then(() => idx);
    });
    // 这里的reduce返回一个Promise.resolve()的promise,是包含了里面所有的feach请求回调注册完成的
    return (
      pics
      .reduce((pre, cur, index) => {
        if (index < maxLoad) {
          return pre;
        }
        return (
          pre
          // 这里的对未来的请求的注册,先给每一个item注册这样的函数
          // 当回调被执行的时候,就是某个位置的请求完成,并且返回位置的下标
          .then(() => Promise.race(promises))
          .catch((err) => console.log(err))
          .then((idx) => {
            // 第几个位置的请求结束,就重新放入一个请求,这个请求是当前的下标,并且返回当前的位置
            console.log(
              `第${idx}个位置的请求结束,将第${index}个接口放入,共${pics.length}个请求`
            );
            promises[idx] = getFetch(cur, index).then(() => idx);
          })
        );
      }, Promise.resolve())
      // promise.all控制并发数
      .then(() => Promise.all(promises))
    );
  }

  // 开始函数
  function start() {
    limitLoad()
      .then((res) => {
      console.log('资源全部加载成功', res);
    })
      .catch((rej) => {
      console.log('资源加载失败', rej);
    });
  }
  start();
}

我们一起来看一下结果:也是非常完美的串行+并行执行的效果。

我们再来看看响应的log输出,注意每一个log的输出顺序,第几个被执行,第几个请求结束,所有请求执行结束。

关于使用promise的控制并发量的方法,网上已经有很多现成的demo和库。这里demo的实现是完全自己的思路去完成的,大家可以多多参考,找一个自己最能理解的方式去实现。

写在最后

到这里,本文就结束了。最后,给出两个问题让大家来思考一下:

1.浏览器为什么要对接口的请求量做限制?

2.为什么要使用reduce,reduce处理异步的问题有什么优势吗?

如果有想法,可以直接在评论区回复哦。