axios请求调度实战:从并发控制到优先级队列的工程化之路
一说ax前端同事基本都知道我想表达什么——axios。这个几乎人人都会配置的HTTP客户端库绝大多数项目的使用深度也就停留在封装get和post拦截器里放token。但我今天想认真聊的是另一个很少被系统讲透的话题ax调度也就是把请求层从发请求的工具升级成管请求的系统。之所以突然想写这个是因为去年大促我负责的活动页在流量高峰期白屏了。监控里接口耗时从800毫秒一路涨到4秒出头请求堆积、连接池耗尽、雪崩式超时整个链路被打到动弹不得。那一刻我意识到请求量小的时候怎么发都对请求量一旦上来没有秩序就是一锅粥。这篇文章我会把调度这件事拆开为什么要调度、调度器的核心怎么做、超时重试取消怎么配合、优先级怎么设计以及我在生产环境里踩过的坑。不管你用的是axios、fetch还是自己封装的请求库这套思路都能直接搬过去。1. 为什么请求层会堵死并发冲击下你没看到的三个故障面1.1 服务端连接池被打满的连锁反应很多人觉得请求发不出去是前端的问题其实压力最先体现在服务端。以Java服务为例Tomcat默认线程池在200左右每个请求占用一个线程线程池满了之后后续请求会进入等待队列队列再满就直接拒绝。前端同时发出几百个请求相当于一个早高峰地铁站所有闸机瞬间涌进几千人——不是每个人都能刷票进站后面的只能堵在门口。连接池打满带来的连锁反应很隐蔽。TCP层有keep-alive机制连接释放不干净会在服务端积累大量TIME_WAIT状态HTTP/1.1下浏览器对同一域名的并发连接数受限通常6个超出的请求会排队到了HTTP/2虽然单连接多路复用打破了6这个限制但服务端的处理压力反而更加集中——所有请求都挤在一条连接里任何一个慢接口都可能拖住整个连接上的其他响应。所以调度管的第一件事不是怎么发请求而是同一时间到底该发多少请求出去。没有这一层闸门再多的并发都是自我消耗。1.2 浏览器并发限制比你想的更死板我见过不少人以为浏览器对同一域名能同时发出几十个请求实则不然。HTTP/1.1下Chrome对同一主机的最大连接数是6Firefox是6Safari甚至更低。这意味着你一次性用axios.all发出10个请求其中4个必然在浏览器内部排队等前6个有响应了才开始走。问题在于这种排队是开发者在请求发起那一刻完全无法感知的。你以为是并行其实是串行你以为是请求出了问题其实是排队超时。尤其当这些请求分散在不同接口、每个接口响应时长还不同的时候整体耗时几乎不可控。HTTP/2解决了连接数量的限制却又引入了新的问题多路复用是一把双刃剑。一个慢请求比如10秒才返回的报表接口会阻塞同一连接上的后续小请求。所以即便是HTTP/2我依然建议在应用层做并发控制只不过粒度和策略要重新思考。调度不是给浏览器添乱而是自己掌握排队的规则。1.3 接口依赖链导致的内涝比单纯并发更恶心的是接口之间的依赖关系。一个页面先拉用户信息再根据用户类型拉对应的列表列表回来之后还要拉详情——这种链式依赖在复杂业务里极其常见。一旦上游某个接口变慢下游所有请求都会排队等待形成一种内涝不是水请求不够是水全堵在没有排水能力的支流里。这时候单纯限制并发数已经没有意义因为请求之间本来就串行。需要的是依赖感知的调度策略关键路径上的请求给最高优先级非关键路径比如埋点、推荐位给低优先级且允许延迟到空闲窗口发出。我记得有一次排查一个页面为什么首屏要6秒打开Performance面板发现真正链路是A接口1.2s→依赖A的B接口1.8s→依赖A和B的C接口2.5s——光依赖链就耗时5.5秒其余并发请求都没问题但用户只看得到页面空着。所以调度从来不是一个限流工具它管的是一整个请求生态的秩序。2. 拦截器与队列搭建请求调度的两大基础件2.1 拦截器为什么是天然抓手axios的拦截器设计得确实聪明。请求拦截器可以在请求发出之前统一处理加token、加签名、LOGGING、甚至把请求拦截下来放进调度队列。响应拦截器则统一处理错误码、刷新token、格式化数据。很多项目已经用上了这两层但很少人想过——拦截器完全可以承担调度的职能。我的做法是在请求拦截器里不直接return config而是先让调度器决定这个请求是立即发出还是排队等待。调度器内部维护一个Promise队列和并发窗口只有拿到放行令牌的请求才真正走下一步。import axios from axios; // 拦截器里接入调度器 service.interceptors.request.use(async (config) { // 拿到请求先过调度闸门 await scheduler.waitForSlot(config.__priority ?? 0); return config; });这个模式的优点是侵入性极小。已有的业务代码不用改任何调用方式调度逻辑全部收敛在请求层内部换服务端、换接口方案都不影响。如果你用的是fetch也可以封装一层request函数做同样的处理。2.2 调度器内部的状态设计调度器本身不复杂关键在于数据结构。我在实际项目里用的是三个核心状态running当前正在执行的请求数。queue等待执行的请求数组每一项包含请求配置、优先级、resolve/reject。limit最大并发数通常取6-10具体值依赖服务端能力。队列的入队和出队必须保证两个性质先进来的请求不能被后进来的低优先级请求插队高优先级的请求可以被放到更前面。用数组实现优先级队列插入时做二分查找复杂度O(log n)对前端请求级别完全够用。2.3 一个能跑的最小调度器实现光说原理容易飘贴一段我在项目里精简过的实现class RequestScheduler { constructor(limit 6) { this.limit limit; this.running 0; this.queue []; } waitForSlot(priority 0) { return new Promise((resolve, reject) { const task { priority, resolve }; const insertIndex this.queue.findIndex( (item) item.priority priority ); if (insertIndex -1) { this.queue.push(task); } else { this.queue.splice(insertIndex, 0, task); } this._drain(); }); } _drain() { while (this.running this.limit this.queue.length 0) { const next this.queue.shift(); this.running 1; Promise.resolve(next.resolve()) .then(() console.log(slot released)) .catch(() {}) .finally(() { this.running - 1; this._drain(); }); } } }这里waitForSlot返回一个Promise只有当调度器把当前请求放到闸门出口、决定给它释放执行权时这个Promise才会resolve。注意Promise.resolve(next.resolve())不是发请求而是把放行信号传到拦截器里真正发起HTTP请求还是在拦截器return config之后。生产环境我还会加几个增强支持动态调整limit高峰期调小空闲期调大、支持超时踢出队列、支持按域名分桶不同接口组分别限流。调度器最怕的是做成全局单例一锁到底那样一个慢接口会拖死整个页面的所有请求。3. 调度参数三件套超时、重试与取消的工程化处理3.1 超时值不能一刀切很多项目的统一超时时间就是10秒不管什么接口都是这个值。这其实是很大的浪费。首屏关键接口如果10秒不返回用户早就走了而批量导出接口又经常超过15秒直接超时误伤。我建议把接口按场景拆成几档超时核心渲染接口5秒、普通查询接口10秒、后台任务接口30秒甚至更长。调度的队列里也要区分对待——超时短的请求应该优先发出因为它在队列里多排一秒留给自己执行的时间就少一秒。这里有个容易忽略的点超时时间是从请求真正发出开始计算的还是从进入调度队列开始计算的我用的是后者即队列等待时间也算进超时。这样调度器的公平性就有了底不会出现某个请求在队列里排了8秒发出去后只有2秒执行时间的情况。const config { timeout: 5000, // 自定义字段调度器会读取它计算入队时间 __deadline: Date.now() 5000, };3.2 重试策略的指数退避重试不是失败了再发一次这么简单。没有退避策略的重试在高并发场景下就是雪崩的帮凶——大家都失败了大家都立刻重试服务端刚缓过来一口气又被拍回去。我的重试逻辑分两类网络错误和业务错误。网络错误连接超时、DNS失败、断网可以重试幂等接口可以重试非幂等接口比如下单绝对不自动重试。业务错误要看错误码像token过期应该走刷新token的流程而不是死磕重试。退避时间我用指数抖动重试延迟 Math.min(初始延迟 * 2^(重试次数 - 1), 最大延迟) 随机抖动(0~1000ms)初始延迟我取300ms最大延迟3秒重试上限3次。抖动很重要——它让一小撮请求提前试探服务端是否恢复而不是所有请求在同一瞬间发起猛攻。这个思路跟TCP的指数退避一个道理对付瞬时故障特别有效。3.3 取消请求的正确姿势axios的取消机制新版本推荐AbortController旧版CancelToken已经被官方标记为弃用。取消主要用在这几个场景页面卸载时取消未完成的请求用户在搜索框输入新关键词时取消上一个搜索请求图片切换时取消上一个资源的加载。调度器跟取消机制配合时有个坑必须注意取消请求不能直接清掉队列里的任务否则后续请求的计数就乱了。我在调度器里给每个任务加了独立的abort控制器队列入队时生成controller取消时调用controller.abort()同时把任务标记为cancelled_drain出队时遇到cancelled就跳过并递减计数。function createAbortableTask(config) { const controller new AbortController(); const task { config: { ...config, signal: controller.signal }, abort() { controller.abort(); task.cancelled true; }, }; return task; }3.4 队列里的孤儿请求问题取消机制还有一个副作用容易被忽略一个请求被取消后如果它还有依赖它的后续请求比如从列表页点进详情页列表请求被取消但详情页的其他并发请求已经发过去了就会出现孤儿请求——前端已经不再需要响应数据但请求仍然占用服务端资源。调度层面可以做依赖清理如果父请求被取消归属于它的子请求一并取消。这个我用一个简单的依赖ID来关联每个页面或模块一个ID取消时遍历队列把所有同一ID的任务都标记为cancelled。4. 让调度有主次优先级队列和业务场景映射4.1 三类典型请求优先级天然不同不是所有请求都值得被同等地对待。我把业务里的请求分成三类请求类型典型场景优先级队列策略致命请求登录态校验、首屏核心数据最高插队到队首立即执行交互请求点击按钮后的查询、分页加载正常按入队顺序执行后台请求埋点上报、预拉取、列表刷新最低只在空闲窗口执行这个分类不用特别精致但在调度器里的收益非常直接。首屏优化的时候我把埋点请求的优先级设为-1并且设了一个窗口保护逻辑只要致命请求还没结束后台请求一律憋着不发。首屏白屏时间直接降了四成。4.2 优先级队列的工程实现前文那个RequestScheduler已经用了简单的优先级插入但如果优先级档位很多每次都做数组查找也不够优雅。生产环境我推荐用分组队列为每个优先级建一个数组调度时从高优先级队列开始消费。class PriorityScheduler { constructor(limit 6) { this.limit limit; this.running 0; this.buckets new Map(); // key: priority, value: task[] } enqueue(task, priority 0) { if (!this.buckets.has(priority)) { this.buckets.set(priority, []); } this.buckets.get(priority).push(task); this._drain(); } _drain() { while (this.running this.limit) { const highest this._nextHighest(); if (!highest) return; const task highest.shift(); this.running 1; // 执行task... } } _nextHighest() { const keys [...this.buckets.keys()].sort((a, b) b - a); for (const key of keys) { if (this.buckets.get(key).length 0) { return this.buckets.get(key); } } return null; } }用数组桶还有一个好处同一优先级内的请求实现了严格的FIFO公平性不会出现某个请求被无限插队的情况。如果你只有三档优先级且每档数量不大直接硬编码三个数组会更直观不一定上Map。4.3 优先级并不是静态定死的有些请求的优先级会在运行过程中动态变化。比如用户快速滚动时正在可视区内的图片请求应该升为高优先级移出可视区的图片请求降为低优先级。这在虚拟列表里很常见。我实现的方案是调度任务支持updatePriority方法重设优先级后把任务从原队列摘下、放入新的优先级桶里。注意摘下时计数不要出错。这个动态调整要小心别跟取消机制打架——一个正在执行的请求是不能被调整优先级的只能等它结束一个被取消的任务调整优先级只会被标记已取消然后从队列里掉出去。4.4 状态管理联动别让请求层和UI层互相拖累调度器和前端状态管理Redux、Vuex、Pinia之间的关系工业化之后应该完全解耦。请求层只管何时发出请求、何时返回结果状态管理只管拿到结果之后如何更新UI。但我见过不少项目把dispatch放在调度队列里结果路由跳转之后一个排队中的请求还试图dispatch一个已卸载页面的action内存泄漏和报错一起出现。我的建议是调度的发信号和状态管理的更新信号分开响应数据回来之后先判断组件/页面是否还挂载再决定要不要交给UI层。这虽然不是调度器本身的问题但却是调度落地时一定会遇到的联动问题。5. 生产环境实测从加了调度反而更慢到稳定可预期5.1 我踩过的三个坑第一个坑是死锁。我最初设计的调度器limit6但某个页面的一个接口内部又连续发了8个子请求并行发出去公共依赖异步加载这8个子请求进入队列等待而它们的父请求已经在占用6个slot了父请求等子请求子请求被limit卡住——死锁所有请求全部超时。解决方式是支持嵌套调度子请求不进入父请求的同一个队列而是用独立的并发窗口或者干脆把父请求的limit单独提高。后来我把调度器设计成可按作用域分桶每个业务模块一个独立的调度实例互不干扰。第二个坑是误伤长任务。批量导出接口耗时30秒我把limit设到6之后导出请求和普通查询共用队列结果导出接口一直占着一个slot其他5个slot还被拖累。后来给长任务单独设了一个桶limit1不允许抢其他请求的资源。第三个坑是重复请求没有被调度器拦截。用户连续双击提交按钮同一个创建请求被发出两次产生了两条订单。这虽然不算调度器本身的问题但被我统一收口到调度层了同路径同参数的写请求1秒内只允许一个真正发出其余直接复用Promise。这个用set去重即可但去重后的请求取消逻辑要跟上——如果第一个请求被取消了复用的那个promise也要一起取消。5.2 排查请求堆积的完整思路如果你哪天发现页面请求异常缓慢先别急着改代码。我积累了一套排查顺序看Network面板区分是排队中还是请求已发出但没响应。前者是前端调度或浏览器并发限制问题后者是服务端问题。前端排队检查调度器的running和queue长度看limit是不是设太小看是否有高优先级请求一直在插队把普通请求饿死。服务端问题看连接数看接口的P99耗时看是否有接口偶发超时触发重试风暴。这里有一个很常见的伪装场景响应里某个接口耗时正常但它的上游依赖比如数据库慢查询、外部API调用超时导致这个接口自己也在等待。前端只能看到自己等得很久实际上问题在链路后端。5.3 调度参数调优的参考值调度参数没有标准答案我把自己实际调出来的经验值列一下供参考参数建议值说明全局并发limit6-8太高服务端扛不住太低页面又不够快首屏致命请求limit3-4优先保证关键数据快速返回后台请求limit2只做预加载和埋点不抢资源普通请求超时10s多数查询接口在这个范围内首屏请求超时5s超过直接降级或提示重试次数1-2幂等接口最多2次写接口不重试初始延迟300ms指数退避的基数调优最忌讳的是把limit一次调到12甚至20我实测过同一个接口组limit从6提到20之后页面吞吐量并没有线性提升服务端响应时间反而从600ms涨到1.2秒。限流不是用来压榨服务端的是用来保护整体体验的。5.4 可观测性调度器不能是黑盒调度器一旦上线你就要能回答三个问题当前有多少请求在等待为什么在等待等待超时后发生了什么我的做法是在调度器里埋了几个简单的计数器和事件监听class SchedulerMetrics { constructor() { this.waitingCount 0; this.runningCount 0; this.starvationCount 0; this.avgWaitTime 0; } record(task) { task.startedAt Date.now(); task.promise.finally(() { const wait Date.now() - task.startedAt; this.avgWaitTime this.avgWaitTime * 0.9 wait * 0.1; }); } }在DevTools的Performance面板里我习惯给每个调度任务打一个Performance Mark这样就能在时间轴里清晰看到哪个请求在排队、排了多久、谁插了它的队。排查问题的时候简直是透视眼。最后再分享一个小经验不要一开始就把调度器做得太重。我见过一些团队直接上一套配置中心、动态权重、流量预测的调度平台最后维护成本比业务代码还高。先做对最基础的——并发控制、优先级、取消、超时、重试这五件事足够覆盖80%的问题。等你的系统真的到了每天几亿请求的量级再考虑动态调参也不迟。调度器的本质是给失控的并发建立秩序秩序一旦建立起来很多以前跟着一起塌掉的东西自然就稳了。