Engineering

请求返回了,为什么结果已经过期:异步竞态与副作用的心智模型

请求成功只代表它完成了,不代表它仍有资格修改当前状态。理解异步竞态的关键,是管理过期结果和状态所有权。

请求成功返回,只代表它完成了,不代表它现在仍然有效。

这是理解异步竞态最重要的一句话。许多异步 bug 并不是请求失败,而是一个已经过期的请求成功返回,并覆盖了更新的用户意图。

一个最常见的例子

用户在搜索框里依次输入:

a → ab → abc

页面随之发出三个请求。我们希望最终显示 abc 的结果,但网络并不保证请求按发出顺序返回:

请求 abc 先返回
请求 a 随后返回
请求 ab 最后返回

如果每个请求完成后都直接更新列表:

const result = await search(keyword)
setList(result)

最后返回的 ab 就会覆盖 abc。三个请求都成功了,页面却展示了错误结果。

问题不在请求本身,而在于多个完成顺序不确定的操作都在争夺同一个状态。

竞态需要三个条件

不能简单地说“异步会导致竞态”。更准确的判断是:

  1. 存在多个操作。
  2. 它们的完成顺序不确定。
  3. 它们会修改同一个结果或资源。

单独的异步读取通常没有问题。真正需要警惕的是异步完成后的写入:更新 UI、写缓存、跳转路由、提交订单、修改数据库。

可以把两者的关系压缩成一句话:

异步制造不确定顺序,副作用把这种不确定性变成真实影响。

因此,看到 await 时不必立刻焦虑。应该继续向后看:它回来以后会写什么?还有谁可能写同一个状态?

异步回来时,世界可能已经变了

假设用户先打开商品 A,又很快切换到商品 B。A 的请求出发时完全合理,但它返回时,页面的当前目标已经变成 B。

所以异步代码不能只按顺序理解:

发出请求 → 等待 → 得到结果 → 更新状态

还要加入时间变化:

请求出发时:记录它服务于哪个意图
请求执行中:用户和系统状态可能变化
请求返回时:检查它是否仍然有效
确认有效后:才允许产生副作用

真正要问的不是“请求成功了吗”,而是:

这个结果现在还有资格更新状态吗?

先判断多个异步操作是什么关系

不同关系需要不同的处理策略。

替代关系

搜索词、筛选项、Tab 或详情对象发生变化时,新请求会替代旧请求。

这类场景通常只认最后一次请求,或者直接取消旧请求。

并列关系

页面初始化时同时加载用户、权限、菜单和通知数量。这些请求互不替代,可以并发执行:

const [user, permissions, menus] = await Promise.all([
  getUser(),
  getPermissions(),
  getMenus(),
])

如果它们写入不同状态,竞态风险通常较低。

依赖关系

先获取用户,再根据 userId 获取订单。后一步依赖前一步,但订单返回时仍要确认当前用户没有切换。

合并关系

分页加载不是互相覆盖,而是把多页结果合并。此时要处理页码乱序、重复加载和结果去重,不能简单地让最后一个请求覆盖列表。

五种常见处理方式

1. 只认最后一次请求

给请求递增编号,只有最新编号有权写入状态:

let currentRequestId = 0

async function loadData(params: Params) {
  const requestId = ++currentRequestId
  const data = await fetchData(params)

  if (requestId !== currentRequestId) return

  setData(data)
}

这适合搜索、筛选和详情切换。旧请求可以完成,但它的写入资格已经失效。

2. 取消旧请求

当旧请求不再有价值时,可以通过 AbortController 终止它:

let controller: AbortController | null = null

async function loadData(params: Params) {
  controller?.abort()
  const nextController = new AbortController()
  controller = nextController

  try {
    const response = await fetch('/api/data', {
      signal: nextController.signal,
    })

    setData(await response.json())
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') return
    throw error
  }
}

取消可以减少无效工作,但“取消请求”和“阻止过期结果写入”是两个不同目标。并非所有异步操作都能被真正取消,资格校验仍然是更通用的边界。

3. 把结果绑定到查询条件

数据不是孤立存在的,它属于某组参数。请求返回时,比较响应对应的参数和当前参数;如果两者已经不同,就丢弃结果。

这比只保存一个裸 data 更清楚:状态不仅要记录结果,也要记录结果属于谁。

4. 阻止重复提交

保存、支付、删除等操作不应该只靠 UI 禁用,但前端可以先减少重复请求:

let submitting = false

if (submitting) return

submitting = true
try {
  await submitForm()
} finally {
  submitting = false
}

5. 让后端守住最终一致性

前端只能降低重复操作和错误展示,不能承担最终安全边界。涉及数据修改时,后端仍需使用幂等键、唯一约束、事务、乐观锁或状态机校验。

例如支付按钮在前端被禁用,并不代表请求不会因重试、超时或多端操作而重复到达服务器。

React Effect 中的资格失效

React 组件里的典型问题是:依赖变化以后,上一次 Effect 发出的请求仍可能返回。

useEffect(() => {
  let ignore = false

  async function run() {
    const data = await fetchUser(userId)
    if (!ignore) setUser(data)
  }

  run()

  return () => {
    ignore = true
  }
}, [userId])

这里不是假设旧请求一定会被取消,而是在 Effect 清理后撤销它更新当前组件状态的资格。React 官方文档也使用这一模式避免数据请求的竞态问题。

一条可用于代码审查的问题链

以后检查异步代码,可以依次问:

  1. 这个操作的完成时间是否不受我控制?
  2. 它完成后会不会更新 UI、缓存、路由或数据库?
  3. 是否还有其他操作会写同一个状态?
  4. 用户意图或查询参数在等待期间会不会变化?
  5. 结果回来时,代码如何证明它仍然有效?

如果前四个问题的答案都是“会”,而第五个问题没有答案,通常就存在竞态风险。

异步编程的核心不是记住更多 await 写法,而是管理过期结果和状态所有权:

异步开始时,记录我是谁;异步回来时,确认我是否仍然有效;确认之后,再允许副作用发生。

Back to Blog

Related Posts

View All Posts »