我测了 6 种防请求竞态的写法:只有 1 种真能打

0 阅读7分钟

搜索框里打个"abc",列表闪一下,显示的却是"a"的结果。刷新一下好了,再打一遍又复现——这就是请求竞态。写搜索、筛选、分页的前端人人都见过它,也人人都有一套防法:加 loading、上防抖、请求带序号。下拉筛选、tab 切换、无限滚动,全是同一个雷换了个皮。

我把这 6 种防法全部丢上同一台实验台,两种打字节奏各跑几十轮,判分标准跑前定死。先看判决:

写法快速连打正常打字速度服务端实际算(快打)判决
什么都不加100% 显示旧数据47% 显示旧数据3/3出事
loading flag 守卫100% 错,新输入直接被吞43% 显示旧数据1/3(错得更狠)出事
300ms 防抖全对(多花 300ms)50% 显示旧数据3/3出事
请求序号守卫全对全对3/3 白算能用
最新值比对全对全对3/3 白算能用
AbortController全对全对1/3,真取消全能

最流行的两个兜底——loading flag 和防抖——全在"出事"那一档。下面按最坑的顺序一个一个说。

判决表怎么读:前两列是 30 轮里最终显示错数据的比例,第三列是快打 30 轮里服务端实际算完的请求数(总共发了 3 个)——它衡量的是浪费,不是对错。

实验台怎么搭的

判分标准跑前定死:最终 UI 正确且不白发请求 = 全能;正确但白算 = 能用;只要存在一种打字节奏会显示旧数据 = 出事。不允许跑完再定标准。

服务端是本机起的一个搜索接口,语义只有一条:词越短,命中越多,算得越慢("a" 要 600ms,"abc" 只要 150ms,带随机抖动)。这不是刁难——真实搜索后端就是这样,短词匹配的文档多、聚合重,反而比长词慢。缓存更会把这件事放大:长词经常命中缓存秒回,短词每次都打穿到计算层。

输入流 a → ab → abc,跑两种节奏:间隔 60ms 的快速连打(输入法联想那种手感),和间隔 400ms 的正常打字速度。为什么必须跑两种?快打测的是"防线扛不扛得住",慢打测的是"会不会悄悄失守"——有的防线快打满分,慢打开漏(防抖说的就是它)。只测一种节奏等于白测。Node 跑 30 轮,再开真 Chrome 跑 10 轮交叉验证,两边结论完全一致。判决表里"正常打字速度"那一列,就是防抖的死亡之列。

旧数据是怎么合法上屏的

裸奔组快打 30 轮全错。把时间线摊开看,机制很朴素:

t=0     发出 a   (后端 600ms)
t=60    发出 ab  (后端 350ms)
t=120   发出 abc (后端 150ms)
t=270   abc 回来了 → 上屏 ✓
t=530   ab 回来了 → 上屏(此刻 UI 已经错了)
t=720   a 回来了 → 上屏(最终停在错误结果上)

回来顺序和发出顺序完全相反,谁最后到谁上屏——a 永远是最后一个。abc 的正确结果在屏幕上停留了不到半秒,就被两个过期响应先后冲掉。用户看到的是转圈、闪两下、然后一份"a"的旧数据。

你等的那一下转圈,转完经常是错的。慢打组也有 47% 的错率,这意味着不修也"能用"是错觉:正常用半天就能撞见一次。

有人会问:慢打间隔 400ms,a 都回来 100ms 了才发 ab,怎么还会乱序?答案在后端的方差。我给接口加了 ±600ms 的随机抖动——真实后端就是这种脾气:查缓存 50ms,没查中算 800ms,同一接口两次耗时能差十几倍。慢打时 a 恰好抽中一次长耗时,就被后写的 ab 反超。乱序不需要你打得快,后端波动一个随机数就够了。

loading flag 不防竞态,它吞输入

流传最广的防法长这样:

let loading = false;
async function search(q) {
  if (loading) return;      // ← 雷
  loading = true;
  const res = await fetch(`/api/search?q=${q}`);
  render(await res.json());
  loading = false;
}

快打时发生的事:"a" 发出,loading 变 true,紧接着的 "ab" 和 "abc" 全被 return 挡掉,a 的结果回来上屏,loading 释放,结束。

你输入的 abc 这个请求,从头到尾没有发出去过。

30/30 全错,而且界面上没有任何异常——列表有数据、不报错、不转圈,用户以为搜完了。慢打时 flag 会释放,但释放之后的窗口照样是裸奔,43% 错。

这是六种写法里我唯一建议你今晚就去 grep 代码库的:搜 if (loading) return,命中在搜索函数里的,大概率埋着这颗雷。

为什么这种写法遍地都是?三个原因:代码最短,两行搞定;看起来省请求,符合直觉;连不少面试教学都这么写。但它防的是"重复提交",不是竞态——竞态的本质是响应乱序,flag 从头到尾没碰过乱序问题,只是把发请求的入口关小了。入口关小了,里面该乱还是乱。

防抖是个节奏赌徒

防抖在快打时确实完美:键间隔小于 300ms,请求被合并成 1 个,全对。「加了防抖就没竞态」这句话就是这么来的。

但把键间隔放到 400ms——正常人打字就是这个速度——防抖每次都放行,每个键各发一个请求,竞态窗口和裸奔一模一样:50% 显示旧数据。

防抖只管频率,不管正确性。它赌你打得快,赌输了就是裸奔。(防抖背锅这不是第一次,懒加载那篇它刚背过一回。)

替它说句公道话:防抖本身没错,它解决的是"别每个键都发请求"这个频率问题。错的是大家把它当成了竞态防线——它压根没接过这个活。所以结论不是弃用防抖,而是别指望它防竞态。

而且快打全对也有代价:300ms 延迟税。正确结果 576ms 才上屏,AbortController 只要 269ms。

UI 全对的两种,服务端在白算

序号守卫和最新值比对是"正确派":响应回来先验明正身,过期的丢弃。序号守卫比对请求序号,最新值比对 query 字符串,思路等价,UI 60/60 轮零错。

序号守卫长这样,核心三行:

let seq = 0;
async function search(q) {
  const mySeq = ++seq;
  const res = await fetch(`/api/search?q=${q}`);
  if (mySeq !== seq) return;   // 过期响应,丢弃
  render(await res.json());
}

问题在另一头。服务端实际算了几次?3/3。每个键都白发一个完整请求,三分之二的计算结果被扔掉。搜索联想是每键一发的场景,这个浪费是乘在 QPS 上的。移动端弱网下更贵:3 个请求 3 份流量 3 份电量,用户只看到 1 份结果。

为什么不全用它?abort 是把请求掐死在半路,序号是让请求跑完全程再验票——票作废了,车钱照付。能用。但要知道自己在花这个钱。

AbortController,唯一的全能选手

let ctrl;
async function search(q) {
  ctrl?.abort();                // 上一个请求,死
  ctrl = new AbortController();
  const res = await fetch(`/api/search?q=${q}`, { signal: ctrl.signal });
  render(await res.json());
}

过期请求被中途取消,根本没有"回来覆盖"的机会——UI 60/60 全对。服务端日志里看得见取消的现场:

ABORT q=a    after=63ms
ABORT q=ab   after=70ms

连接被客户端提前断开,服务端不再计算。30 轮均值下,服务端从算 3 次降到算 1 次,三分之二的算力直接省掉。换到服务端视角,被取消的请求是一次 socket 提前断开:请求头进来了,算到一半对端没了。如果你的后端在监控接口耗时,被 abort 掉的请求根本不会计入完成耗时——前端这一手省算力,后端监控看得见。

两个注意事项。一,abort 会抛 AbortError,不 catch 就是控制台红字,永远 .catch() 接住。二,组件卸载时也要 abort——React 里最典型的写法是 useEffect 里带上 cleanup:

useEffect(() => {
  const ctrl = new AbortController();
  fetch(`/api/search?q=${q}`, { signal: ctrl.signal })
    .then((r) => r.json())
    .then(render)
    .catch((e) => { if (e.name !== 'AbortError') throw e; });
  return () => ctrl.abort();   // q 变了或组件卸了,旧请求当场死
}, [q]);

没有这行 cleanup,切页之后还会飞回来一个幽灵响应——你的 setState 打在一个已经卸载的组件上,竞态从搜索框转移到了页面生命周期里,套路一模一样。

有人觉得 abort 门槛高——兼容性、库支持。2026 年这个顾虑可以放下了:fetch、axios、uni.request 全都吃 signal,Node 的 fetch 也原生支持,浏览器支持早就全绿。它反而是今天六种写法里写起来最不费劲的一个。

正确背法

一句话:防抖管频率,abort 管正确性,两个一起上。

let ctrl, timer;
function onInput(q) {
  clearTimeout(timer);
  timer = setTimeout(() => {
    ctrl?.abort();
    ctrl = new AbortController();
    fetch(`/api/search?q=${q}`, { signal: ctrl.signal })
      .then((r) => r.json())
      .then(render)
      .catch((e) => { if (e.name !== 'AbortError') throw e; });
  }, 300);
}

防抖把请求频率压下来,abort 兜住所有竞态窗口。请求没法 abort 的场景(POST 查询、GraphQL)退而求其次用序号守卫,但要清楚服务端在白算。React Query、SWR 这类库内部干的就是 abort 加守卫这套——手写场景照抄这个思路就够了。

存量代码不用推倒重来:老搜索函数里已经套了防抖的,在请求发起那一行补上 abort 三件套就够了,改动不超过 5 行;想彻底省心就把整段换成 TanStack Query 的 useQuery({ queryKey: [q] }),竞态处理它自带。三行起步,看你想投多少。

一行速查版:

  • 每键即搜的搜索联想:防抖(管频率)+ abort(管正确性),缺一不可
  • 请求发不出去的(POST 查询、GraphQL):序号守卫顶上,接受三分之二白算
  • 不想手写:TanStack Query / SWR,内部就是这套
  • 唯独不能选的:什么都不加,和只加 loading flag

两个抬杠我先接了

「我没加任何防竞态,从来没出过事。」两种可能:你的请求恰好永远比旧请求快(比如接口严格按顺序返回),或者你根本没盯着看。慢打 47% 的错率,撞上是时间问题。真正可以不加的场景也有——整个页面只有一个请求的详情页,或者后端做了请求排队。但"我们后端会排队"这句话,先去验证了再信,别听嘴上的。

「序号守卫够用了,别吓唬人。」够用,判决表里我写的就是"能用"。但每键白发三分之二请求是真实成本。知道成本再选,和不知道成本瞎选,是两回事。

「axios CancelToken 行不行?」行,但它已经被 axios 官方标了 deprecated,官方文档自己推荐的替代品就是 AbortController——新代码别再往 CancelToken 上写了。还有人觉得 React 的 setState 批处理或并发特性能兜住乱序:批处理管的是多次 setState 合并成一次渲染,不管网络响应谁先谁后,两码事。乱序发生在网络层,框架层接不住,只能你自己在请求层接。

实验边界

三条。一,服务端是单机本地接口,没有公网 RTT 方差——真实公网只会乱得更凶,不会更轻。二,判决表数字是 Node 30 轮 + Chrome 10 轮的均值口径,实验代码全部在 race-lab 目录,可复跑。三,框架层不在本次范围:React 严格模式的 double-effect、React Query 内部的去重机制,值得单独写一篇。

评论区报一下你们项目在用哪种——尤其还在写 if (loading) return 的。踩过 flag 吞输入的更好,说说当时怎么排查的,攒够素材我把库层竞态那篇提前。