如何轮询 API 而不压垮服务器
构建适用于生产环境的 API 轮询器:逐个发送请求、支持取消、抖动退避、处理 Retry-After、暂停隐藏标签页并使用 ETag 缓存。
一个可用于生产环境的 API 轮询器应当做到以下几点:只在上一个响应处理完毕后才发送下一个请求;任务进入终态时停止轮询;请求失败时采用带抖动(jitter)的退避策略;遇到 429 和 503 时遵循 Retry-After;标签页处于隐藏状态时不做任何操作;并使用 ETag,使未变化的数据以空的 304 响应返回。
大多数轮询器最初都是 setInterval 加 fetch 的组合。这种写法一直能正常工作,直到某天接口变慢、API 开始返回 429,或者有人周末忘了关标签页,到周一它还在不停地发请求。
如果你的服务器能够主动推送更新,可能根本不需要轮询。可以对比一下 WebSockets、SSE 与长轮询,对于服务器之间的通信,也可以考虑用 Webhook 替代轮询。如果轮询确实是合适的方案,本文将逐步修复问题,构建一个零依赖的轮询器。每一节都会先展示有问题的版本,再给出修复方案。
核心要点
- 在每次响应之后链式调用
setTimeout,可以确保同一时间只有一个请求在进行中,而setInterval无法保证这一点。 - 全抖动(full jitter)将每次重试的延迟设为
random(0, min(cap, base * 2 ** attempt)),使重试的客户端分散开来,而不是同时涌向正在恢复的服务器。 Retry-After有两种形式:延迟秒数或 HTTP 日期。轮询器必须能解析这两种形式,并在该头缺失或无法解析时回退到退避策略。- 浏览器会对后台标签页中的定时器进行节流,但节流只会让轮询器变慢。真正让它停下来的是检查
document.hidden。 - 在
If-None-Match中发送上一次的ETag,服务器就能在数据未变化时返回不带响应体的304 Not Modified。
API 轮询有哪四种常见错误?
简单粗暴的 API 轮询通常会以四种方式出问题:请求重叠、轮询永不停止、失败时反而更猛烈地请求服务器,以及隐藏的标签页仍在轮询。以下代码片段同时存在这四个问题:
setInterval(async () => {
const res = await fetch('/api/jobs/42');
render(await res.json());
}, 2000); // overlaps when slow, never stops, ignores errors and hidden tabs
- 请求重叠。
setInterval按固定时钟触发,而不是在上一个响应到达后触发。当接口耗时超过间隔时间时,请求就会层层堆积。 - 没有退出条件。 任务完成后没有任何机制停止循环,于是它会一直获取一个永远不会再变化的结果。
- 没有退避。 当服务器返回 500 时,每个客户端都会全速重试,恰恰在服务器最难以承受的时候进一步加重负载。
- 隐藏的标签页。 一个没人在看的标签页仍在持续轮询。
如何链式调用 setTimeout 并在满足完成条件时停止?
如果只在当前响应处理完毕后才调度下一次轮询,那么同一时间最多只会有一个请求在进行中。一个在每次迭代之间休眠的 async 循环就能做到这一点,并且在任务进入终态时立即退出:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function pollJob(url, isDone, interval = 2000) {
while (true) {
const res = await fetch(url);
const data = await res.json();
render(data);
if (isDone(data)) return data;
await sleep(interval);
}
}
pollJob('/api/jobs/42', (job) => job.status === 'done' || job.status === 'failed');
完成条件必须同时覆盖成功和终态失败。如果只检查 'done',那么一个以 'failed' 结束的任务就会被永远轮询下去。
如何使用 AbortController 取消轮询器?
取消轮询器意味着要停止两样东西:正在进行的请求和正在等待的定时器。传给 fetch 的 AbortController 信号可以处理请求,但对已经排队的 setTimeout 不起作用。因此 sleep 也必须监听该信号:
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(id);
reject(signal.reason);
};
const id = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
调用 controller.abort() 会使当前处于 pending 状态的那一个(无论是 fetch 还是 sleep)被 reject,循环随之退出。定时器触发时会移除 abort 监听器,因此复用同一个信号的长时间轮询不会在每次迭代时都新增一个监听器。
如何实现带抖动和上限的指数退避?
请求失败时,在重试前等待 random(0, min(cap, base * 2 ** attempt))。这就是 AWS Exponential Backoff and Jitter 分析中提出的“全抖动”策略。没有随机性的有上限指数退避,仍然会让客户端以同步的集群方式重试。在零与指数上限之间随机选择每次的延迟,就能打散这些集群。
const backoff = (attempt, base = 1000, cap = 30000) =>
Math.random() * Math.min(cap, base * 2 ** attempt);
这个单行的 backoff 函数与 AWS 退避模拟器源码中的全抖动函数一致。在 AWS 2015 年针对 100 个客户端争用同一资源的模拟中,加入抖动后调用总数减少了一半以上。需要注意的是,该模拟针对的是写入争用,而非读取轮询,因此应将这一数据视为 AWS 在该特定场景下的结果。
在循环中,每次失败后递增 attempt,任何一次成功后将其重置为 0。如果不重置,早期的一次偶发故障就会让之后的每次失败都从较长的延迟开始。服务端的 worker 也面临同样的重试问题,任务队列也采用了类似的重试策略来处理。
轮询器应如何处理 429、503 和 Retry-After?
当服务器返回带有 Retry-After 头的 429 或 503 响应时,按照该头指示的时长等待。你自己的退避策略只是该头缺失时的后备方案。Retry-After 头有两种形式:响应到达后需要等待的整数秒数,或者可以重试的 HTTP 日期。RFC 9110 定义了这两种形式,MDN 也介绍了它与 429 Too Many Requests 配合使用的方式。
const MAX_RETRY_AFTER = 5 * 60_000; // design choice, not a spec rule
function retryAfterMs(res) {
const value = res.headers.get('Retry-After');
if (!value) return null;
if (/^\d+$/.test(value.trim())) return Math.min(Number(value) * 1000, MAX_RETRY_AFTER);
const date = Date.parse(value);
if (Number.isNaN(date)) return null;
return Math.min(Math.max(0, date - Date.now()), MAX_RETRY_AFTER);
}
日期形式是与客户端时钟进行比较的。如果两端时钟不一致且该日期已经过去,延迟会被限制为零。如果 Date.parse 返回 NaN,函数返回 null,调用方改用退避策略。上限截断则可以防止配置错误的响应头让轮询器停滞数小时。
对于跨域请求,只有当服务器在 Access-Control-Expose-Headers 中列出 Retry-After 时,JavaScript 才能读取它,因为它不属于 CORS 安全列表中的响应头。如果未暴露该头,res.headers.get() 会返回 null,轮询器会在没有任何报错的情况下回退到普通退避。
如何在标签页隐藏时暂停轮询?
浏览器会对后台标签页中的定时器进行节流,但被节流的轮询器仍会发送请求,只是频率更低。要真正让它停下来,需要检查 document.hidden 并监听 visibilitychange 事件,二者都属于 Page Visibility API。下面这个辅助函数在页面可见时立即 resolve,否则会一直等到页面变为可见:
function whenVisible(signal) {
if (signal?.aborted) return Promise.reject(signal.reason);
if (!document.hidden) return Promise.resolve();
return new Promise((resolve, reject) => {
const done = () => {
document.removeEventListener('visibilitychange', onChange);
signal?.removeEventListener('abort', onAbort);
};
const onChange = () => { if (!document.hidden) { done(); resolve(); } };
const onAbort = () => { done(); reject(signal.reason); };
document.addEventListener('visibilitychange', onChange);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
如果在每次迭代开始时 await 这个函数,隐藏的标签页就不会发送任何请求,而用户返回后的第一个请求会立即发出。有一个局限:如果标签页变为可见时一个较长的退避休眠已经在进行中,轮询器会先等它结束。
使用 ETag,然后整合所有部分
如果在 If-None-Match 头中回传上一次的 ETag,服务器就可以在资源未变化时返回不带响应体的 304 Not Modified。在默认缓存模式下自行设置 If-None-Match,会让 Fetch 规范跳过 HTTP 缓存,因此你的代码会直接收到 304,并且需要自行处理。对于跨域请求,有两个 CORS 细节需要注意:第一,服务器必须暴露 ETag;第二,由于 If-None-Match 不属于 CORS 安全列表中的请求头,发送它会触发预检请求,因此服务器还必须在 Access-Control-Allow-Headers 中列出它。
下表展示了最终的轮询器如何应对各种结果(各状态码的含义请参阅上文链接的 HTTP 状态码参考):
| 响应 | 轮询器操作 | 下次延迟 |
|---|---|---|
| 200 | 更新 UI、检查完成条件、重置尝试次数 | interval |
| 304 | 不更新、重置尝试次数 | interval |
| 429 / 503 | 递增尝试次数 | Retry-After,否则使用退避 |
| 其他 5xx / 网络错误 | 递增尝试次数 | 退避 |
| 其他 4xx | 抛出并暴露错误 | 无 |
| 中止(Abort) | 重新抛出并退出 | 无 |
下面的函数使用了前面各节中的 sleep、backoff、retryAfterMs 和 whenVisible。状态码分支位于 try/catch 之外,因此 4xx 永远不会被误认为网络错误而进行重试。
async function poll(url, { isDone = () => false, onUpdate, signal, interval = 2000, maxAttempts = 8 } = {}) {
let etag = null;
let attempt = 0;
while (true) {
await whenVisible(signal);
let res = null;
try {
res = await fetch(url, { signal, headers: etag ? { 'If-None-Match': etag } : {} });
} catch (err) {
if (signal?.aborted) throw err; // otherwise: network error, res stays null
}
let wait = interval;
if (res?.status === 304) {
attempt = 0; // no body: never call res.json() here
} else if (res?.ok) {
attempt = 0;
etag = res.headers.get('ETag'); // null if absent or not exposed
const data = await res.json();
onUpdate?.(data);
if (isDone(data)) return data;
} else if (!res || res.status === 429 || res.status >= 500) {
if (++attempt > maxAttempts) throw new Error('Polling gave up');
wait = (res && retryAfterMs(res)) ?? backoff(attempt);
} else {
throw new Error(`Polling failed: HTTP ${res.status}`);
}
await sleep(wait, signal);
}
}
由于 attempt 采用前置递增,第一次重试时就已经使用 attempt = 1,因此该次延迟的上限为 2 秒。
封装为 React Hook
import { useEffect, useState } from 'react';
function usePoll(url, isDone) {
const [state, setState] = useState({ status: 'polling', data: null });
useEffect(() => {
const controller = new AbortController();
poll(url, { isDone, signal: controller.signal, onUpdate: (data) => setState({ status: 'polling', data }) })
.then((data) => setState({ status: 'done', data }))
.catch((error) => { if (!controller.signal.aborted) setState({ status: 'error', error }); });
return () => controller.abort();
}, [url]);
return state;
}
React 会在组件卸载时以及 url 变化导致 effect 重新执行之前运行 effect 清理函数,因此旧的轮询器总会被中止。请将 isDone 定义在组件外部,以保持其引用稳定。'error' 状态的作用是让 UI 能够渲染“我们已停止检查,是否重试?”这样的状态。在那些不向外暴露自身状态的轮询器的会话回放中,经常能看到一个永远转个不停的加载动画,而其背后的网络请求早已停止或在持续失败。
总结
一个不会压垮服务器的轮询器归结为以下几条规则:同一时间只发一个请求;有明确的退出条件;使用带抖动且在成功后重置的退避策略;遵从 Retry-After;隐藏的标签页不发请求;使用条件请求,让未变化的数据几乎零成本。用上文的 poll 函数替换你的 setInterval,然后检查服务器是否返回了 ETag 和 Retry-After,并将二者暴露给跨域客户端。如果缺少这些服务端支持,客户端代码会静默地回退到普通退避和完整响应。
常见问题
当用户切换到其他应用窗口时,轮询器会暂停吗?
不一定。当标签页切换到后台、浏览器窗口被最小化或操作系统锁屏时,页面会变为隐藏状态。而页面只要仍有一部分在屏幕上可见,其 visibilityState 就会保持为 'visible',失去焦点给其他窗口也不会让它变为隐藏。这种情况下轮询器会继续运行。如果你希望在窗口失去焦点时也暂停,可以额外监听 blur 和 focus 事件。
全抖动、等抖动和去相关抖动有什么区别?
它们分别以不同的方式对有上限的指数退避进行随机化。全抖动在零与 min(cap, base * 2 ** attempt) 之间选取延迟。等抖动将该上限的一半固定,另一半随机化。去相关抖动在 base 与上一次延迟的三倍之间选取延迟,并设有上限。在 AWS 的模拟中,等抖动是三种抖动方案中效果最差的;全抖动的工作量比去相关抖动更少,但耗时略长。
可以在 Node.js 中使用这个轮询器吗?
可以,只需做一处修改。Node.js 18 及以上版本提供了全局 fetch(在 Node.js 21 中成为稳定特性)和全局 AbortController。Node.js 中没有 document 对象,因此 whenVisible 会抛出 ReferenceError。添加一个 typeof document 检查,在非浏览器环境下直接 resolve 即可。退避、Retry-After 和 ETag 处理无需任何改动。Node.js 不强制执行 CORS,因此所有响应头都可以读取。
为什么在我没有自行设置 If-None-Match 时,fetch 从不返回 304?
浏览器的 HTTP 缓存会在内部处理重新验证。当某个已存储的响应需要重新验证时,浏览器会自行添加条件请求头。如果服务器返回 304,fetch 会把缓存中的响应以 200 状态码和完整响应体交给你的代码,因此你无法得知这次请求是条件请求。只有手动设置 If-None-Match,才能让 JavaScript 看到 304。