尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

前端ax调度:异步请求队列、并发控制与取消重试完整方案

发布时间:2026/9/28 23:05:54

资讯中心
01
ARTICLE

前端ax调度:异步请求队列、并发控制与取消重试完整方案

前端ax调度:异步请求队列、并发控制与取消重试完整方案
做前端的朋友多少都遇到过这种糟心事页面一打开七八个接口同时发出去有的在转圈有的已经报错了返回顺序还对不上用户输入一个关键词搜索防抖已经做了结果慢的旧请求还是把快的新请求结果覆盖了批量上传文件一口气几十个请求直接把后端连接数打爆服务端直接返回 502。这些问题看着五花八门本质上是同一件事——异步请求没有调度。我习惯把这套东西叫做ax 调度。所谓 ax就是异步请求async的简称“x”在我们圈子里经常代表交换、交互所以 ax 调度说白了就是给页面里所有异步请求安排一个合理的执行秩序。今天不聊那些重型框架里的现成封装我直接用一整个项目的思路从零拆一个轻量、可复用的请求调度方案把队列、并发控制、优先级、取消、重试这些核心细节全部讲透。无论你是刚接触前端的初学者还是在传统项目中想优化现有请求逻辑的老手这套思路都可以直接抄过去用。1. 先搞清楚 ax 调度到底在解决什么问题1.1 从一次常见的接口乱序讲起我接手过一个后台管理系统列表页需要同时请求五六个接口用户信息、角色权限、菜单列表、操作日志、待办数量。最早的写法很直白const [user, roles, menus, logs, todos] await Promise.all([ fetch(/api/user), fetch(/api/roles), fetch(/api/menus), fetch(/api/logs), fetch(/api/todos), ]);看起来没问题对不对后来线上反馈页面有时候要卡四五秒打开控制台一看所有请求几乎同一时间发出去最慢的那个接口耗时 3.8 秒期间页面一直白屏。更麻烦的是用户权限校验的接口如果慢了一点菜单接口已经把权限外的内容渲染出来了虽然权限系统会兜底但闪烁的菜单栏真的很掉价。这就是没有调度的代价。Promise.all确实能做到“并行”但它没有任何秩序感不限制并发数量、不区分优先级、不支持单个任务超时和重试、更没法取消一个已经发出去的请求。当页面规模一大接口一多这套裸奔的写法就会成为线上事故的温床。ax 调度要解决的就三件事控制同时发多少个请求决定先发谁后发谁以及请求发出去之后怎么处理失败、超时和取消。1.2 调度的四个设计目标我在写这个调度器之前先给自己列了一份清单这四个目标后来也成为整个项目的核心支柱目标要解决的问题直观类比并发控制避免瞬间打爆服务器和浏览器连接池收费站只开三个窗口车到了就在入口排队队列管理任务多了要排队不能乱插银行叫号先来先办优先级调度重要请求优先发次要请求靠后急诊病人优先就诊任务取消与重试过期请求要作废失败请求要有补救外卖超时了可以催单也可以取消重下这四个目标之间互相影响。比如并发控制做不好队列再长也没意义因为前面的请求会一直占着位置优先级做不好一个不重要的导出接口可能把用户点击的关键接口挤到后面取消机制做不好搜索这种高频场景必然出现竞态。搞设计之前先别急着写代码把目标想清楚了后面每一步都有据可依。2. 核心概念拆解队列、并发窗口这一次听明白2.1 一次请求从进来到离开的完整生命周期调度器里的每个请求我把它分成三个状态pending排队中任务已提交但还没轮到它执行。running执行中任务已进入并发窗口真正的网络请求已经发出。settled已结束请求成功、失败、超时或被取消任务从调度器里移出。画个简单的流程add() 入队→排队→进入并发窗口→发起请求→成功/失败/超时/取消→调度器回收并发槽位→从队列里拉下一个任务。这个状态机是整个调度器的心脏。你可能会想这不就是一个队列吗对本质就是一个队列但真正难的是当任务结束之后如何平稳地把资源让给下一个任务。很多初学者写调度器翻车就是忘记了“回收并发槽位”这一步导致前几个任务跑完之后队列再也不动了。2.2 并发控制背后的数学直觉并发控制最经典的思路是“信号量”但前端场景里我更喜欢把它理解成一个并发窗口。什么意思假设窗口的最大并发数是 3意思就是同一时刻只能有 3 个请求在飞。不用敲太多数学公式你可以想象一个停车场只有 3 个车位。车子想要进场必须有一个空车位没有车位就在门口排队。每辆车离场请求结束门卫才放下一辆车进场。这个“放行”的动作就是调度器里的_schedule()方法。从工程角度并发数怎么定我给自己总结过一个经验公式并发数 目标服务端每秒可承受请求数QPS× 单请求平均耗时秒打个比方后端接口能扛 200 QPS一个请求通常要花 0.5 秒那理想并发就是 200 × 0.5 100。但实际前端场景里还要留余量因为服务端不只是为你一个前端服务。经验上中小型后台上限设 58移动端网络不稳的上限设 24纯前端静态资源可以放宽到 10 左右。2.3 优先级、超时、重试这三个参数别一起乱调优先级、超时、重试这三兄弟看着简单实际组合起来非常讲究。优先级我用一个整数表示数值大的先执行。业务里最好只分三种高用户主动触发、普通页面初始化加载、低预加载/统计。因为人类对超过三档的优先级就失去直觉了分五档反而会给后续排障带来麻烦。超时不是所有接口都适合一个超时值。我见过有些项目全局统一 10 秒超时结果数据导出接口本身就要跑 20 秒每次必超时。超时设置需要按接口能力区分详情查询类接口给 5~8 秒文件上传/导出类接口给 30 秒以上上报类接口甚至可以不给超时。重试重试是一把双刃剑。网络抖动导致的重试很有价值服务端返回 500 时重试基本没用。我的原则是只有幂等请求GET、PUT才允许自动重试POST 这类非幂等请求坚决不自动重试否则可能会出现重复下单、重复扣费这种事故。这三个参数单看都好理解但把它们一起放进调度器必须要有清晰的优先级判断顺序先判断有没有超时再判断决议重不重试同时要能响应取消信号。顺序一旦乱了超时的任务可能又被重试一次然后用户却已经取消了它。3. 手写一个轻量 ax 调度器240 行代码搞定3.1 类设计调度器只有两个职责整个调度器我拆成了两部分任务Task和调度器AxScheduler。Task 只描述一件事这个任务是谁、有多重要、要在多久内完成、最多允许失败几次、真正执行的函数是什么。它不关心调度逻辑。AxScheduler 负责三件事维护待执行队列需要支持按优先级排序。维护“正在执行的集合”控制并发窗口。在任意一个任务结束时把并发槽位让给队列里的下一个任务。为什么要把“正在执行的集合”单独拎出来因为队列和正在执行的任务完全是两回事。我见过很多半吊子实现把正在执行的任务也放在队列里然后通过标记状态来区分一旦并发数变大状态判断的逻辑就乱成一锅粥。分开维护逻辑清爽排障也容易。3.2 完整代码实现可以直接复制下面是我在实际项目里打磨过的一个简化版本去掉了业务依赖你拿到手改改就能用。class AxTask { constructor(options) { this.id options.id || task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}; this.priority options.priority ?? 0; this.timeout options.timeout ?? 8000; this.retry options.retry ?? 0; this.handler options.handler; // 接收 signal 并返回 Promise 的函数 this.status pending; this.resolve null; this.reject null; } } class AxScheduler { constructor({ maxConcurrency 3, defaultTimeout 8000, defaultRetry 0 } {}) { this.maxConcurrency maxConcurrency; this.defaultTimeout defaultTimeout; this.defaultRetry defaultRetry; this.queue []; this.pool new Map(); // 执行中的任务key 是任务 id this.abortMap new Map(); // 任务 id - AbortController } add(options) { return new Promise((resolve, reject) { const task new AxTask({ ...options, timeout: options.timeout ?? this.defaultTimeout, retry: options.retry ?? this.defaultRetry, }); task.resolve resolve; task.reject reject; this.queue.push(task); this._schedule(); }); } // 核心扩容并发窗口 _schedule() { this.queue.sort((a, b) b.priority - a.priority); while (this.pool.size this.maxConcurrency this.queue.length 0) { const task this.queue.shift(); task.status running; this.pool.set(task.id, task); this._execute(task); } } async _execute(task) { let attempts 0; let lastError; while (attempts task.retry) { attempts; const controller new AbortController(); this.abortMap.set(task.id, controller); try { const result await this._runWithTimeout(task, controller); this._finish(task); task.resolve(result); return; } catch (err) { this.abortMap.delete(task.id); // 取消导致的异常不再重试直接对外抛错 if (err err.name CancelError) { this._finish(task); task.reject(err); return; } lastError err; if (attempts task.retry) { // 简单退避500ms * 重试次数 await new Promise((r) setTimeout(r, 500 * attempts)); continue; } } } this._finish(task); task.reject(lastError); } _runWithTimeout(task, controller) { const timer setTimeout(() { controller.abort(); }, task.timeout); return Promise.race([ Promise.resolve().then(() task.handler(controller.signal)), new Promise((_, reject) { controller.signal.addEventListener( abort, () { const error new Error(任务 ${task.id} 已取消或超时); error.name CancelError; reject(error); }, { once: true }, ); }), ]).finally(() { clearTimeout(timer); }); } // 任务结束回收并发窗口 _finish(task) { this.pool.delete(task.id); this.abortMap.delete(task.id); this._schedule(); } cancel(taskId) { if (this.abortMap.has(taskId)) { this.abortMap.get(taskId).abort(); } } cancelAll() { for (const controller of this.abortMap.values()) { controller.abort(); } } get pendingCount() { return this.queue.length; } get runningCount() { return this.pool.size; } } module.exports { AxScheduler };代码不长几个关键点我解释一下_schedule()是唯一一个“放行”入口。任何任务入队、任务结束都会触发它。它做的事情很简单先按优先级排序队列然后把并发窗口塞满。这个设计最大的好处是你不需要在任务结束时手动去找下一个任务只需要调用_schedule()它会自动判断。把AbortController和超时绑定在一起。这里用Promise.race同时跑真实请求和超时计时器一旦超时就直接 abort 真实请求的 signal两件事通过同一个信号源联动逻辑闭环。这也是原生 fetch 的标准用法fetch(/api/xxx, { signal });取消和超时统一用一种异常类型。我特意把它们都命名为CancelError这样调度器内部就知道这个任务是被外部因素终止的不应该重试、不应该记录成业务失败。3.3 一次真实调度过程推演光看代码没感觉我模拟一个场景并发窗口设为 3依次提交 5 个任务。时刻 t0任务 A、B、C 入队_schedule()发现池子里是空的直接从队列里取出 A、B、C 三个任务进入执行状态。t0t1任务 D 提交入队。此时池子 3/3 满了D 只能排队等待。同时任务 A 率先结束_finish(A)被调用池子变成 2/3_schedule()触发从队列里取出 D池子回到 3/3。t0t2任务 C 超时抛出CancelError调度器重试次数假设 retry1用尽最终 reject。_finish(C)触发池子变成 2/3队列已空调度结束。整个过程非常顺滑没有任何一处需要手动判断“下一个该谁”。这就是把调度器做成“状态收拢 统一放行”的好处。3.4 核心参数到底怎么设我整理了一个参数选择表方便你直接参考参数推荐值原因maxConcurrency中小后台 58移动端 24兼顾并发效率与服务端承受能力defaultTimeout查询类 58 秒上传类 30 秒以上越重的任务越要给足时间窗口defaultRetry幂等请求 12非幂等请求 0防止重复提交产生业务副作用priority高 100 / 普通 0 / 低 -100三档足够五档会让人失去直觉4. 三种真实业务场景就这么往里套4.1 搜索联想词防抖、串行、取消过期请求搜索联想是 ax 调度最经典的场景。用户在输入框里连续输入每隔几百毫秒发一个请求如果不对请求做调度必然会出现“旧请求返回比新请求晚把新结果盖掉”的竞态。以前我用防抖 时间戳对比来做不好用时间戳只能挡最后一瞬间请求多了还是会漏。现在我的做法是三层输入防抖 250ms减少无效请求的数量。把搜索任务串行化maxConcurrency设为 1保证同一时刻只有一个搜索请求在飞。每次发起新搜索时取消上一个尚未完成的搜索任务从根上消灭旧请求。配合调度器的cancel(taskId)代码大概是这样的let currentSearchTask null; function onInput(keyword) { clearTimeout(timer); timer setTimeout(() { if (currentSearchTask) scheduler.cancel(currentSearchTask); currentSearchTask scheduler.add({ priority: 100, handler: () fetch(/api/search?q${keyword}).then((res) res.json()), }); }, 250); }这样旧请求的响应哪怕晚到一秒钟也永远不会出现在界面上因为它的任务已经被取消了。这个方案我在实际项目中用了一年没有再出现过搜索结果跳动的问题。4.2 批量上传/下载任务并发限制 重试 进度批量上传文件如果一次性把几十个文件全部发出去不仅浏览器连接池扛不住后端更是分分钟 502。这时候用调度器就非常简单maxConcurrency设为 2 或者 3然后一次add()一个文件。const fileList [...]; // 用户选择的文件 const scheduler new AxScheduler({ maxConcurrency: 2, defaultRetry: 2 }); fileList.forEach((file) { scheduler.add({ priority: 0, handler: () uploadFile(file), // 返回 Promise }).then(() { console.log(${file.name} 上传完成); }).catch((err) { console.error(${file.name} 上传失败, err); }); });这里有个经验重试次数不要写死在上传函数内部而是交给调度器。因为上传这种任务往往是被网络抖动打死的重试一次就好但如果服务端是 4xx 错误比如文件格式不对重试一百次都是白搭。所以我建议重试策略里的请求必须幂等因为重复上传同一个文件至少对文件服务来说是无害的。顺带提醒一句上传任务如果用户中途取消了整个批次记得调用scheduler.cancelAll()别让那些任务在后台默默跑到超时白白浪费时间。4.3 首屏接口并行优化慢请求兜底与优先级调整首屏加载往往需要同时请求几个接口这时候并不适合全部并行。我的做法是核心渲染接口页面主数据优先级设 100maxConcurrency至少给到 3。辅助接口站点配置、用户偏好等优先级设 0 或 -100不能抢占核心接口的资源。主数据接口超时后不要一直白屏等待给个兜底重新请求或者直接展示错误页避免用户干等。举个例子如果首屏要请求 6 个接口服务器扛不住 6 个并发但可以扛 3 个。把调度器的maxConcurrency设为 3核心接口优先发剩下的排队既保住了性能又稳住了用户体验。5. 常见问题与排查实录速查表5.1 队列卡死任务提交后永远不执行现象页面加载了接口迟迟不发请求控制台没有任何报错。排查先看pendingCount和runningCount。如果runningCount一直等于maxConcurrency但池子里没有任务说明有任务占着坑但永远不结束。最常见的原因有两个任务返回的 Promise 没有调用 resolve/reject或者请求函数里因为异常被吞掉了既没 catch 也没往外抛。这个坑我踩过封装的请求库在某个错误分支里return undefined导致返回的 Promise 永远 pending。给调度器接一个“任务卡死心跳检测”就很有价值最简单的做法是每个任务加一个超时兜底反正我们本来就做了超时如果你发现超时也没用那就要检查是不是 Promise 链断裂了。5.2 重试的时候把错误吞了现象服务端 500 了任务重试一次成功但用户看到的是第一次失败的报错提示因为第一次 catch 已经弹了 toast。原因错误提示写在了add()里而不是重试结束后的最终 catch 里。调度器重试的是内部逻辑但外层 Promise 只在所有重试都结束后才会进入 reject。所以错误提示一定要统一放在最外层的.catch()不要在任务函数里又弹 toast 又抛异常双份反馈很让人困惑。5.3 竞态问题旧响应覆盖新响应现象页面上有两个请求一个是查 A 数据的一个是查 B 数据的B 先返回了A 后返回A 的结果把 B 的给覆盖了。或者搜索场景里旧结果的延迟返回盖掉新结果。排查没有在发送新请求前取消旧任务也没有用请求 ID 做对比。用调度器解决的办法就是前面说的新任务入队前调用cancel()把旧任务取消掉同时检查响应里携带的任务 ID务必发回来的数据和当前展示的条件一致才更新页面。5.4 取消监听器泄漏现象页面不断开关抽屉每次关闭都触发 cancel页面越来越卡内存占用逐步上升。原因_runWithTimeout里注册的abort监听器在任务成功时必须移除。我代码里用{ once: true }就是为了让监听器在触发后自动摘掉但如果你自己封装任务记得在finally里也要移除监听否则每次取消就多一个监听函数挂在 signal 上慢慢把内存吃满。5.5 并发为 1 时的隐式死锁现象maxConcurrency设为 1任务 A 执行过程中又调用了scheduler.add()添加任务 B但任务 A 没有结束B 永远排不进去而任务 A 又可能在等待 B 的结果返回于是双双卡死。原因这是所有调度器都会遇到的“递归依赖”问题。解决办法有三种禁止在执行中的任务内部新增任务简单粗暴写个断言检测一下。新增任务时如果检测到调用栈里已有任务在跑直接绕过队列、立即执行风险较高不推荐。把任务拆成两段先执行 A 的前半段再在回调里执行 B最后在回调里把 A 的后半段加回调度器。大多数业务场景用第 1 种就够了至少能在开发阶段快速暴露问题。排查问题我整理成一张速查表直接按图索骥现象排查方向解法入队后不执行执行中任务是否永远 pending给任务加超时兜底检查 Promise 是否白返回重试后只看到错误提示错误提示写在任务内部移到最外层.catch()旧响应覆盖新响应没有取消旧任务 /* 没有用任务 ID 校验入队前 cancel回复时校验 ID内存缓慢上涨abort 监听器未移除使用{ once: true }并在 finally 里清理并发为 1 时卡死线程内加入新任务导致循环等待禁止递归依赖或拆分任务流程6. 我的一点实操心得6.1 调度粒度别设计太细一个调度器别想着把所有东西都包含进去比如把“接口缓存”“请求合并”“失败降级”全塞进去。一开始我犯过这个错想做一个全能调度器结果代码越来越肥bug 越来越多。后来我把能力拆开调度器只负责任务的排队、执行、取消、重试缓存、降级是另外的模块通过任务函数自己组合进去。比如某个接口想加缓存就在 handler 里先查缓存查不到再 fetch。这样拆的好处是调度器可以私有化部署在任何项目里其他模块换掉也不影响请求调度的稳定性。6.2 日志先行一切好排查调度器这种基础设施一定要从一开始就打日志。每个任务从入队到结束的全过程都记录一条结构化日志console.log([ax-scheduler], { event: task_executed, taskId: task.id, status: task.status, runningCount: this.runningCount, pendingCount: this.queue.length, costMs: Date.now() - task.createdAt, });有了这些日志线上出问题时你一眼就能看出是并发窗口太小导致排队过久还是某个任务超时导致链路断掉。磨刀不误砍柴工这个建议一定要听。6.3 往链路追踪方向扩展调度器本身就是一个天然的“请求时序记录器”。我给调度器加过一个轻量监控当一个任务从入队到结束耗时超过 3 秒就把日志上报到监控平台并且把该任务的完整请求链从哪里触发、依赖哪些接口记录下来。后来定位性能问题时这个监控帮了大忙有一个页面慢不是接口慢而是队列里排在它前面的 8 个低优先级预加载任务把并发窗口挤满了。我调整了优先级之后页面秒开。没有调度器的日志这种问题基本只能猜。现在我自己的每个项目几乎都保留了这套 ax 调度的骨架不同的只有那些挂在 handler 外面的具体业务。分享这些是希望你能避开我踩过的坑。最后提醒一句调度器的价值不体现在代码量上而是体现在它能不能在你毫不知情的情况下替你把所有请求的秩序维持好。就像红绿灯它不说话但有了它路口就不乱了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。