请求队列在高并发场景下的实战应用与优化策略

上官博硕 优化 阅读 2,132
赞 10 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年做了一个数据密集型的管理后台,页面上要同时加载几十个图表、表格和状态面板。一开始图省事,每个组件都自己发请求,结果一进页面浏览器直接卡住,network 面板里一堆 pending 的请求,还时不时超时失败。用户反馈“点进去像死机”,我一看 console 里全是 Failed to load resource: net::ERR_INSUFFICIENT_RESOURCES,这才意识到:并发请求太多,浏览器扛不住了。

请求队列在高并发场景下的实战应用与优化策略

其实早该想到的——Chrome 对同一个域名最多开 6 个并发连接(HTTP/1.1),超过的就得排队。但我们这页面动不动就发 30+ 个请求,队列直接爆了。当时第一反应是“加个 loading 等所有数据回来再渲染”,但产品说不行,得优先展示关键数据。思来想去,还是得自己搞个请求队列,控制并发数,让请求按顺序、分批次地发出去。

核心代码就这几行

我写了个简单的队列类,核心逻辑就是维护一个待执行的请求列表,同时只允许 N 个请求在跑。跑完一个,就从队列里拿下一个补上。代码如下:

class RequestQueue {
  constructor(concurrency = 4) {
    this.concurrency = concurrency; // 最大并发数
    this.running = 0;               // 当前运行中的请求数
    this.queue = [];                // 待处理的请求队列
  }

  add(requestFn) {
    return new Promise((resolve, reject) => {
      this.queue.push({
        requestFn,
        resolve,
        reject
      });
      this.process();
    });
  }

  async process() {
    if (this.running >= this.concurrency || this.queue.length === 0) {
      return;
    }

    this.running++;
    const { requestFn, resolve, reject } = this.queue.shift();

    try {
      const result = await requestFn();
      resolve(result);
    } catch (error) {
      reject(error);
    } finally {
      this.running--;
      this.process(); // 处理下一个
    }
  }
}

用起来也很简单,比如原来这样发请求:

// 旧代码
fetch('/api/chart1').then(...);
fetch('/api/chart2').then(...);
// ... 一堆并行请求

改成这样:

const queue = new RequestQueue(4); // 控制最多4个并发

queue.add(() => fetch('https://jztheme.com/api/chart1'));
queue.add(() => fetch('https://jztheme.com/api/chart2'));
// ... 其他请求都塞进队列

这样至少不会把浏览器干趴下了。

最大的坑:性能问题

上线后发现,虽然不卡了,但首屏加载变慢了!因为所有请求串行排队,哪怕只设了 4 并发,非关键请求也拖慢了关键数据的返回。比如一个无关紧要的日志接口卡了 3 秒,结果导致下面重要的用户信息也得等它跑完才能发。

折腾了半天才发现:队列是 FIFO(先进先出),但业务上需要优先级!有些请求必须插队,比如用户点击“刷新”按钮触发的请求,肯定要比后台轮询的数据优先处理。

于是我在 add 方法里加了个 priority 参数,高优先级的请求插到队列前面:

add(requestFn, priority = 0) {
  const task = { requestFn, resolve, reject, priority };
  // 高优先级插到前面
  if (priority > 0) {
    this.queue.unshift(task);
  } else {
    this.queue.push(task);
  }
  this.process();
}

但这样又引出新问题:如果一直有高优先级请求进来,低优先级的可能永远跑不到(饥饿问题)。后来妥协了一下,改成“每处理 5 个请求,强制处理一个低优先级的”,虽然糙,但够用。

另一个头疼的问题:错误重试

网络不稳定时,个别请求会失败。最开始的做法是直接 reject,但产品要求自动重试 2 次。于是我在 process 里加了重试逻辑:

async process() {
  // ...
  let attempts = 0;
  const maxRetries = 2;

  while (attempts <= maxRetries) {
    try {
      const result = await requestFn();
      resolve(result);
      return;
    } catch (error) {
      attempts++;
      if (attempts > maxRetries) {
        reject(error);
        return;
      }
      // 等 500ms 再试
      await new Promise(r => setTimeout(r, 500));
    }
  }
}

结果发现:重试的时候,this.running 还占着坑位,导致队列卡住!因为 running++ 只在第一次调用时执行,重试期间 running 数没释放,其他请求进不来。改了好几次才理清楚:重试必须在同一个 task 内完成,不能释放 running 计数,否则并发控制就乱了。最后确认当前写法是对的——running 在整个 task 完全结束(成功或彻底失败)后才减 1。

最终的解决方案

综合下来,最终版本做了这些调整:

  • 支持优先级(但限制高频插队)
  • 内置重试机制,带退避延迟
  • 暴露 clear() 方法,用于页面卸载时取消所有 pending 请求(避免内存泄漏)
  • 关键请求单独走一个高优先级队列,非关键走默认队列

实际效果:首屏关键数据加载时间从 8s 降到 3s 左右,浏览器不再报资源不足错误,用户反馈“流畅多了”。虽然非关键数据可能晚几秒出来,但体验上可接受。

回顾与反思

这个方案不算优雅,比如优先级调度很粗糙,重试策略也没做指数退避。但胜在简单、可控,两周就搞定了,没引入第三方库(比如 axios-retry 或 p-queue),维护成本低。

现在回头看,其实还有优化空间:

  • 可以按接口类型分多个队列(比如 /api/user 走一个队列,/api/log 走另一个),避免互相阻塞
  • 重试时应该区分错误类型,4xx 错误没必要重试
  • 队列长度太长时应该告警,而不是无脑排队

不过目前项目稳定运行半年多了,没出过大问题。那几个小瑕疵(比如偶尔低优先级任务延迟稍高)对业务影响不大,也就没再动它——毕竟“能跑就行”是很多项目的现实。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的队列调度思路,或者遇到类似问题怎么解的,欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论