做 Web 开发这些年,几乎每个项目都会碰到同一个需求:怎么让网页实时拿到服务器的最新状态?从早年被人吐槽的“定时刷新”,到后来的长轮询、WebSocket,再到现在的 SSE、WebTransport、Web Push,前端实时消息推送的路子其实一直在进化。
很多人一上来就喊“用 WebSocket”,但 WebSocket 并不是所有场景的最优解。我整理过 7 种能在 Web 项目中落地的实时消息推送方案,从原理、代码到坑点一次讲清楚。这 7 种方案分别是:短轮询、长轮询、HTTP 流式响应、SSE(Server-Sent Events)、WebSocket、WebTransport、Web Push。我会把它们按实现难度、实时性、兼容性、适用场景逐一拆开,再结合我实际项目中踩过的坑,告诉你到底该怎么选、怎么用。
1. 实时消息推送到底在解决什么问题
1.1 先弄清楚需求类型再谈方案
很多人选错方案,不是因为技术不行,而是没搞明白自己要解决的是哪一类问题。实时消息推送的“实时”二字,在不同业务里的含义差别很大。
一类是状态同步型,比如后台任务执行进度、订单状态流转、服务器监控指标刷新。这种场景实时性要求没那么苛刻,1 到 3 秒的延迟用户基本感知不到,重点是“别让用户手动刷新页面”。
第二类是事件通知型,比如新消息提醒、告警推送、版本发布通知。用户希望第一时间收到,但通常不需要和服务器持续双向通信,服务器往浏览器单向推就可以了。
第三类是双向交互型,比如网页聊天、多人协同编辑、实时游戏。这种场景不但要服务器推给浏览器,浏览器也要频繁给服务器发消息,而且对延迟极其敏感。
三种类型对应的技术方案差异很大。状态同步用轮询或 SSE 都行,事件通知适合 SSE 或 Web Push,双向交互基本绕不开 WebSocket。先想清楚业务属于哪一类,再往下看方案对比,思路会清楚很多。
1.2 七种方案的横向对比
我先把 7 种方案放在一张表里,方便你快速建立整体印象。这里的实时性、复杂度都是相对概念,后面会逐项展开。
| 方案 | 通信方向 | 实时性 | 浏览器兼容 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 短轮询 | 单向(浏览器拉取) | 取决于间隔,秒级 | 全部 | 最低 | 低频状态刷新、内部系统 |
| 长轮询 | 单向(客户端等待响应) | 准实时,秒级 | 全部,兼容老浏览器 | 中 | 无 WebSocket 环境下的兼容方案 |
| HTTP 流式响应 | 单向(服务器推送) | 实时 | 现代浏览器 | 中 | 早期直播流、日志输出 |
| SSE | 单向(服务器推送) | 实时 | 除 IE 外基本全支持 | 低 | 通知、行情、进度推送 |
| WebSocket | 全双工 | 实时 | 现代浏览器全支持 | 高 | 聊天、协同、游戏 |
| WebTransport | 全双工 | 超低延迟 | Chrome 系,其他有限 | 很高 | 实时音视频、高频遥测 |
| Web Push | 单向(浏览器推送中心) | 准实时 | 相当广,依赖浏览器服务 | 中 | 离线通知、营销触达 |
从这张表能看出来,WebSocket 不是唯一答案,甚至不是最省事的答案。比如你只需要服务器单方向推消息,SSE 明显比 WebSocket 省心得多。接下来我会把每个方案都拆开讲透。
2. 七种方案逐一拆解(原理与实操)
2.1 短轮询:最朴素也最容易被吐槽
短轮询的原理一句话就能说清:前端用定时器,每隔几秒向服务器发一次 HTTP 请求,服务器把最新数据返回,前端拿到后渲染页面。
前端代码大概长这样:
async function poll() { const response = await fetch('/api/status'); const data = await response.json(); render(data); } // 每 3 秒拉一次 setInterval(poll, 3000);这套方案最大的优势是没有任何门槛,不需要特殊协议,不需要额外依赖,服务端就是一个普通接口,前端就是一次普通请求。在老的 B 端系统、内部管理后台里,到今天还能见到大量这种写法。
但它的缺点同样明显。
首先是无效请求太多。比如 3 秒轮询一次,一天下来就是 28800 次请求,其中绝大部分返回的数据和上一次一样,纯属浪费带宽和服务器资源。其次是实时性受间隔限制,你把间隔调到 1 秒,实时性是上去了,服务器压力也上去了;调到 5 秒,服务器压力小了,用户看到的状态又不够“实时”。
我还踩过一个更微妙的坑:当轮询接口响应变慢时,上一个请求还没返回,下一个请求又发出去了,会出现乱序覆盖。比如第一次请求的数据晚于第二次返回,前端用晚到的旧数据覆盖了新数据,页面状态反而“回退”了。解决方法是加个标志位,或者把间隔改成“上次请求完成后再计时”。
我现在的建议是:短轮询只适合实时性要求极低、请求量很小、不想引入任何复杂度的场景,比如后台管理页面里的“最近一条任务状态”刷新。但凡对实时性有一点要求,就往下看长轮询。
2.2 长轮询:让请求“挂一会儿”再回来
长轮询是对短轮询的一次重要改良。思路仍然是浏览器发 HTTP 请求,但服务器收到请求后不立即返回,而是把连接挂起,直到有新数据或者超时才响应。浏览器收到响应后,立刻再发下一个请求。
这样请求次数从“固定频率”变成“有数据才返回”,实时性提升不少,消息来了基本能在几百毫秒内到达前端。
后端如果用 Java Servlet 3.0+,可以用 AsyncContext 实现挂起和异步返回:
@WebServlet("/poll") public class PollServlet extends HttpServlet { private static final long TIMEOUT = 30000; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) { AsyncContext asyncCtx = req.startAsync(); asyncCtx.setTimeout(TIMEOUT); // 挂起连接,业务线程中调用 asyncCtx.getResponse().getWriter().write(...) // 写完数据后 asyncCtx.complete(); } }Node.js 的 Express 里可以用一个简单的技巧实现:
app.get('/poll', (req, res) => { // 暂时不调用 res.end() messageBus.once('new-message', (msg) => { res.json({ msg }); }); // 30 秒没有新消息就超时返回 setTimeout(() => res.json({ msg: null }), 30000); });长轮询在浏览器兼容性上是全能的,连老掉牙的 IE 都能跑,这也是它在 WebSocket 普及之前成为“准标准”方案的原因。
但它的服务器资源占用很重。每个挂起的请求都占用一个连接和一个线程,几千个客户端同时挂着,服务器线程池很容易被打满。另外,长轮询需要处理好超时、重连和消息堆积。我记得有一次压测时,连接数一上去,服务端线程直接飙到几百个,最后不得不加消息中间件做削峰,才把压力降下来。
所以长轮询更适合的场景是:浏览器环境受限不能上 WebSocket、客户端量级不大、但又要比短轮询更高的实时性。比如老系统的兼容升级,或某些政企内网环境下了 WebSocket 协议。现在的新项目如果只是为了兼容性,我更推荐 SSE,见 2.4。
2.3 HTTP 流式响应:响应不断,数据不断
HTTP 流式响应(也叫 Streaming/Chunked Transfer)的思路和长轮询又不一样:前两者都是“请求-响应”闭环,流式响应则是服务器第一次响应之后,一直不结束响应流,有数据就往流里写,浏览器持续读取。
最早的前端实现方式是 XHR 监听readyState === 3分段读取数据:
const xhr = new XMLHttpRequest(); xhr.open('GET', '/stream'); xhr.onreadystatechange = function () { if (xhr.readyState === 3) { // 读取已接收到的部分内容 console.log(xhr.responseText); } }; xhr.send();后端 Express 里直接往响应对象写内容:
app.get('/stream', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); setInterval(() => { res.write(`当前时间:${new Date().toISOString()}\n`); }, 1000); });这套方案本质上是 SSE 的“非标准前任”,它的问题在于没有一个统一的事件格式,前端拿到的是原始文本,需要自己切分解析;而且浏览器对流式响应的处理行为各不一样,老旧浏览器根本不支持分段读取。
现在开发新功能,我不建议直接裸写 HTTP 流式响应。但你仍然可能在老项目里碰到这种实现,看懂它能帮你排查很多“页面数据一直停在某个值不动”“控制台 Network 里响应一直转圈”的问题。如果你需要标准化的单向实时推送,请直接看下一个方案——SSE,它就是官方把流式响应标准化后的产物。
2.4 SSE:浏览器原生的服务端单向推送
SSE 全称 Server-Sent Events,是 HTML5 标准里专门为“服务器向浏览器单向推送”设计的方案。它建立在 HTTP 协议之上,服务端只需要把响应头设为text/event-stream,然后持续向连接写入数据即可。
服务端 Express 实现:
app.get('/events', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); const timer = setInterval(() => { res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`); }, 1000); req.on('close', () => clearInterval(timer)); });前端使用EventSource这个原生 API,代码非常简洁:
const source = new EventSource('/events'); source.onmessage = (event) => { const data = JSON.parse(event.data); console.log('收到推送:', data); };相比 WebSocket,SSE 最突出的优点有三个:
第一,实现简单,它就是普通 HTTP 请求,不需要协议升级,跨域限制也比 WebSocket 好处理。第二,自带重连和事件恢复机制,浏览器断开后会自动重新发起请求,服务端还可以通过Last-Event-ID下发断点续传的消息,这套机制 WebSocket 要自己写半天。第三,它天然支持多个事件类型,服务端可以发event: order、event: chat之类的事件名,前端用addEventListener分别监听。
SSE 的限制也很明确:只能服务器往浏览器单向推,如果业务里前端也需要频繁发消息,那得上 WebSocket。另外 IE 系浏览器不支持 EventSource,老项目里需要加 polyfill,实际上 polyfill 的实现也是基于轮询或长轮询,效果差不多的。
还有一点容易被忽略:HTTP/1.1 下浏览器对同一域名有 6 个连接的限制,如果同时打开多个 SSE 流,可能占满连接导致页面其他资源加载变慢。HTTP/2 下没有这个问题,但生产环境要记得确认 Nginx 已开启 HTTP/2。
我把 SSE 列为“项目里优先级最高的单工推送方案”,理由就一句话:它把实时推送的最大痛点(长连接管理)交给浏览器实现了。后面章节里我会详细讲 Nginx 配置里的几个坑,SSE 连接莫名其妙断开基本都是栽在那儿。
2.5 WebSocket:全双工实时通信的事实标准
WebSocket 是目前 Web 实时推送绕不开的方案,也是大多数人第一时间想到的方案。它通过一次 HTTP 握手(Upgrade头)把连接从 HTTP 升级为 TCP 长连接,之后客户端和服务器可以互相发消息,全双工通信,协议开销远小于 HTTP 轮询。
前端基础写法:
const ws = new WebSocket('wss://example.com/socket'); ws.onopen = () => { ws.send(JSON.stringify({ type: 'join', room: 'lobby' })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); console.log('收到消息:', msg); }; ws.onclose = () => { console.log('连接关闭'); };Node.js 服务端使用ws库:
const { WebSocketServer } = require('ws'); const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', (socket) => { socket.send(JSON.stringify({ type: 'welcome', message: '连接成功' })); socket.on('message', (data) => { // 广播给所有客户端 wss.clients.forEach((client) => { if (client !== socket && client.readyState === 1) { client.send(data.toString()); } }); }); });我用 WebSocket 做过网页聊天和协同白板,也踩过不少坑,这里挑重点说。
第一个是代理和网关的配置问题,Nginx 默认不允许连接升级,必须在配置里显式打开。第二个是心跳机制。很多人以为 WebSocket 连上了就不会断,实际上一旦长时间没有数据包,中间的网关、负载均衡器都会把空闲连接回收掉。必须在服务端和客户端都实现 ping/pong 心跳。第三个是断线重连,不能一断就疯狂重连,要用指数退避,比如第 1 次等 1 秒、第 2 次等 2 秒、第 3 次等 4 秒,最多到 30 秒封顶,并加入随机抖动防止大量客户端同时重连打满服务器。
如果项目里用 Socket.IO 这类库,其实是好事,因为它把心跳、重连、自动降级到长轮询这些事都封装好了。但要注意:把你的业务逻辑和 WebSocket 协议解耦,不要前端代码里到处是onmessage再拆 JSON,最好统一封装成事件总线风格,后面换库或者接别的端都容易得多。
WebSocket 的适用面最广:聊天、协同编辑、联机游戏、实时报表更新都属于它的主场。只要项目需要双向高频通信,直接选它就对了。
2.6 WebTransport:面向未来的低延迟传输方案
WebTransport 是较新的传输协议,基于 HTTP/3(QUIC)工作,支持可靠的多路复用流和尽力而为的数据报,解决了 WebSocket 在 TCP 层面的队头阻塞问题。简单说,WebTransport 是 WebSocket 的性能进阶版,适合超低延迟、高吞吐场景。
浏览器端使用方式:
const transport = new WebTransport('https://example.com:4433'); await transport.ready; // 创建双向流 const stream = await transport.createBidirectionalStream(); const writer = stream.writable.getWriter(); await writer.write(new TextEncoder().encode('hello')); // 读取服务器消息 const reader = stream.readable.getReader(); const { value } = await reader.read();WebTransport 对开发者的吸引力在于:它支持多路复用,多个数据流互不阻塞;它支持不可靠传输(类似 UDP),适合丢一帧没关系、但延迟必须极低的场景,比如实时音视频、游戏状态同步;它还支持服务端主动推送,天然满足实时需求。
但现阶段要冷静看待它的落地条件。浏览器支持方面,Chrome 系目前比较友好,Firefox 和 Safari 的支持度不够理想,生产环境中要面向全量用户使用,通常还得回退到 WebSocket。服务端也需要完整的 HTTP/3 支持,Nginx 配置 QUIC 需要特定模块,整体复杂度比 WebSocket 高一个量级。
我的判断是:WebTransport 是 2025 年后值得关注的方向,如果你正在做实时音视频、云游戏这类对网络性能要求极高的业务,可以提前小范围试水;一般业务没必要第一个吃螃蟹,WebSocket 在绝大多数场景已经足够了。
2.7 Web Push:页面关了也能弹通知
Web Push 和前面六种方案不是一个维度的东西,它解决的是“浏览器页面关闭后,还能不能收到消息提醒”的问题。比如用户把网页标签页关了,你希望他照样收到新私信、新订单通知,这时候就要用 Web Push。
Web Push 的完整链路是这样的:前端在 Service Worker 里通过PushManager.subscribe()向浏览器申请订阅,得到PushSubscription对象(包含端点地址和密钥),把订阅信息发给服务器保存。服务端通过浏览器的推送服务把消息发出去,浏览器唤醒 Service Worker 处理消息并弹出通知。
服务端发送 Web Push 消息(Node.js 用web-push库):
const webpush = require('web-push'); // 生成 VAPID 密钥对,公钥给前端,私钥保存在服务端 const vapidKeys = webpush.generateVAPIDKeys(); webpush.setVapidDetails( 'mailto:admin@example.com', vapidKeys.publicKey, vapidKeys.privateKey ); // 这个 subscription 是前端发来并存库的 webpush.sendNotification(subscription, JSON.stringify({ title: '新订单提醒', body: '您有一笔新订单待处理' }));前端 Service Worker 收到推送事件后弹通知:
// sw.js self.addEventListener('push', (event) => { const data = event.data.json(); event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: '/icon.png' }) ); });Web Push 的优点是完全不占用常驻连接,对服务器压力很小,通知能在浏览器关闭时送达。缺点也很明显:一是需要用户主动授权才能订阅,随意推送会被浏览器直接拦截;二是推送消息由浏览器厂商的推送服务中转,不同地区网络环境下可达性有差异,而且消息体大小有限制,一般约 4KB,不适合传大段内容;三是通知的实时性不如 WebSocket 稳定,本质上属于“尽力而为”的送达。
在业务流程上,Web Push 通常作为“兜底通知渠道”:页面开着时走 WebSocket 或 SSE 实时更新,页面关了时用 Web Push 提醒用户重新打开。这种组合在即时通讯、电商、SaaS 系统里非常常见。
3. 方案选型:你的项目到底该用哪个
3.1 从四个维度反向筛选
实时消息推送没有“最好的方案”,只有“最合适的方案”。我一般从四个维度做取舍。
第一个维度是实时性要求。如果业务能接受 3 秒延迟,短轮询或 SSE 就够;如果要求 1 秒内甚至毫秒级,那只能 WebSocket 或 WebTransport。别一上来就把需求定成“必须毫秒级”,真实业务里没那么多场景需要毫秒,把指标量化后往往能省一半工作量。
第二个维度是通信方向。只需要服务器推给浏览器,优先 SSE,因为它实现简单、自带重连;需要双向互发,无脑选 WebSocket。单工需求用 WebSocket 不是不可以,但服务端和客户端都要处理连接生命周期、重连逻辑、消息编码,属于杀鸡用牛刀。
第三个维度是浏览器兼容范围。纯内部系统,可以大胆用 WebSocket、SSE;面向公众,如果用户里还有老版本浏览器,要么用长轮询兼容,要么接受小部分用户降级到轮询。我在一个互联网金融项目里就是用了“WebSocket + 轮询兜底”的策略,WebSocket 连不上时自动切换成 10 秒轮询,体验差异不大,但兼容性大幅提升。
第四个维度是服务器成本和团队维护能力。长轮询和短轮询虽简单,但并发一大服务器压力猛增;WebSocket 虽然省请求,但状态管理、心跳、广播逻辑都要团队持续维护。如果你团队只有两三个人,业务量不大,用 SSE 加一个现成的消息队列就能撑住大多数场景。
3.2 典型业务场景的推荐组合
我整理了几个真实业务场景的选型参考,你可以直接对照着看。
| 业务场景 | 推荐方案 | 组合说明 |
|---|---|---|
| 后台任务进度条、审批流状态 | SSE | 服务端单向推送,前端自动重连,实现最省 |
| 在线客服、网页聊天 | WebSocket | 双向高频,必须上 WebSocket,建议配合心跳和重连 |
| 邮件/短信/营销通知(网页关掉也要收) | Web Push | 结合 SSE 做页面内实时更新,Web Push 做兜底 |
| 实时股票行情、运营大屏 | WebSocket 或 SSE | 大屏单机多块面板,SSE 足够;行情交互频繁则 WebSocket |
| 兼容老浏览器的内部系统 | 长轮询 | 不折腾 WebSocket 协议,兼容性拉满 |
| 实时音视频、云游戏 | WebRTC 或 WebTransport | 常规业务不要碰,场景特殊才需要 |
另外多说一句,现在很多“实时推送需求”其实是“页面自动更新需求”。我在多个项目里用一句话帮产品经理把需求聊明白:“你是要用户不刷新就看到变化,还是要用户即使不在线也被通知到?”前者用 SSE 就够了,后者才需要 Web Push。需求一旦聊清楚,方案选型通常立刻就能定了。
4. 实战避坑:连接、缓冲、重连、广播
4.1 基础设施层:Nginx 缓冲和超时
我踩过最经典的一个坑就是 Nginx 缓冲导致 SSE 数据延迟。当时前端 EventSource 连上了,但就是收不到数据,页面一直空白。排查到 Nginx,默认开启了proxy_buffering on,服务器推送的数据被缓冲到 Nginx 层,前端要等缓冲满了才一次性收到,延迟高得离谱。
配置里需要处理三件事:
location /events { proxy_pass http://backend_service; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; gzip off; }第一是关掉缓冲,第二是调长读超时。Nginx 默认proxy_read_timeout是 60 秒,SSE 和 WebSocket 这类长连接一超过 60 秒没有新数据就被切断了。你把它调到 3600 秒,并在业务层用心跳保持活跃,基本能解决“莫名其妙掉线”的问题。第三是关掉 gzip,SSE 是分段推送的,gzip 压缩整个响应流会带来额外解析成本,很多老版本 Nginx 还因此产生延迟。
给 WebSocket 用的 Nginx 配置也一并贴在这,注意有两处容易漏:
location /socket { proxy_pass http://backend_socket; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }没加Upgrade和Connection两个请求头,WebSocket 握手阶段就会被 Nginx 当成普通 HTTP 处理,返回 400 Bad Request 或者直接断连。这是新手最容易踩的坑,没有之一。
4.2 连接层:心跳、重连与消息顺序
长连接的连接管理,核心三件事:心跳、断线重连、消息有序。
心跳的作用是“活着证明”。WebSocket 有内置的 ping/pong 帧,服务端定期发 ping,客户端回 pong,任何一方发现对端连续几次没回应,就判定连接已死,主动释放。我用 ws 库时会这样处理:
// 服务端每 30 秒发一次心跳 const heartbeatInterval = setInterval(() => { wss.clients.forEach((socket) => { if (socket.isAlive === false) { socket.terminate(); return; } socket.isAlive = false; socket.ping(); }); }, 30000); wss.on('connection', (socket) => { socket.isAlive = true; socket.on('pong', () => { socket.isAlive = true; }); });断线重连的要点是“退避 + 随机抖动”。我之前见过一个前端在 WebSocket 断开后 500 毫秒重连一次,结果后端一宕机,重连请求把服务端日志刷了一整页,连重启都变慢。后来全部改成指数退避:1 秒、3 秒、7 秒、15 秒……上限 30 秒,再加上 0 到 3 秒的随机值,服务端稳定多了。
消息顺序问题容易被忽略。由于 TCP 本身保证传输顺序,单机 WebSocket 不会乱序,但一旦引入断开重连,客户端在重连期间错过的消息怎么补?我现在的做法是:每条消息带一个服务端递增的序号,客户端维护本地水位线,重连后把最后一次收到的序号发给服务端,服务端找出之后的增量消息批量补发。这套“序号 + 水位线”机制在实时项目里非常实用。
4.3 架构层:多实例怎么广播
如果你只有一个后端实例,WebSocket 或 SSE 都能直接存内存状态,没什么架构问题。但一旦部署了多个实例,某个客户端连接到实例 A,实例 B 上来了新消息,怎么推给那个客户端?
核心思路是引入消息总线。常见的做法是 Redis Pub/Sub(或 MQTT、RabbitMQ、Kafka)。流程是:某个实例收到业务消息后,先发布到 Redis 频道;所有后端实例订阅这个频道,收到消息后看一下哪些客户端长连接落在自己身上,就推给谁。
// 以 ioredis 为例:订阅频道 const redisSub = new Redis(); redisSub.subscribe('push:message'); redisSub.on('message', (channel, message) => { const { userId, data } = JSON.parse(message); // 找到当前实例上的 userId 连接并推送 socketMap.get(userId)?.send(JSON.stringify(data)); });用 Redis 做广播还有一个额外的好处:它可以顺便实现“连接归属”的管理,比如把 userId 到实例的映射存到 Redis,当 WebSocket 需要跨实例鉴权时就查这个映射。另外要注意,如果用了 WebSocket 做多实例,负载均衡层最好开启粘性会话(sticky session),否则 WebSocket 握手打到实例 A,之后数据却发到实例 B,会导致大量无谓的广播和转发。
4.4 安全与鉴权
实时推送的安全问题,我在实际项目里碰过的坑集中在三处:连接鉴权、跨域、消息越权。
连接鉴权要注意的是:WebSocket 握手时浏览器只能识别 URL 和协议头,没法像普通 AJAX 那样随时加自定义 Header(原生 WebSocket API 不能自定义 Header)。常见做法有两种:一是把 token 放在 URL 查询参数里,二是放在子协议里。放查询参数最直观,但要防日志泄露,顺手把日志里的 query 参数打码;放子协议更干净但不那么通用。SSE 同理,EventSource也不支持自定义 Header,要么把 token 放查询参数,要么用 Cookie 方式传。
跨域方面,WebSocket 本身不受同源策略限制,但服务端必须校验 Origin 头。我见过有人开了跨域还忘记校验 Origin,导致任意网站都能连后端 WebSocket 收发消息,这比 CSRF 还危险,可以完全伪造合法用户。服务端加一行判断:
const allowedOrigin = ['https://your-domain.com']; if (!allowedOrigin.includes(request.headers.origin)) { socket.close(); }消息越权是更隐蔽的坑:连接鉴权通过了,但连接内发的业务消息没有逐条做权限校验。比如聊天室的 WebSocket 连接建立后,客户端伪造一条{ "type": "delete", "room": "admin" },如果服务端只在握手时鉴权,这条消息就能直通。正确做法是:数据包级别的权限校验,每一条业务消息都要解析出当前用户身份,和操作目标比对权限。
4.5 常见问题速查表
把实际运维中最高频的问题整理成一张速查表,遇到对应症状直接排查对应位置。
| 问题现象 | 可能原因 | 快速排查方式 | 解决方案 |
|---|---|---|---|
| SSE 连接建立后很快断开 | Nginxproxy_read_timeout默认 60 秒 | 看 Nginx 错误日志或连通性测试 | 调大超时、加心跳 |
| SSE 数据很久才到达一次 | Nginxproxy_buffering开启 | 响应被 Nginx 缓冲 | 配置proxy_buffering off |
| WebSocket 握手返回 400 | 缺少 Upgrade/Connection 请求头 | 查看 Nginx 配置 | 补上proxy_set_header两个配置 |
| WebSocket 连上几分钟就断 | 链路里某层空闲超时 | 检查所有网关/防火墙超时配置 | 应用层加 ping/pong 心跳 |
| 断线后重连风暴打挂服务端 | 重连退避策略缺失 | 看服务端连接数和日志频率 | 指数退避 + 随机抖动 |
| 多实例下消息只推给部分用户 | 未引入消息总线 | 客户端连到的实例和消息到达实例不一致 | 接入 Redis Pub/Sub |
| 推送内容被乱码截断 | 消息压缩或参数格式问题 | 抓包看原始字节 | 关闭 gzip、确认编码一致 |
| Web Push 通知一直收不到 | 用户未授权、订阅信息失效 | 检查浏览器 Notification 权限 | 申请授权、刷新订阅信息 |
很多所谓“玄学断连”问题,排查到最后都是超时或者缓冲配置没调。建议新建项目时,先把网关层的超时、缓冲、心跳三件套配齐,再写业务逻辑,省得后面反复返工。
在多个实时推送项目里摸爬滚打之后,我最大的体会是:技术方案永远是为业务体验服务的,别为了炫技选最复杂的。实时聊天用 WebSocket 没得选,但如果你只是做一个“进度通知”或者“运营大屏刷新”,SSE 会让你省掉一大半维护精力。页面关了还要提醒用户,那 Web Push 才是真正该看的方案。
最后分享一个小习惯:每次接新的实时推送需求,我会先写一段伪代码把数据流画出来——数据从哪个节点产生,经过哪些中间件,最终怎么到浏览器。这段伪代码一旦能顺下来,方案基本就定型了。希望这篇文章能让你下次选型时心里更有底,少走点我当年走过的弯路。