拓冰建站拓冰建站
首页 / 资讯中心 / 正文

ax调度实战:从浏览器并发约束到前端请求队列设计与性能优化

我最近在一个数据看板项目里被自己的代码逼到了墙角页面一打开就要同时请求二十多个图表接口浏览器直接卡成PPT接口超时、下拉列表加载不出来测试同学在群里连着艾特我三次。排查到最后问题根本不是某一个接口慢而是请求并发失控。那段日子让我认认真真把“ax调度”掰开揉碎研究了一遍也踩了不少坑。ax其实就是 AJAX 请求的日常简称而“ax调度”这四个字可以理解成所有和请求编排、排队、限流、优先级、取消、重试相关的处理逻辑。它的本质不是某个框架而是一种思路把零散的请求从“想发就发”改成“按规矩发”。这篇文章我会从浏览器并发约束这种底层原因开始讲然后给出一个能直接抄作业的最小调度器实现再拆解它如何落到数据看板、批量导出、消息推送这些真实场景里最后把我在实战中遇到的几个迷惑行为整理成排查记录。不管你是刚接手复杂前端项目的新人还是被接口风暴反复折磨过的老手这篇应该都能帮上点忙。1. ax调度的本质从浏览器的“并发天花板”说起1.1 先把概念对齐调度到底在调什么很多人一听到“调度”这个词就觉得是给自己写代码增加复杂度。我没有一开始就搞调度器最初只是给每个接口加了 try/catch在回调里用 Promise.all 一把梭。结果就是页面初始化时一旦某个慢接口阻塞后面所有数据都跟着堵。真正上手做调度之后我才意识到调度的对象不是在写代码时“调”而是请求真正发出前的决策过程这个请求现在能不能发、该不该排到前面、有没有可能合并、如果失败了怎么退。调度器说白了就是一个中间层夹在业务代码和网络请求之间。业务代码只管说“我想请求某个接口”调度器去决定“什么时候发出、用多大利好去发”。这个决策过程里至少包含几件事一份排队列表、一个同时运行的窗口、一套优先级规则、一组失败后的重试策略。如果把网络请求比作一个繁忙的路口调度器就是红绿灯和交警没有它所有车都挤在路口谁也走不动。需要特别说明的是ax调度并不是把请求变慢恰恰相反它是把有限的网络资源拆给更值得的任务。常见场景里用户主动点击产生的请求、页面首屏的数据请求应该优先于那些低频的轮询和埋点上报。没有调度逻辑时代码只能按顺序一个一个写或者不负责任地全发出去有了调度逻辑之后请求之间的先后顺序、轻重缓急就变成了显式规则一眼能看出来维护成本也降下来了。1.2 为什么非做不可浏览器和服务器都扛不住“洪峰”第一层约束来自浏览器本身。HTTP/1.1 协议对单个域名的并发连接数有硬限制主流浏览器一般是六个左右。也就是说同一时间发给同一个服务端的请求超过六个剩下的就得排队。很多人觉得用了 HTTP/2 就能彻底放开实际上 HTTP/2 确实支持多路复用但在某些场景下连接数的逻辑依然存在而且服务器端还要处理连接和流的调度并不是无上限的。你把二十个请求同时打出去浏览器层面先给你排一队这种排队是隐式的你根本不知道谁先谁后。第二层约束来自服务端。一次大促活动秒杀接口可能有专门的限流但普通项目的后端一般不会为每个页面做精细化配置它默认前端不会一次性发太多请求。二十个请求同时到达网关数据库连接池、线程池都要被扎堆占用个别慢查询一旦拖住资源后面的请求全部变慢表现到前端就是整体接口耗时暴涨。第三层约束其实是用户感受。网络请求在移动端更容易出问题弱网环境下并发拉满超时重试再叠加整个页面性能雪崩。我做性能优化时发现一个规律大量前端性能问题不是单接口耗时引起的而是请求并发策略导致的总耗时不可控。单接口三百毫秒看起来很快二十个并行接口交织在一起加上浏览器的排队时间和服务端的处理竞争用户看到的完整加载时间可能是十到二十秒。这三层约束叠在一起诉求就变得很明确了请求不能裸奔必须有一个可配置、可观测、可降级的调度层。这个调度层要能控制同一时刻的并发上限要能把重要请求插到前面要能在失败时做有限次数的重试还要能在页面销毁时把没用的请求取消。2. 调度器设计队列、优先级、并发窗口三大核心2.1 任务模型先定义清楚一个“ax任务”是什么设计调度器我习惯先定义数据结构因为后面所有逻辑都是围绕任务在转。一个任务至少要包含这些信息唯一标识、请求配置、优先级、超时时间、重试次数、当前状态、挂在任务上的回调。用 JavaScript 描述的话大概长这样// 一个调度任务的基本结构 const task { id: chart_001, request: { url: /api/chart/data, method: GET, params: { date: 2024-01-01 } }, priority: 10, // 数值越大越优先 timeout: 10000, // 整体超时时间 retries: 2, // 失败后重试次数 status: pending, // pending | running | done | canceled | failed createdAt: Date.now(), onSuccess: response { /* 业务处理 */ }, onError: error { /* 业务处理 */ } }为什么要把任务和请求分开因为同一个请求配置在不同场景下会有不同的调度行为。比如同一个用户信息接口页面初始化时可能是高优先级后台定时刷新时可能就是低优先级。把请求配置作为任务的一个字段调度器就能只关注任务本身不需要知道接口的业务含义。另外任务状态机一定要设计完整。我在初版实现里漏掉了“canceled”状态结果页面切换后前一个页面发起的请求回调依旧触发了 setStateReact 直接警告“对已卸载组件执行状态更新”。如果你喜欢用 class 实现调度器状态字段就用字符串常量收敛起来避免到处写魔法字符串。2.2 优先级策略业务价值怎么映射成一个数字优先级是调度器里最容易被过度设计也最容易被完全忽略的部分。一开始我把优先级做成了五档结果业务方不知道怎么填所有接口都选“最高”等于没有优先级。后来我简化成三档并且给每个接口标注来源用户主动触发、首屏核心数据、后台轮询和埋点。这里是个人经验值仅供参考来源类型优先级值典型接口用户主动触发100点击按钮查询、表单提交、翻页加载首屏核心数据60表格初始化、图表首帧数据后台轮询/埋点/预取0轮询状态、上报日志、离线预取优先级数值之间的间距大一点是有好处的因为后续如果要做“抢占打断”只有差值足够大降低优先级的任务才有实际意义。比如轮询优先级是 0用户点击查询优先级是 100调度器在队列里就能很明确地把新任务插到前面。不过优先级不是万能的。高优先级任务如果一直插入低优先级任务就会“饿死”永远得不到执行。我在调度器里加了一个保护机制队列里低优先级任务每等待 N 秒就把它的优先级提升一点保证极端情况下所有任务最终都能被执行。这个机制在排障阶段救了我挺多次。2.3 并发窗口与动态调节不是越大越快并发控制是调度器最核心、同时也是最讲究的一个参数。窗口太小请求串行执行页面加载慢得让人崩溃窗口太大并发压力全部打给浏览器和服务端和什么都不做没区别。我比较常用的默认值是 4 到 6具体取决于页面业务类型和接口平均耗时后面第四章会展开讲怎么测算。更进阶的做法是动态调节并发窗口。维护一个滑动窗口记录最近 N 次请求的成功率和平均耗时如果失败率升高或者耗时明显变长就把并发数降下来等网络恢复稳定再慢慢升回去。这个逻辑站在服务端的视角其实是“自我保护”因为服务端不会因为前端调低了并发而表扬你但你的页面会明显变稳。一个容易踩的坑是把并发窗口设置成和浏览器连接数上限一致比如六个。浏览器还有域名解析、预连接、静态资源请求等因素会占住连接六个并发请求对于数据接口来说往往已经是临界值。我建议计算并发数时往保守方向取宁可让一小部分请求队列等待也不要让浏览器和服务器同时飙红。3. 实操落地一个可直接参考的 ax 调度器实现3.1 最小可用版本队列加上并发窗口就能干活理论上讲了那么多核心代码其实可以很精简。下面这个版本我已经在项目里跑过没有引入任何第三方依赖直接基于 fetch 实现完整暴露了调度器的基本能力。class Scheduler { constructor(maxConcurrency 4) { this.queue []; this.runningCount 0; this.maxConcurrency maxConcurrency; } // 调度一个任务 schedule(task) { return new Promise((resolve, reject) { const wrappedTask { ...task, resolve, reject, status: pending }; this.queue.push(wrappedTask); // 按优先级排序数值大的在前 this.queue.sort((a, b) b.priority - a.priority); this.next(); }); } // 启动下一个任务 next() { if (this.runningCount this.maxConcurrency) return; if (this.queue.length 0) return; const task this.queue.shift(); this.runningCount 1; task.status running; this.runTask(task); } // 真正执行请求 async runTask(task) { const { url, options } task.request; try { const response await fetch(url, options); if (!response.ok) throw new Error(HTTP ${response.status}); const data await response.json(); task.status done; task.resolve(data); } catch (error) { task.status failed; task.reject(error); } finally { this.runningCount - 1; // 无论如何都要推进队列 this.next(); } } } // 使用方式 const scheduler new Scheduler(4); scheduler.schedule({ priority: 10, request: { url: /api/user, options: { method: GET } } }).then(data { console.log(用户数据, data); }).catch(error { console.error(请求失败, error); });这个版本已经能解决最痛的问题同一时刻最多只有四个请求在跑其他请求在队列里等位。它的缺点是调度器和业务耦合在一起没有重试、超时和取消。实际项目中我会在此基础上扩展几层而不是直接拿这个类去怼业务。队列排序我用了最简单的数组 sort任务量不大时没问题。如果页面一秒内会塞入几百个任务建议换成二叉堆时间复杂度从 O(n log n) 退化成 O(n) 的边界情况会很明显。3.2 接入业务层把 axios 和 fetch 的调用包装起来调度器不能直接让业务团队去用他们不想关心排队和优先级只想把请求发出去。我的做法是封装一个统一的 request 方法对内管理调度器对外保持和 axios 或 fetch 差不多的调用习惯。function createScheduledRequest() { const scheduler new Scheduler(4); return function scheduledRequest(config) { // config 里可以带 priority、timeout、retries 等调度参数 const { url, method GET, data, priority 0, retries 0, timeout 0 } config; return scheduler.schedule({ priority, retries, timeout, request: { url, options: { method, headers: { Content-Type: application/json }, body: data ? JSON.stringify(data) : undefined } } }); }; } const request createScheduledRequest(); // 业务里正常调用 request({ url: /api/table/data, priority: 60 }) .then(res renderTable(res)) .catch(err showError(err));这个包装的好处是业务代码几乎不用感知调度器的存在唯一变化是传参时多了几个可选字段比如 priority。团队里如果已经有 axios 的统一封装可以考虑把调度器嵌进去在 config 里加入一个scheduler: true开关请求自动走调度逻辑否则走默认逻辑。这样新旧代码可以共处一个项目不会出现一次性改造全局代码的阵痛。有一点提个醒fetch 和 axios 的取消机制不同。axios 是用 CancelToken 或 AbortControllerfetch 原生支持 AbortController。调度器如果要支持取消必须在请求配置里塞入 signal。3.3 重试、超时和取消调度器真正变复杂的三个地雷光有并发控制还不够只有把它和重试、超时、取消结合起来调度器才算真正可用。下面是我的扩展代码关键部分写了详细注释。class Scheduler { constructor(maxConcurrency 4) { this.queue []; this.runningCount 0; this.maxConcurrency maxConcurrency; } schedule(task) { return new Promise((resolve, reject) { this.queue.push({ ...task, resolve, reject, retryCount: 0 }); this.queue.sort((a, b) b.priority - a.priority); this.next(); }); } next() { if (this.runningCount this.maxConcurrency) return; if (this.queue.length 0) return; const task this.queue.shift(); this.runningCount 1; this.executeWithRetry(task).finally(() { this.runningCount - 1; this.next(); }); } async executeWithRetry(task) { const { url, options } task.request; const controller new AbortController(); const timeoutTimer task.timeout ? setTimeout(() controller.abort(), task.timeout) : null; try { const response await fetch(url, { ...options, signal: controller.signal }); if (!response.ok) throw new Error(HTTP ${response.status}); const data await response.json(); task.resolve(data); } catch (error) { // 超时和网络错误才重试HTTP 错误看状态码决定 const retryable error.name AbortError || error.name TypeError; if (retryable task.retryCount task.retries) { task.retryCount 1; // 指数退避第一次等 1s第二次等 2s第三次等 4s... const delay Math.pow(2, task.retryCount - 1) * 1000; await this.sleep(delay); return this.executeWithRetry(task); } task.reject(error); } finally { if (timeoutTimer) clearTimeout(timeoutTimer); } } sleep(ms) { return new Promise(resolve setTimeout(resolve, ms)); } }重试这块的坑在于“重复请求会不会产生脏数据”。比如下单接口不能盲目重试否则同一笔订单可能重复提交。我的判断标准是幂等接口可以重试非幂等接口只能在网络层明确失败时重试而且必须加最大次数限制。调度器本身不背这个锅业务方在传入retries时要自己斟酌。超时时间要区分“从队列开始等待算起”还是“从请求发出算起”。我在初版代码上吃过亏把排队时间算进了超时导致一个低优先级任务在队列里等了五秒发出请求半秒就超时了。正确的做法是超时只针对 fetch 发起之后的时间也就是纸面上AbortController计时器在executeWithRetry内部创建这样就剔除了排队时间。4. 真实场景地图三个典型场景怎么用调度器4.1 数据看板几十个图表接口怎么编排数据看板是我最初做 ax 调度的直接动机。看板页面通常由若干图表组件构成每个图表组件在挂载时各自请求自己的接口数量一多就成了并发风暴。我可以给出的配置方案是首屏所有图表接口优先级设为 60并发窗口设为 4每个接口超时设置成 8 秒失败重试 1 次。这里真正要处理的是“图表之间的启动顺序”和“用户切换 tab 时的取消”。比如某个图表依赖另一个图表的筛选条件如果这两个接口并发发出后一个接口可能拿到空参数。调度器虽然不直接解决参数依赖但它能把依赖图表的高优先级任务排在前面配合 Promise 串行就能天然解决这个问题。另外看板页面频繁切换日期时旧图表的请求和当前请求会互相打架。调度器配合 AbortController 可以实现在组件卸载时取消队列中的同类任务这个逻辑我在第五章会详细说。一个很隐蔽的问题是轮询。看板里往往有实时刷新需求比如每三十秒刷新一次某个核心指标。如果页面有五个轮询接口每个间隔三十秒那么它们很容易凑到一个时间点上形成周期性的小洪峰。调度器可以把轮询任务的优先级调低并且做“合并协作”在同一时刻的多个轮询任务只保留一个在测其他往后排。4.2 批量导出文件下载被打爆怎么破批量导出是典型的“请求量虽少但单次任务特别重”的场景。比如管理员勾选一千条订单点导出前端要生成一个包含筛选条件的导出任务创建之后还需要不断轮询任务状态。这个过程中最容易踩的坑是用户连续点击导出按钮三次三次任务全部发到服务端数据库直接被打爆。有调度器之后导出请求可以限制成一个“独占任务”并发窗口我能设定为 1并且给导出相关的请求加上“互斥标记”同一个导出类型同时只能有一个任务在运行。其他请求如果已经开始新的导出请求会被直接丢弃或者返回一个“已有任务在跑请稍候”的提示。导出类任务的进度展示也离不开调度器。任务创建、任务轮询、任务下载三个阶段前两阶段是高频请求最后一个阶段是大资源下载。下载阶段我会从调度器里拿出来单独走浏览器的原生下载逻辑因为几百 MB 的下载文件如果也走 fetch浏览器内存消耗会很高。这个取舍在调度逻辑里可以写成一个分支普通 JSON 请求走调度器下载文件直接 window.open。4.3 消息推送瞬时事件风暴怎么削峰消息系统是最需要削峰的场景。服务端通过 WebSocket 推送一批实时消息前端收到后往往要立即请求对应的详情接口比如会话列表里的五十条新消息每条都要拉取用户信息。五十个请求同时打出去服务端压力瞬间飙升。调度器在这里的用武之地是批次合并和限流。收到五十条消息后先把它们放进一个“待拉取详情”队列调度器每次只消费三个优先级按消息的展示位置决定当前会话里的消息优先级高边缘的消息优先级低。如果用户快速滑动列表已经没有在当前屏幕里展示的消息对应的任务可以直接取消省掉大量无效请求。另一个技巧是“消息聚合请求”。如果服务端提供批量查询接口比如/api/users?ids1,2,3调度器还能做一个自动合并层收集 50ms 内产生的相同类型请求把它们的参数合并成一次批量请求。这个思路需要服务端配合但收益非常明显我做完之后详情接口的调用量直接降了八成。5. 常见问题与调试实录5.1 请求堆积页面内存飞涨现象调度器上线后页面倒是没那么卡了但运行十分钟后内存占用肉眼可见地往上涨手机端表现尤其明显。排查我先看任务队列的长度发现轮询任务一直在往里塞因为每次轮询都会创建一个新任务而旧任务还没执行完就不断堆在队列里。真正的问题不在于调度器执行而在于“任务永远在生成”队列堆积速度超过消费速度。解决给调度器增加了一个“任务去重”和“队列上限”的逻辑。同 URL 同参数的任务如果已经在队列中直接返回已有任务的 Promise不创建新任务队列长度超过一百条时主动丢弃低优先级任务丢弃前通知业务方。这两个规则加完后内存曲线立刻平下来了。5.2 调度器导致接口超时误判现象接口明明只要几百毫秒但统计超时率时发现很多请求都超过了 10 秒一开始怀疑是网络问题后来才发现是调度器把排队时间算进去了。排查我最初在 schedule 方法里计算超时也就是从入队那一刻起计时。低优先级任务在队列里等待五秒钟真正发出请求半秒钟就完成了但这个任务已经被标记为超时前端提示“请求超时”服务端那边其实处理得很快。两边记录的时间对不上看起来就像系统故障。解决把超时计时器挪到请求真正发出的地方也就是 fetch 调用时创建刚才第三章的代码就是这么写的。另外我给调度器加了两个时间戳入队时间和开始时间。入队时间用于统计用户等待总时长开始时间用于统计网络耗时两者分开排查问题时才能看清到底是排队慢还是接口慢。5.3 并发窗口设多大才合适我一直觉得并发窗口是个经验值但项目里被业务方问过太多次我就把测算方法整理成了一个公式。你需要先统计线上接口的平均耗时 T 和服务端单实例能够承受的黄金并发数 N黄金并发数可以从压测报告拿没有的话就用服务端 CPU 核数的两到三倍做参考。前端的并发窗口建议取 N 除以服务端实例数再除以每页面的平均请求数最后和 6 做比较取比较小的结果。我的经验是纯数据接口为主的页面并发窗口在 4 到 6图片和文件上传下载比较多的页面建议 2 到 3因为大资源传输非常耗时并发拉满容易阻塞小请求。最终上线前先压测再调整别拍脑袋直接填一个数。打点监控能帮上忙后续如果接口平均耗时从 500ms 变成 900ms说明并发压力已经影响到服务端了就该把窗口调小。5.4 操作实录速查表问题可能原因处理方式请求大量超时排队时间被算进超时超时计时只对 fetch 开始之后有效首屏请求被低优先级任务挡住优先级差距不够或没有等待提升机制设置百级差值增加等待提升保护内存持续上涨队列堆积速度大于执行速度增加任务去重和队列上限逻辑页面销毁后仍触发 setState任务缺少取消状态在任务状态机里加 canceled组件卸载时主动取消下载文件阻塞普通接口大资源走了调度器下载类任务单独分支不占用调度并发定时轮询形成并发“心跳”多个轮询刚好在同一时刻人为错开场间隔或调度器内做轮询合并这些坑我基本都真实踩过尤其是超时误判那次前后花了两天时间才定位到问题。排查工具上我强烈建议给调度器加事件钩子比如每次任务开始、成功、失败都发出一个日志事件通过一个简单的埋点方案收集展示在浏览器控制台或者自己的监控面板里。有了这些事件流调度器再出问题时不用靠猜打开日志一眼就能看出某个任务是什么时候入队、什么时候开始、最后是成功还是失败。我自己最后真正用顺手的是给调度器加了一个“状态中心”面板能实时看到并发窗口、排队数量、各个优先级的任务量。上线初期我就盯着这个面板观察了一周根据数据把并发窗口从 6 调到 4把某个轮询接口的优先级调低了页面性能指标明显改善。调度器这东西单看代码觉得不复杂但真正让它为业务创造价值靠的是持续的观测和调整。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门