做前端这几年,“轮询”这俩字只要出现在业务里,我眉头就会先皱一下。不是轮询不能用,而是它太像复读机了:为了拿到一条新消息,客户端得每隔几秒问一遍服务端,服务端每次都空手而归,一来一回全是无效开销。直到 WebSocket 出现,浏览器才真正有了“服务端主动推数据”的能力,一条 TCP 连接建立后,两边随时都能说话,不用再反复握手。这篇东西我不打算照着 RFC 文档念,而是把 WebSocket 协议从握手到帧格式、从心跳到断线重连,结合我实际做过和排查过的项目,一次讲透。适合刚接触 WebSocket 的前后端同学,也适合线上已经跑着 WebSocket 服务、却还没把协议细节吃透的人。
1. 为什么需要 WebSocket:HTTP 轮询并不够用
先说个最基本的场景:在线客服。用户发一条消息,前端要立刻展示客服的回复。放在 HTTP 1.1 时代,最朴素的方案就是定时请求接口,比如每 5 秒拉一次未读消息。这个方案能跑,但问题非常多。
1.1 轮询的“复读机”困境
轮询的痛处可以从三个角度看。
第一是实时性永远有缺口。用户消息发出的瞬间,服务端是有数据的,但客户端下一次拉取可能要等 5 秒甚至更久。把轮询间隔调到 1 秒,延迟是降下来了,可代价是每个在线用户一秒钟产生一个请求,一万人就是每秒一万次请求,大部分都空转。
第二是服务端资源被白白消耗。每一次轮询都要走完 HTTP 请求的完整生命周期:建立连接、处理请求体、查数据库、序列化响应、关闭连接。哪怕你做了连接复用,业务代码该执行的 SQL 还是会执行。我曾经在一个在线协作项目里统计过,高峰期百分之七十的轮询请求查出来的数据都是空的,这些机器资源等于在空转。
第三是移动端省电问题,这个常被忽略。每 5 秒一次网络请求,手机射频模块就得从低功耗状态醒过来一次。一天攒下来,耗电量和流量消耗都相当可观。很多用户不会抱怨,但后台数据会暴露问题。
轮询适合的场景其实很窄:数据变化不频繁、对实时性要求不高、后端接口是现成的不想动。凡是真实时场景,轮询都是最后的选项。
1.2 长轮询与 SSE:过渡方案的补丁
轮询不好用,于是有人发明了长轮询。思路很简单:客户端发请求过去,服务端先不急着响应,挂起这个请求,等有新数据了再一次性返回。客户端收到结果后立刻再发一次请求,相当于“数据一到就响应,没数据就占着一个连接慢慢等”。
长轮询确实把“服务端主动通知”这件事变成了现实,但问题也没少。最典型的是连接超时。浏览器、网关、负载均衡器对请求都有超时时间,通常 30 到 60 秒。服务端如果 40 秒才来数据,网关早就把连接掐断了。于是大家只能把超时时间往死里调,但中间设备不可控。更麻烦的是重连风暴:如果某一秒服务端积压了几千条数据,那几千个挂着的请求同时返回,客户端立刻重新发起,服务端瞬间被打满,整个系统跟着抖。
再提一个同样基于 HTTP 的方案 SSE(Server-Sent Events)。SSE 服务端可以持续向客户端推送文本数据,天然支持断线重连和事件 ID,很多场景比 WebSocket 简单得多。但它有个硬伤:单向。客户端想给服务端发消息,还是得单独走 HTTP POST。如果你只有服务端推送、不需要客户端频繁上行,SSE 其实更合适。需要双向时就没办法了。
1.3 WebSocket 真正解决了什么问题
WebSocket 解决的正是“双向 + 实时 + 低开销”这个组合问题。它通过一条 TCP 连接承载一个独立的帧协议,客户端和服务端任何一方都能随时主动发数据,没有 HTTP 请求响应的这种结构性约束。
看个对比就明白了:
| 方案 | 方向 | 实时性 | 连接开销 | 适合场景 |
|---|---|---|---|---|
| 普通轮询 | 单向请求 | 差,受间隔限制 | 高,频繁建连 | 低频通知 |
| 长轮询 | 半双向,靠请求挂起 | 中,受超时影响 | 中,一直占着一个请求 | 早期推送改造 |
| SSE | 服务端到客户端单向 | 好 | 低,一条长连接 | 股票行情、进度推送 |
| WebSocket | 全双工 | 好 | 低,一条长连接 | 聊天、协同编辑、游戏对战 |
实际项目里常见的 WebSocket 应用我已经不记得接了多少个了:弹幕评论、多人协作文档的光标同步、运维平台的命令行回显、IoT 设备状态上报、后台有数据主动推前端。这些场景在 WebSocket 之前每一个都有方案,但每一个都做得很憋屈。用了 WebSocket 之后,代码结构反而简单了:连接建立好,消息直接往 socket 上写,不需要设计一套“轮询请求 + 响应补偿”的复杂机制。
所以一句话:WebSocket 不是来替代 HTTP 的,它只是把 HTTP 握手作为“开门的钥匙”,门开了之后,走自己的协议。
2. 协议握手:从 HTTP 到 WebSocket 的一次“升舱”
WebSocket 协议最容易被忽略、也最值得先搞清楚的部分就是握手。因为握手没成功,后面全是空中楼阁。
2.1 Upgrade 机制与连接升级
WebSocket 的握手本质上是 HTTP 的一个特殊用法。客户端发起一个普通的 HTTP GET 请求,但请求头里带着几个特殊字段,告诉服务端:“我不想按普通 HTTP 流程走了,我要升级成 WebSocket 协议。”
一个标准的握手请求长这样:
GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13这里最关键的是Sec-WebSocket-Key和Sec-WebSocket-Version。Version 固定是 13,表示使用 RFC 6455 版本;Key 是客户端生成的随机值。服务端如果同意升级,会返回:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=101 状态码不是错误,它是协议切换成功的标志。这里的“切换”只发生在应用层,TCP 连接本身没有断开,所以就省掉了重新经过三次握手的过程。这也是为什么 WebSocket 建连看起来比 HTTP 请求还“轻”的原因之一:真正昂贵的 TCP 连接建立只需要一次,之后一直是同一条。
很多初学者会在握手环节翻车,最常见的症状是服务端返回 404 或者 200 而不是 101。404 说明路径不对,或者服务端根本没实现 WebSocket 路由;200 说明请求被当成普通 HTTP 处理了,可能是服务端组件没启用 Upgrade 支持。后面我会专门讲排查。
2.2 Sec-WebSocket-Key 的校验逻辑
Sec-WebSocket-Accept这串值不是随手写死的,它有一套计算规则。服务端拿到客户端的 Key 之后,拼上一个固定的 GUID 字符串,做 SHA-1 哈希,然后 Base64 编码。
这个固定字符串是协议规定的:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11计算方法用代码演示就是:
const crypto = require('crypto'); const key = 'dGhlIHNhbXBsZSBub25jZQ=='; const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const accept = crypto.createHash('sha1') .update(key + GUID) .digest('base64'); console.log(accept); // 输出:s3pPLMBiTxaQ9kYGzzhZRbK+xOo=为什么要设计这个 Key 校验?核心作用有两个。第一,防止客户端随便连一个普通 HTTP 服务就以为自己在用 WebSocket,Accep 的校验相当于服务端证明“我知道你在说什么协议”。第二,避免中间缓存代理把 HTTP 响应错缓存成 WebSocket 流量。
实际开发时这两步都不用自己写,浏览器内置的 WebSocket API 会自动生成 Key、自动校验 Accept。但如果你要自己造轮子,或者用 Node.js 的底层 socket 裸写服务端,这个算法就必须搞对。我见过有人直接把 Accept 写死返回,客户端连上来立马被浏览器判定握手失败,这类问题看一眼响应头就能发现。
2.3 手把手模拟一次握手的排查方法
如果你想把握手过程的每一步都看明白,最快的办法就是用命令行手动发一次请求。假设本地起了一个 WebSocket 服务在 8080 端口,路径是/ws:
printf 'GET /ws HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n' | nc 127.0.0.1 8080如果服务端正常,你会看到返回头里带101 Switching Protocols。如果返回的是一大段 HTML,说明请求被当成普通 HTTP 处理了,后端路由没接住 WebSocket。
遇到握手失败,我建议按这个顺序排查:
- 请求路径和配置的 path 是否一致,大小写敏感
- 反向代理(Nginx 等)是否传了
Upgrade和Connection: upgrade头 - 服务端框架是否注册了 WebSocket handler,还是被普通 Controller 拦截了
- 防火墙或网关设备是否支持协议升级,部分老旧设备会把非法头部直接丢弃
有一个细节很多人不知道:有些 Nginx 配置里Connection头必须写成Connection: upgrade,但如果客户端用的是 HTTP/2,很多代理根本没法正常升级。生产环境建议握手走 HTTP/1.1,不要给升级请求上 HTTP/2。
3. 帧格式:真正读懂协议在传什么
握手结束后的数据就不走 HTTP 格式了,改成 WebSocket 自有的帧格式。很多人接口调通了、数据发出来了,但从来没看过线上报文,这会导致出问题时无从下手。
3.1 一帧数据的逐字节拆解
WebSocket 帧的头部最简形式只有 2 个字节,复杂情况下会变长。先看最基础的结构:
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | | +-+-+-+-+-------+-+-------------+-------------------------------+第一个字节的高位是 FIN,标记这是不是最后一个分片,置 1 表示整条消息结束。接下来三位是 RSV,通常是 0,只有扩展协商过才会有值。低四位是 Opcode,决定这条帧干什么用。
第二个字节高位是 MASK 位,客户端发给服务端的帧必须置 1,服务端发客户端的必须置 0。剩余的 7 位是载荷长度。长度特别大的时候这 7 位位数不够用,协议定义了一个规则:长度值小于 126 就直接存;等于 126 表示后面两个字节才是真实长度;等于 127 表示后面八个字节才是真实长度。
常用 Opcode 就 5 个:
| Opcode | 含义 | 说明 |
|---|---|---|
| 0x1 | 文本帧 | UTF-8 编码的文本数据 |
| 0x2 | 二进制帧 | 任意二进制数据 |
| 0x8 | 关闭帧 | 发起关闭握手 |
| 0x9 | Ping 帧 | 心跳探测 |
| 0xA | Pong 帧 | 回复 Ping |
实际抓包时,一条81 05 48 65 6C 6C 6F这么长的二进制,读法是:0x81表示 FIN=1、opcode=1,这是一个完整的文本帧;0x05表示后面有 5 字节数据;48 65 6C 6C 6F就是 “Hello” 的 ASCII。能看懂这种原始字节,排查问题时心态完全不一样。
3.2 掩码:为什么客户端非要“加盐”
掩码(Masking)是 WebSocket 协议里最容易让人困惑的一个规则。客户端发给服务端的帧,MASK 位必须置 1,并且消息体要和一组 4 字节的随机掩码做一次异或运算;服务端发给客户端的不允许加掩码。
那为什么客户端要加盐?协议设计的初衷是为了防止一种叫“缓存污染攻击”的场景。因为帧头里带着长度和内容后缀,没有掩码的话,攻击者可以利用一些代理服务器的缓存机制,把伪造的字节流注入到不可信的缓存里,后续请求可能拿到污染后的响应。随机掩码的目的就是让中间设备无法预测内容。
掩码算法非常简单:取 4 个字节作为掩码键,然后对载荷逐字节做异或,第 i 个字节与 mask[i % 4] 异或。
const mask = [0x12, 0x34, 0x56, 0x78]; const payload = Buffer.from('Hello', 'utf8'); const masked = Buffer.alloc(payload.length); for (let i = 0; i < payload.length; i++) { masked[i] = payload[i] ^ mask[i % 4]; }服务端收到后要做同样的异或才能还原数据。如果你在用 Node.js 裸 socket 实现服务端,忘了对客户端发来的帧做解掩码,收到的中文就会变成一堆乱码。成熟的 ws 库都自动处理了这个步骤,但理解原理有助于你定位“发出来是好的,收到端是乱的”这类问题。
3.3 分片、Ping/Pong 与关闭帧
协议允许一条逻辑消息被拆成多个帧发送,拆帧的标准是 FIN=0 的起始帧加中间帧,最后 FIN=1 的终止帧。浏览器 API 基本不会让你手动拆帧,服务端框架也很少暴露分片接口,但如果是网关做协议转发,或者你自己实现客户端时,必须正确拼装分片。
控制帧有几个限制:Ping、Pong、Close 都属于控制帧,它们的载荷长度最大 125 字节,而且不能分片。Ping 和 Pong 的载荷内容随意,常见做法是放一段时间戳或随机字符串,用来做匹配判断。
关闭帧则带着状态码。正常关闭是 1000,协议规定双方要各自发一次 Close 帧完成“关闭握手”:一端发 Close,另一端回 Close,然后 TCP 连接才关闭。浏览器里主动调用 close() 时,如果传了 code 参数,也是用同样的机制。一些异常情况你可能见过:1001 表示服务端要重启了,1006 是异常断开且没有任何 Close 帧。1006 这个码在浏览器端几乎看不到具体原因,接到这个编码就只能走自己的重连逻辑。
4. 客户端与服务端落地实现
理解协议之后,最重要的就是落地。这一节我从浏览器客户端和服务端两个方向讲实操。
4.1 浏览器端 WebSocket API 的关键细节
浏览器的 WebSocket API 很简单:
const ws = new WebSocket('wss://example.com/ws'); ws.addEventListener('open', () => { ws.send(JSON.stringify({ type: 'join', room: 'test' })); }); ws.addEventListener('message', (event) => { const data = event.data; // data 可能是 Blob,也可能是文本 }); ws.addEventListener('close', (event) => { console.log('closed', event.code); }); ws.addEventListener('error', (event) => { console.error('error', event); });几个细节值得强调。
第一,构造函数里如果传了第二个参数,那是子协议列表,一般业务用不到,别乱传。第二,message事件里event.data的类型取决于服务端发的是文本帧还是二进制帧,以及你设置的binaryType。如果是二进制帧,建议把ws.binaryType设为'arraybuffer',这样拿到的是 ArrayBuffer,可以方便走 protobuf 或自定义协议;默认'blob'类型处理起来麻烦得多。
第三,客户端关闭连接有两种方式。调用ws.close()是规范关闭;如果服务端挂了、网络闪断,浏览器会触发 error 再触发 close,不会自动重连。所以前端一定要手动处理重连。第四,send()方法在连接没建立好的时候调用会抛异常,因为 WebSocket 没有消息排队机制,必须在 open 之后再发。我之前做过一个需求,用户点击按钮瞬间 socket 恰好断了,没做状态判断直接 send,结果控制台飘错误。正确处理方式是检查ws.readyState === WebSocket.OPEN再发,或者把要发的内容缓存起来,等 open 时统一发送。
4.2 Node.js + ws 服务端实战
服务端我用的比较多的是 Node.js 的 ws 库,稳定、内存占用低、API 直观。
const { WebSocketServer } = require('ws'); const wss = new WebSocketServer({ port: 8080, path: '/ws' }); wss.on('connection', (ws, req) => { const ip = req.socket.remoteAddress; console.log('连接进入', ip); ws.on('message', (data, isBinary) => { // 业务判断,这里简单回显 if (!isBinary) { ws.send(`echo: ${data}`); } }); ws.on('close', () => { console.log('连接关闭', ip); }); ws.on('error', (err) => { console.error('连接错误', err); }); });业务上真正要处理的是广播。比如一个聊天室,一个用户发言,要推给房间里的所有人。我给每个连接分配一个 userId 和 roomId,存在一个 Map 里,广播时遍历这个 Map,找到同 room 的客户端逐个 send。这里最容易踩的坑是:给已经关闭的 socket 调用 send。虽然 ws 库内部做了校验,但业务代码最好也用ws.readyState === ws.OPEN判断一下。
ws 库内置的心跳方案我直接贴出来参考:
const interval = 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 秒轮询一次,所有连接先标记为“可能死了”,发一个 Ping 帧;如果客户端回 Pong,就把它重新标记为存活;直到下一个周期发现某个连接还是没有 isAlive,直接terminate()掉。这是我从真实项目里抄出来的写法,稳。
4.3 Python Django Channels 等方案与选型
后台有数据想实时推给前端的场景,用 Python Django 比较多。Django Channels 提供了 WebsocketConsumer 和 AsyncWebsocketConsumer 两种类。最朴素的一个实现长这样:
# consumers.py from channels.generic.websocket import AsyncWebsocketConsumer import json class ChatConsumer(AsyncWebsocketConsumer): async def connect(self): self.room_name = self.scope['url_route']['kwargs']['room_name'] await self.channel_layer.group_add(self.room_name, self.channel_name) await self.accept() async def disconnect(self, close_code): await self.channel_layer.group_discard(self.room_name, self.channel_name) async def receive(self, text_data): text_data_json = json.loads(text_data) message = text_data_json['message'] await self.channel_layer.group_send( self.room_name, {'type': 'chat.message', 'message': message} ) async def chat_message(self, event): await self.send(text_data=json.dumps({'message': event['message']}))后台某个业务逻辑处理完,想主动推送数据到一组前端,用channel_layer.group_send就行。这个机制在单机下没问题,多机部署时要用 Redis channel layer 做跨节点广播,这是 Django Channels 的常规扩展。
服务端选型的建议:如果整个团队是 JS 技术栈,直接用 Node.js 的 ws 或 Socket.IO;如果项目已经是 Java Spring 体系,用 Spring WebSocket 的@ServerEndpoint很省心;如果是 Python 后端,Django Channels 和 FastAPI 的 WebSocket 都行。选型最重要的不是框架本身,而是你怎么处理集群广播。单实例直接内存广播,多实例必须引入消息中间件。早期项目我为了省事在单实例上跑 WebSocket,等需要扩容时才发现广播逻辑写死了,持杯具。
还有一个场景值得提一句:有些 IoT 设备在内网,没法直接被云端连进来,设备主动向外到一个公网服务发起 WebSocket 长连接,云端再通过这条通道下发指令。这其实就是所谓的反向 WebSocket,本质还是同一个协议,只是发起方是设备,而不是浏览器。做封测设备、工业设备对接时经常用到。
5. 心跳机制、断线重连与消息可靠性
很多 WebSocket 项目上线后表现不稳定,问题往往不在协议本身,而在连接的生命周期管理。TCP 连接没有“死链通知”,TCP 层为了不干扰业务,对长时间静默的连接不会主动探测。所以你需要靠心跳机制去区分“活着但没消息”和“已经死了但没人知道”。
5.1 心跳设计:多久一次才算合理
心跳最常见的实现是基于 Ping/Pong 帧。服务端定个定时器,每隔 N 秒给客户端发一个 Ping,客户端收到后自动回 Pong。如果超过 M 秒没收到 Pong,就认为连接死了。
N 和 M 怎么定?要考虑三层因素。
第一层是 NAT 空闲超时。很多路由器或运营商 NAT 映射表会在 60 到 120 秒内回收没有流量进展的连接。心跳间隔必须小于这个值,不然连接会被中间设备掐断。我一般设 30 秒。
第二层是负载均衡器和反向代理的空闲超时。Nginx 默认proxy_read_timeout是 60 秒,超过 60 秒没有可读的数据,Nginx 就会断开后端连接。这意味着心跳间隔最好小于 60 秒,否则要么调大 Nginx 超时,要么让心跳更快一些。
第三层是客户端和服务器的时间容忍度。如果服务端 30 秒发一次 Ping,客户端 90 秒没收到,基本可以判断网络已经出现严重问题,没必要傻等。一个常见设计是:服务端每 30 秒 Ping,90 秒无 Pong 断开;或者客户端每 30 秒主动发一个业务心跳消息,服务端 3 次未收就断开。
注意:不要依赖 TCP 的 keep-alive 机制来代替应用层心跳。TCP keep-alive 默认 2 小时一次探测,间隔太长,而且中间代理很容易把这类包透传后不做判断,应用层根本不知道状态。应用层心跳才是唯一可靠的方式。
5.2 断线重连的指数退避
断线重连的最大坑是“重连风暴”。一旦服务端重启或网络抖动,所有客户端同时断线,如果大家立刻重连,服务端瞬间收到几万个连接请求,雪上加霜。
正确做法是指数退避加随机抖动。先等 1 秒,失败再等 2 秒、4 秒、8 秒,封顶 30 秒。随机抖动的目的是防止同一批客户端在同一时间发起重连,把请求峰拉平。
一个参考实现:
function connectWithRetry(url, maxDelay = 30000) { let delay = 1000; function attempt() { const ws = new WebSocket(url); ws.addEventListener('open', () => { delay = 1000; // 成功后重置重连间隔 }); ws.addEventListener('close', () => { ws.removeEventListener('open', () => {}); const jitter = Math.random() * 1000; const nextDelay = Math.min(delay + jitter, maxDelay); setTimeout(attempt, nextDelay); delay = delay * 2; }); return ws; } return attempt(); }还要注意浏览器自动触发的重连场景:用户网络从 Wi-Fi 切到 4G,socket 会关;电脑休眠唤醒,socket 也会关。监听window的online事件和visibilitychange事件,在这些时机主动检查连接状态并恢复,比干等 close 事件更快。
5.3 消息可靠性:序号、ACK 与补偿
WebSocket 只保证传输层的数据交付,不保证业务层的“消息一定被处理”。比如一个客户端断线 10 秒,这期间服务端往它的 socket 上写了三条消息,但 socket 已经断了,这三条消息就丢了。客户端重连之后,它只知道上次显示到哪一条,不知道中间少了哪些。
这个问题协议本身解决不了,需要业务层设计。我常用的方案是消息带递增序号。服务端给每个连接维护一个消息序号,发消息时带上seq;客户端记录自己收到的最大 seq。重连成功后,客户端把断线前最后收到的 seq 发给服务端,服务端用 seq 号把缺口补上。这个方案简单可靠,很适合聊天、通知类场景。
再进一步,如果消息很重要不能丢,客户端收到并渲染后需要回一个 ack。服务端维护发送队列,超过一定时间没收到 ack 就重发。这里要明确语义:至少一次、至多一次、精确一次。WebSocket 这种长连接实时场景,精确一次太浪费,绝大多数需求用“至少一次 + 客户端去重”就能满足。
消息乱序问题也要提一嘴。WebSocket 帧在 TCP 连接上是顺序到达的,不存在网络层乱序;但如果你同时从多个服务端实例往同一个客户端推数据,比如集群广播,消息到达顺序就不受控制了。这种场景建议在消息体里加时间戳和序号,客户端按需排序。
6. 高频问题排查实录
最后这部分我梳理一下这几年线上遇到的高频问题。这些问题搜索引擎上几乎每天都会有人问,但大多数帖子都只给了一个字段级解释,没有给排查路径。我尽量说全。
6.1 连接成功但收不到消息
“WebSocket 连接建立成功了,状态也是 OPEN,但就是收不到数据”,这是我见过最多的问题。可能的原因有几个。
第一,服务端确实发了,但发到了别的连接上。比如广播时遍历的是一个集群里的部分客户端,漏掉了当前这个连接。用 wscat 或者浏览器 devtools 的 Network 面板打开 WS 标签,可以直接看帧流。如果整个面板一个消息帧都没有,说明服务端根本就没往这个连接发。第二,客户端注册onmessage太晚了。我用过不少老代码在new WebSocket()之后先写了一堆业务初始化,初始化到一半服务端消息已经来了,没有 listener 处理,消息就被丢弃了。个人建议用addEventListener在连接创建后立刻注册。第三,代理服务器缓存了握手响应,导致客户端以为自己还在跟 HTTP 代理对话。这种情况通常发生在没有正确配置Upgrade头的 Nginx 上,需要检查响应是不是 101。
还有一个容易被忽略的点:前端拿到二进制消息时,如果服务端发的是文本帧,event.data是字符串,直接JSON.parse没问题。如果服务端误发了二进制帧,event.data就是 Blob 或 ArrayBuffer,直接 parse 一定报错。排查时先看 Network 面板里的帧类型。
6.2 1006 异常关闭与代理配置
浏览器端看到 close 事件里的 code 是 1006,意味着连接被异常终止,本端没有收到任何 Close 帧。这个编码讨厌在它什么细节都不告诉你。
1006 排查我总结过一条主线:先看服务端日志,看进程有没有崩溃、有没有主动 kill 连接;再看代理层,Nginx 的error.log是不是出现了upstream prematurely closed connection;最后看网络设备,机房防火墙是否对静默连接做了老化。
Nginx 代理 WebSocket 的推荐配置如下:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }这里最关键的是两个头,少了任何一个,客户端握手都走不过去。另外proxy_read_timeout必须比心跳间隔大,否则 Nginx 会先于业务层断开空闲连接,客户端就会收到 1006。很多团队忘了调这个参数,然后困惑为什么 60 秒必断一次,其实 Nginx 默认值就是 60 秒。
6.3 WSS 证书与跨域安全
浏览器对ws://混用很敏感,生产环境一律用wss://。WSS 就是 WebSocket over TLS,跟 HTTPS 关系类似。使用 wss 时证书要有效,不能有自签名问题,否则浏览器直接拒绝连接,报错信息往往只有一句“WebSocket connection failed”,非常劝退。排查用openssl s_client -connect 域名:443 -servername 域名看证书链是否完整。
跨域方面,WebSocket 不受同源策略限制,服务器需要自己校验。最基础的是校验Origin头,防止第三方网站偷偷建立连接。但 Origin 可以伪造,不能当成唯一安全手段。更可靠的是在握手时通过 URL 参数或自定义 Header 携带 token,服务端校验通过后再接受连接。我踩过的坑是:客户端只带了一次 token,断线重连时 token 过期了,服务端直接拒绝,最后前端一脸懵。解决方案是重连前重新走一遍鉴权,或者把 token 换短一些,重连时动态刷新。
服务端还要注意 ping/pong 帧不能携带 cookie,浏览器也不会自动为 WebSocket 带自定义 header,所以很多项目选择把 token 放在查询参数里。查询参数会进访问日志,记得做脱敏,否则 token 会被打出来。
6.4 性能与稳定性复盘
WebSocket 服务稳定运行的前提是你知道自己的瓶颈。每个连接维持一个 socket 文件描述符,还有接收缓冲区和发送缓冲区。单机 5 万个连接并不夸张,但每多一个连接就多一点内存,启动时预留的内存不够就会 OOM。
消息广播是性能大敌。一万人同时在线的聊天室,一个人发消息,你给全部人遍历 send,一万人就是一万次系统调用。如果在 for 循环里做耗时操作,广播延迟会肉眼可见地增加。我的建议是发送只做轻量转发,重业务交给队列异步处理;必要时按房间分片,或者用 Redis pub/sub 做集群广播。
ws.send()返回值值得留意。当后端发送消息的速度大于客户端消费速度时,TCP 发送缓冲区会被填满,send()返回 false,表示数据开始积压。这时候如果不做任何处理,内存会一直涨。合理做法是暂停或丢掉一些非关键消息,优先保证实时性。比如行情服务,新行情来了,旧的没发出去的行情其实没必要补发,丢给后面最新数据就行。
压测工具方面,命令行可以用wscat -c wss://...手动连一把,批量压测可以用 Autobahn 测试套件,或者直接写个 Node.js 脚本创建几千条连接做并发测试。上线前至少要把“断线重连 + 心跳”这一整套跑通,模拟服务端重启,观察客户端是否能在预估时间内自动恢复。这些动作看起来琐碎,但真正遇到线上故障时都是救命稻草。
我自己实际做项目时最后还会强制加一条监控:把连接数、每秒收发的消息数、待处理队列长度、废弃连接率这几个指标接入告警。很多 WebSocket 服务不是说代码写得不对才挂,而是机器已经跑大妈了,没人发现。等到用户开始反馈“消息收不到”的时候,基本已经晚了。