
做前端这几年“轮询”这俩字只要出现在业务里我眉头就会先皱一下。不是轮询不能用而是它太像复读机了为了拿到一条新消息客户端得每隔几秒问一遍服务端服务端每次都空手而归一来一回全是无效开销。直到 WebSocket 出现浏览器才真正有了“服务端主动推数据”的能力一条 TCP 连接建立后两边随时都能说话不用再反复握手。这篇东西我不打算照着 RFC 文档念而是把 WebSocket 协议从握手到帧格式、从心跳到断线重连结合我实际做过和排查过的项目一次讲透。适合刚接触 WebSocket 的前后端同学也适合线上已经跑着 WebSocket 服务、却还没把协议细节吃透的人。1. 为什么需要 WebSocketHTTP 轮询并不够用先说个最基本的场景在线客服。用户发一条消息前端要立刻展示客服的回复。放在 HTTP 1.1 时代最朴素的方案就是定时请求接口比如每 5 秒拉一次未读消息。这个方案能跑但问题非常多。1.1 轮询的“复读机”困境轮询的痛处可以从三个角度看。第一是实时性永远有缺口。用户消息发出的瞬间服务端是有数据的但客户端下一次拉取可能要等 5 秒甚至更久。把轮询间隔调到 1 秒延迟是降下来了可代价是每个在线用户一秒钟产生一个请求一万人就是每秒一万次请求大部分都空转。第二是服务端资源被白白消耗。每一次轮询都要走完 HTTP 请求的完整生命周期建立连接、处理请求体、查数据库、序列化响应、关闭连接。哪怕你做了连接复用业务代码该执行的 SQL 还是会执行。我曾经在一个在线协作项目里统计过高峰期百分之七十的轮询请求查出来的数据都是空的这些机器资源等于在空转。第三是移动端省电问题这个常被忽略。每 5 秒一次网络请求手机射频模块就得从低功耗状态醒过来一次。一天攒下来耗电量和流量消耗都相当可观。很多用户不会抱怨但后台数据会暴露问题。轮询适合的场景其实很窄数据变化不频繁、对实时性要求不高、后端接口是现成的不想动。凡是真实时场景轮询都是最后的选项。1.2 长轮询与 SSE过渡方案的补丁轮询不好用于是有人发明了长轮询。思路很简单客户端发请求过去服务端先不急着响应挂起这个请求等有新数据了再一次性返回。客户端收到结果后立刻再发一次请求相当于“数据一到就响应没数据就占着一个连接慢慢等”。长轮询确实把“服务端主动通知”这件事变成了现实但问题也没少。最典型的是连接超时。浏览器、网关、负载均衡器对请求都有超时时间通常 30 到 60 秒。服务端如果 40 秒才来数据网关早就把连接掐断了。于是大家只能把超时时间往死里调但中间设备不可控。更麻烦的是重连风暴如果某一秒服务端积压了几千条数据那几千个挂着的请求同时返回客户端立刻重新发起服务端瞬间被打满整个系统跟着抖。再提一个同样基于 HTTP 的方案 SSEServer-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: s3pPLMBiTxaQ9kYGzzhZRbKxOo101 状态码不是错误它是协议切换成功的标志。这里的“切换”只发生在应用层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); // 输出s3pPLMBiTxaQ9kYGzzhZRbKxOo为什么要设计这个 Key 校验核心作用有两个。第一防止客户端随便连一个普通 HTTP 服务就以为自己在用 WebSocketAccep 的校验相当于服务端证明“我知道你在说什么协议”。第二避免中间缓存代理把 HTTP 响应错缓存成 WebSocket 流量。实际开发时这两步都不用自己写浏览器内置的 WebSocket API 会自动生成 Key、自动校验 Accept。但如果你要自己造轮子或者用 Node.js 的底层 socket 裸写服务端这个算法就必须搞对。我见过有人直接把 Accept 写死返回客户端连上来立马被浏览器判定握手失败这类问题看一眼响应头就能发现。2.3 手把手模拟一次握手的排查方法如果你想把握手过程的每一步都看明白最快的办法就是用命令行手动发一次请求。假设本地起了一个 WebSocket 服务在 8080 端口路径是/wsprintf 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关闭帧发起关闭握手0x9Ping 帧心跳探测0xAPong 帧回复 Ping实际抓包时一条81 05 48 65 6C 6C 6F这么长的二进制读法是0x81表示 FIN1、opcode1这是一个完整的文本帧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 与关闭帧协议允许一条逻辑消息被拆成多个帧发送拆帧的标准是 FIN0 的起始帧加中间帧最后 FIN1 的终止帧。浏览器 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_datajson.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 秒 Ping90 秒无 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 切到 4Gsocket 会关电脑休眠唤醒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 服务不是说代码写得不对才挂而是机器已经跑大妈了没人发现。等到用户开始反馈“消息收不到”的时候基本已经晚了。