前阵子维护一个后台管理系统,列表页每 5 秒用定时器发一次状态轮询,结果接口一抖动,请求就堆在浏览器里,用户输入都跟着卡。后来改成 WebSocket 实时推送,同一个页面,数据从服务端到前端基本在 100 毫秒内到达,服务器 QPS 也直接掉了一个量级。这套从“轮询卡顿”到“实时推送”的改造,技术门槛其实不高,真正磨人的是那些藏在细节里的连接处理、心跳策略和断线重连问题。今天这篇把这些东西完整拆一遍,顺便把轮询、SSE、WebSocket 的使用边界也说清楚。
1. 先从“轮询卡顿”说起:被误用的定时器
1.1 轮询不是不能用,而是“定时拉取”和“即时收到”之间有鸿沟
状态轮询是大多数人接触实时数据的第一反应:前端写好setInterval,每 3 秒或 5 秒发一次接口,拿到数据后更新页面。逻辑简单,后端只要有一个查询接口就能跑,不需要额外组件,所以很多项目从第一天就是轮询。
但轮询的本质是“不管数据有没有变,我都按时来问一次”。这就带来两个问题:第一,大量请求是无效的,比如订单状态 5 分钟内没变化,前端可能已经请求了 60 次,每次都拿到一模一样的数据;第二,数据变化恰好发生在两次请求之间时,用户看到的结果就存在延迟,延迟时间平均是轮询间隔的一半,最差是一个完整间隔。如果轮询间隔设成 1 秒,延迟是能降下来,但服务器压力会成倍增加,尤其是并发用户多的时候,很容易出现接口超时、排队,甚至整站变慢。
我见过最典型的一个案例是内部工单系统:运营开着页面等一个审核结果,前端每 3 秒轮询一次,后端为了支撑几百个在线用户,专门加了一台只跑轮询请求的服务器。后来统计发现,90% 以上的轮询响应里数据根本没有变化,这就是典型的“用资源换实时性”,短期能撑,长期一定出问题。卡顿的来源也在这里:大量请求占满带宽和服务线程,真正重要的业务接口反而排队变慢,用户感知就是页面“卡”。
1.2 长轮询和 SSE:两种过渡方案,也各有限制
短轮询不够好,很多人会想到长轮询:客户端发一个请求,服务端不立刻返回,而是挂住连接,直到有数据变化再响应,客户端收到后再发起下一个请求。长轮询确实把“主动问”变成了“等通知”,但它本质上还是 HTTP 请求,连接有超时时间,服务端需要维护大量挂起请求,Nginx、网关层都要改超时配置,实现不好还会出现请求堆积和重复响应。常见的支付订单状态查询里,很多老系统就是这么做的:接口只提供查询,不主动推送,外面套一层轮询任务,用户付款成功后页面要过好几秒才能刷新出来。这类“订单状态轮询系统”在体验上永远差一口气。
SSE(Server-Sent Events)是比长轮询更干净的过渡方案。它基于 HTTP,使用EventSource接口,服务端可以持续向客户端推送文本消息,浏览器原生支持自动重连,不需要额外引入 WebSocket 协议栈。React 项目里要实时展示构建日志、配置文件变化、后台任务进度,用 SSE 往往比 WebSocket 更合适,因为数据流主要是单向的,服务端往客户端推,客户端不需要频繁反向发消息。但 SSE 有一个硬限制:它只支持服务端到客户端,客户端想发消息还是得另外走普通 HTTP 接口。所以一旦业务需要双向通信,比如在线协同编辑、聊天、实时操作指令,SSE 就不够用了,这时候才轮到 WebSocket。
2. WebSocket 核心机制:不只是“长连接”三个字
2.1 连接是怎么建立的
WebSocket 常被说成“长连接”,但它并不是凭空建立一条 TCP 连接,而是先通过 HTTP 协议完成一次升级握手。客户端发一个带Upgrade: websocket头的请求,服务端确认后返回101 Switching Protocols,之后的通信就切换到 WebSocket 帧格式,不再走普通 HTTP 请求响应。
握手阶段有几个关键点:Sec-WebSocket-Key是客户端生成的随机字符串,服务端需要用固定算法算出Sec-WebSocket-Accept返回,这个过程主要是为了确认双方都支持 WebSocket 协议,也能防止缓存代理把旧响应错发给新连接。生产环境里一定要用wss://,因为明文ws://的流量可以被中间人截获和篡改,用户登录态、业务数据全在上面跑,不安全。手写握手很繁琐,所以实际开发中一般直接用现成库:Node.js 里可以用ws,Python 里可以用websockets库,Django 项目则用 Channels 来处理 ASGI 协议。理解握手过程不是为了自己造轮子,而是排查问题时有方向:比如连接一直 502,多半是网关没配置 WebSocket 升级;连接能建立但很快断开,可能是安全策略或心跳问题。
2.2 帧、消息和二进制/文本
建立连接之后,数据传输的单位是“帧”。WebSocket 协议定义了多种帧类型,最常用的是文本帧、二进制帧、Ping 帧、Pong 帧和关闭帧。客户端发给服务端的业务数据,在协议层会被打上掩码,服务端发回客户端的数据则不需要掩码,这是协议设计上的安全考虑,防止某些恶意客户端通过构造特定数据去影响代理缓存。
对业务开发者来说,不需要手工处理分帧,但要知道“一帧不等同于一条业务消息”。WebSocket 协议允许一条逻辑消息拆成多个帧传输,所以一些底层库在处理大消息时会在内部做重组。推送的数据格式建议统一用 JSON,结构至少要包含type和data两个字段,比如{ "type": "order.update", "data": { "id": 123, "status": "paid" } }。这样前端收到消息后,先根据type分发到不同处理函数,而不是拿到什么都往页面上塞。如果涉及二进制数据,比如推送图片、文件块,则使用二进制帧,解析时注意字节序和长度前缀,这块和普通 Socket 编程的思路是一样的。
2.3 心跳与重连:连接“活着”的假象
这是整个 WebSocket 实战里最容易翻车的地方,没有之一。TCP 连接建立之后,如果长时间没有数据流动,中间的网络设备、云厂商的负载均衡器、办公网出口 NAT 设备都可能把这条空闲连接静默回收。客户端这边看起来连接还在,但实际已经断了,直到下一次发消息才发现写不进去,或者服务端根本收不到任何东西。这种“半开连接”比直接报错更讨厌,因为它不会主动触发onclose,你的业务代码会一直以为在线。
解决方案就是心跳机制。最简单的实现是客户端定时发送 Ping 帧或自定义的{ "type": "ping" }消息,服务端收到后回 Pong 或{ "type": "pong" }。如果客户端连续多次没收到回应,就主动关闭连接并触发重连。心跳间隔要根据实际网络和设备来定,内网场景可以 30 秒一次,公网场景建议 20 到 30 秒一次。间隔太短会制造大量无效心跳消息,太长又起不到及时检测的作用。注意,WebSocket 协议自带 Ping/Pong 帧,但部分代理和浏览器实现并不完全一致,所以很多团队直接用业务层的 JSON 心跳,实现简单,也方便在服务端记录最后活跃时间,一举两得。
3. 搭建一套实时推送链路:从后端到前端全流程
3.1 先定架构:谁推谁、怎么推、怎么知道客户端还活着
改造的第一步不是写代码,而是画清楚数据流。实时推送通常有三种形态:第一种是客户端连上 WebSocket,服务端持有连接,主动把消息推给单个客户端,适合给指定用户发通知;第二种是群组推送,比如工单系统里一个项目组的所有在线成员都要收到更新,服务端需要把连接按分组维护;第三种是企业内部的后台服务作为 WebSocket 客户端,主动连到中央网关,把状态推给网关再转发给前端,这种也被叫做“反向 WebSocket”模式,适合多语言微服务环境下由 Python 或 Java 后台统一上报事件,前端只连一个网关入口。
小型项目里,一个现成的 WebSocket 服务器加一个内存里的连接集合就够了。只要连接数不超过几千,单机方案简单可靠。但一旦要横向扩展,内存里的连接列表就不是全局的了,比如用户 A 连着节点 1,业务处理在节点 2 上跑,节点 2 想通知用户 A 就找不到连接。业界通用做法是引入 Redis Pub/Sub 或消息队列做“广播总线”:WebSocket 服务节点都订阅同一个频道,业务服务把消息 publish 到频道,所有节点收到后查自己手上的本地连接,把消息推给对应客户端。这套架构比在多个节点之间搞同步锁简单得多,也是 Django Channels 默认推荐的思路。
3.2 前端部分:封装一个带心跳和自动重连的客户端
很多前端项目直接在组件里new WebSocket(),组件卸载时随便close(),这种做法在简单页面能跑,但真实项目里很快会暴露出重复连接、断线不重连、心跳没人管的问题。我建议从第一天就封装一个小类,把心跳、重连、事件分发都收进去。核心逻辑大概长这样:
class RealtimeClient { constructor(url, options = {}) { this.url = url; this.heartbeatInterval = options.heartbeatInterval || 30000; this.reconnectBaseDelay = options.reconnectBaseDelay || 3000; this.maxReconnectTimes = options.maxReconnectTimes || 10; this.reconnectTimes = 0; this.forceClosed = false; this.listeners = new Map(); this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.reconnectTimes = 0; this.startHeartbeat(); }; this.ws.onmessage = (event) => { let message; try { message = JSON.parse(event.data); } catch (error) { return; } if (message.type === 'pong') { this.lastPongAt = Date.now(); return; } const handlers = this.listeners.get(message.type) || []; handlers.forEach((handler) => handler(message.data)); }; this.ws.onclose = () => { this.stopHeartbeat(); if (!this.forceClosed) { this.scheduleReconnect(); } }; this.ws.onerror = () => { this.ws.close(); }; } startHeartbeat() { this.lastPongAt = Date.now(); this.heartbeatTimer = setInterval(() => { if (Date.now() - this.lastPongAt > 60000) { this.ws.close(); return; } const payload = JSON.stringify({ type: 'ping' }); this.ws.send(payload); }, this.heartbeatInterval); } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); } } scheduleReconnect() { const delay = Math.min(30000, this.reconnectBaseDelay * Math.pow(2, this.reconnectTimes)); this.reconnectTimes += 1; setTimeout(() => this.connect(), delay); } on(type, handler) { if (!this.listeners.has(type)) { this.listeners.set(type, []); } this.listeners.get(type).push(handler); } close() { this.forceClosed = true; this.stopHeartbeat(); this.ws.close(); } }这段代码里有几个细节值得说。心跳检测不是只看“有没有发出去”,还要看“有没有收到回应”,所以维护了一个lastPongAt时间戳,超过一分钟没收到 Pong 就主动断线,交给重连逻辑处理。重连用指数退避,第一次失败等 3 秒,第二次 6 秒,之后封顶 30 秒,避免服务端恢复时所有客户端同时涌上来,把刚站起来服务又压垮。forceClosed标记用来区分“用户主动关闭”和“意外断线”,组件卸载时调用close(),就不会在页面销毁后还反复重连。
3.3 后端部分:Node.js 与 Django Channels 两种实现
后端选型取决于团队技术栈。Node.js 生态里ws库是最常见的,性能好,API 简单,适合写独立的推送服务。一个最小的单机推送服务大概是这样的:
const { WebSocketServer } = require('ws'); const wss = new WebSocketServer({ port: 8080 }); const clients = new Set(); wss.on('connection', (ws) => { clients.add(ws); ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (message) => { try { const payload = JSON.parse(message.toString()); if (payload.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); } } catch (error) { // 忽略非法消息,但可以记录日志 } }); ws.on('close', () => { clients.delete(ws); }); }); setInterval(() => { for (const ws of clients) { if (!ws.isAlive) { clients.delete(ws); ws.terminate(); continue; } ws.isAlive = false; ws.ping(); } }, 30000); function broadcast(data) { const message = JSON.stringify(data); for (const ws of clients) { if (ws.readyState === 1) { ws.send(message); } } }这个服务端同时做了三件事:一个是回应客户端的应用层心跳,一个是自己主动发协议层 Ping 来探测半开连接,一个是维护统一广播方法。生产环境里广播方法不应该直接遍历所有连接,而要支持按用户 ID、按房间分组,最简单的做法是把clients从Set换成Map,key 是用户 ID,value 是连接对象,再额外维护一个“房间到用户集合”的索引。
Python 技术栈的场景通常会遇到一个具体需求:Django 后台有数据变化,想实时推给前端。正统做法是用 Django Channels 把 WebSocket 接到 Django 的异步生态里。先在routing.py里配置协议路由:
# routing.py from channels.routing import ProtocolTypeRouter, URLRouter from django.urls import path from .consumers import OrderConsumer application = ProtocolTypeRouter({ "websocket": URLRouter([ path("ws/orders/", OrderConsumer.as_asgi()), ]), })Consumer 里加入分组,前端连接后服务端把这个连接放到orders组里,之后后台任何业务代码都能向整个组推送消息:
# consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer class OrderConsumer(AsyncWebsocketConsumer): async def connect(self): await self.channel_layer.group_add("orders", self.channel_name) await self.accept() async def disconnect(self, close_code): await self.channel_layer.group_discard("orders", self.channel_name) async def order_update(self, event): await self.send(text_data=json.dumps(event["data"]))在其他业务函数里推送时,不需要关心前端连在哪个节点上,只要调用group_send,底层的 channel layer 会负责把消息路由到所有订阅了这个组的连接。这就回答了热词里那个“Django WebSocket 后台有数据前端推送”的经典问题:改动后台业务表后,在写操作完成的地方group_send一下,而不是让前端去轮询数据库。这个模式才是 WebSocket 在 Web 项目里最值得落地的用法。
4. 轮询、SSE、WebSocket:到底该用哪个
4.1 一张表说清三者的区别
很多人纠结技术选型,其实是把三者的能力边界搞混了。用下面这张表可以快速对照:
| 维度 | 短轮询 | SSE | WebSocket |
|---|---|---|---|
| 通信方向 | 客户端请求,服务端响应 | 服务端单向推送 | 全双工双向通信 |
| 协议 | HTTP | HTTP | 基于 HTTP 升级,独立帧协议 |
| 浏览器支持 | 全部 | 原生 EventSource,支持自动重连 | 原生 WebSocket API |
| 实时性 | 取决于轮询间隔 | 基本实时 | 实时 |
| 服务端压力 | 高,大量无效请求 | 低,一条连接持续推送 | 低,但需要维护连接状态 |
| 客户端反向发消息 | 直接 HTTP 请求 | 需要额外的 HTTP 接口 | 直接在连接上发送 |
| 适用场景 | 低频状态查询、接口无推送能力 | 通知流、日志流、文件变化、进度条 | 聊天、协同编辑、游戏、行情、实时操作 |
这里想强调一点:WebSocket 不是所有场景的最优解。如果业务只是“服务端定时产生数据,客户端被动展示”,SSE 更轻、更稳,因为它天然支持自动重连,浏览器断网恢复后会自己重新建立连接,少写很多代码。真正需要客户端向服务端实时发消息时,比如在线表格里的光标位置同步、视频通话的指令协商,WebSocket 才不可替代。
4.2 三种常见选型场景
第一种场景是构建日志和文件变化。React 项目里要实时展示编译进度,或者监控某个目录下文件变化,服务端可以把变化事件通过 SSE 推给浏览器。之所以不用 WebSocket,是因为这个场景里用户只会“看”,不会“往回发”,SSE 的断线自动恢复还省掉了前端重连逻辑。第二种场景是业务通知和订单状态。用户下单后,支付回调可能到得很快也可能很慢,前端关心的是“最后结果那一刻”,用 WebSocket 订阅订单状态,后台支付回调一触发,立即推送给对应前端。这样就不需要像某些支付接口对接一样写一个“订单状态轮询系统”,每几秒去查一遍,既省服务器资源,也让用户觉得页面是“自己变”的。第三种场景是低频率的状态查询,比如设备每隔 10 分钟上报一次数据,前端展示最近状态,这时候根本没必要上 WebSocket,一个普通轮询接口就够,还能省掉连接维护和心跳的复杂度。
4.3 混合方案也可以很实用
很多团队不敢直接全面切到 WebSocket,主要担心浏览器兼容、老网络环境、以及后端改造成本。这时候可以先做降级策略:主链路用 WebSocket,连接失败或重连多次失败后自动降级到 SSE,SSE 再不支持就退回到 10 秒轮询。这听起来复杂,实现时其实只需要把推送封装成统一的接口,让上层业务只关心onUpdate回调,底层用哪种通道由工厂决定。我实践下来,这种“WS 优先、轮询兜底”的方案在稳定性要求很高的运营后台里特别管用,既能享受实时推送的体验,又不会因为个别网络环境导致功能不可用。
5. 常见问题与排查实录
5.1 连接一建立就被断开:多半是网关和代理没配置好
WebSocket 连接在浏览器里正常握手后,如果几秒到几十秒就断开,优先查两件事:反向代理有没有开启连接升级,空闲超时设置是多少。Nginx 里要显式配置Upgrade相关的 Header,并把proxy_read_timeout调大,否则默认 60 秒的超时一到,Nginx 就把连接掐了。这不是 WebSocket 服务的问题,而是代理层不知道这是一条长连接,还在按普通 HTTP 请求的超时策略处理。改完配置记得在测试环境用 WebSocket Test Client 验证长连接是否超过原来的超时窗口,不要只看握手成功就上线。
5.2 心跳发了但连接还是断:要检查双向心跳
我之前犯过一个错:服务端只调用了协议层的ws.ping(),前端onmessage没有处理协议层的 Pong 事件,结果浏览器自动回了协议层的 Pong,前端业务代码根本感知不到,最后连接被服务端回收。所以团队里如果约定用应用层 JSON 心跳,就前后端同时用 JSON 心跳;如果协议层心跳,就确保前端框架、浏览器调试面板里能正确处理。混合使用也可以,但要把“心跳超时判定”的逻辑放在同一层,不要在服务端用 JSON 心跳、在前端却只处理协议层 Pong,这样两边对“连接是否存活”的判断永远对不上。
5.3 消息丢失和重复消费:重连后要做状态对齐
WebSocket 是消息推送,不是消息队列,本身不保证“你断线期间的消息都补给你”。前端断线重连成功后,如果后端不补发,页面就会缺一段数据。最简单的方案是重连后客户端发一条resubscribe消息,里面带上自己关心的事件类型和最后一条消息的序号,服务端根据序号把缺失的消息重新推送一遍。如果不想自己做序号,就在业务接口里加一个“快照”接口,重连成功后先拉一次全量状态,再等待后续增量推送。很多实时页面看起来断线后数据错乱,其实不是 WebSocket 坏了,而是状态没有对齐。
5.4 后端横向扩展后推送找不到人:用公共广播层
单机部署的时候没问题,一旦上了两台 WebSocket 节点,就会发现某个用户连接在节点 A,但业务逻辑跑在节点 B,B 想推消息推不出去。解决办法不是用 IP 直连,也不是在前端存两个连接地址,而是用 Redis Pub/Sub 或消息队列做广播。所有 WebSocket 节点都订阅同一个频道,业务服务只往频道发消息,收到消息的节点再检查本地连接是否需要推送。要注意订阅频道的消费者要做幂等处理,因为消息广播到多个节点后,只有持有目标连接的那个节点能把消息发出去,其他节点应该直接丢弃。
5.5 调试工具和典型问题速查
排查 WebSocket 问题时,不要只靠前后端打印日志。浏览器开发者工具的 Network 面板里可以看 WebSocket 的 Frame 详情,能直观看到 Ping/Pong 和业务消息的时间线;命令行下可以用wscat这类 WebSocket 测试客户端连到服务端手动发消息,验证服务端行为;也可以用在线 WebSocket Test Client 测试公网连接是否被墙、端口是否通、握手是否成功。下面是我踩过的一些典型坑:
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| 握手 404 | 路由没匹配到,或路径写错 | 检查后端路由配置,确认 WebSocket 路径和前端一致 |
| 握手 502 | 反向代理没配置 Upgrade | 配置Upgrade和ConnectionHeader,并加长超时 |
| 连接频繁断开 | NAT 空闲超时,或代理主动回收 | 缩短心跳间隔到 20-30 秒,必要时前后端双心跳 |
| 页面卸载后还在重连 | 没有区分主动关闭和意外断开 | 封装客户端时加入forceClosed标记 |
| 消息推不到指定用户 | 连接和用户 ID 没有绑定 | 连接建立后先认证,把连接注册进用户 ID 索引 |
| 断线期间消息丢失 | WebSocket 不保证补发 | 重连后先拉快照,再用递增序号补差 |
| 多节点重复推送 | 内部广播消息没有做本地过滤 | 每个节点发布时带上目标连接标识,非本节点连接直接丢弃 |
6. 写在最后的几个实践体会
从我自己的维护经验来看,实时推送改造最大的收益往往不在技术上,而在产品体验上。轮询页面是“用户盯着等刷新”,WebSocket 页面是“系统主动告诉你有变化”,这种感觉差异很难用数字衡量,但用过的人都会明显感受到。另一个体会是,不要为了显得高级而强行上 WebSocket。如果业务里根本没有双向通信需求,SSE 甚至短轮询就是更合适的选择,连接数少、代码量少、排查也简单。真正值得花心思的是先把心跳、重连、状态对齐这些基础能力做扎实,再往上面堆业务消息。最后分享一个小技巧:推送消息的字段结构从第一天就统一成带type和data的格式,并且把版本号放进data里,这样后续加字段、加事件类型都会很从容,不会出现改一个消息格式就要前后端同时发版的尴尬。