跳到主要内容

JavaScript 异步与竞态问题

⏱️ 异步问题不能只靠“加一个 AbortController”解决。需要分别处理:结果是否还能提交、网络请求是否被取消、服务端任务是否真正停止

先区分三件事

层级目标常用方案
状态层旧结果不能覆盖新结果TanStack Query、版本号、只接受最新请求
网络层不再传输无用的请求与响应AbortControllerAbortSignal
任务层服务端停止数据库查询、计算或队列任务断连传播、任务 ID + 取消接口、协作式取消

前端取消请求,不等于后端任务已经取消。 浏览器停止等待响应后,服务端可能仍在执行 SQL、生成文件或调用第三方接口。

常见问题

  • 搜索词连续变化,旧请求晚返回并覆盖新结果
  • 组件卸载后,异步回调继续写入状态
  • 用户连续点击,产生重复订单或重复提交
  • 页面虽然调用了 abort(),服务端仍持续消耗 CPU 或数据库连接
  • 请求超时后自动重试,进一步放大服务端压力

首选方案:用 TanStack Query 管理服务端状态

TanStack Query 适合处理“从服务端读取并缓存的数据”。它统一管理缓存、请求去重、过期策略、重试和竞态,通常比在每个组件里手写 loading/error/data 与请求版本号更可靠。

查询时消费 signal

import { useQuery } from '@tanstack/react-query'

function useSearch(keyword: string) {
return useQuery({
queryKey: ['search', keyword],
enabled: keyword.trim().length > 0,
queryFn: async ({ signal }) => {
const response = await fetch(
`/api/search?q=${encodeURIComponent(keyword)}`,
{ signal },
)

if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}

return response.json()
},
staleTime: 30_000,
})
}

当查询失效、被显式取消,或不再需要时,TanStack Query 提供的 signal 会被中止;只有把它传给 fetch 或请求库,底层网络请求才会真正收到取消信号。

显式取消查询

await queryClient.cancelQueries({
queryKey: ['search'],
})

TanStack Query 解决什么

  • 通过 queryKey 隔离不同参数的数据
  • 缓存并复用相同查询,减少重复请求
  • 管理重新获取、重试和数据过期
  • 防止组件中散落大量异步状态代码
  • 配合 AbortSignal 取消不再需要的查询

它不自动解决什么

  • 写操作的业务幂等性
  • 已经开始的服务端任务取消
  • 用户连续点击导致的重复订单
  • 所有闭包、定时器和订阅泄漏

mutation 往往代表有副作用的写操作。即使前端不再等待结果,服务端写入也可能已经发生,因此不能把“取消 mutation”当成事务回滚。提交按钮锁只能改善交互,真正的重复提交保护仍应依赖服务端幂等键或唯一约束。

原生请求取消:AbortController

取消上一次搜索

let controller: AbortController | undefined

async function search(keyword: string) {
controller?.abort('superseded')
controller = new AbortController()

try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(keyword)}`,
{ signal: controller.signal },
)
return await response.json()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
throw error
}
}

超时与组件卸载

const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort('timeout'), 5_000)

fetch('/api/report', { signal: controller.signal })
.finally(() => clearTimeout(timeoutId))

// React effect cleanup、Vue onUnmounted 等卸载流程中:
controller.abort('unmounted')

取消错误通常不应展示成“请求失败”。需要把用户取消、超时、离线和真实服务端错误分开处理。

只阻止旧结果提交:版本号模式

有些异步 API 不支持 AbortSignal,这时至少要保证旧结果不能覆盖新结果。

let version = 0

async function load() {
const currentVersion = ++version
const data = await fetchData()

if (currentVersion !== version) return
render(data)
}

这个方案只解决状态竞态,不会节省网络和服务端资源

怎么让后端真正取消任务

方案一:无副作用的短请求,可在连接提前结束时尝试取消

适合搜索、列表查询等与 HTTP 响应同生命周期、没有业务副作用的短任务。客户端已经无法接收结果时,继续执行通常没有价值。

但要先明确:服务端只能观察到 HTTP 连接或 stream 提前结束,无法判断具体原因。以下情况在服务端看来可能完全相同:

  • 前端代码主动调用 controller.abort()
  • 用户刷新或关闭页面
  • 路由切换导致请求被取消
  • 浏览器、代理或网络异常断开

controller.abort('superseded') 中的 reason 只存在于浏览器 JavaScript,不会自动通过 HTTP 发送给服务端。因此,监听连接关闭不能表达“用户明确要求取消业务任务”。

以 Express 为例,可以在响应尚未正常结束时,将断连信号向下游传播:

app.get('/api/search', async (req, res, next) => {
const controller = new AbortController()

const handleClose = () => {
// close 也可能发生在正常结束之后,需要额外判断
if (!res.writableEnded) {
controller.abort(new Error('Client disconnected'))
}
}

res.on('close', handleClose)

try {
const rows = await repository.search({
keyword: String(req.query.q),
signal: controller.signal,
})

if (!controller.signal.aborted) {
res.json(rows)
}
} catch (error) {
if (!controller.signal.aborted) {
next(error)
}
} finally {
res.off('close', handleClose)
}
})

这段代码仍然只是示意,实际实现需要确认:

  • 当前 Node.js 版本和 Web 框架对 closeaborted 等事件的准确语义
  • 数据库驱动、HTTP 客户端和下游 SDK 是否真正支持取消
  • 代理、网关和负载均衡器是否会及时传播客户端断连
  • 服务端是否设置了独立 deadline,避免只能依赖客户端断开

如果底层依赖不支持取消,AbortSignal 只能阻止后续响应或状态提交,不能停止已经开始的 SQL、计算或第三方调用。

⚠️ 订单、支付、写入、导出、AI 生成等任务不应因为页面关闭就自动停止。它们需要独立的任务生命周期;如果用户必须明确表达取消意图,应使用任务 ID 和取消接口。

方案二:任务 ID + 显式取消接口

适合导出、AI 生成、视频处理等脱离单次 HTTP 生命周期的长任务。

POST /api/jobs -> 202 { jobId, status: "running" }
GET /api/jobs/:jobId -> 查询进度
DELETE /api/jobs/:jobId -> 请求取消

前端示例:

const { jobId } = await fetch('/api/jobs', {
method: 'POST',
}).then(response => response.json())

await fetch(`/api/jobs/${jobId}`, {
method: 'DELETE',
})

后端不要直接声称“已取消”,而应区分:

  • cancel_requested:已收到取消请求
  • cancelled:工作线程确认停止并完成清理
  • completed:取消到达前任务已经完成
  • failed:任务异常失败

协作式取消

大多数服务端任务不能被安全地强制终止,而是由任务主动检查取消状态。

async function generateReport(jobId: string) {
for (const batch of batches) {
if (await isCancelRequested(jobId)) {
await cleanupPartialResult(jobId)
await markCancelled(jobId)
return
}

await processBatch(batch)
}

await markCompleted(jobId)
}

检查点应放在批次之间、外部调用之前和昂贵计算阶段之间。单个步骤如果运行很久,仍然需要该依赖自身支持取消或超时。

后端取消的关键设计

  • 幂等:重复调用取消接口结果一致
  • 权限:只能取消当前用户有权管理的任务
  • 状态机:处理“完成”和“取消”同时发生的竞态
  • 清理:释放锁、临时文件、数据库连接和未完成上传
  • 事务边界:已提交的数据不能靠取消自动回滚
  • 下游传播:数据库、RPC、第三方 HTTP 调用都接收 deadline 或取消信号
  • 可观测性:记录 requestIdjobId、取消原因、耗时和最终状态
  • 兜底超时:即使客户端消失,服务端任务也必须有最大执行时间

方案选择

场景推荐方案
列表、详情、搜索等读取TanStack Query + 在 queryFn 中消费 signal
用户快速切换筛选条件正确设计 queryKey,必要时取消旧查询
不支持取消的异步 API版本号或只接受最新结果
普通表单提交mutation + 服务端幂等键 + 唯一约束
无副作用、与响应同生命周期的短任务检测连接提前结束 + 全链路传播取消 + deadline
导出、生成、转码等长任务任务 ID + 取消接口 + 协作式取消
定时器、订阅、事件监听在组件卸载时清理资源

排查清单

  • queryKey 是否包含所有会影响结果的参数
  • queryFn 是否真正消费了 TanStack Query 提供的 signal
  • 取消错误是否与真实失败分开处理
  • 写操作是否有服务端幂等键或唯一约束
  • 浏览器取消后,服务端日志中的任务是否仍在运行
  • 服务端取消信号是否传播到数据库和下游请求
  • 长任务是否有明确的状态机与取消接口
  • 任务是否定期检查取消状态并清理部分结果
  • 客户端和服务端是否都设置了合理超时
  • 页面卸载后是否仍有定时器、订阅或状态写入

核心结论

  1. TanStack Query 负责服务端状态与读取竞态,是前端请求管理的优先方案。
  2. AbortController 取消的是客户端等待和网络请求,不天然等于服务端任务停止。
  3. 无副作用、与响应同生命周期的短任务,可以在连接提前结束时尝试取消;这不能代表用户的明确取消意图。
  4. 长任务或带副作用的任务应使用任务 ID、显式取消接口和协作式取消,不应与浏览器连接生命周期绑定。
  5. 写操作必须依靠幂等、事务和状态机,不能把前端取消当成回滚。