轮询实现的几种方式及性能优化实践
项目初期的技术选型
最近在搞一个后台管理系统的实时订单监控模块,客户那边想要看到新订单进来的时候页面能立马有反应,不能靠手动刷新。一开始我寻思着上 WebSocket 吧,高大上,实时性也好。但问题是后端同学说他们那边现在没打算搞长连接,服务架构也不支持,而且上线时间紧,没法配合改太多东西。
那就只能退而求其次——轮询。虽然这玩意儿听起来有点老派,像是十年前的方案,但在这种对接受限、工期紧张的情况下,反而是最稳妥的路子。最后决定用短轮询,每隔几秒请求一次接口,看有没有新订单进来。
其实我也考虑过长轮询或者 SSE(Server-Sent Events),但考虑到兼容性和维护成本,加上团队里新人接手的可能性,干脆就用最简单的 setInterval + fetch 实现。毕竟不是所有项目都要追求技术先进,稳定交付才是第一位。
核心代码就这么几行
轮询本身的实现非常简单,真正花时间的是后续的各种边界处理和优化。下面是我最初写的版本:
const POLLING_INTERVAL = 5000; // 5秒轮询一次
let pollingId = null;
function startPolling() {
const url = 'https://jztheme.com/api/orders/latest';
pollingId = setInterval(async () => {
try {
const response = await fetch(url);
const data = await response.json();
if (response.ok) {
handleNewOrders(data);
} else {
console.warn('轮询失败,状态码:', response.status);
}
} catch (error) {
console.error('网络异常:', error);
}
}, POLLING_INTERVAL);
}
function stopPolling() {
if (pollingId) {
clearInterval(pollingId);
pollingId = null;
}
}
然后在页面加载时调用 startPolling(),离开页面时调用 stopPolling()。看上去没啥问题对吧?我也这么觉得,直到上了测试环境……
最大的坑:性能问题
刚上线测试,前端监控系统就开始报警了——内存占用持续上涨,几个小时后页面卡得几乎点不动。我以为是内存泄漏,排查半天发现并不是 DOM 没销毁或者事件没解绑,而是轮询过程中频繁创建异步请求,加上响应数据量不小,导致 GC 来不及回收。
更严重的是,用户可能开了多个标签页,每个都在疯狂轮询,服务器压力也上来了。运维大哥直接找上门:“你这个接口 QPS 快到 300 了,能不能悠着点?”
这里注意我踩过好几次坑:setInterval 并不会因为上一次请求还没结束就暂停下一次执行。也就是说,如果接口耗时超过 5 秒(比如网络差),下一个请求就会叠上来,形成“请求堆积”。这在弱网环境下特别致命。
改用递归轮询解决堆积问题
后来调整了方案,不再用 setInterval,而是用递归调用 setTimeout,确保前一个请求完成后再发起下一个。改完之后,QPS 直接降了一半,内存也不再持续增长。
let isPolling = false;
async function poll() {
if (isPolling) return; // 防止并发执行
isPolling = true;
try {
const response = await fetch('https://jztheme.com/api/orders/latest');
const data = await response.json();
if (response.ok) {
handleNewOrders(data);
}
} catch (error) {
console.error('轮询出错:', error);
} finally {
isPolling = false;
}
// 不管成功失败,都等 5 秒再继续
setTimeout(poll, 5000);
}
function startPolling() {
poll(); // 立即启动第一次轮询
}
function stopPolling() {
// 这里其实有个遗憾:无法清除 setTimeout
// 所以我们加了个 isPolling 标志位来控制流程
}
这样改完之后,至少不会再出现请求堆叠的问题。虽然 setTimeout 不能像 clearInterval 那样直接取消,但我们通过布尔锁控制了流程,也算折中方案。
不过这里还是留了个小问题:页面切换路由时,如果没及时调用 stopPolling,setTimeout 还在跑。后来我在组件卸载时加了个标记:
let shouldStop = false;
function startPolling() {
shouldStop = false;
poll();
}
function stopPolling() {
shouldStop = true;
}
async function poll() {
if (shouldStop || isPolling) return;
// ... 请求逻辑
if (!shouldStop) {
setTimeout(poll, 5000);
}
}
虽然不够优雅,但亲测有效。项目赶进度,有时候就得接受这种“不太干净”的写法。
节流策略试了又试
接着我又想,能不能根据页面是否可见来控制轮询频率?比如用户切到别的标签页去了,我就把间隔拉长到 30 秒一次,回来再恢复成 5 秒。这不仅能省资源,还能提升用户体验。
于是引入了 visibilitychange 事件:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// 页面不可见,降低轮询频率
shouldStop = true;
setTimeout(() => {
if (document.visibilityState === 'hidden') {
pollWithSlowRate(); // 慢速轮询
} else {
startPolling(); // 恢复正常轮询
}
}, 100);
} else {
shouldStop = false;
startPolling();
}
});
但这里有个细节要注意:不能直接在 visibilityChange 里调 stopPolling(),否则容易断掉太久。我折腾了半天才搞明白,最好是在状态变化后延迟一点判断当前是否仍处于隐藏状态,避免误判。
最终的解决方案
最后定稿的方案是这样的:
- 使用递归 setTimeout 轮询,防止请求堆积
- 添加
isPolling和shouldStop双标志位控制流程 - 监听页面可见性,动态调整轮询间隔
- 接口返回 304 或无新数据时,不做 UI 更新,减少渲染压力
- 增加错误重试机制,连续失败 3 次后暂停轮询 1 分钟,避免雪崩
整体跑下来,现在平均 QPS 控制在 50 以下,用户反馈也没有卡顿问题。虽然不是最优解,但满足当前需求绰绰有余。
回顾与反思
回头看这次轮询的实现,其实技术本身很简单,真正难的是各种边界情况和线上表现。开始没想到一个定时器能引发这么多问题,但从中学到的东西比写十个 CRUD 组件还多。
有几个点我觉得还可以优化,但没来得及做:
- 没有做节流去重,比如短时间内收到重复订单 ID 的情况
- 轮询间隔还是固定值,其实可以做成自适应(根据负载或数据变化频率动态调整)
- 移动端息屏后没有进一步降频,仍有电量消耗
不过目前这些问题都不影响核心功能,所以暂时搁置了。毕竟项目要上线,完美主义是要付出代价的。
另外提醒一点:轮询一定要配监控!我们后来加了日志上报,记录每次轮询的耗时、成功率、数据量,发现问题能第一时间定位。
以上是我的项目经验,希望对你有帮助
轮询看着土,但在实际项目中真的挺常用。尤其当你面对的是老旧系统、外部接口不支持推送、或者工期压死的情况,它就是最靠谱的选择。
这次从踩坑到稳住,花了差不多一周时间调细节,中间差点被自己写的代码气笑。但也正是这些琐碎的问题,才让项目变得更真实。
如果你也在用轮询,欢迎交流你们的处理方式。有没有更好的控制策略?怎么做的容错?说不定下次我能抄个更优解。

暂无评论