实时消息推送三剑客:轮询、WebSocket、SSE原理对比与选型指南
发布时间:2026/10/7 14:28:27 作者:尧图编辑部 阅读量:1,286

前端做多了就会发现凡是涉及实时消息推送的需求最后一定会回到轮询、WebSocket、SSE这三个选项上。直播间弹幕、客服聊天、运维大屏、订单状态刷新、AI 流式输出……产品开口都是我要实时看到变化可真到技术选型的时候很多人连这三者的区别都说不清更别谈为什么某个方案会把服务器打爆了。这篇文章我就把自己这些年在这条路上踩过的坑、总结的经验全部倒出来把短轮询、长轮询、WebSocket、SSE的原理、优缺点、适用场景掰开揉碎讲清楚还会带上可以直接抄作业的代码片段和真实案例。无论你是刚入行的前端新手还是要做技术方案评审的资深开发这篇都能给你一个相对完整的参考坐标系。1. 先搞清楚HTTP 本身的请求-响应模型是实时推送最大的敌人想理解实时推送先得明白一个基本前提传统的 HTTP 协议是单向的、由客户端发起的。浏览器不发请求服务器就不会搭理你哪怕服务器那边已经炸成一锅粥浏览器也毫不知情。这个机制在设计之初是为了简单可靠但到了实时场景就成了最尴尬的先天缺陷。1.1 为什么实时在 Web 世界里这么难举一个生活化的例子你让外卖小哥送餐传统 HTTP 模式是你每过五分钟打电话问一次送到了吗——这就是轮询。你直接跟小哥加微信他到了随时告诉你——这是 WebSocket。你打开外卖 App 的订单追踪页平台主动往你屏幕上推更新——这是 SSE。所以本质上前端实时消息推送要解决的只有一个问题**如何让服务器在有新数据时能主动通知浏览器而不是等浏览器反复来问。**而围绕如何主动,业界给出的答案无非三条路不断地问轮询、建立一条全双工管道WebSocket、建立一条服务器的单向广播管道SSE。1.2 三种方案横评的第一印象先把结论放在前面方便你有个整体印象轮询兼容性满分实现最简单但浪费最严重服务器压力随客户端数量线性上涨。WebSocket真正的全双工实时通道功能最强大但协议复杂、维护成本高、需要处理的心跳和重连都是额外负担。SSE轻量级单工推送基于 HTTP自动重连对 AI 流式输出和通知类场景非常契合但只能服务端往客户端推。这三者没有绝对的好坏只有合不合适。接下来的内容我会把每个方案的原理、代码、坑点全部铺开来讲。2. 轮询机制详解短轮询与长轮询的伪装实时轮询分两种短轮询和长轮询。很多人以为轮询就是定时器加 setInterval其实长轮询是一个完全不同的思路两者的实时性和服务端压力差别很大。2.1 短轮询写起来最爽炸起来也最爽短轮询的实现几乎没有技术含量。前端每隔几秒发一个普通 HTTP 请求后端查到数据就返回没查到也返回空数据然后前端继续下一轮// 前端短轮询示例 async function pollMessages() { try { const res await fetch(/api/messages?after12345); const data await res.json(); renderMessages(data); } catch (err) { console.error(轮询请求失败, err); } finally { // 无论成功失败3 秒后发起下一次 setTimeout(pollMessages, 3000); } }后端就是一个普通接口毫无特殊处理。这种方式的优点是向前兼容一切哪怕你后端是个纯静态 JSON 文件都能配合调试无比方便。但缺点也致命大多数请求是无效请求。3 秒轮一次如果数据每 30 秒才更新一次那么 90% 以上的请求都在白白消耗带宽和服务端 CPU。这里我吃过一个很大的亏。早期做过一个在线人数统计大屏用的就是 1 秒一次短轮询。单个用户访问没问题结果现场大屏一开、几百个客户端同时轮询服务端直接被打到 CPU 100%。后来加了 200ms 到 800ms 的随机抖动jitter打散请求到来的节奏才勉强撑住。所以如果非要用短轮询请务必注意轮询间隔不要固定随机抖动一定要加否则所有客户端在同一个时间点发起请求会对服务端造成惊群效应。2.2 长轮询用挂起换来接近实时的体验长轮询的思路比较巧妙。客户端发出请求后服务端并不立即返回而是把请求挂起来等有新数据了再返回。客户端拿到数据后立刻发起下一次请求形成一个永远挂着一个请求的状态// 前端长轮询示例 async function longPoll() { try { const res await fetch(/api/messages/long-poll); const data await res.json(); renderMessages(data); } catch (err) { console.error(长轮询连接异常3 秒后重新连接, err); await new Promise(r setTimeout(r, 3000)); } // 关键处理完数据后立即发起下一次请求 longPoll(); }服务端侧Java 可以用 DeferredResultNode.js 可以暂存请求的 response 对象等有数据到达时再 end。这样一来客户端不需要反复发无效请求数据的到达延迟被压缩到极低——只要服务端一有新数据挂着的请求立刻返回。长轮询的实时性其实相当不错几年前的 Web 聊天室基本都是这个方案。但它的代价是把压力从客户端频繁请求转移到了服务端大量挂起连接。每个挂起的请求都占用一个连接、一部分内存如果同时在线人数多连接数会非常恐怖部分服务器或云厂商的负载均衡还有连接数上限很容易触发连接耗尽的问题。2.3 轮询方案的适用场景与红线根据我的实践经验轮询比较适合以下场景数据更新频率低、实时性要求不高比如订单状态、审批进度。短生命周期项目或内部工具不想引入额外复杂架构。目标用户环境老旧不支持 WebSocket。但有两个红线碰了就翻车轮询间隔小于 1 秒——这个频率下大部分请求都在打空气不如直接上长连接。固定轮询间隔且客户端量大——一定要加随机抖动否则流量峰值会把服务端打崩。3. WebSocket 全解析全双工管道的强大与代价如果说轮询是反复打电话问WebSocket 就是加微信实时聊。它是目前唯一能让服务器主动、双向、实时收发数据的方案也是 IM、协同编辑、游戏这类强交互应用的首选。3.1 WebSocket 的握手与双工原理WebSocket 的建立过程其实是在 HTTP 之上借道完成的。浏览器先发一个带 Upgrade 头的 HTTP 请求服务端同意后返回 101 Switching Protocols然后这条 TCP 连接就从 HTTP 协议切换成了 WebSocket 协议。之后双方都可以随时向对方发送数据帧不再受一问一答的束缚。// 前端 WebSocket 基础用法 const ws new WebSocket(wss://api.example.com/ws); // 连接建立 ws.addEventListener(open, () { console.log(连接已建立); ws.send(JSON.stringify({ type: join, room: 001 })); }); // 收到消息 ws.addEventListener(message, (event) { const msg JSON.parse(event.data); handleMessage(msg); }); // 连接关闭 ws.addEventListener(close, (event) { console.log(连接关闭, event.code, event.reason); }); // 连接异常 ws.addEventListener(error, (err) { console.error(WebSocket 错误, err); });注意WebSocket 原生支持发送文本和二进制数据ArrayBuffer、Blob这是 SSE 做不到的。做直播弹幕、实时画板、文件传输这类需要二进制传输的场景基本只能靠它。3.2 心跳机制的实现与作用WebSocket 最大的坑在于连接断开时你和服务器常常都不知道。比如用户网络切到了 Wi-Fi或者中间代理把空闲连接回收了TCP 连接可能已经断了但双方还傻乎乎地以为连接活着。这时候消息就会静默丢失。解决办法就是心跳机制。我的通用做法是每 30 秒发一个 ping服务端回一个 pong超过 3 次没收到 pong 就判定连接断开触发重连。// 心跳 断线重连完整示例 class RealtimeClient { constructor(url) { this.url url; this.ws null; this.heartbeatTimer null; this.missCount 0; this.reconnectAttempts 0; this.reconnectDelay 1000; this.connect(); } connect() { this.ws new WebSocket(this.url); this.ws.addEventListener(open, () { this.reconnectAttempts 0; this.startHeartbeat(); console.log(连接建立开始心跳); }); this.ws.addEventListener(message, (event) { const msg JSON.parse(event.data); if (msg.type pong) { // 收到 pong重置连续未响应计数 this.missCount 0; return; } this.handleMessage(msg); }); this.ws.addEventListener(close, () { clearInterval(this.heartbeatTimer); this.scheduleReconnect(); }); this.ws.addEventListener(error, () { // error 之后一般会触发 close所以重连逻辑放在 close 里 console.error(连接异常); }); } startHeartbeat() { this.missCount 0; clearInterval(this.heartbeatTimer); this.heartbeatTimer setInterval(() { if (this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: ping })); this.missCount; // 连续 3 次未收到 pong主动断开并重连 if (this.missCount 3) { console.warn(心跳超时主动断开); this.ws.close(); } } }, 30000); } scheduleReconnect() { // 指数退避避免断连风暴 const delay Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000); setTimeout(() { this.reconnectAttempts; this.connect(); }, delay); } }这里有个细节值得多说一句重连必须用指数退避1s、2s、4s、8s……封顶 30s。如果不这样做服务端一旦抖动重启所有客户端都会在同一秒疯狂重连造成重连风暴直接把刚起来的服务再次打死。我见过不止一次线上事故是这么发生的。3.3 WebSocket 的真实成本Nginx、负载均衡、鉴权WebSocket 看起来美好但落地时有一堆基础设施要配合。首先如果用了 Nginx 反向代理必须配置 Upgrade 头location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; }其次云厂商的负载均衡SLB默认可能不支持 WebSocket需要单独开启相关选项。我遇到过几次线上连接 100% 失败排查到最后发现是压测环境少开了 SLB 的 websocket 支持。还有鉴权问题。WebSocket 的 URL 无法自定义 Header只能通过 query 参数传 tokenwss://api.example.com/ws?tokenxxx。token 放在 URL 里会出现在日志中有泄露风险需要配合短期有效期 token 使用。4. SSE 深度剖析被低估的单向推送利器SSEServer-Sent Events是三种方案里最容易被人忽略的一个。很多人一听到服务端推送就想到 WebSocket却不知道 SSE 在某些场景下比 WebSocket 更适合、更省事。4.1 SSE 到底是什么SSE 其实很简单客户端用 EventSource 对象发起一个 HTTP 请求服务器端不关闭响应而是源源不断往响应体里写数据。数据格式有固定规范data: 这是一条普通消息\n\n event: orderStatus data: {id: 123, status: shipped}\n\n每个消息以空行结尾data 表示数据内容event 表示自定义事件名。前端代码非常简洁// 前端 SSE 用法 const source new EventSource(/api/events); // 监听默认 message 事件服务端只写了 data source.addEventListener(message, (event) { console.log(event.data); }); // 监听自定义事件服务端写了 event: orderStatus source.addEventListener(orderStatus, (event) { console.log(订单状态变更, event.data); }); // 连接会由 EventSource 自动处理重连 source.addEventListener(error, () { console.log(连接断开EventSource 会自动重连); });最让我欣赏的一点是SSE 的断线重连是浏览器原生支持的。只要连接断开EventSource 会自动按服务端返回的 retry 时间默认几秒重新发起连接完全不需要自己写重连逻辑。相比之下WebSocket 的重连要自己折腾半天。4.2 SSE 的保活机制与数据格式这里必须讲一个非常容易踩的坑SSE 连接如果一段时间没有任何数据中间代理设备Nginx 默认 60 秒就会认为连接闲置直接把连接断开。这种断开会表现成前端事件循环里不停触发 error 事件而服务端那边其实什么都没干。线上最常见的报错就是stream disconnected before completion: idle timeout waiting for sse这就是 Nginx 的 proxy_read_timeout 默认 60 秒无人数据传输导致断开的直接表现。解决办法有二Nginx 放宽超时时间location /api/events { proxy_pass http://backend; proxy_set_header Connection ; proxy_http_version 1.1; proxy_read_timeout 3600s; proxy_buffering off; }注意 proxy_buffering off 也必须加上否则 Nginx 默认会把上游数据缓冲起来一次性转发SSE 的实时性就没了。服务端发送注释行保活在服务端每 15~30 秒输出一个冒号开头的注释行按 SSE 规范这种行会被客户端忽略但能起到保持连接活跃的作用: keep-alive\n\nNode.js 服务里可以这样实现res.write(: keep-alive\n\n); setInterval(() res.write(: keep-alive\n\n), 15000);4.3 SSE 适合什么场景SSE 虽然是单向的但服务器往浏览器推这个场景覆盖了大量业务需求AI 流式输出ChatGPT 式打字机效果服务端生成一段推一段。站内通知、消息提醒右上角小红点、未读消息数。行情看板、监控数据股票价格、服务器指标刷新。日志实时滚动构建日志、任务执行进度。这几个场景的共同点是前端只需被动接收不需要给服务器发送大量实时数据。如果业务需求恰好落在这个区间直接用 SSE 会比 WebSocket 省掉大量协议层面的复杂代码。5. 三种方案对比选型其实有章可循前面分别讲了三个方案的原理和代码这一节我把它们放在同一张桌子上做一次全面对比再给出我自己的选型决策思路。5.1 核心特性对比表对比维度短轮询长轮询WebSocketSSE通信方向客户端→服务端客户端→服务端全双工双向服务端→客户端单向实时性取决于轮询间隔通常有秒级延迟几乎实时实时实时连接开销每次请求都需要新建/销毁连接持续挂起一个连接一次握手长期占用 TCP一次 HTTP 请求长期占用服务端压力高请求频繁且大量无效中高连接挂起占资源中连接持久但消息可管控低纯被动推送二进制支持支持HTTP 普通上传下载支持支持不支持纯文本自动重连无无需自己实现无需自己实现原生支持协议复杂度无低高有握手、帧协议、心跳低纯文本协议兼容性所有环境所有环境现代浏览器现代浏览器IE 不支持从这张表能很清楚地看到没有哪个方案是全面碾压的每个方案都有自己的主客场。5.2 我的选型决策流程这几年前端方案做多了我总结了一套非常朴素的选择方法遇到实时推送需求按这个顺序走基本不会翻车先问服务器需要主动往浏览器推数据吗不需要、只是定时刷新 → 短轮询就够了别过度设计。需要 → 进入下一步。再问浏览器需要向服务器发送高频实时数据吗需要聊天、协同、游戏操作 → 选 WebSocket。不需要或频率很低 → 进入下一步。问题三数据内容是文本为主还是可能有二进制纯文本、JSON → SSE 是性价比最高的方案。有二进制音频流、图像帧 → WebSocket 没得跑。最后问团队维护能力如何小团队、追求开发效率、不想写心跳重连 → 优先 SSE。已有完善的基础设施、需要上生产级 IM → WebSocket。5.3 一个具体的选型案例举个例子之前有个项目要做 AI 对话功能需求是用户发一个问题服务端流式返回答案期间前端要打字机式展示 token不需要用户向服务端发送任何长连接数据。当时团队里有同事直接上了 WebSocket我拦住他理由很简单这事的实时数据流只有一个方向WebSocket 的双向能力完全用不上反而还要自己实现心跳、重连、二进制帧解析。最后用 SSE 一个 EventSource 加 30 行后端代码搞定上线之后跑得很稳Nginx 层把超时调长就再没出过问题。这就是典型的杀鸡用了牛刀。避免过度设计是这一节最想传达的工程价值观。6. 常见问题与排查技巧实录最后这部分我把三个方案在实际运行中会遇到的高频问题整理成一份速查表全部来自真实线上事故不是书上的理论。6.1 问题排查速查表现象涉及的方案原因解决方案SSE 连接周期性断开每隔约 60 秒重连一次SSENginx proxy_read_timeout 默认 60s 超时且没配 proxy_buffering off调大超时并关闭缓冲服务端加注释行保活WebSocket 连接经常断开close event 的 code 是 1006WebSocket1006 表示连接被异常关闭常见原因是中间代理/防火墙强制断开加心跳保活检查 Nginx Upgrade 配置调整代理空闲超时大量客户端同时轮询导致服务端 CPU 飙升短轮询客户端请求同步并发形成惊群效应引入随机抖动jitter打散请求发起时间长轮询连接数缓慢上涨最终达到上限长轮询部分请求失败后客户端没有正确结束或者服务端挂起后一直未释放服务端挂起请求必须设置超时兜底超时返回空让客户端重连页面切到后台WebSocket 一段时间后消息全丢WebSocket浏览器对后台标签页的 JavaScript 定时器降频导致心跳发送变慢结合页面可见性 APIvisibilitychange在页面重新可见时立即检查连接状态Nginx 返回 403 或 502WebSocket 握不上WebSocket负载均衡或网关层未开启 Upgrade 支持检查 Nginx/网关配置确认带 Upgrade 头的请求被正确转发切换网络Wi-Fi 切蜂窝后前端一直连不上WebSocket浏览器缓存了旧的 WebSocket 连接或底层网络切换导致连接失效监听 ononline 事件主动关闭旧连接并触发重连SSE 收到的数据不实时一推一整块SSENginx 默认开启 buffering会把响应缓冲后再转发设置 proxy_buffering off服务端推送频率高前端事件触发顺序乱SSEEventSource 消息事件和 HTTP 响应顺序虽然固定但自定义事件混在一起可能产生理解歧义统一报文结构携带自增序号或时间戳便于前端排序6.2 关于前端 SDK与封装的一点体会最后想多说一句和实时推送相关的前端工程化问题。不管选哪种方案我都强烈建议封装一个独立的前端 SDK 或工具模块不要在业务组件里直接 new WebSocket 或 EventSource。我把自己的 SDK 设计成了三个统一的方法connect()负责建立连接和心跳、on(event, callback)注册订阅回调、disconnect()主动断开。内部可以随意替换底层实现——今天用 WebSocket明天换 SSE业务方只需要改一行配置。这样做的最大好处是当后端从 WebSocket 切换到 SSE或者从长轮询升级到 WebSocket 时业务代码不用改一行。这个设计让我在好几个项目里都受益了。有一次客户的网络环境明文禁止 WebSocket我们只改了几行配置就把整个实时通道从 WebSocket 降级成了 SSE前端页面完全无感。这就是封装的价值也是我建议每个前端团队在做实时推送功能前先想清楚的事情。6.3 一个小型实时系统的实践记录再分享一个最近做的实际案例吧刚好把前面所有技术点串起来。系统是一个客服工作台要求坐席端实时接收客户消息同时有客服在相互转接时的高频状态变更。我的最终方案是双通道并行主要消息走 WebSocket因为坐席要发送消息、接收消息、同时还要同步正在输入这类双向实时状态系统通知和工单状态更新走 SSE因为它只是服务器单向推送给坐席EventSource 自动重连又省了我不少事。上线前踩了一个印象很深刻的坑。压测时发现当 2000 个坐席同时在线时WebSocket 的消息延迟从 5ms 飙升到 300ms。排查后发现是后端在处理心跳消息时触发了全量广播把每条 ping 都广播给了所有人。后来调整心跳的发送频率到 30 秒一次并且心跳消息只在服务端处理、不广播延迟立刻降回正常。这件事给我的教训是在实时系统里消息的路由设计比连接层用什么协议更影响性能。全双工的 WebSocket 固然能力最强但如果业务上不区分消息类型、不加控制地广播再好的协议也扛不住流量洪峰。写在最后我个人在实际项目里的默认选择是**能上 SSE 就不上 WebSocket能用长轮询就别碰短轮询。**实时推送最怕的不是技术难而是过度设计和错误选型。上 WebSocket 之前先问自己两遍——真的需要双向吗真的需要心跳重连的复杂度吗如果答案是否定的SSE 这个半个 HTTP 请求的轻量方案往往会给你带来惊喜。再分享一个小技巧不管用哪个方案上线前都建议做一次真实的弱网测试用 Chrome DevTools 的 Network 模拟 3G 网络重点观察断线重连的表现。很多线上事故都不是逻辑错了而是大家漏了网络不是永远可靠这个最基本的假设。把这个假设补上你的实时系统就稳了一大半。