返回博客

点击「开始同步」后立刻刷新,为什么永远看到「空闲」?

在某个后台管理系统里,有一个「开始同步」按钮,点击后会触发一个后端异步任务——实际上是 spawn 了一个 CLI 进程,同步过程少则 10 秒,多则几分钟。按钮旁边会显示当前同步状态。

有一天发现:每次点完「开始同步」,状态依然显示「空闲」。刷新页面还是「空闲」。再等几秒刷新,才变成「同步中」。

现象

// 问题代码
await api.syncStart({ mode })
await loadOverview()   // ← 立刻拉一次概览

触发接口 200 ms 内返回。loadOverview 紧随其后,拿到的 is_syncing: false——状态仍是空闲。用户看到没反应,再点一次,现在有两个并行任务在跑。

根因

后端触发接口做的事情是:spawn CLI 进程,立刻返回 200。CLI 进程本身需要 2–5 秒才会向数据库写入一行 status='running' 的 batch 记录。loadOverview 在触发后 200 ms 就查了数据库,CLI 还没来得及写入,所以查到的依然是触发前的旧状态。

这是一个典型的时序窗口问题:触发成功 ≠ 任务已落库。前端对这个窗口毫无感知,直接用"触发完就刷新"来"观察"结果,就会稳定地看到预触发状态。

坑:触发后立刻刷新,后端任务还没写入数据库

api.start().then(loadOverview) 是这类异步任务场景的经典反模式。触发接口快速返回(< 500 ms),但实际的后台工作进程(CLI / Worker)需要若干秒才能把"我已在运行"写进数据库。这个窗口期内的任何查询,都只能看到旧状态。

更糟糕的是:用户看到「空闲」以为没有效果,会再次点击——两个并行任务同时跑,数据可能乱掉。

正解

核心思路:触发成功后,立刻在本地乐观地切换到"排队中"状态,同时启动衰减轮询,由轮询去跟踪后端真实状态。不要在触发成功后立刻 reload 全局数据。

后端契约

触发接口返回 task_id,并在短时间窗口内幂等(同 scope 重复触发返回 409 + 已有 task_id):

POST /api/sync/start
→ 202 Accepted
{ "task_id": "sync_20260415_142503", "status": "queued" }

状态接口按 task_id 查,单行索引读,< 50 ms:

GET /api/sync/status?task_id=sync_20260415_142503
→ { "status": "running", "progress": 0.42, "message": "已同步 1234/2941" }

前端四件套

① 触发后立刻乐观跳状态,不等第一次轮询:

const { data } = await api.syncStart({ mode })
this.task = { ...data, status: 'queued' }  // 乐观,无需等待
persistTask('sync', data)                  // 持久化到 localStorage
this.startPolling(data.task_id)

② 衰减轮询,开始密集,逐渐放缓,10 分钟后停止自动轮询:

const INTERVALS = [1000, 1000, 2000, 2000, 5000, 5000, 5000, 10000]
// 每次 tick 后取 INTERVALS[Math.min(attempt++, last)]

③ 持久化 task_idlocalStorage,页面刷新 / 跳转后恢复:

created() {
  const p = loadPendingTasks()['sync']
  if (p) { this.task = p; this.startPolling(p.task_id) }
},
beforeDestroy() {
  if (this.cancelPoll) this.cancelPoll()
  // ⚠️ 不要在这里 clearTask!任务还在跑
},

④ 409 防重复:如果触发返回 409,说明同 scope 任务已在运行,直接 attach 到已有 task_id 的轮询上:

if (e.status === 409 && e.data?.task_id) {
  this.task = { task_id: e.data.task_id, status: 'running' }
  this.startPolling(e.data.task_id)
}
正解:乐观本地状态 + 衰减轮询 + localStorage 持久化

触发成功 → 立刻本地切 queued → 持久化 task_id → 启动衰减轮询跟踪真实状态。刷新 / 导航不丢失进度;双击靠后端 409 幂等兜底;轮询到终态(succeeded / failed / cancelled)后才 clearTask 并刷新业务数据。

一句话外卖

异步任务触发接口的"成功"只代表任务已入队,不代表后台进程已经落库——乐观 UI 让用户立刻看到反馈,衰减轮询负责追真实状态,两者缺一不可。