ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前端异步竞态条件排查与根治:从请求乱序到Rea工具实践

前端异步竞态条件排查与根治:从请求乱序到Rea工具实践

上周我花了整整三天排查一个线上故障,最后发现根子竟然是最常见的异步请求顺序问题。这个项目我内部代号叫 Rea——一个只解决异步竞态的小工具。起因是某跨平台系统的列表页,用户快速切换筛选条件时,旧的接口响应偶尔会覆盖新的响应,页面数据跟着串场。如果你也遇到过“先发的请求后回来”“页面偶尔闪一下旧数据”“loading 状态莫名被关掉”这类诡异问题,这篇把竞态条件(Race Condition)从现象、原理、排查到根治一次讲透,适合所有写前端异步逻辑的开发者参考。

1. 一次线上故障,让我把竞态处理当成正经事来干

1.1 故障现场:导出回执配错了订单批次

先还原一下当时的场景。某跨平台系统里有一个订单导出功能,用户选择日期范围、订单状态、门店编号之后,点击“导出”,后端会异步生成文件,几分钟后通过消息通知用户下载。

上线后的第三天,客服陆续收到同一类反馈:用户明明只导出了 A 批次的数据,收到的回执里却是 B 批次的内容。一开始我们怀疑后端搞混了导出任务,查了后端日志、消息队列消费记录、导出任务表,全部正常,单次调用也没问题。后来我在测试环境连续快速点击导出按钮,才终于抓到猫腻:前端连续创建两个导出任务时,第二个任务返回的“回执通知”会覆盖第一个任务的状态回调,导致页面把两个任务信息混着用。这已经不是后端的问题了,是前端异步处理响应时,发生了典型的竞态。

1.2 排查路径:从后端追到前端的三个线索

那三天我梳理了一个比较完整的排查链路,先卡死后端:

  • 接口层所有任务创建都是同步返回任务 ID,没有乱序;
  • 消息队列按用户维度消费,任务状态更新有幂等控制;
  • 数据库记录里两个任务的数据完全没有交叉污染。

后端干干净净,那就只能往前端找。我重新在浏览器里录制了整个操作过程,打开了网络面板,盯着瀑布图看了很多遍,找到三个线索:第一,用户点击导出后,前端会先发一个“创建任务”的请求,拿到任务 ID 后再轮询“任务状态”;第二,快速点击时,轮询请求是按顺序发出的,但浏览器里显示下拉刷新时它们的完成时间完全是乱的;第三,我把网络限速调到最慢之后,旧任务的轮询结果反而比新任务更晚回来,回调里拿当前任务 ID 去更新全局状态,就把新任务的状态覆盖了。

到这里我终于确认:这不是后端问题,也不是简单的“重复提交”,而是请求响应乱序导致的竞态条件。

1.3 为什么“先发不一定先至”

竞态这个词听起来玄乎,其实用快递类比特别好懂:两辆快递车同时出发,先出发的那辆可能因为绕路、堵车晚到,后出发的上了高速反而先到。网络请求也是一样,微服务调用链路、网关转发、CDN 缓存、设备网络抖动,都会让响应时长变得不可控。

所以异步代码里有一个铁律:请求发出的顺序,绝不代表响应回来的顺序。谁先回来,谁后回来,取决于每一跳的处理速度。我们写的回调函数拿到的只是“一个响应”,如果不主动去校验“这个响应属于谁”,就很容易让旧响应覆盖新响应,最终表现为数据错乱。

2. 竞态条件的本质解剖:别把所有锅都甩给网络

很多人一听竞态就想到网络,其实竞态在代码里无处不在。我把平时遇到的竞态归纳成三层:请求层、业务层、渲染层。理解这三层,才能对症下药。

2.1 请求层的“口水战”:旧响应覆盖新响应

最典型的就是搜索框。用户输入“苹果”,发出请求 A,再输入“香蕉”,发出请求 B。A 的接口比较慢,B 的接口比较快,B 先回来,A 后回来,最后页面显示的是“苹果”的搜索结果,但输入框里明明是“香蕉”。

代码长这样:

async function onSearch(keyword) { const data = await fetch(`/api/search?q=${keyword}`); setResult(data); // 谁先回来不重要,关键是最后谁把结果写进去 }

这种写法天生就有竞态隐患。setResult不关心数据是谁的,只管把数据塞进状态。两个请求并发时,最后塞进去的可能是旧数据。很多前端新手遇到这个问题,第一反应是“后端接口太慢”,多加点 loading 动画,其实治标不治本,loading 结束之后数据照样错。

2.2 业务层的共享可变状态:loading、缓存、分页游标

请求层的竞态比较容易被发现,业务层的就隐蔽多了。我举个例子:页面上有两个并发请求,一个查列表,一个查详情,共用一个全局 loading 状态。列表请求先发出但后回来,详情请求后发出但先回来。详情请求回来时,loading 被置为 false;列表请求返回之后,如果按照同样的逻辑把 loading 也置为 false,看起来没问题。可如果列表请求里有一段逻辑:拿到数据后要写入本地缓存,并且更新一个“最近更新时间”字段,刚好这个字段又被另一个模块读取,那就会出现“A 请求的数据带着更晚的时间戳覆盖掉 B 请求的数据”,缓存直接错乱。

分页游标也是重灾区。列表下翻加载第二页时,用户又快速切换了筛选条件。第一页的游标、第二页的游标、新筛选条件的游标,三个请求几乎同时飞出去,响应顺序完全随机。如果代码里直接拿旧游标追加数据,分页数据就会混进不同筛选条件下的记录。

2.3 渲染层的竞态:列表闪烁、图表抖动和“幽灵状态”

到了 React、Vue 这类响应式框架里,竞态会和组件生命周期纠缠在一起。最常见的报错是:“Can't perform a React state update on an unmounted component”。组件卸载之后,异步回调仍然执行了 setState。React 17 以下会直接报警告,React 18 之后虽然警告弱化了,但底层的问题还在:回调的闭包捕获了第一次渲染时的 props 和 state,用旧值去更新新界面。

具体表现是:用户从列表页跳进详情页,详情页还在加载时,用户又立刻返回列表页。详情请求回来后,回调里 setState 一个已经不存在的组件实例,或者把一个过期的 loading 状态写进共享状态。这种“幽灵状态”特别难查,因为界面可能只闪一下,或者只在特定机型、特定网络下出现。本质上还是响应乱序叠加了组件生命周期,导致状态被没有资格写入的一方篡改。

3. 实战复盘:某跨平台系统的“列表筛选竞态”是怎么抓出来的

3.1 现象还原:图表比表格慢半拍

这次故障来自另一个模块:一个 B 端数据看板。页面上有城市筛选、日期范围、业务线三个维度,筛选条件一变,同时发三个请求:表格数据、图表聚合数据、汇总卡片数据。上线后测试反馈一个偶现问题:表格数据已经更新成新条件了,图表还是旧条件的。尤其是快速把日期范围从“近 30 天”切到“近 7 天”,再切回“近 30 天”时,图表有较高概率显示的是第二次切换之前的内容。

一开始大家以为是图表组件缓存问题,因为表格重新请求了,图表看着像走了本地缓存。后来把接口响应时间打出来才发现,图表接口因为聚合逻辑复杂,平均响应时间比表格慢 300 到 800 毫秒。用户切完条件,表格先渲染新数据,图表接口才姗姗回来,结果拿着旧条件的响应把新图表覆盖了。

3.2 排查三件套:网络面板、Performance 时间线、日志插桩

我排查这类问题有一套固定的“三件套”:

第一件,网络面板。打开浏览器开发者工具,切到 Network,筛选 XHR/Fetch,把录制的交互过程里的请求按“完成时间”排序,而不是按“发起时间”排序。这样能直观看到响应乱序。这一步能快速判断到底是不是“先发后至”。

第二件,Performance 时间线。用录制功能记录一次完整的筛选切换操作,能看到每个请求的开始时间、结束时间、渲染阻塞情况。这对确认“数据更新到 UI 的先后顺序”很有帮助,因为有时候接口没问题,是渲染队列把任务排到后面了。

第三件,日志插桩。这个最关键,也最容易被忽略。给每个请求加一个 requestId,打印出发起时间和返回时间,同时打印当前页面上的筛选条件快照。代码大概长这样:

const requestId = ++globalRequestCounter; console.log(`[req ${requestId}] start`, getFilterSnapshot()); fetchData().then(() => { console.log(`[req ${requestId}] done`, getFilterSnapshot()); // 处理数据 });

通过日志时间轴,就能精确判断哪个响应在返回时“顺手污染”了当前状态。

3.3 复现方法:限速、条件断点、延迟注入

定位竞态问题,复现是最大的难点。很多时候你盯着页面看半天,它偏偏不犯错。我的做法是人为制造乱序:在开发者工具的网络面板里设置较慢的限速档位,把网络环境模拟成 3G 水平,然后快速切换筛选条件。这个方法能显著放大竞态出现的概率。

更进一步,在本地 mock 层给不同接口加随机延迟。比如表格接口延迟 100 到 300 毫秒,图表接口延迟 800 到 1500 毫秒,人为制造“你有你的节奏,我有我的节奏”的效果。然后打条件断点:当响应数据里的 requestId 不等于当前最新的 requestId 时,断住程序,观察调用栈。这一步能直接把竞态发生的位置从“抽象的感觉”变成“具体的代码行”。

3.4 定位结论:三个竞态点叠加,才是真正的“坑王”

排查到最后,我发现问题不是单点故障,而是三个竞态点叠加在一起:

  • 竞态点一:接口响应本身乱序,旧数据覆盖新数据;
  • 竞态点二:页面全局筛选状态被多段代码同时读写,没有统一的“最新条件”权威源;
  • 竞态点三:前端引入了本地缓存代理,缓存写入时机和响应返回时机绑定,导致旧条件的数据被当成新条件数据缓存。

这三个点单独拆开看,每一个都不难解决,但它们叠加在一起,就会表现出“修好一个,另一个又冒出来”的诡异状态。所以我没有继续打补丁,而是回头做了一个统一处理方案,也就是后面要说的 Rea 工具。

4. 五种修复方案的对比,以及我最终选定的组合拳

竞态的修复方案业界有很多,我在这里把常用的几种都列出来,对比一下适用场景和代价,再讲我最后选定的组合。

4.1 方案 A:取消旧请求

原理是:发出新请求时,把上一个还没完成的请求直接取消。浏览器原生提供了 AbortController,axios 也支持 cancelToken。

let controller = null; async function onSearch(keyword) { controller?.abort(); controller = new AbortController(); try { const data = await fetch(`/api/search?q=${keyword}`, { signal: controller.signal }); setResult(data); } catch (e) { if (e.name === 'AbortError') return; // 被取消,忽略 throw e; } }

这种方案最适合“动作触发型”场景,比如导出按钮、支付按钮、保存按钮。用户连续点导出时,取消旧任务能省掉无谓的轮询请求。缺点是:后端可能已经开始处理旧请求,前端取消只是不再处理响应,真正的“取消”需要在后端配合实现幂等。

4.2 方案 B:请求序号标记法

用一个递增的序号,响应回来时只处理最新序号对应的那次。这个方案兼容性最好,哪怕是不支持 AbortController 的旧浏览器也能跑。

let seq = 0; async function onSearch(keyword) { const currentSeq = ++seq; const data = await fetch(`/api/search?q=${keyword}`); if (currentSeq !== seq) return; // 已经不是最新请求,丢弃 setResult(data); }

这种方案对输入触发型场景非常友好,搜索框、筛选条件、分页翻页都适用。它不会节省流量,但能保证状态不被旧响应污染。实现成本极低,我几乎每次遇到竞态都会先加这个。

4.3 方案 C:串行化队列

把请求改为绝对有序:前一个请求完成之后,再发下一个请求。这种方式能彻底杜绝乱序,但缺点也很明显,速度慢,请求会排队等待,不适合高频触发的场景。我一般只在写操作里用,比如必须严格按顺序执行的批量保存操作,或者对顺序敏感的轮询逻辑。

4.4 方案 D:业务层状态机 / 快照对比

记录发起请求时的筛选条件快照,响应返回后对比当前条件和快照是否一致。这个方法本质上是“响应归属校验”,比 requestId 更贴近业务。比如:

let lastFilter = null; async function fetchWithFilter(filter) { lastFilter = filter; const data = await fetchByFilter(filter); if (JSON.stringify(lastFilter) !== JSON.stringify(filter)) return; setData(data); }

这种方案的优点是不用管请求是谁发起的,只关心“当前这条数据还符不符合用户正在看的场景”,非常直观。缺点是需要业务自行构造快照字段,比较逻辑写不好也会出问题。

4.5 组合拳:Rea 工具的核心设计

我在实践里没有只选一种,而是把 B 和 D 结合,再包上一层可选取消能力,做成了 Rea。Rea 核心只有一个概念:每一次任务执行都会刷新“代数”,回调或响应回来时,如果“代数”已经过期,就丢弃结果。

核心代码用 TypeScript 写大概是这样:

export function createRaceGuard() { let generation = 0; let controller: AbortController | null = null; return { async run<T>( task: (signal: AbortSignal) => Promise<T>, fallback?: () => void ): Promise<T | undefined> { generation += 1; const current = generation; controller?.abort(); controller = new AbortController(); try { const result = await task(controller.signal); if (current !== generation) { fallback?.(); return undefined; // 过期结果,直接丢弃 } return result; } catch (err) { if (current !== generation) { fallback?.(); return undefined; // 被新任务打断造成的失败,不算错误 } throw err; } } }; }

用法很简单:

const guard = createRaceGuard(); async function onFilterChange(filter) { const data = await guard.run((signal) => fetchData(filter, { signal })); if (data) { setTable(data.table); setChart(data.chart); } }

这里把“取消旧请求”和“丢弃过期结果”合二为一。旧的请求会被 abort 掉,极端情况下 abort 事件和响应事件同时触发,generation 检查也能兜住,保证状态只被最新一次操作写入。加了 fallback 之后,还能在丢弃旧结果时做一些额外清理,比如关闭专属 loading。

我还把 Rea 封装成了 React Hook 版本,配合 useEffect 处理页面卸载的场景:

function useRaceGuard() { const guardRef = useRef<ReturnType<typeof createRaceGuard>>(); if (!guardRef.current) { guardRef.current = createRaceGuard(); } useEffect(() => { const guard = guardRef.current; return () => { guard.run(() => Promise.resolve()); // 触发一次新代数,使未完成响应全部过期 }; }, []); return guardRef.current; }

使用的时候在组件卸载时调用一次,所有未完成的异步回调都会因为代数不匹配而被丢弃,从根上避免 setState on unmounted 问题。

5. 把经验沉淀成规范:竞态防护清单与常见误区

解决了两个项目之后,我把经验沉淀成一份清单,贴在团队每个涉及异步请求的模块的评审清单里。

5.1 这份清单,我贴在每个模块的 PRD 评审里

下面的检查项每个都踩过坑:

  • 所有异步请求在更新状态之前,必须校验“本次响应是否仍然代表当前最新请求”。不管用请求序号、快照还是 AbortController,至少要选一种。
  • 按钮触发型操作必须防重复提交。手段可以是禁用按钮、防抖、或者给请求加幂等令牌。
  • 列表筛选、分页、搜索这类高频触发场景,必须处理“旧响应覆盖新响应”的问题。
  • 组件卸载时必须停止所有异步状态的写入。要么走 abort,要么走代数过期,不能裸写 setState。
  • 全局状态库(例如 Redux、Pinia)里的异步 action,必须自带竞态防护,不能指望每个调用者都记得处理。
  • 缓存代理层写入数据时,要附加时间戳或版本号。读取缓存时校验版本,避免旧缓存污染新页面。

5.2 三个让人迷惑的“假竞态”

在排查过程中,我还遇到过三个看起来很像是竞态、其实不是的情况,列出来免得大家走弯路:

第一个是“防抖导致的数据延迟”。用户已经停止输入了,防抖计时器还在跑,晚 300 毫秒后才发请求。这时候如果页面先用旧数据渲染了一次,又用新数据渲染第二次,看起来像竞态,其实是防抖参数不合理。这类问题和竞态的复现手段完全不同,调一调 debounce 延迟就能解决。

第二个是“服务端同步返回导致的前端覆盖”。有的后端接口虽然是异步任务,但在内部做了同步等待,响应时间就是比另一个接口长。前端需要拉的接口多时,晚到的接口总是携带更旧的条件参数。这种问题要从接口合并或请求编排入手,而不是单靠前端丢弃响应。

第三个是“偶现”这个特征本身。不少测试觉得“偶尔出现一次,刷新就好了”就不算 bug。但竞态的典型特征就是偶现,网络波动一大就出现,波动一缓就消失。遇到这种反馈,别急着关单,先按照限速复现的方法测一轮。

我用一张表总结真竞态和假竞态的差异:

对比项真竞态假竞态(防抖/缓存/依赖顺序问题)
触发条件异步响应乱序、并发操作固定延迟、缓存读取时序
复现方式限速+快速操作调整延迟参数可稳定复现
表现特征数据随机错乱、偶现每次复现结果大致一致
修复思路取消/丢弃过期响应调整业务逻辑或参数

5.3 测试与压测:验证防护是否有效的小技巧

修复完竞态之后,怎么证明真的修好了?我习惯做三层验证。

第一层,单元测试。手写一个可以手动控制 Promise 状态的工具,先发起请求 A,再发起请求 B,然后先 resolve B,再 resolve A,最后断言页面状态是 B 的结果。这一步能直接验证 Rea 的代数机制是否生效。

第二层,集成测试。用浏览器扩展拦截请求,给不同的接口注入不同的延迟。筛选条件切换后,让图表接口比表格接口晚 1 秒返回,检查最终 UI 是否是正确的组合。

第三层,压力测试。把网络限速调到最慢档,快速切换筛选条件 20 次,观察控制台有没有异常,UI 有没有错乱。如果 20 次都没问题,基本可以放心上线。

这里有个小技巧:随机延迟比固定延迟更能暴露竞态。因为竞态的本质是随机性,你用固定延迟只能复现某一种乱序,随机延迟却能覆盖各种排列组合。

5.4 写在最后:我现在写异步代码的固定动作

经过这几轮折腾,我现在写任何一段异步代码都会先问自己三个问题:这个响应回来的时候,用户还停留在这个页面吗?这个响应里的数据,和用户当前看到的筛选条件是一致的吗?如果有多个请求同时飞出去,最终谁负责写入哪一个状态?

如果回答过程中有任何犹豫,我就会把 Rea 套上去,或者至少加一个请求序号检查。这不复杂,但能省掉后面无数个加班的夜晚。异步竞态不像语法错误那样有一个报错信息告诉你错在哪,它总是悄无声息地破坏数据。你只有从机制上把它堵死,才能真正睡得安稳。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进