新闻详情

新闻详情

首页 / 资讯中心 / 详情

前端请求调度实战:从防重复到并发控制,打造可控的请求闸门

发布时间:2026/9/28 17:10:11来源:尧图网络
前端请求调度实战:从防重复到并发控制,打造可控的请求闸门
去年重构公司前端基础设施的时候我顺手写了一个叫 ax 的轻量库。起因特别简单页面里到处都是重复请求、并发毛刺和莫名其妙的 504后端同学天天在群里问“前端能不能别把同一接口打十遍”。我一开始也以为是后端不够强壮后来把请求日志拉出来一看才发现在前端这一层请求其实非常“失控”——按钮连点、组件重复挂载、路由切换没取消、滚动加载没有限速全都在给后端添乱。ax 就是在这种情况下冒出来的。它的定位不是再做一个 HTTP 客户端而是聚焦在“请求调度”这件小事上底层仍然走 axios但所有请求都必须经过 ax 的调度器由调度器决定这个请求什么时候发、能不能发、发完失败了怎么办。最近大家聊得比较多的“ax 调度”本质上就是这套机制。说白了就是在业务代码和网络层之间加了一个调度闸口把无序的流量变成有序的车流。这篇文章我就把这套东西的来龙去脉、核心机制、可复现的代码和真实场景里的表现完整写一遍。适合被重复请求和并发问题困扰的前端、全栈以及 Node.js 同学参考你会看到一个请求库在“发出”之前到底还能做多少事。1. 为什么会有“ax”请求调度这个事到底在调什么很多团队对请求库的认知停留在“发请求、拿数据、报错提示”这三步前端代码里到处是axios.get(url)直接裸调。平时流量低的时候看不出问题一旦活动流量上来各种诡异现象就全冒出来了。1.1 一次请求从发起到落地藏着多少不可控一次再普通不过的请求从用户手指按下按钮到页面最终渲染中间经历了事件触发、参数组装、HTTP 传输、响应解析、状态更新、错误处理这一长串链路。每一步都可能出现“不听话”的情况事件可能在一秒内被触发十几次参数可能在响应返回前就已经变化路由切走了但请求还在后台跑多个请求返回后完全不知道先后顺序。这些问题的共性是业务侧只关心“我要数据”但没有一个人关心“这些请求在路上的秩序”。前端请求的所有行为最终都汇到了服务端服务端看到的就是一阵无节奏的乱流。换句话说后端经常被搞挂前端的责任往往比想象中大得多。1.2 我见过的三种请求事故第一种是典型的高频重复提交。用户手速快双击按钮同一个下单接口在几十毫秒内被调了两次最后生成两条订单。这种问题 DBA 和后台开发深恶痛绝但纯靠后端做幂等又很被动因为前端完全可以先拦一道。第二种是并发毛刺。页面进入时十个组件同时发各自的请求本来单请求耗时也就 80ms结果因为瞬间抢满浏览器连接池和服务端线程池所有请求一起变慢页面白屏时间直逼三秒。后端没有故意拖慢你是前端自己把通道堵死了。第三种是排队饿死。一个慢请求占住连接不放手后面来了一个真正紧急的请求比如用户点击“立即支付”结果它排在慢请求后边用户看到按钮一直在转圈。这种体验事故在只有浏览器连接数限制的移动端尤其明显。1.3 ax 的定位只做调度层不重新造 HTTP 客户端我调研过一些已有的封装库发现很多库的坏毛病是动不动就自带整套 HTTP 实现或者把拦截器做得极其复杂结果就是团队根本不敢升级。ax 的设计一开始就定了原则底层就是 axios调度器只干活不掺和协议细节。打一个比方调度器就像高速公路入口的收费站。车请求先排队进站有 ETC 的已经发过且还在有效期的请求可以直接走来了救护车高优先级任务可以插队闸口一次放行几辆最大并发数完全由调度器说了算前方堵死了还可以临时封路熔断。这个定位带来的好处是业务代码改造成本极低原来怎么写 axios现在只需要把调用函数换成ax.request()剩余的去重、排队、限速、重试、取消逻辑全部由调度器接管。2. ax 调度核心机制拆解这一节讲机制。调度不是玄学它的核心其实就是“任务来了之后我该把它放哪、让谁先走、同时放几个走”。ax 用到了三层模型、去重表、优先级队列、并发闸门和令牌桶这些基础组件。2.1 三层模型入口、调度、执行ax 的内部严格分为三层。入口层负责把业务调用标准化成任务项业务传什么参数我不管但到了 ax 这里必须生成一个包含url、method、params、priority、dedupeKey、timeout、retry等字段的 task 对象。调度层负责全局决策手里攥着队列、去重表、并发计数器和限速器。执行层最单纯只负责真正调 axios处理超时、取消和重试。为什么强行拆三层因为职责分离之后每一层都可以独立测试。调度层完全不关心 HTTP 细节给它塞一个假执行器就能跑单元测试执行层也不关心业务优先级不会因为业务逻辑的变更而跟着改。2.2 去重表的核心设计去重这件事很多团队以为是“时间戳比对一下就行”真做起来细节特别多。ax 的去重表是一个 Mapkey 是由method url 序列化后的参数生成的字符串value 是一个包含 Promise 和过期时间戳的记录。第一次来一个请求时去重表里没有记录于是放行并把 Promise 存进去。接下来的 5 秒内如果来了一个 key 完全相同的请求调度器不会发网络请求而是直接把已存的 Promise 返回给调用方。这样做有一个隐藏收益同一个 key 的多个调用方共同等待同一个请求结果后端只需要承受一次压力前端的多个业务模块却都能拿到同一个结果。需要注意的是去重记录的过期时间点到了以后必须立刻从 Map 里删掉否则内存会一直膨胀。这是性能和内存空间的平衡点后面实操部分我会专门写。2.3 优先级队列与防饿死队列里不只有普通任务还有高优先级任务。ax 给每个任务带一个 priority 字段普通是 0重要是 1紧急是 2。调度器每次从队列里取任务的时候永远先走优先级最高的那个。但这里有个陷阱如果紧急任务永无止境地进来普通任务可能永远轮不到执行这就叫饥饿。ax 的处理方式是对每个任务记录一个入队时间并配置一个最长等待时间参数比如普通任务等了 6 秒还没被执行就自动把它的优先级往上提升一级。这样既保证了重要请求能插队又不会让低优先级任务彻底饿死。我试过用普通数组加排序来实现优先级实测在任务量少的时候没问题一旦队列里堆积几百个任务每次都对整个数组排序的开销不小。后来改成二叉堆实现优先级队列插入和取最大优先级的时间复杂度都是 O(log n)明显更稳。2.4 并发闸门与令牌桶限速并发闸门解决的是“同时有多少请求在飞”的问题。ax 维护一个activeCount每次要发请求时先检查是否小于maxConcurrent满了就必须排队。这个参数直接对应服务端的连接池上限设置不合理闸门就成了摆设。令牌桶解决的是另一个维度的问题“每秒最多能发多少个请求”。有时候服务端明明能同时扛住 20 个连接但对 QPS 有硬性限制比如最多每秒 30 次。令牌桶会按固定速率往桶里放令牌请求来了先拿令牌拿不到就等或者丢弃。并发闸门和令牌桶并不冲突一个管存量一个管增量。举个例子我配置了maxConcurrent 6和rate 20/s意味着同一时刻最多只有 6 个请求在路上而每秒最多只放进去 20 个新请求。两个条件都满足才放行。这就像一个水管既要管住水管里同时能流过多少水也要管住水龙头每秒流出多少水。3. 从零实现一个可用的 ax 调度内核光讲机制容易飘这一节直接上可复现的代码。我摘了一个简化版本把核心调度逻辑拆出来你把下面这些代码拼在一起就是一个能用的最小 ax。3.1 数据结构先行任务项是整个调度器的血液。我在设计时把它定义成一个不可变对象这样调度器在处理过程中不会出现“某个字段被不小心改掉”的隐性 bug。// task 任务项 // id: 唯一标识 // url / method / params: 请求基本信息 // dedupeKey: 去重用的 key // priority: 0 普通, 1 重要, 2 紧急 // createdAt: 入队时间用于防饿死 // maxWait: 最长等待时间超过则自动提升优先级 // timeout: 请求超时 // retry: 失败重试次数我特别强调dedupeKey的生成方式它必须稳定。比如同一个接口参数对象{ a: 1, b: 2 }和{ b: 2, a: 1 }在逻辑上等价但如果直接JSON.stringify会生成两个不同的 key去重就失效了。ax 里我写了个 canonicalize 函数先把对象的 key 排序再做序列化保证语义相同的参数得到完全相同的 key。3.2 调度主循环调度器本身是个单例内部维护一个任务队列、一个去重表、一个并发计数器和一个定时器。enqueue 负责接收任务并触发调度dispatch 负责从队列里取出任务并执行。class Scheduler { constructor(maxConcurrent 6) { this.maxConcurrent maxConcurrent; this.activeCount 0; this.queue []; this.dedupeMap new Map(); } enqueue(task) { const hit this.dedupeMap.get(task.dedupeKey); if (hit Date.now() hit.expireAt) { // 去重命中直接复用已有的 Promise return hit.promise; } return new Promise((resolve, reject) { task.resolve resolve; task.reject reject; this.queue.push(task); this.dispatch(); }); } dispatch() { if (this.activeCount this.maxConcurrent) return; // 取出优先级最高的任务 const idx this.findHighestPriorityIndex(); if (idx -1) return; const task this.queue.splice(idx, 1)[0]; this.activeCount 1; this.execute(task) .then((data) { task.resolve(data); // 成功后写入去重表 this.setDedupe(task.dedupeKey, data); }) .catch((err) { task.reject(err); }) .finally(() { this.activeCount - 1; this.cleanDedupeIfExpired(); this.dispatch(); }); } }这段逻辑里最关键的是finally里的activeCount - 1和重调dispatch()。一旦一个任务执行完并发闸门让出一个坑位必须立刻从队列里补一个新任务进去否则会出现“明明队列里有任务却因为没人拉闸导致一直等待”的假死状态。这个顺序我在初版代码里写反过结果所有任务执行完第一个之后全部卡住排查了挺久。3.3 执行器与 axios 集成执行器是整个调度内核里最“薄”的一层但它承担了超时、取消、重试这些脏活累活。ax 在执行器里创建一个独立的 axios 实例避免污染全局默认配置。async function execute(task) { const source new AbortController(); task.signal source.signal; let lastError; for (let attempt 0; attempt task.retry; attempt) { try { const res await axios.request({ url: task.url, method: task.method, params: task.params, timeout: task.timeout, signal: source.signal, }); return res; } catch (err) { lastError err; if (err.name CanceledError || err.code ECONNABORTED) { throw err; } // 指数退避加一点随机抖动避免重试风暴 await sleep(attempt * 200 Math.random() * 100); } } throw lastError; }这里有个容易被忽略的点取消用的是AbortController而不是 axios 老版本的CancelToken。因为新版本 axios 对 CancelToken 已经标记废弃而且CancelToken的取消错误类型和超时错误不太好区分。用AbortController之后取消状态完全由调度器控制执行器只管透传。3.4 关键参数怎么定并发放多少、超时设多少、重试几次这三个参数是使用 ax 的时候问得最多的。先说并发数maxConcurrent不是拍脑袋定的要先看服务端连接池能承受多少并发再看业务接口的平均响应时长。比如后端单接口能扛住 50 并发但前端在极限情况下可能出现 100 个请求同时发起那 ax 的并发闸门就该设成 40 到 50 之间给服务端留出缓冲。再说超时超时时间最好是“接口 P99 响应时间乘以 1.5 再加一点网络抖动的余量”。我见过有人把超时统一设成 10 秒可接口 P99 本来只有 300ms真出问题的时候前端要傻等 10 秒用户早就走了。建议按 API 分组设置不同的超时而不是全站一个值。最后是重试次数。只对 GET 这类幂等请求开启重试POST、PUT 这些写操作要非常克制重试可能导致重复写入。重试次数一般 1 到 2 次足够配合指数退避加随机抖动避免失败瞬间所有请求同时重试造成二次雪崩。4. 真实场景里的请求调度复盘ax 写完以后我在公司内部推了三个高频场景作为试点搜索联想词、无限滚动列表、秒杀活动页。这三个场景分别对应输入高频、滚动高频、绝对并发峰值也是我认为前端请求调度最值得覆盖的三类典型问题。4.1 搜索联想词搜索框每次输入一个字符都会触发联想词请求用户打字一快一秒能触发十几次。第一个版本里我用防抖函数把触发放慢到 300ms 一次但防抖之后快速输入时依然会有好几次冗余请求比如用户最终输入了“axios”而联想接口实际上只需要“ax”和“axios”这两个词的结果。ax 在这里起了第二道闸门。每个联想词请求的dedupeKey直接由关键词生成同一个关键词在 5 秒内只发给后端一次。两道闸门配合下来请求量降了约 70%后端同学反馈接口压力明显小了一半以上。还有一个隐藏问题用户输入“ax”时发出去的请求响应慢结果用户已经改成输入“axios”先发的慢响应反而后返回把后发但先到的快响应结果覆盖了。ax 里我为每个任务加上一个自增序号 seq响应返回时只有 seq 最大的那个更新界面其余直接丢弃。这个机制不是调度器必须做的但它救了整个搜索体验。4.2 无限滚动列表无限滚动列表是另一个请求风暴重灾区。用户快速滑动时滚动事件一秒触发几十个“加载下一页”如果用防抖限频又会导致滚动停止前列表一直不加载新数据体验很怪。ax 的处理方式是把每一个分页请求都变成可取消任务。滚动触发的“加载第 N 页”请求优先级设成 1重要同时只允许 3 个并发避免滚一屏结果同时有 8 个请求在飞。每次触发新任务时调度器自动取消当前未完成的相同列表请求这样即使滑得飞快后端收到的也只是有效的几页请求。这个场景实测下来的收益不只是给后端减压前端列表的乱序问题也基本消失了。以前页码错乱通常是因为第 5 页的响应先于第 4 页返回导致列表里出现两条第 4 页的数据。现在同一个列表同时只有一个请求在飞天然杜绝了这种乱序。4.3 秒杀活动页秒杀场景是我最担心的也是 ax 表现最明显的一次。当时页面同时在线约 500 人所有人都在同一个时间点去点那个抢购按钮。如果不加任何限制500 个请求在一个时间窗口内同时打向上游后端必挂。ax 在秒杀页上配置了maxConcurrent 10和rate 30/s请求并发数超过 10 就直接进入等待队列而不是直接发出。真正挤进前 10 的请求正常提交剩下的在前端排队每秒钟最多放行 30 个请求。用户侧看到的表现是“按钮转圈等一下”而不是“直接报错”体验好了很多。同时我开了熔断策略如果连续 10 次请求的失败率超过 30%调度器自动暂停所有新请求 3 秒给后端一个喘息的机会。3 秒之后流量慢慢恢复而不是瞬间又把后端打趴。这个熔断器是一个独立状态机但它用的是执行器上报的错误数据和调度器本身互不干扰。5. 常见问题与排查技巧实录ax 跑了两个多月我也收到过不少问题反馈。这一节挑了几个有代表性的实录排查过程比结果更有价值因为很多坑是文档里不会写的。5.1 所有请求都卡住任务队列像死锁有同事反馈某个页面上线后第一个请求能发出后面的请求全部卡住按钮一直转圈。我看了一眼调度器的日志发现activeCount一直是 6但没有任何请求真正在执行。问题出在 Promise 的 resolve 回调被放到了任务对象上而任务对象又放进了队列里。当一个任务被别人复用去重命中时原任务引用没有得到释放导致finally没有按预期触发。这个问题的本质是“并发闸门被占用但执行任务已经结束”。排查方法是在 dispatch 里加一条日志每分钟输出一次activeCount / queueSize / 最近完成任务数量很快就能定位到是哪个环节没有释放闸门。5.2 去重没生效后端依然收到大量重复请求去重失效的第一大原因是参数序列化不稳定。上面说的 key 排序问题很多同事一开始没有用 canonicalize导致{ status: 1, page: 2 }和{ page: 2, status: 1 }被认为是两个请求。另一个原因是去重窗口设得太短。如果后端接口本身要 200ms 才返回去重窗口只设了 100ms那前一个请求还在路上后一个请求就会被当作新请求放行。我一般建议去重窗口至少是接口 P95 响应时间的两倍同时如果接口有轮询刷新需求窗口就不要设得太长否则用户会看不到最新数据。5.3 优先级没按预期插队有人配了 priority 之后发现高优先级请求还是被排在了后面。查了半天原来是队列用数组实现每次新插入任务时从头扫一遍找最高优先级但如果新任务插到数组尾部下一次扫描又从头开始就可能出现“高优先级任务被后来的低优先级任务顶到后面”的情况。排队最好的方式还是用二叉堆或者每次插入后对队列重新排序。ax 后来直接改用一个小顶堆实现每次取heap.peek()就能拿到当前最高优先级任务整个过程严格 O(1) 到 O(log n)。5.4 取消请求后内存一直涨另一个报告是页面反复切换路由后内存持续上涨。排查发现被取消的请求虽然停止了网络传输但它的 Promise 链没有被清理dedupeMap 里的记录也没有删除导致每次路由切换都残留下一个无法回收的任务对象。正确做法是在取消一个任务时立即把它在 dedupeMap 里的记录删掉并调用reject让 Promise 链结束。执行器里也要处理好CanceledError避免错误被重试逻辑捕获之后重新发出一个已经不该存在的请求。5.5 问题速查表症状可能原因排查方法解决方案所有请求卡住不执行activeCount 未被正确释放检查 finally 是否覆盖所有分支在 finally 中统一减计数并重调 dispatch去重未生效dedupeKey 序列化不稳定打印入队时的 key 做对比对参数做 canonicalize 排序优先级不生效数组排序/扫描时机不对打印队列顺序改用二叉堆实现内存持续上涨取消请求后未清理 dedupe检查 dedupeMap 大小取消时立即删除 key重试导致二次雪崩重试无退避观察失败瞬间并发数加指数退避与随机抖动请求响应乱序无 seq 控制后端时间戳对比使用自增序号丢弃陈旧响应6. 我后来给 ax 加的扩展与一点体会ax 上线之后我并没有把它当成一个只跑在浏览器里的工具。接着往服务端方向扩展了几块能力这里挑几个有意思的分享。6.1 与服务端任务队列互通浏览器端的并发限制更多是保护用户设备和上游但到了 Node 服务端内网服务之间互相调用时调度同样重要。ax 在 Node 端可以把任务投递到一个统一的任务队列中间件中由队列服务控制全局并发。这样即使前端有多台机器同时发起请求服务端也能用一个统一的闸门管住整体流量而不是每台机器各管各的。6.2 与组件生命周期绑定前端框架里最麻烦的一个场景是组件卸载后请求还在响应回来时操作了一个已经不存在的 DOM。ax 提供ax.register(ctx)接口把组件或页面上下文与调度器绑定上下文销毁时自动取消该上下文名下所有未完成任务。这样就不需要业务代码里每个请求都写一遍if (this.disposed) return模板代码少了一大堆。6.3 预算调控我还加了一个比较细的功能预算调控。每个业务方接入 ax 时可以先领一个配额比如每分钟最多发 500 个请求超过配额的请求直接排队或降级。这个设计是为了防止某个业务方在调试时不小心出现死循环请求把整个公司的 API 网关额度耗尽。预算用完以后调度器会打一条 warning 日志指出是哪个业务方在超限。最后再聊一点个人感受。写完 ax 这件事最大的收获其实不是代码本身而是我重新理解了“请求也是需要被管理”这件事。前端平时太容易把网络层当成一个黑盒觉得发出去就完事了但黑盒里的秩序不佳背锅的往往是下游。我建议第一次接入调度器的团队前两周先不开启任何限制只开日志模式每天看一眼队列长度、去重命中率、重试次数这三个指标。等数据积累起来再按真实压力设置参数会比直接拍脑袋靠谱得多。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

NVIDIA AI Agent零拷贝迁移:ROS 2与CUDA显存共享实战 2026/9/28 17:56:25

NVIDIA AI Agent零拷贝迁移:ROS 2与CUDA显存共享实战

1. 从一次真实的迁移需求说起去年底我在做一个边缘侧的机器人视觉项目,硬件平台是搭载RTX 4060 Laptop GPU的工控机,软件栈是Ubuntu 22.04 ROS 2 Humble。项目里有一个跑在独立进程里的AI Agent节点,负责接收深度相机点云、做目标检测、再把…

阅读更多 →
Visual Studio 2022 接入 OpenAI 兼容 AI 编程:Inferpal 与 Ace Data Cloud 实战 2026/9/28 17:56:25

Visual Studio 2022 接入 OpenAI 兼容 AI 编程:Inferpal 与 Ace Data Cloud 实战

1. 为什么要在 Visual Studio 里折腾 AI 编程接入Visual Studio 2022 这个老伙计,做 C、C#、.NET 开发的人基本都绕不开。但这两年 AI 编程助手铺天盖地,Cursor、Windsurf、VS Code Copilot、Trae 一个比一个热闹,反倒是 Visual Studio 这边的…

阅读更多 →
ADS中用DAC控件与MDF文件实现双频阻抗匹配 2026/9/28 17:56:25

ADS中用DAC控件与MDF文件实现双频阻抗匹配

1. 项目概述:为什么双频匹配在射频前端设计中绕不开DAC控件与MDF文件ADS(Advanced Design System)是射频微波工程师日常工作中几乎每天都要打开的仿真平台,尤其在功放、滤波器、天线馈电网络等高频电路设计中,它不是“…

阅读更多 →
STM32F4+OV2640+DCMI+DMA摄像头采集实战与避坑指南 2026/9/28 17:56:25

STM32F4+OV2640+DCMI+DMA摄像头采集实战与避坑指南

做嵌入式这几年,STM32F4 OV2640 DCMI DMA这套组合我前前后后折腾过好几轮,从最早对着寄存器手册硬啃,到后来用 CubeMX 一把梭,踩过的坑能堆满一抽屉。说句实在话,OV2640 这颗传感器虽然老,但它的灵活性和…

阅读更多 →
金融系统开发为何必须明确业务动作与技术载体 2026/9/28 17:56:12

金融系统开发为何必须明确业务动作与技术载体

我无法根据当前输入生成符合要求的博文。原因如下:项目标题 "financial-services" 是一个宽泛的行业领域术语,本身不具备具体项目特征(如无技术实现、无场景约束、无功能指向);项目正文为空,未提…

阅读更多 →
内网离线部署MonkeyOCRv2:Docker镜像构建与vLLM GPU调优实战 2026/9/28 17:56:12

内网离线部署MonkeyOCRv2:Docker镜像构建与vLLM GPU调优实战

1. 为什么要在内网离线环境折腾 MonkeyOCRv2把 MonkeyOCRv2 部署到内网离线环境,这件事听起来像是"把大象装进冰箱",但真正动手之后你会发现,难点从来不是"装",而是"装完之后它能不能跑起来、跑得稳不稳…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉