做前端这么多年,我越来越觉得,真正难的不是把请求发出去,而是让一堆请求服从管理。先交代一个语境:我们组在代码里习惯把 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: () => Promise<any>; 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; } add<T>(task: () => Promise<T>, priority = 0): Promise<T> { 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 request<T>(config: AxiosRequestConfig, priority = 0) { return scheduler.add(() => ax.request<T>(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 Map<string, Promise<any>>(); export function dedupeRequest<T>(key: string, task: () => Promise<T>): Promise<T> { 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 前判断一下入队时间加总预算是否已经超时,超时就直接抛错,不再发请求:
add<T>(task: () => Promise<T>, options: { priority?: number; queueTimeout?: number } = {}): Promise<T> { 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 Map<AbortController, 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 requestWithRetry<T>( config: AxiosRequestConfig, options: { retries?: number; priority?: number } = {} ): Promise<T> { const { retries = 1, priority = 0 } = options; for (let attempt = 0; attempt <= retries; attempt += 1) { try { const { data } = await request<T>(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里,业务调用方一般不传。这样别人看到一段调用代码,不需要关心重试了几次、超时了几秒,心智负担会小很多。
我个人建议,无论你现在项目多大多小,都值得把“请求调度”这四个字认真过一遍。哪怕最后决定不引入任何封装,只是弄明白请求之间的并发关系、响应顺序和生命周期,也能帮你少踩很多坑。写代码到了后面,难的不是把功能做出来,而是把一群容易互相干扰的异步行为管得井井有条。