前端请求除了要控制并发,还要控制 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)

是不是很简单~

相关推荐
CappuccinoRose23 分钟前
CSS 语法学习文档(十七)
前端·css·学习·布局·houdini·瀑布流布局·csspaintingapi
keyborad pianist35 分钟前
Web开发 Day1
开发语言·前端·css·vue.js·前端框架
Never_Satisfied1 小时前
在HTML & CSS中,可能导致父元素无法根据子元素的尺寸自动调整大小的情况
前端·css·html
We་ct1 小时前
LeetCode 101. 对称二叉树:两种解法(递归+迭代)详解
前端·算法·leetcode·链表·typescript
码云数智-大飞1 小时前
微前端架构落地实战:qiankun vs Module Federation 2026 深度对比与选型指南
前端·架构
IT枫斗者1 小时前
MyBatis批量插入性能优化:从5分钟到3秒的工程化实践
前端·vue.js·mysql·mongodb·性能优化·mybatis
Coder_Boy_1 小时前
Java高级_资深_架构岗 核心知识点全解析(模块四:分布式)
java·spring boot·分布式·微服务·设计模式·架构
前端 贾公子1 小时前
深入理解 Vue3 的 v-model 及自定义指令的实现原理(中)
前端·html
Never_Satisfied2 小时前
在HTML & CSS中,img标签固定宽度时,img和图片保持比例缩放
前端·css·html
Cache技术分享2 小时前
327. Java Stream API - 实现 joining() 收集器:从简单到进阶
前端·后端