「等人」状态被当成「等机器」——轮询频率分类错误引发的请求爆炸
某个后台任务管道(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)queued 和 running 是机器在忙——CPU 在跑、网络在传、服务在处理,1.2s 一次的轮询是合理的,用户也能感知到进度在推进。
action_required(以及 awaiting_scan、pending_sms、oauth_consent_required 之类)是人在忙——服务器已经暂停等待,瓶颈是坐在电脑前的那个人。人的反应时间以秒到分钟计,用 1.2s 轮询这类状态没有任何意义,只是在白白烧钱。
状态枚举里同时存在「机器正在处理」和「等待人类操作」两类值时,如果用单一布尔值把它们合并为「任务活跃」,轮询频率就会被机器侧的高频需求拖着走——即使当前瓶颈是用户本人,服务器也会以同样的频率被反复拉取。
人忘记操作?系统就会一直以机器的速度「催」一个不在场的人,直到有人注意到。
正解
将状态集合按「谁是瓶颈」拆分为两档,分别赋予不同的轮询间隔:
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 时,轮询会自动从慢档切回快档。
有人会想「既然不是机器在跑,干脆不轮询」——这会导致用户完成扫码后,前端感知不到状态变化,页面冻结直到手动刷新。人类操作是异步的、前端不知道什么时候会发生,慢轮询而非零轮询才是正确答案。
- 机器忙(
queued/running)→ 快档 1.2s,进度实时可见 - 人在等(
action_required/awaiting_scan/pending_sms等)→ 慢档 5s,成本降至 20%,UX 无损 - 状态切换后,重新调用
syncPolling自动换档 - 如果 human-wait 窗口通常超过 30s,可进一步考虑 SSE/WebSocket 推送,彻底消灭轮询
排查清单
收到「网络面板一直在闪 /api/xxx」的 bug 报告时,先确认:
- 这个端点被哪些
setInterval调用?(多个地方可能并发) - 触发轮询的条件布尔值里,哪些枚举值是「等人」而非「等机器」?
- 前端在 human-wait 状态下,60s 内发出多少请求?
- 该接口的响应有没有被缓存或喂给 LLM 上下文?(如果是,成本放大)
一句话外卖
设计轮询逻辑之前,先问自己:现在这个状态的瓶颈是 CPU 还是人——答案不同,合理频率相差 4 倍以上。