JavaScript 异步与竞态问题
⏱️ 异步问题不能只靠“加一个 AbortController”解决。需要分别处理:结果是否还能提交、网络请求是否被取消、服务端任务是否真正停止。
先区分三件事
| 层级 | 目标 | 常用方案 |
|---|---|---|
| 状态层 | 旧结果不能覆盖新结果 | TanStack Query、版本号、只接受最新请求 |
| 网络层 | 不再传输无用的请求与响应 | AbortController、AbortSignal |
| 任务层 | 服务端停止数据库查询、计算或队列任务 | 断连传播、任务 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 框架对
close、aborted等事件的准确语义 - 数据库驱动、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 或取消信号
- 可观测性:记录
requestId、jobId、取消原因、耗时和最终状态 - 兜底超时:即使客户端消失,服务端任务也必须有最大执行时间
方案选择
| 场景 | 推荐方案 |
|---|---|
| 列表、详情、搜索等读取 | TanStack Query + 在 queryFn 中消费 signal |
| 用户快速切换筛选条件 | 正确设计 queryKey,必要时取消旧查询 |
| 不支持取消的异步 API | 版本号或只接受最新结果 |
| 普通表单提交 | mutation + 服务端幂等键 + 唯一约束 |
| 无副作用、与响应同生命周期的短任务 | 检测连接提前结束 + 全链路传播取消 + deadline |
| 导出、生成、转码等长任务 | 任务 ID + 取消接口 + 协作式取消 |
| 定时器、订阅、事件监听 | 在组件卸载时清理资源 |
排查清单
-
queryKey是否包含所有会影响结果的参数 -
queryFn是否真正消费了 TanStack Query 提供的signal - 取消错误是否与真实失败分开处理
- 写操作是否有服务端幂等键或唯一约束
- 浏览器取消后,服务端日志中的任务是否仍在运行
- 服务端取消信号是否传播到数据库和下游请求
- 长任务是否有明确的状态机与取消接口
- 任务是否定期检查取消状态并清理部分结果
- 客户端和服务端是否都设置了合理超时
- 页面卸载后是否仍有定时器、订阅或状态写入
核心结论
- TanStack Query 负责服务端状态与读取竞态,是前端请求管理的优先方案。
- AbortController 取消的是客户端等待和网络请求,不天然等于服务端任务停止。
- 无副作用、与响应同生命周期的短任务,可以在连接提前结束时尝试取消;这不能代表用户的明确取消意图。
- 长任务或带副作用的任务应使用任务 ID、显式取消接口和协作式取消,不应与浏览器连接生命周期绑定。
- 写操作必须依靠幂等、事务和状态机,不能把前端取消当成回滚。