前端请求除了要控制并发,还要控制 QPS

背景

之前写了一篇 《ChatGPT 给我的「万能的」控制 Promise 并发的方法》,里面控制了网络请求并发时的数量,也就是同一时刻,最多只能有 n 个请求发送。

值得一提的是 ChatGPT 给了我非常大的启发,它提供的这种信号量(Semaphore)的方式是我第一次了解(也可能是我孤陋寡闻),这种思路绝对是可以复用在很多其它地方的。

比如这次,在项目中虽然前端请求用了频控,但是后端接口规定 QPS < 15 才可以 。也就是说如果每个接口返回的都很快,即使用了频控,也很有可能在 1s 内发出 15 个以上的请求。所以在实际使用过程中,还是会出现接口报错。

参考信号量频控的思路,我们来修改一下逻辑就可以了。

分析

我们先来看下频控的使用方法:

ts 复制代码
// 自定义装饰器函数,用于限制并发请求数量
function limitConcurrency(fn, limit) {
  const semaphore = new Semaphore(limit);
  
  return async function(...args) {
    await semaphore.acquire(); // !!!!注意这里!!!!!
    
    try {
      return await fn.apply(this, args);
    } finally {
      semaphore.release();
    }
  };
}

// 使用装饰器包裹 fetch 请求
const limitedFetch = limitConcurrency(fetch, 5);

// 并发发起 20 个请求
const urls = ['url1', 'url2', 'url3', ...]; // 假设有 20 个请求
const promises = urls.map(url => limitedFetch(url));

Promise.all(promises)
  .then(responses => {
    // 处理所有请求的响应
  })
  .catch(error => {
    // 处理错误
  });

看过 《ChatGPT 给我的「万能的」控制 Promise 并发的方法》 文章的朋友可能注意到了,整个方法的关键就是在 await semaphore.acquire(); 这一句。没看过也不要紧,简单粗暴一点的解释,就是这个地方一直在阻塞着,等待着,不满足条件这里就进行不下去。好了,我们借着这个思路,来改造一下这里的逻辑就可以了。

首先得有计数和计时,它们的消费逻辑大概是:

  1. 如果 1s 以内的请求数小于 n,那就正常通过,不阻塞;
  2. 如果超过了 n,并且还没超过 1s,就无限循环阻塞,每次判断是否可以通过;
  3. 如果时间超过了 1s,就可以将计数清零,通过正在阻塞的请求;
  4. 重复 1~3 的逻辑。

我们直接上代码。

代码实现

ts 复制代码
class Semaphore {
  private limit: number;
  private count: number; // 计数
  private lastRunTime: number; // 计时
  private duration = 1000;

  constructor(limit: number) {
    this.limit = limit;
    this.count = 0;
    this.lastRunTime = Date.now();
  }

  public acquire() {
    // 每次发起都判断下是否超过了 1s,如果超过就更新下计数和计时
    if (Date.now() - this.lastRunTime > this.duration) {
      this.lastRunTime = Date.now();
      this.count = 0;
    }
    // 靠 Promise 控制 resolve 的触发来实现阻塞
    return new Promise<void>((resolve) => {
      // 计数未超过限制时正常执行
      if (this.count < this.limit) {
        this.count++;
        resolve();
      } else {
        // 计数超过限制后开始无限循环
        const timer = setInterval(() => {
          if (Date.now() - this.lastRunTime > this.duration) {
            // 直到超过 1s 后,更新计数和计时
            this.lastRunTime = Date.now();
            this.count = 0;
          }
          if (this.count < this.limit) {
            // 满足条件时,正常执行,跳出循环
            this.count++;
            resolve();
            clearInterval(timer);
          }
        }, 100);
      }
    });
  }
}

我们看到,这回不需要 release 方法来解除 acquire 的阻塞了。直接在 acquire 内部实现解除阻塞的逻辑就可以了。所以 Decorator 的代码稍微改动一下,去掉 release 就可以了,如下:

ts 复制代码
type AsyncFunction = (...args: any[]) => Promise<any>;

export const limitConcurrency = (
  fn: AsyncFunction,
  limit: number,
): AsyncFunction => {
  const semaphore = new Semaphore(limit);

  return async (...args): Promise<any> => {
    await semaphore.acquire();

    try {
      return await fn(...args);
    } catch (e) {
      console.error('limitConcurrency', e);
    }
    // 去掉了 semaphore.release();
  };
};

// 使用装饰器包裹 fetch 请求
const limitedFetch = limitConcurrency(fetch, 15);

到此代码就都完成了,使用的话把所有的 fetch 替换成 limitedFetch 就可以了。

总结

这种信号量的思路的确很精彩,但是其本质又非常的简单。就是在你原本的方法前面加了一个 Semaphore 阻塞的逻辑。

这个阻塞的逻辑又是内聚在 Semaphore 内部的,非常的解耦和易于维护,侵入性极小。强烈推荐掌握这种思路。

大胆一点,如果既想要频控,又想要控 QPS,把 2 个 Decorator 同时使用就可以了。

ts 复制代码
const limitedFetch = limitCount(limitQps(fetch, 15), 5)

是不是很简单~

相关推荐
涛涛ing6 小时前
2026 年,你可以从项目中删掉这 5 个 npm 包了
前端
乐迪信息7 小时前
航道船舶逆行AI识别,港口安全智能告警系统
大数据·前端·人工智能·安全·计算机视觉·音视频
Sayai7 小时前
不重新打包,vim 直接改 jar 包内的前端文件:原理、步骤与避坑
前端·vim·jar
IT_陈寒7 小时前
SpringBoot自动配置失效时我差点把电脑扔了
前端·人工智能·后端
何何____7 小时前
web方向js考核题解
开发语言·前端·javascript
风骏时光牛马7 小时前
AI面试复盘:沉淀经验,找准求职短板
前端
liangshanbo12157 小时前
面试题终极整理:手写 Promise.all / Promise.allSettled
前端·javascript·promise
心易行者7 小时前
用HTML在线运行搭后台管理系统:5个核心模块+0服务器,3天跑通完整业务
大数据·前端·网络·人工智能·python
单线程121388 小时前
从案例分析 Vue3 Tokenizer+Parser 源码五
前端·javascript·vue.js
神奇的程序员8 小时前
Apple Watch S12,送一台
前端