做过实时推送的同学,大概率都经历过这么一段:后端一把数据塞进数据库,前端为了拿到它,要么死循环轮询接口,要么用长轮询把请求挂着,延迟和服务器压力两头受罪。后来我把项目里的推送链路逐步迁到了 WebSocket 上,配合心跳机制和断线重连,才算真正把"服务端主动说话"这件事做顺。这篇文章不打算逐条背 RFC 6455 的条文,而是想从实际使用出发,把 WebSocket 协议里最核心的握手、帧格式、心跳设计、后端推送实战和排错经验讲透。
不管你是没接触过 WebSocket 的新手,还是已经被"连接建立的但消息过不来"这类问题折磨过的老手,这篇内容应该都能给你一些参考。我会尽量用大白话解释底层逻辑,也会给出可以直接抄的代码和配置。
1. 从 HTTP 到 WebSocket:一次 Upgrade 解决的问题
1.1 HTTP 轮询的痛,只有做过实时功能的人才懂
先回到最原始的场景。假设你的页面上有一个"订单状态"的显示区域,订单状态由后端异步更新,可能是几秒后,也可能是几分钟后。最朴素的做法就是写一个setInterval,每隔两秒调一次查询接口:
setInterval(async () => { const res = await fetch('/api/order/status?id=12345'); const data = await res.json(); renderStatus(data); }, 2000);这个方案最致命的问题不是代码丑,而是它把压力全部转移给了服务器。每个用户每两秒产生一次完整的 HTTP 请求,包括请求头、响应头、可能的 Cookie 和鉴权信息。1000 个在线用户,意味着每秒要处理 500 个请求,其中绝大多数请求返回的数据和上一次一模一样。数据库压力大、带宽浪费严重,而且延迟还不理想——用户下单成功后,状态变化可能要等上最长两秒才在页面上刷新。
长轮询(Long Polling)稍微好一点:客户端发起请求后,服务端把请求挂住,等到有新数据才返回,然后客户端立刻再发下一个请求。这种方式减少了请求次数,但连接长期占用同样是一个隐患,而且在代理层(比如 Nginx)超时、服务重启等场景下,长轮询的连接管理非常容易出幺蛾子。我在生产环境里遇到过代理把挂起的请求直接掐掉、客户端傻傻等待直到超时的情况,排查起来相当费劲。
1.2 一次 HTTP 握手,把连接"升级"成 WebSocket
WebSocket 的设计思路很巧妙:它没有另起炉灶发明一套全新的传输层协议,而是先通过一次普通的 HTTP 请求完成握手,然后利用 HTTP 头里的Upgrade字段,把这条 TCP 连接从 HTTP 协议切换成 WebSocket 协议。这样一来,所有现成的 HTTP 基础设施——端口、负载均衡、鉴权中间件——都能在握手阶段正常工作。
握手的请求长这样:
GET /ws/chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13服务端收到后,会校验Sec-WebSocket-Version,然后用Sec-WebSocket-Key拼上一个固定的 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),做一次 SHA-1 哈希,再 Base64 编码,得到Sec-WebSocket-Accept返回给客户端:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=我第一次看到这个 Key 的时候挺疑惑:这不就是一道不需要密码的"校验"吗?它防的其实不是恶意攻击者,而是防止缓存代理把 WebSocket 握手响应缓存下来发给其他客户端。有了这个动态生成的 Key,代理就没办法把一次握手的结果复用给另一个请求,从协议层面堵住了缓存错配的问题。
握手完成后,这条 TCP 连接就不再走 HTTP 的请求-响应模型了,客户端和服务端随时可以往连接里写数据,这就是"全双工"的含义。WebSocket 是应用层协议,它跑在 TCP 之上,但它不是为了替代 HTTP,而是为了补充 HTTP 在实时双向通信上的短板。
1.3 为什么不用裸 TCP
接触过网络编程的人肯定要问:TCP 本身不就是全双工的吗?客户端和服务端各开一条双向通道,直接发消息不就行了?
问题在于浏览器环境。浏览器里的 JavaScript 无法直接创建一条裸 TCP 连接,这是浏览器安全模型的基本约束。WebSocket 实际上是给浏览器里的应用提供了一条"受控的 TCP 连接",它由浏览器负责建立和维护,同时通过协议保证了消息的边界和格式。所以,虽然 WebSocket 在原理上就是"在 TCP 上跑自定义协议",但它把建立连接、数据分帧、关闭连接这些底层的脏活都封装好了,这才是它能被浏览器原生支持的根本原因。
2. 帧格式与消息边界:读懂协议数据流
2.1 帧结构里最重要的几个位
WebSocket 的数据传输单位是"帧"(Frame)。理解帧结构,对排查"消息收到一半""粘包拆包异常"这类问题非常有帮助。一个 WebSocket 帧从头部开始,核心字段包括:
- FIN(1 位):标记这是不是消息的最后一帧。一个"消息"可以由多个帧组成,FIN=1 表示这是最后一个分片。
- Opcode(4 位):标识帧的类型。
0x1是文本帧、0x2是二进制帧、0x8是关闭帧、0x9是 Ping 帧、0xA是 Pong 帧。 - Mask(1 位):标识载荷是否做了掩码处理。所有从客户端发往服务端的帧,Mask 位必须为 1,也就是说必须做掩码。
- Payload Length(7 位、7+16 位或 7+64 位):载荷长度。当长度小于 126 时直接用 7 位表示;在 126 到 65535 之间时,这 7 位固定为 126,后面再跟 16 位长度;更大的长度则固定为 127,后面跟 64 位长度。
- Masking-Key(4 字节):当 Mask 位为 1 时存在,用于对载荷做异或解码。
我最开始看协议文档时,觉得 Payload Length 这种"三档长度"的设计挺啰嗦。后来自己写协议解析器时才明白,这是为了在小消息上节省头部开销——大部分业务消息(JSON、短文本)都小于 126 字节,用 7 位就能表示,头部只占 2 字节;只有传输大文件时才需要扩展长度字段。这种"按需伸缩"的设计思路,和 HTTP/2 的 HPACK 压缩头、HTTP/3 的变长整数一脉相承,都是为了在常见场景下把开销压到最低。
2.2 强制掩码机制:一个被历史教训逼出来的设计
客户端发给服务端的帧必须掩码,服务端发给客户端的帧不需要掩码。这个不对称设计,是 WebSocket 协议最有意思的细节之一。
掩码的实现并不复杂:客户端随机生成 4 字节的 Masking-Key,然后对载荷的每个字节做异或运算,接收方拿到 Masking-Key 后做同样的异或即可还原。但为什么非要这么做?这要从 2010 年的一篇安全研究报告说起。当时的研究者发现,如果浏览器发出的 WebSocket 数据不被混淆,攻击者可以构造一个恶意网页,往一个受害者的 TCP 连接里注入伪造的数据——利用代理服务器对某些 URL 的缓存机制,可能会污染甚至劫持整个通信链路。强制掩码让攻击者无法预测客户端发出的载荷内容,从根源上削弱了这类"缓存投毒"攻击。
这个设计对开发者的实际影响是:当你自己实现 WebSocket 客户端(比如用 Python 的socket从零写一个简陋客户端)时,必须记得实现掩码逻辑,否则服务端会直接断开连接。我见过不少新手在这上面栽跟头——代码看起来完全正确,但服务端就是秒断,日志里写着"client frames must be masked"。
2.3 粘包问题的协议层解法
TCP 是流式协议,数据是一段连续的字节流,没有"消息边界"。如果同时发两条消息,接收方可能一次读完,也可能分多次读,这就是经典的"粘包"与"半包"问题。
WebSocket 在协议层面已经把这个问题解决了:每个帧头部都有明确的载荷长度,接收方只需要先读帧头的长度字段,再按需读取对应字节数即可。所以,只要你的 WebSocket 实现是正确的,就不需要像处理裸 TCP 那样自己去拼缓冲区、设计分隔符。这也是我向别人推荐 WebSocket 而不是直接上裸 TCP 的一个重要原因——少处理一类极其容易出错的底层问题。
但这里有个隐藏的坑:帧的边界是协议保证的,WebSocket 下层的数据包边界却不是。一个 WebSocket 帧可能分多个 TCP 包到达,多个帧也可能挤在一个 TCP 包里。如果你的代码假设"一次 recv 就是一个完整的 WebSocket 帧",那在高并发或大消息场景下必出问题。正确的做法永远是:维护一个接收缓冲区,先解析帧头,再根据长度字段判断是否收到完整载荷,不完整就继续等。
3. 心跳机制:连接看似健在,其实早就凉了
3.1 WebSocket 连接为什么会"假死"
WebSocket 连接建立之后,客户端和服务端之间有一条 TCP 连接。但这不意味着这条连接永远可用。最常见的坑是:连接没有任何报错,但数据已经推不过去了。
这种"假死"现象通常有两个原因。
第一个原因是网络设备空闲超时。TCP 连接在长时间没有数据传输时,中间的路由器、交换机、负载均衡设备可能会认为这条连接已经空闲,主动把它清理掉。很多 NAT 设备的空闲超时时间在 30 秒到 5 分钟不等,WebSocket 连接一旦超过阈值没有任何往来数据,就可能被设备静默断开。断开后,由于没有 RST 包通知,双方都以为连接还健在——这就是"假死"。
第二个原因更隐蔽:服务端进程还在,但某个中间代理节点已经不再转发数据。我在生产环境遇到过 Nginx 作为反向代理时,因为proxy_read_timeout设定过短,代理在后端长时间没有数据时主动断开,但客户端的 WebSocket 连接状态没有感知,直到下一次发送数据才触发超时错误。
3.2 协议级 Ping/Pong:最规范的心跳姿势
WebSocket 协议自带 Ping 和 Pong 帧,目的之一就是让连接双方可以主动探活。
- 一端发送 Ping 帧,另一端必须回复 Pong 帧(Pong 的载荷必须与 Ping 相同)。
- 收到 Pong 帧,可以确认对端仍然存活。
在浏览器端,WebSocket API 没有暴露主动发送 Ping 帧的方法,你只能靠服务端发 Ping,浏览器自动回 Pong。所以大多数场景下,心跳的方向是:服务端定期发 Ping,客户端自动回 Pong;服务端在一段时间内没有收到任何数据(包括 Pong),就判定连接已死,主动关闭。
服务端的心跳实现(Node.js 为例):
// 服务端心跳:每 30 秒发一次 Ping const heartbeatInterval = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { ws.terminate(); return; } ws.isAlive = false; ws.ping(); }); }, 30000); wss.on('connection', (ws) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); });这里有个经验值:心跳间隔一般设置为 30 秒左右。太短会增加无谓的网络负载,太长又可能让 NAT 设备先于你的心跳动作超时。我把服务器端的 Nginxproxy_read_timeout设为 75 秒,同时心跳间隔 30 秒,这样即使偶发一次 Pong 延迟,也有足够的余量。
3.3 业务心跳 vs 协议心跳:如何选择
协议级 Ping/Pong 的缺点是它不带业务信息,只能证明"连接活着",不能证明"业务正常"。有些场景下,我更倾向于使用"业务心跳"——客户端周期性地发送业务消息(比如{"type": "heartbeat", "ts": 1234567890}),服务端收到后更新该连接的最后活跃时间,超时未收到直接判定离线。
这种做法的好处有三点:
- 可以携带业务数据(时间戳、客户端状态等),方便服务端做更丰富的状态判断。
- 在复杂的网络环境(如公司防火墙)下,业务数据包比协议控制帧更容易穿透代理设备。
- 同一套机制可以服务端和客户端双方向使用。
坏处也很明显:业务心跳本质上是在"用数据流量换活性",在高频推送的应用中会增加消息量。我的经验是:低频应用(几秒一条数据)直接依赖正常消息做活性检测即可,完全不用额外心跳;高频应用(每秒几十条消息)也天然不需要心跳;只有那些长时间空闲,或者需要穿越严格 NAT 的应用,才需要专门的心跳机制。
另外,我给很多同学的实操建议是:在客户端也维护一个"最后收到数据"的时间戳。如果超过 N 秒没有收到任何服务端数据,主动触发重连。这个机制能有效解决"服务端挂了但客户端连接还没感知"的死局。
4. 实战:Django 后端有数据就主动推给前端
4.1 整体架构:谁负责"推"这件事
做 Web 后端时间久了,会形成一种思维定式:客户端请求,服务端响应。但 WebSocket 场景下,服务端需要主动推送,这要求你的后端架构从"请求-响应"模型切换到"发布-订阅"模型。
在 Django 里实现 WebSocket 推送,最成熟的方式是使用Django Channels。Channels 的核心思想是把 Django 从纯 HTTP 的框架扩展为可以处理 WebSocket、异步任务等协议的框架。它的架构里有三个关键角色:
- ASGI 服务器(如 Daphne 或 Uvicorn):负责接收 WebSocket 连接,把协议解析成 ASGI 事件。
- Channel Layer(消息队列层):负责跨进程通信。默认支持内存和 Redis 两种后端,生产环境通常用 Redis。
- Consumer:业务处理者,负责接收和发送 WebSocket 消息。
所谓"后台有数据,主动推给前端",本质上就是:后台任务(或者另一个进程)往 Channel Layer 里发一条消息,WebSocket Consumer 从 Channel Layer 里接收到这条消息,再通过它管理的连接推给浏览器。
4.2 一个最小可运行的 Django Channels 推送示例
第一步,安装依赖:
pip install channels channels-redis daphne第二步,在settings.py里配置 ASGI 和 Channel Layer:
# settings.py INSTALLED_APPS = [ # 其他 Django 应用 'channels', ] ASGI_APPLICATION = 'myproject.asgi.application' CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { "hosts": [("127.0.0.1", 6379)], }, }, }第三步,配置asgi.py:
# asgi.py import os from django.core.asgi import get_asgi_application from channels.routing import ProtocolTypeRouter, URLRouter from channels.auth import AuthMiddlewareStack from myapp.routing import websocket_urlpatterns os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') application = ProtocolTypeRouter({ "http": get_asgi_application(), "websocket": AuthMiddlewareStack( URLRouter(websocket_urlpatterns) ), })第四步,定义 WebSocket 路由和 Consumer:
# myapp/routing.py from django.urls import path from myapp.consumers import NotificationConsumer websocket_urlpatterns = [ path('ws/notifications/', NotificationConsumer.as_asgi()), ]# myapp/consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer class NotificationConsumer(AsyncWebsocketConsumer): async def connect(self): self.group_name = 'notifications' await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def disconnect(self, close_code): await self.channel_layer.group_discard(self.group_name, self.channel_name) # 从 channel layer 收到消息后,推给前端 async def send_notification(self, event): await self.send(text_data=json.dumps({ 'message': event['message'], }))第五步,后台任务推送消息。假设你的后台有一个爬虫或定时任务,获取到了新数据,需要推送给连接在ws/notifications/上的所有客户端:
# myapp/tasks.py from asgiref.sync import async_to_sync from channels.layers import get_channel_layer def push_notification(message): channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( 'notifications', { 'type': 'send_notification', 'message': message, } )注意type字段的值是send_notification,Channel Layer 会把它映射到 Consumer 里的send_notification方法——把type里的下划线换成点号就是方法路径。这个约定刚开始很容易忽略,我踩过一次:group_send 的 type 写错了,消息发不出去,而且没有任何报错,排查了很久才发现是方法映射问题。
第六步,前端接收:
const ws = new WebSocket('ws://localhost:8000/ws/notifications/'); ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log('收到推送:', data.message); // 更新页面 renderNotification(data.message); }; ws.onclose = () => { // 触发重连 reconnect(); };这套架构的核心理解点在于:Django 的即时任务进程和 WebSocket 服务进程可能不在同一个进程里,它们之间的桥梁就是 Channel Layer。后端任务只负责往 Redis 里发消息,Consumer 进程从 Redis 订阅并推给浏览器。消息不经过数据库,延迟极低,实测中端到端延迟通常在 10ms 以内。
4.3 生产部署时的 Nginx 配置
如果 WebSocket 服务前面有 Nginx,几行配置就能毁掉整个推送链路。最常见的坑是代理缓冲和连接超时。我在生产环境踩过一次:服务端确实推了数据,但客户端就是收不到,最后定位到是 Nginx 缓冲了推送的数据,迟迟没有转发给客户端。
正确的 Nginx 配置片段:
location /ws/ { proxy_pass http://backend_servers; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; # 关闭缓冲,保证消息实时转发 proxy_buffering off; # 连接超时设置,必须大于心跳间隔 proxy_read_timeout 75s; proxy_send_timeout 75s; }proxy_http_version 1.1不是可选项,而是必须项——HTTP/1.0 不支持 Upgrade 头。proxy_buffering off尤其重要,否则 Nginx 会尝试缓冲 WebSocket 的数据,导致消息延迟甚至卡死。proxy_read_timeout设置为 75 秒是我多次调参后的经验值,它需要大于心跳间隔(30 秒左右),这样不会因为心跳而误断。
5. 防坑指南:连接成功不等于消息畅通
5.1 一个真实排错案例:连接建立了,但消息收不到
有一次同事找到我,说他的 WebSocket 连接可以建立成功(状态码是 101、状态显示 OPEN),但服务端发消息,前端onmessage一直不触发。我远程帮他排查,整个过程可以给各位复现一遍,因为这类问题在 WebSocket 开发里非常典型。
排查链路如下:
- 先确认服务端确实发了消息。在服务端 Consumer 里加日志,确认
send()被调用了,日志显示正常。 - 再确认消息到了哪一层。在 Nginx 的 access log 里看不到 WebSocket 消息记录,因为代理层只记录了握手请求。于是我在 Nginx 里临时开
debug级别的 error log,发现确实有数据从上游传回。 - 问题浮出水面。日志里出现了
upstream sent too big frame while reading response header from upstream,这个报错和通道层返回数据有关。但我当时看的 WebSocket 消息本身很小,怎么会"too big"?继续追,发现是 Nginx 的proxy_buffers配置太小。 - 根因其实是"粘包"。服务端一次
send()了多条消息的合并数据——代码里有个地方把多条消息拼在一起发送了,加上 Nginx 缓冲区不够,导致代理层解析失败。最终解决方案是:第一,规范服务端消息发送,一条消息一个send();第二,把 Nginx 的 proxy_buffers 调大。
这个案例值得记住的是:WebSocket 连接建立成功,只代表 HTTP 握手成功,之后的每一帧数据都有可能在代理层、业务层出问题。日志是最好的老师,但前提是日志足够详细。我现在的习惯是,凡是用到 WebSocket 的服务,服务端 Consumer 的connect、disconnect、receive、send四个方法全部打结构化日志,带上连接标识。出问题的时候,Golden 链路一拉,很快就能定位是哪一层的锅。
5.2 客户端断线重连与指数退避
WebSocket 的onclose事件只能告诉你"连接关了",不能告诉你为什么关。可能是服务端主动关闭、可能是网络中断、可能是代理超时,不一而足。客户端唯一能做的,就是自动重连。
最朴素的实现是断线后 1 秒重连,但真正生产环境里,直接暴力重连会让服务器雪崩——大量客户端在同一时间断线再同时重连,服务端瞬间负载飙升。标准做法是指数退避:
let retryTimes = 0; const maxRetryTimes = 10; function connect() { const ws = new WebSocket('wss://example.com/ws/'); ws.onopen = () => { retryTimes = 0; // 连接成功后重置重试次数 }; ws.onclose = () => { if (retryTimes >= maxRetryTimes) { console.log('重试次数过多,停止重连'); return; } const delay = Math.min(1000 * Math.pow(2, retryTimes), 30000); console.log(`将在 ${delay}ms 后重连`); setTimeout(connect, delay); retryTimes++; }; } connect();指数退避配合一点抖动(Jitter)效果更好。固定时间间隔(比如 1 秒、2 秒、4 秒、8 秒)会让重连请求呈现明显的"同步峰",抖动则能让这些请求随机散开。这里扯一句:任何时候都不建议用死循环重连,也不建议用固定 1 秒重连。在移动端,指数退避能显著降低网络切换时的电量和流量损耗。
5.3 重连后的状态同步问题
重连成功之后,前端往往需要"补数据"。比如你是一个股票行情页面,断线这 30 秒里行情已经变化了,不能等下一次推送才更新。
常用的工程方案是消息序列号 + 增量拉取:服务端每条推送消息带有自增序列号,前端本地记录最后收到的那条序列号,重连后握手时把序列号发给服务端,服务端根据序列号把缺失的消息补推回来。这个方案在聊天、行情、通知类应用里都适用。如果服务端无法精确回放历史消息,退一步的做法是重连成功后强制请求一次全量状态接口(拉最新的状态再继续监听增量推送)。状态一致性比低延迟重要,先保证不丢,再考虑快。
5.4 鉴权和连接生命周期
WebSocket 握手的本质是 HTTP 请求,所以可以在 URL 参数或 Header 里携带鉴权信息。我推荐在握手请求的 Header 里携带 Token,避免 Token 出现在服务端日志和浏览器历史记录里。但这个方案要求 WebSocket 客户端能自定义 Header,浏览器原生 WebSocket API 不支持。这时候有两个选择:
- 用 URL 参数传 Token(简单但会出现在日志中)。
- 用子协议(
Sec-WebSocket-Protocol)传 Token,服务端解析子协议判断身份。
还有第三种路径:先通过普通 HTTP 接口换取一个一次性 Ticket,然后用带 Ticket 的 URL 建立 WebSocket,服务端在握手时校验 Ticket 并作废。这种方式复杂度偏高,适合对安全要求很严格的系统。
握手成功之后做什么?在 Consumer 的connect里完成身份校验,校验不通过就直接关闭连接。注意不要在connect里做耗时的同步操作,比如查数据库,因为这些操作会阻塞事件循环,影响同一进程里的其他连接。正确的做法是accept()之后异步地做身份初始化。
6. 技术选型和踩坑经验:WebSocket 不是唯一答案
6.1 WebSocket、SSE 与轮询的对比
很多人一听说"实时推送",第一反应就是 WebSocket。但实际上,不同的实时场景应该用不同的技术方案。我整理了一个对比表,可以当作选型参考:
| 维度 | WebSocket | SSE(Server-Sent Events) | 短轮询 |
|---|---|---|---|
| 通信方向 | 全双工(双向) | 单向(仅服务端到客户端) | 双向(但轮询本质是客户端发起) |
| 协议基础 | 独立协议(HTTP 握手后升级) | 基于 HTTP,纯文本流 | 普通 HTTP |
| 消息格式 | 文本/二进制均可 | 仅文本(可自定义事件) | 任意 HTTP 响应 |
| 自动重连 | 需要手写 | 浏览器原生支持 | 需要手写 |
| 浏览器兼容性 | 所有现代浏览器 | 除 IE 外现代浏览器均支持 | 全部支持 |
| 服务端资源占用 | 连接常驻,适合高频率双向通信 | 连接常驻,但只需单向推送 | 仅请求瞬间占用 |
| 穿透代理复杂度 | 高(需要配置 Upgrade、关闭缓冲) | 低(普通 HTTP 请求) | 最低 |
6.2 我的选型经验:什么时候用 WebSocket,什么时候用 SSE
如果业务场景只是"服务端有更新,客户端需要实时知道",比如网页版的订单通知、日志流、进度条,SSE 比 WebSocket 更合适。理由很实在:
- SSE 是基于 HTTP 的,不需要特殊握手,不需要配置 Nginx 的 Upgrade 头,遇到问题排查思路和普通 HTTP 完全一致。
- 浏览器原生支持自动重连,不需要自己写指数退避。
- SSE 只做单向推送,协议开销比 WebSocket 更小,服务端实现也更简单(Django 里一个普通 View 就能实现
StreamingHttpResponse)。
我在一个监控大屏项目里,就用 SSE 替代了原本计划好的 WebSocket。那个项目的需求是每隔几秒推送一次服务器 CPU 和内存数据,没有双向消息的需要,用 WebSocket 纯属杀鸡用牛刀。
反过来,如果需要双向实时通信——聊天室、在线协同编辑、多人白板、实时对战——那就必须上 WebSocket。Node.js 的ws库、Python 的 Channels、Go 的gorilla/websocket,都是成熟稳定的选择。
还有一类场景比较特殊:低频心跳 + 高频业务推送混合。比如证券行情,平时每秒钟推送好几条行情,但也要有心跳保活。这时候我倾向于用 WebSocket 承载全部流量,因为 SSE 的 HTTP 连接在长时间占用时,代理层同样有超时问题,而且 SSE 没有内建的心跳机制(虽然可以通过retry字段控制重连时间,但它本身不发送保活包)。
6.3 关于协议层之外的工程化提醒
最后分享几点我在多个项目中沉淀下来的经验,这些内容在官方文档里基本不会写:
第一,不要在 WebSocket 里传大文件。我见过有人试图把一张几 MB 的图片通过 WebSocket 发送,结果导致连接假死、其他消息全部排队。WebSocket 适合高频小消息,大文件应该走 HTTP 上传,然后通过 WebSocket 推送一个"文件就绪"的通知即可。
第二,服务端要设置消息大小上限。在 Consumer 里加一个receive时的长度校验,超过阈值直接断开连接。浏览器端发一个几十 MB 的帧,如果服务端没有限制,内存会瞬间被吃掉。这是一个非常简单但极其重要的安全门槛。
第三,连接数监控很重要。WebSocket 连接是长占用资源,建议在监控系统里单独建立连接数指标,设置告警阈值。连接数异常上涨通常意味着客户端重连逻辑有 Bug(比如死循环重连),连接数异常下跌可能意味着服务端或网络存在问题。没有监控,这类问题往往要等用户投诉才能发现。
另外,如果团队用的云厂商负载均衡,需要确认四层和七层两种模式对 WebSocket 的支持情况。有的负载均衡器七层模式需要显式开启 WebSocket 支持,否则连接会在握手后立刻被切断。这个问题在自建 Nginx 的环境下反而不容易遇到,但在云环境里简直是最常见的"连接秒断"根因之一。
第四,也是我最想说的一点:不要为了用 WebSocket 而用 WebSocket。技术选型永远要回到业务本身。你的真实需求是低延迟、双向推送,才值得为 WebSocket 的复杂度买单;如果只是单向的通知、事件流,SSE 和消息队列配合短轮询常常是更稳的选择。实际开发中,"能用简单方案解决"本身就是一种最佳实践。