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

是不是很简单~

相关推荐
铁皮饭盒23 分钟前
TypeBox 比 Zod.js 校验 快10倍, 还兼容AI 工具调用, 他做对了什么?
前端·javascript·后端
Bigger9 小时前
Tauri (26)——托盘图标总对不上系统主题?一行 Template Image 搞定
前端·rust·app
kyriewen11 小时前
面试官问你:“AI 能写 80% 的代码了,公司为什么还需要你?”
前端·javascript·面试
甲维斯12 小时前
又升级咯!坦克大战2026,科技与复古并存!
前端·人工智能·游戏开发
搬砖的码农14 小时前
(08)为什么我的 Agent 一跑后台服务就卡死
前端·agent·ai编程
飘尘14 小时前
前端转全栈(Java 后端)必须要知道的:开发中的锁机制与分布式并发控制
前端·后端·全栈
亲亲小宝宝鸭14 小时前
前端性能监控:web-vitals
前端·性能优化·监控
花椒技术15 小时前
HJPusher / HJPlayer SDK 实践:我们为什么把直播推播链路拆成一套可复用能力
设计模式·harmonyos·直播
陆枫Larry15 小时前
可滚动页面背景填不满:`height: 100vh` vs `min-height: 100vh`
前端