返回博客

「等人」状态被当成「等机器」——轮询频率分类错误引发的请求爆炸

某个后台任务管道(Job Pipeline)前端有一个标准的轮询循环:只要有任务「未完成」,就每隔 1.2 秒刷新一次状态。逻辑看起来无懈可击——直到有一天,监控告警显示某个 API 端点在凌晨到早上连续 12 小时每分钟收到 50 次请求,且没有任何业务进展。

原因很简单:一名操作员触发了一个需要扫码登录第三方平台的子任务,但当晚忘记扫码,直接关电脑下班了。

现象

  • 浏览器 Network 面板里 /api/state 以 1.2s 间隔持续刷新,彻夜不停
  • 服务端日志里清一色是相同任务 ID 的状态读取,全部返回 action_required
  • 后端 RDS 读 QPS 异常偏高,Anthropic cache_read token 消耗出现不明尖峰(该接口的响应被下游 LLM 上下文复用)
  • 累计产生约 36,000 次零进展请求(1200ms 间隔 × 60 分钟 × 12 小时)

根因

问题出在一个看似合理的 hasActive 布尔值把两类本质不同的状态混为一谈:

// 出事前的代码
const hasActive = jobs.some(job =>
  ['queued', 'running'].includes(job.status) ||
  ['running', 'action_required'].includes(job.subTask?.status)
)
if (hasActive && !pollTimer) pollTimer = setInterval(refresh, 1200)

queuedrunning机器在忙——CPU 在跑、网络在传、服务在处理,1.2s 一次的轮询是合理的,用户也能感知到进度在推进。

action_required(以及 awaiting_scanpending_smsoauth_consent_required 之类)是人在忙——服务器已经暂停等待,瓶颈是坐在电脑前的那个人。人的反应时间以秒到分钟计,用 1.2s 轮询这类状态没有任何意义,只是在白白烧钱。

坑:用一个 hasActive 把「机器忙」和「人在等」混在一起

状态枚举里同时存在「机器正在处理」和「等待人类操作」两类值时,如果用单一布尔值把它们合并为「任务活跃」,轮询频率就会被机器侧的高频需求拖着走——即使当前瓶颈是用户本人,服务器也会以同样的频率被反复拉取。

人忘记操作?系统就会一直以机器的速度「催」一个不在场的人,直到有人注意到。

正解

将状态集合按「谁是瓶颈」拆分为两档,分别赋予不同的轮询间隔:

const FAST_MS = 1200   // 机器在干活,进度敏感
const SLOW_MS = 5000   // 人在操作,5s 对 UX 无感知影响
 
let pollTimer = null  // { id, interval }
 
function syncPolling() {
  const machineBusy = jobs.some(j => ['queued', 'running'].includes(j.status))
  const humanWait   = jobs.some(j => ['action_required', 'awaiting_scan'].includes(j.subTask?.status))
 
  const want = machineBusy ? FAST_MS : humanWait ? SLOW_MS : 0
 
  if (!want) {
    if (pollTimer) { clearInterval(pollTimer.id); pollTimer = null }
    return
  }
  // 间隔没变则不重建 timer,避免抖动
  if (pollTimer?.interval === want) return
  if (pollTimer) clearInterval(pollTimer.id)
  pollTimer = { id: setInterval(refresh, want), interval: want }
}

关键:syncPolling 必须在每次 refresh() 拉完数据后调用一次,这样当用户完成扫码、状态从 action_required 变回 running 时,轮询会自动从慢档切回快档。

反模式:直接把 action_required 从活跃集合里删掉

有人会想「既然不是机器在跑,干脆不轮询」——这会导致用户完成扫码后,前端感知不到状态变化,页面冻结直到手动刷新。人类操作是异步的、前端不知道什么时候会发生,慢轮询而非零轮询才是正确答案。

正解:两档轮询 + 状态语义分类
  • 机器忙(queued / running)→ 快档 1.2s,进度实时可见
  • 人在等(action_required / awaiting_scan / pending_sms 等)→ 慢档 5s,成本降至 20%,UX 无损
  • 状态切换后,重新调用 syncPolling 自动换档
  • 如果 human-wait 窗口通常超过 30s,可进一步考虑 SSE/WebSocket 推送,彻底消灭轮询

排查清单

收到「网络面板一直在闪 /api/xxx」的 bug 报告时,先确认:

  1. 这个端点被哪些 setInterval 调用?(多个地方可能并发)
  2. 触发轮询的条件布尔值里,哪些枚举值是「等人」而非「等机器」?
  3. 前端在 human-wait 状态下,60s 内发出多少请求?
  4. 该接口的响应有没有被缓存或喂给 LLM 上下文?(如果是,成本放大)

一句话外卖

设计轮询逻辑之前,先问自己:现在这个状态的瓶颈是 CPU 还是人——答案不同,合理频率相差 4 倍以上。