请求队列在高并发场景下的实战应用与优化策略
项目初期的技术选型
去年做了一个数据密集型的管理后台,页面上要同时加载几十个图表、表格和状态面板。一开始图省事,每个组件都自己发请求,结果一进页面浏览器直接卡住,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 错误没必要重试
- 队列长度太长时应该告警,而不是无脑排队
不过目前项目稳定运行半年多了,没出过大问题。那几个小瑕疵(比如偶尔低优先级任务延迟稍高)对业务影响不大,也就没再动它——毕竟“能跑就行”是很多项目的现实。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的队列调度思路,或者遇到类似问题怎么解的,欢迎评论区交流!

暂无评论