ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端请求调度实战:信号量队列、竞态治理与axios编排

前端请求调度实战:信号量队列、竞态治理与axios编排 做前端这么多年我越来越觉得真正难的不是把请求发出去而是让一堆请求服从管理。先交代一个语境我们组在代码里习惯把 axios 统一简写成ax——const ax axios.create(...)——所以平时大家说“ax调度”指的就是 axios 请求的调度编排。这件事听着不起眼但我在好几个项目里都为它收拾过烂摊子页面上七八个请求同时飞出去先发的反而后返回表格被旧数据覆盖用户狂点导出按钮后端收到几十个任务数据库连接池当场被打满A接口要用B接口的响应当参数代码里只能嵌套三层回调。这些都是没有调度导致的而且很容易演变成线上事故。本文不准备讲太多理论直接从我踩过的坑出发给你一套可以从零复制的请求调度方案包括信号量队列的实现、优先级编排、竞态治理、超时取消这些细节也会聊聊什么时候不该用调度。无论你是刚接手前端项目的新人还是正在给老项目做性能治理的资深开发都应该能从这里找到可以直接抄作业的部分。1. 从乱序响应说起请求不加调度早晚要出事1.1 一个典型的竞态事故现场先讲一个我实际排查过的线上问题。项目是一个数据看板用户切换筛选条件后页面会同时重新拉取一张明细列表和一个汇总卡片。逻辑看似不复杂监听到条件变化发两个请求拿到数据之后渲染。但用户切换速度快的时候列表里的数据和汇总卡片经常对不上。比如筛选条件选的是“华东”汇总卡片显示的是华东的销售额列表前几行却还是“华南”的旧记录。当时第一反应是后端给的数据错了后来抓请求日志才发现问题出在前端。页面每次切换都会发出两个请求但网络返回的顺序并不固定。前一次切换发出的请求可能比后一次切换发出的请求回来得更晚于是后一次请求先渲染了前一次请求回来之后又把数据覆盖掉。这就是典型的竞态请求并发越多返回顺序越不可控旧响应覆盖新响应的概率就越高。我把问题简化成下面这段代码很多小伙伴看完应该会觉得眼熟let currentPage 1; async function load(page) { currentPage page; const { data } await ax.get(/api/list?page${page}); // 没有校验请求序号时旧请求会覆盖新结果 render(data); }这段代码里如果用户快速从第1页切到第2页两次请求几乎同时发出。先响应的可能是第1页的数据如果第1页的数据最后回来页面就会显示第1页的内容而地址栏和第2页的按钮状态都还停在“第2页”。这种问题在本地开发时很难复现因为开发环境网络延迟低请求返回顺序通常比较稳定上了生产环境网络抖动一出现立刻翻车。1.2 请求调度到底调度什么很多人以为“请求调度”就是限制并发数让请求不要同时发太多。这话对了一半但只碰了皮毛。真正把调度做好至少需要管住四件事。第一是并发上限。浏览器对同域名的连接数有限制后端对瞬时并发也有承受能力。不控制并发高峰期几十个请求同时打到服务端接口响应会被拖慢甚至会触发网关限流。第二是执行顺序。接口之间经常有依赖关系比如先拿用户信息再根据用户角色拿权限列表先创建订单再查询订单详情。这些依赖如果靠堆嵌套回调来实现代码会越来越难维护。第三是优先级。同样是请求有些必须优先处理。比如用户触发的刷新操作应该排在后台静默统计类请求的前面正在编辑保存的数据优先级应该高于一个预加载的列表。第四是生命周期管理。请求不是发出去就完事了它还有超时、取消、重试这些环节。尤其是组件卸载之后未完成的请求应该被取消否则回调里操作DOM会报错或者造成内存泄漏。把这四个维度都管住才是完整的调度。我在项目里跟别人说“ax调度”基本就是指这样一套机制而不是简单写个Promise.all或者Promise.race。1.3 为什么这些问题经常被人忽视我觉得调度不受重视核心原因是单次请求实在太简单了。ax.get(/api/list)一行代码就能拿到数据Promise.all也能处理简单的并行于是大家默认“请求不就是这样发吗”。等到接口数量多起来、页面模块复杂起来问题才开始慢慢浮出水面。人脑在追踪并发任务时是有局限的。你可以轻松理清两个请求的关系但当请求数量变成八个、十个而且其中有依赖、有优先级、有先后顺序光靠代码审查已经看不出问题了。我在好几个项目里都见过类似的情况团队里每个人都觉得自己那一块代码没问题但这些代码合在一起行为就失控了。调度不是给单个请求增加复杂度而是把一群请求的组织方式显式地管理起来让“请求之间怎么协作”这件事有章可循。2. 手写一个信号量队列ax调度器的内核实现2.1 核心数据结构与任务流转链路调度的核心数据结构其实非常朴素一个待执行任务队列加上一个当前活跃任务计数器。所谓信号量在并发编程里就是一个计数器用来控制同时执行的资源数量对应到请求调度就是运行中的请求数不能超过我们设定的阈值。我把调度器的完整实现贴出来这个版本我在项目里跑了大半年稳定性和可读性都经过了验证interface QueueItem { task: () Promiseany; priority: number; resolve: (value: any) void; reject: (reason?: any) void; } class RequestScheduler { private queue: QueueItem[] []; private activeCount 0; private concurrency: number; constructor(concurrency 3) { this.concurrency concurrency; } addT(task: () PromiseT, priority 0): PromiseT { return new Promise((resolve, reject) { this.queue.push({ task, priority, resolve, reject }); this.queue.sort((a, b) b.priority - a.priority); this.drain(); }); } private drain(): void { while (this.activeCount this.concurrency) { const next this.queue.shift(); if (!next) { return; } this.activeCount 1; next .task() .then(next.resolve) .catch(next.reject) .finally(() { this.activeCount - 1; this.drain(); }); } } }这段代码的流转链路是这样的外部调用add时会把任务包装进一个 Promise队列收到新任务后立即按优先级排序然后触发drain。drain是个循环只要活跃任务数低于并发上限就从队头取出一个任务执行。任务执行完毕无论成功还是失败活跃数都会减少并再次触发drain把后续任务顶上来。这里有一个容易被忽视的细节drain用的是while循环而不是简单的if判断。这样可以保证当并发上限是 3、队列里同时有 10 个任务时首次调用add会一口气启动 3 个任务而不是每添加一个任务只启动一个。很多初学调度器的人在这里踩坑写着写着就变成了串行执行。2.2 把调度器接进 axios 请求层调度器本身不关心任务是什么它执行的只是返回 Promise 的函数。所以接入 axios 非常自然我们只需要封装一个统一的请求函数const scheduler new RequestScheduler(3); const ax axios.create({ baseURL: /api, timeout: 10000, }); // 项目里统一从这里发请求 export function requestT(config: AxiosRequestConfig, priority 0) { return scheduler.add(() ax.requestT(config), priority); } // 使用示例 const { data } await request{ id: number }({ url: /user/info }, 1);让所有请求都走request这个入口之后调用方一行额外的代码都不用写就能获得并发上限的控制。这里我建议把并发数设为 3 或 4这是我在实际项目中摸索出的一个比较稳妥的值既能保证页面并行加载的效率又不会把后端打得太狠。如果团队维护的是老接口、性能本来就一般设成 2 心里更踏实。要注意的是调度器应该放在调用侧也就是 axios 实例之上、业务代码之下。有人说可以改 axios 的适配器实现调度我不太推荐。原因是拦截器要继续负责统一 header、错误提示、登录态跳转这些横向逻辑调度器只负责任务编排两者职责分开排查问题的时候边界才清晰。搅在一起之后一个请求超时了你都不确定是拦截器的问题还是队列的问题。2.3 为什么用信号量而不是递归 Promise 串行早期我在项目里写过一种很天真的实现把请求一个一个塞进数组然后用reduce把上一个任务的完成作为下一个任务启动的条件。这种写法能保证顺序但有两个硬伤。串行的第一个问题是慢。10 个请求本来能 3 个一组并行串行之后所有请求排队一个一个走总耗时就变成了原来的三倍多。用户感知到的就是页面转圈时间变长。串行的第二个问题是不好插队。真实的业务里有时候一个高优先级请求需要插到队列前面比如用户离开详情页前的最后一条保存请求。递归式串行很难支持这种操作因为任务之间是链式then绑定的一旦启动就没法调整位置。而基于队列加计数器的方案插入优先级只是改一下数组排序重启drain就行。其实p-limit和p-queue这两个现成库底层也是同样的队列加计数器思路。p-queue还自带优先级队列实现。所以如果你不想自己维护直接用p-queue完全没问题。我在中大型项目里选择自研主要是希望调度器能和项目里的request.ts封装得再紧密一些顺便把超时、取消、重试这些逻辑统一进去。后面会详细展开。3. 业务层最常见的三类调度场景3.1 依赖接口编排先拿用户信息再拿权限在调度器有了之后接口依赖的编排会变得非常清晰。比如进入后台首页需要先拿当前用户信息再根据用户的角色去取权限菜单同时首页的公告和待办事项可以立即并行请求不受用户信息影响。典型写法是这样的const userPromise request{ id: number; role: string }( { url: /user/info }, 1 ); const [userResult, announcement, todo] await Promise.all([ userPromise, request({ url: /announcement }), request({ url: /todo/list }), ]); // 拿到角色后再请求权限菜单 const { data: permissions } await request({ url: /permissions, params: { role: userResult.data.role }, });我看到很多同学处理这种场景时喜欢写成一长串await一个接口等一个接口。那样虽然也能跑通但公告和待办这两个跟用户信息没有依赖的接口白白等了用户接口一个来回的时间。借助调度器之后并发上限虽然只有 3但上面这段代码中的三个并行请求会同时进入队列并立即开始执行依赖链路只发生在权限菜单这一步。这种编排的好处不只是快还在于代码是声明式的读者一眼就能看出哪个请求依赖哪个请求而不是通过阅读函数体里去猜。维护接口多的页面这种可读性的提升非常明显。3.2 竞态治理最后发出的请求说了算前面说过竞态的本质是旧响应覆盖新响应。调度器能控制并发数和执行顺序但它不能解决所有竞态问题。比如说用户快速切换两个筛选条件第一次切换的请求可能已经在队列里了我们并不希望取消它但我们也不希望它到达之后把第二次切换的结果覆盖掉。这时候需要在业务层做“请求序号校验”。let latestRequestId 0; async function loadDashboard(filter: string) { const requestId latestRequestId; const { data } await request({ url: /dashboard, params: { filter }, }); // 如果已经有更新的请求丢弃本次结果 if (requestId ! latestRequestId) { return; } renderDashboard(data); }这个方案在业界有一个很形象的名字叫“最后写入者胜”。每次发出新请求时递增序号请求返回后先检查自己的序号是否仍然是最新的不是就直接丢弃。我说的“丢弃”不是取消网络请求而是指不再渲染结果。请求本身爱怎么走怎么走反正回来看一眼序号不对就扔掉。这种思路实现成本极低却解决了前端异步编辑中最让人头疼的一类问题。我自己的习惯是凡是可能因为用户高频操作而反复触发的请求比如搜索联想、筛选器切换、分页跳转一律加请求序号校验。哪怕现在感觉本地联调没出问题上线之后网络环境一变这种坑几乎是必踩的。3.3 按钮防重与分批提交另一个高频场景是防重。用户在网络慢的时候点“导出报表”点了一次没反应又点了一次如果再点一次后端就收到三个导出任务。我真实遇到过导出任务把数据库连接池打满的情况事后复盘就是按钮层没做防重也没有任何请求调度。用调度器实现防重有两种思路。第一种是在按钮层加状态锁请求发起后按钮置灰请求结束后恢复第二种是在请求层做一些去重处理比如短时间内相同参数的请求直接复用同一个 Promise。const pendingMap new Mapstring, Promiseany(); export function dedupeRequestT(key: string, task: () PromiseT): PromiseT { if (!pendingMap.has(key)) { const promise task().finally(() { pendingMap.delete(key); }); pendingMap.set(key, promise); } return pendingMap.get(key)!; }在调度器的场景里我更推荐把防重放在请求层而不是按钮层。原因很简单按钮层防重只防住了“这个按钮的点击事件”但同一个接口还可能被其他代码路径调用比如快捷键触发、路由自动加载、定时轮询。请求层去重是收口方案覆盖面更宽。当然请求层的去重也要注意 key 的设计一般把请求方法、URL、关键参数拼起来就行参数里有时间戳的话要注意排除掉否则每次都生成新 key去重就失效了。提到分批提交这也是调度器擅长的事情。假设要批量导入一万条数据每次提交一百条调度器可以把每一批封装成一个任务设置并发数为 3。这样既不会瞬间把后端打挂又能保持较快的整体导入速度。轮询场景也同理把每次轮询封装成任务设置一个较低的并发上限再配合任务间的间隔控制就能避免轮询还没结束又开新一轮的问题。4. 超时、取消与重试调度器之外的三个边界4.1 排队等待时间也要计入超时axios 的timeout配置只能控制请求发出之后的等待时间控制不了请求在队列里的排队时间。如果一个任务在队列里排了 20 秒前面一个接口一直超时重试任务本身还没被发出axios 的超时配置是管不到这段排队时间的。我之前就遇到过这种问题某个页面有十几个后台统计请求并发上限只有 3其中一个接口因为网关抖动反复超时后面的请求就在队列里干等着。用户早就切到了别的页面这些请求还在排队直到排到它们时才开始计时然后又因为超时失败。要解决这个问题可以把入队时间和请求超时统一管理。调度器里可以在add的时候记录入队时间在真正执行 task 前判断一下入队时间加总预算是否已经超时超时就直接抛错不再发请求addT(task: () PromiseT, options: { priority?: number; queueTimeout?: number } {}): PromiseT { const { priority 0, queueTimeout 15000 } options; const enqueueTime Date.now(); return new Promise((resolve, reject) { this.queue.push({ task: () { if (Date.now() - enqueueTime queueTimeout) { return Promise.reject(new Error(QUEUE_TIMEOUT)); } return task(); }, priority, resolve, reject, }); this.queue.sort((a, b) b.priority - a.priority); this.drain(); }); }排队长、请求超时快之间需要平衡。我给内部定的默认值是队列里的容忍时间 15 秒axios 请求超时 10 秒。这样总等待上限大约 25 秒。如果后端接口本身就慢那你要调整的是接口性能而不是在这里不断加大超时时间。4.2 组件卸载后请求必须能取消前端调度里最容易被忽略的是页面都销毁了请求还在飞。在单页应用里组件卸载之后如果异步请求的回调里还在操作 DOM 或者更新全局状态轻则控制台报错重则内存泄漏。更麻烦的是这类问题通常不会立刻暴露只会在用户反复切换页面之后页面逐渐变卡。axios 支持通过AbortController取消请求const controller new AbortController(); scheduler.add(() ax.get(/api/heavy/detail, { signal: controller.signal, }) ); // 页面组件卸载时 useEffect(() { return () { controller.abort(); }; }, []);有了信号量队列之后取消这件事还多了一层含义如果任务还在队列里没有真正开始AbortController是无法影响它的。这种情况下需要给调度器提供一个从队列中移除任务的能力。一种简化做法是把取消控制器也存起来取消时遍历队列找到对应的任务标记为已取消出队执行时检测到已取消就直接拒绝掉不再发起请求。const abortMap new MapAbortController, boolean(); function cancelTask(controller: AbortController) { if (controller.signal.aborted) return; controller.abort(); // 标记队列里对应的任务 abortMap.set(controller, true); }在drain里执行任务前先检查一下该任务对应的controller是否已经被 abort被 abort 就直接抛一个取消错误不发起实际请求。这样取消机制就同时覆盖了“正在请求”和“正在排队”两个状态的请求。4.3 重试策略不是所有错误都值得重试调度器本身只负责编排重试要放在任务内部做。我见过不少项目不管三七二十一请求失败就自动重试三次结果把后端打得雪上加霜。重试前必须想清楚两件事这个错误是临时的还是永久的这个操作是幂等的吗我的重试策略很简单。网络错误、网关超时、5xx 这类的临时错误可以重试一般重试一到两次就够。4xx 这种业务错误比如参数校验失败、权限不足重试多少次都一样直接放弃。非幂等操作比如创建订单、扣减库存不能盲目自动重试。要么后端做幂等要么重试前提醒用户确认。一种比较稳妥的做法是给request函数加上重试次数和重试条件async function requestWithRetryT( config: AxiosRequestConfig, options: { retries?: number; priority?: number } {} ): PromiseT { const { retries 1, priority 0 } options; for (let attempt 0; attempt retries; attempt 1) { try { const { data } await requestT(config, priority); return data; } catch (error) { if (attempt retries) throw error; const status (error as any)?.response?.status; const isNetworkError !(error as any)?.response; const isServerError status 500 status 599; if (!isNetworkError !isServerError) { throw error; } // 每次重试之间稍微拉开一点间隔避免集中冲击 await new Promise((r) setTimeout(r, 500 * (attempt 1))); } } }重试间隔我这里用了最简单的线性退避500 毫秒起步。实际生产环境里指数退避更合理比如 500 毫秒、1 秒、2 秒递增另外可以加一点随机抖动防止多个客户端的请求在同一时刻集中重试。5. 什么时候不该用调度我给自己的红线5.1 调度器不是银弹它也在引入复杂度写到这里可能有的朋友会觉得调度器这么有用是不是项目里所有请求都应该套上它。我的答案是千万别。调度器本质是给请求池加了一个“管理员”而管理员本身也是开销。调度器会增加排查问题的难度。一个请求迟迟没有响应以前你只需要看是不是接口慢了现在你还要看它是不是被队列卡住了是不是前面有个高优先级任务反复重试把并发额度占满了。这种排查在日志不完善的项目里非常痛苦。我给内部定了两个红线请求数量没超过五个、不存在明显的依赖或防重需求的页面一律不套调度器只有核心链路的请求才走统一调度的request入口。另外要意识到调度会改变请求的时序。教育用户接受“快就是快”很容易但让业务方接受“你的请求排队了所以变慢了”很难。有时候调度反而会成为性能瓶颈。比如一个详情页只需要三个并行请求你却把它放到一个并发上限为 2 的全局调度器里整体加载时间反而被拖慢了。调度器应该分层设计而不是全局只用一个。5.2 自研调度器和现成库的取舍很多朋友问过我既然p-queue这么好用为什么还要自己写。我的回答是看你想要什么。方案适用场景优点需要注意的点自研信号量队列项目里有定制需求比如排队超时、队列任务取消、和内部请求层深度集成完全可控代码量小逻辑透明需要自己维护和写单测p-limit只需要最简单的并发控制代码量极少思路清晰不支持优先级和队列任务取消p-queue需要并发控制加优先级队列功能完整社区成熟需要额外依赖定制取消和超时要包装一层axios 拦截器直接做全局限流只想限制所有请求的瞬时并发接入成本最低容易限制过头无法精确控制业务依赖如果是小而美的开源项目我建议直接用p-queue它解决了 80% 的队列需求。如果是公司内部大型中后台请求数量多、团队协作频繁我更推荐自研。自研调度器的代码量其实只有几十行主要价值是你可以按业务需要加各种钩子比如请求的埋点、性能统计、排队时间告警。这些用开源库也能加但加的过程往往就是在别人的抽象里打补丁时间长了代码会变得很别扭。5.3 一些我们团队一直在用的约定文章最后分享几个我们团队沉淀下来的约定。这些不是技术原理而是踩过坑之后总结出来的规矩。第一所有请求都从一个request.ts入口出禁止页面里直接import axios发请求。这样调度、拦截、埋点都只有一个落点新同事入职看代码也只需要看这一个文件。第二调度器的并发数默认 3核心链路可以申请 5但不允许全局并发开得太大。并发不是越大越好尤其是对接老后端时瞬时并发上来容易触发限流。第三每个调度器要分配默认优先级常量比如Priority.USER_TRIGGER 1、Priority.BACKGROUND 0、Priority.SILENT -1不许在业务代码里随手写魔法数字。第四竞态校验必须在请求返回后、渲染前做。这句话我提了很多次因为它真的是前端异步编辑里错误率最高的一环。第五超时和重试参数统一收敛在request.ts里业务调用方一般不传。这样别人看到一段调用代码不需要关心重试了几次、超时了几秒心智负担会小很多。我个人建议无论你现在项目多大多小都值得把“请求调度”这四个字认真过一遍。哪怕最后决定不引入任何封装只是弄明白请求之间的并发关系、响应顺序和生命周期也能帮你少踩很多坑。写代码到了后面难的不是把功能做出来而是把一群容易互相干扰的异步行为管得井井有条。
返回列表