做一个需要实时刷新的功能,大部分人第一反应是轮询:setInterval 里每秒拉一次接口,数据看起来就"实时"了。但这个方案在连接数稍微上去之后,服务器负载、请求延迟、移动端耗电全都会变成问题。HTML5 时代的 WebSocket 把这个问题从"轮询等待"改成了"长连接推送"——一个 TCP 连接上既能收又能发,服务端可以主动把数据推到浏览器,不再需要前端反复问"有没有新消息"。这篇文章我会把 WebSocket 从握手原理、帧格式、前后端实战到运维排障整个链路过一遍,既有协议层面的拆解,也有可以直接抄走的代码和配置,适合刚接触 WebSocket 的前端同学,也适合被线上连接问题折磨过、想系统补一遍基础的后端和运维朋友。
1. 为什么实时通信不能靠轮询硬撑——WebSocket 要解决的问题
1.1 轮询、长轮询的三大痛点
HTTP 协议是典型的请求-响应模型:客户端不发请求,服务器就不能回数据。想做到"实时",传统方案只有两条路。短轮询是定时发请求,比如每 2 秒拉一次最新状态;长轮询是客户端发一个请求,服务器攥着不响应,等有新数据了再返回,然后客户端立刻再发下一个请求,用这种"挂起-返回-再挂起"的方式模拟推送。
长轮询的实时性确实比短轮询好,但两个方案都有绕不开的问题。
第一是资源浪费。HTTP 请求自带方法、路径、Cookie、一堆 Headers,一次请求光头部就好几百字节,几十上百个连接同时高频轮询,网关和业务服务器的 QPS 会被大量无效请求抬高。我见过一个监控大屏项目,200 个客户端每 3 秒轮询一次,Nginx 的 access log 刷得飞快,后端接口多半时间在返回"没有变化"。
第二是实时性天花板。轮询间隔设得太小,服务器吃不消;设得太大,用户感知就是迟顿。哪怕是长轮询,一次连接建立、断开、再建立的周期里依然有不可避免的延迟窗口。
第三是服务器无法"反向"主动推送。金融服务里的风控提醒、协作编辑里的光标位置同步、游戏里的实时状态广播,这些场景本质都是服务器掌握最新数据,需要主动把变化告诉客户端。用轮询实现这类需求,等于每次都让客户端先问"有事吗",整个架构就别扭。
WebSocket 解决的就是这三件事:一条 TCP 长连接上双向通信,服务器可以随时推,头部开销极小,连接建立一次长期复用,实时性是真正的"事件触发级",而不是"定时扫描级"。
1.2 WebSocket 和 HTTP 是"亲戚"但绝不是"替代品"
很多人把 WebSocket 理解成"HTTP 的升级版",这个说法不准确。准确的说法是:WebSocket 借用 HTTP 完成了一次握手,之后通信就走自己的协议,和 HTTP 再无关系。
握手阶段,客户端发一个普通的 HTTP GET 请求,带上 Upgrade: websocket 头,服务器同意后返回 101 状态码,TCP 连接直接升级成 WebSocket 连接。升级之后,传输的不再是 HTTP 报文,而是 WebSocket 数据帧。也就是说,HTTP 和 WebSocket 都跑在 TCP 之上,WebSocket 只是借了 HTTP 的"门卫"完成协议切换。
这个设计的好处是握手阶段可以复用 HTTP 的端口(80/443)、代理、鉴权和负载均衡能力,部署成本低。坏处是 WebSocket 本身不处理 HTTP 语义,没有 REST 那套状态码、缓存、幂等概念,业务逻辑得自己在协议之上设计。
还有一点容易混淆:WebSocket 和 HTTP/2、HTTP/3 不是竞争关系。HTTP/2 的多路复用虽然能在一条连接上并发多个请求,但依然是"客户端发起、服务器响应"的单向模式,服务器想主动推送数据仍然受限。WebSocket 解决的是"全双工、服务器主动推",两者解决的问题不同,实际项目里甚至可以配合使用。
1.3 哪些场景真的需要 WebSocket,哪些不需要
我见过不少项目把 WebSocket 当万能药,其实很多场景用不上。需要 WebSocket 的场景有个共同特征:数据变化频繁、实时性要求高、而且服务器需要主动推。
典型的有四类:
- 在线聊天和 IM:不只是发消息,还有输入中状态、已读回执、在线状态,都是服务器主动推。
- 行情和实时数据:股票、加密货币、体育比分、物流位置,价格变化每秒都可能发生。
- 多人协作和联机游戏:共享文档的光标位置、玩家位置同步,要求低延迟双向通信。
- 实时监控大屏和告警:服务器产生告警时立刻推给前端展示。
反过来,如果你的需求只是"前端每隔一段时间拉一次最新数据"、数据变化不频繁,或者本来就是用户主动触发的查询,那普通 HTTP 请求完全够用,没必要引入长连接。长连接是要维护成本的:连接状态、心跳、断线重连、鉴权续期、集群同步,每一件都是事。选型的原则是"能用简单方案就不用复杂方案",别为了技术上的炫酷给自己挖坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 握手细节:从 HTTP 升级请求到 Sec-WebSocket-Accept 的计算
2.1 一次完整的握手长什么样
WebSocket 握手本质上就是一次 HTTP 升级请求。用浏览器的 new WebSocket('ws://example.com/chat'),浏览器会自动发出类似下面的请求:
http复制GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
逐个说这些头的作用:
Upgrade: websocket和Connection: Upgrade告诉服务器:我想把这条连接升级成 WebSocket。Sec-WebSocket-Key是一个随机生成的 base64 字符串,用于握手校验。它不是密钥,更像一个随机数,防止服务器缓存旧连接导致误升级。Sec-WebSocket-Version是协议版本号,目前标准是 13。Origin是来源校验头,服务器可以用它做跨域来源检查,防止其他网站页面偷偷连你的 WebSocket 服务。
服务器如果同意升级,会返回:
http复制HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
状态码 101 表示协议切换成功。看到 Sec-WebSocket-Accept 这个头就说明握手完成了,这条 TCP 连接从此刻起开始承载 WebSocket 数据帧。
2.2 Sec-WebSocket-Accept 的计算过程
Sec-WebSocket-Accept 不是随便生成的,它的计算规则在 RFC 6455 里有明确定义,服务端一定要按规则算,否则标准的 WebSocket 客户端不会承认这次连接。
计算步骤只有三步:
- 取出客户端请求头里的
Sec-WebSocket-Key。 - 把它和固定的 GUID 字符串
258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。 - 对拼接结果做 SHA-1 哈希,再把哈希值做 base64 编码。
GUID 是协议写死的常量,你不需要理解它为什么是这个值,只需要记住它必须原样使用,一个字符都不能错。用 Node.js 的 crypto 模块可以这样算:
javascript复制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=
前端不需要自己算这个值,浏览器会自动校验 Sec-WebSocket-Accept。但在自己写服务端或者用测试工具调试时,这个计算逻辑就很有用。我早期手写过 WebSocket 服务端,踩过一个很典型的坑:GUID 字符串里的横杠位置抄错了,导致浏览器一直报握手失败,查了半天才发现是这个原因。协议实现里凡是这种固定常量,建议直接复制官方规范里的原文,不要手打。
2.3 握手失败的常见状态码和原因
握手失败时服务器会返回普通的 HTTP 状态码,浏览器端的表现是 WebSocket 对象的 onerror 被触发,连接停留在 CONNECTING 状态直到超时。常见的有这么几种:
| 状态码 | 含义 | 常见原因 |
|---|---|---|
| 400 | 请求格式不对 | Sec-WebSocket-Key 缺失、版本号不支持 |
| 403 | 被拒绝 | Origin 校验失败、鉴权失败 |
| 404 | 地址不存在 | 升级路径写错,请求打到了不存在的接口 |
| 500 | 服务端异常 | 后端握手处理逻辑抛异常 |
排查握手问题最快的办法是打开浏览器开发者工具的 Network 面板,找到那条 WebSocket 请求,直接看响应状态码和响应体。如果是后端自研协议,可以在服务端打印完整的请求头和计算出的 Accept 值,和客户端期望值对比。别凭感觉猜,握手阶段的错误基本都是可复现的,把请求头原样拿出来分析,很快能定位。
3. 连接状态机与帧格式:onopen 之后协议层发生了什么
3.1 readyState 四状态与生命周期
WebSocket 对象的 readyState 属性表示连接当前所处阶段,一共有四个值,理解了这个状态机,很多"连接时好时坏"的问题就好解释多了。
CONNECTING = 0:正在握手,连接还没建立。OPEN = 1:连接已建立,可以收发数据。CLOSING = 2:正在关闭,已经调用了 close 方法或收到了关闭帧,等待确认。CLOSED = 3:连接已关闭,不能再收发数据。
实际开发中比较容易被忽略的是 CLOSING 状态。调用了 ws.close() 之后连接不会立刻销毁,要等服务端回一个 Close 帧,或者等待超时,状态才变成 CLOSED。如果你在做资源清理时判断 readyState === 3 才释放资源,注意中间有 CLOSING 这个过渡态,别把清理逻辑写漏了。我见过有人用 if (ws.readyState) { ... } 判断连接是否可用,OPEN 和 CONNECTING 的数值恰好一个是 1 一个是 0,这种写法在连接建立瞬间判断就会出错,建议显式写 ws.readyState === WebSocket.OPEN。
3.2 数据帧结构:FIN、opcode、掩码与数据长度
握手完成之后,收发消息就全部走 WebSocket 帧格式了。帧结构不复杂,但里面有几个设计点直接影响实际开发。
帧头的基本结构:
FIN(1 bit):表示这是不是消息的最后一帧。opcode(4 bits):帧类型。0x1是文本帧,0x2是二进制帧,0x8是关闭帧,0x9是 ping,0xA是 pong。MASK(1 bit):客户端发送的数据必须置 1,服务端发送的数据必须置 0。Payload length(7 bits / 7+16 bits / 7+64 bits):数据长度,分三档表示。Masking-key(4 bytes):当 MASK 为 1 时存在,用于对数据做异或解码。Payload data:实际数据。
为什么客户端发数据必须加掩码?协议设计者的说法是为了防止缓存污染攻击。简单理解就是:给数据加一层随机异或,避免攻击者构造出和 HTTP 请求包长得很像的 TCP 报文骗过中间代理。这个细节平时不需要你手动处理,因为浏览器内置的 WebSocket 实现会自动加掩码,服务端库也会自动解掩码。但如果你自己写协议解析器,记住服务端收到的客户端帧一定要先按掩码异或才能还原数据,漏掉这一步会解出一堆乱码。
分片是另一个值得了解的机制。当一条消息很大时,发送方可以先发一个 opcode 为 0x1 或 0x2 的起始帧,FIN 置 0,然后连续发多个 FIN 为 0 的中间帧,最后发一个 FIN 为 1 的结束帧。浏览器 API 层不会让你感知到分片,但是用抓包工具看时会看到多条帧记录。实际业务里很少需要手动处理分片,只要知道存在这个机制,排查"为什么一条消息收到多次 onmessage 回调"时就不会懵——不过大多数库和浏览器已经帮你把分片重组好了。
3.3 控制帧协作:ping、pong 与 close
WebSocket 协议内置了三类控制帧,它们是保证长连接健康的关键。
Close帧(0x8):发起关闭时,可以带一个状态码和原因字符串。常见的关闭码有 1000(正常关闭)、1001(正在离开)、1006(异常关闭,但这个码不会出现在 Close 帧里,因为连接直接断了)、1009(消息太大)。Ping帧(0x9):主动探测对方是否存活。Pong帧(0xA):收到 Ping 后必须回 Pong,内容保持和 Ping 一致。
应用层的"心跳"机制就是利用 Ping/Pong 实现的。客户端定时发 Ping,服务端收到后回 Pong,如果客户端连续多次没收到 Pong,就认为连接已经死了,可以主动清理。注意:浏览器原生 WebSocket API 没有暴露发送 Ping 帧的方法,所以前端心跳一般直接发一个业务层的"心跳消息"(比如字符串 {"type":"ping"}),后端收到后回业务层 pong。真正的协议层 Ping/Pong 通常由服务端库来做,比如 Node.js 的 ws 库会自动回 Pong。
我做过一个长连接服务,早期没做心跳,结果线上积累了几百个"僵尸连接"——TCP 层看着还连着,但实际上网络路径早就断了,服务端也不知道。后来加了 30 秒一次的协议层心跳,10 秒没收到 Pong 就断开,连接池一下就干净了。心跳间隔的选择需要权衡:太频繁浪费流量,太慢则发现故障不及时。一般 30 秒到 60 秒是个常用区间,移动端可以考虑 60 秒以上,电量敏感。
4. 前端接入实战:基础 API 与连接管理
4.1 从创建连接到收发消息的完整示例
前端使用 WebSocket 的 API 非常简洁,核心就四个事件和两个方法。一段可用的最小示例:
javascript复制const ws = new WebSocket('wss://api.example.com/ws');
ws.onopen = () => {
console.log('连接已建立');
ws.send(JSON.stringify({ type: 'auth', token: 'xxxx' }));
};
ws.onmessage = (event) => {
// 文本消息直接是字符串,二进制消息是 Blob 或 ArrayBuffer
const data = JSON.parse(event.data);
console.log('收到消息:', data);
};
ws.onerror = (err) => {
console.error('连接出错:', err);
};
ws.onclose = (event) => {
console.log('连接关闭,code=', event.code, 'reason=', event.reason);
};
// 需要关闭时主动调用
ws.close(1000, 'client closed');
有几个细节值得注意。
ws.send() 只能在 readyState === WebSocket.OPEN 时调用,如果在 CONNECTING 状态调用会抛异常。所以封装发送函数时一定要判断状态,或者把消息缓存起来等 onopen 后再发。
onmessage 的 event.data 类型取决于服务器发的是文本还是二进制。默认文本消息就是字符串,二进制消息浏览器会包装成 Blob。如果你需要处理二进制协议,可以设置 ws.binaryType = 'arraybuffer',拿到手的就是 ArrayBuffer,方便用 DataView 解析字节。
还有一个经常被忽略的点:没有原生的"消息送达确认"。WebSocket 的 send 只是把数据交给操作系统发送队列,不代表服务器一定处理成功了。业务上如果需要可靠投递,得自己在消息里带上消息 ID,服务端处理后回一个 ack,客户端超时未收到 ack 就重发。实时聊天类应用尤其要设计这一层,否则弱网环境下会丢消息。
4.2 心跳保活与断线重连策略
真实项目中,WebSocket 连接不可能永远稳定。移动网络切换、服务器重启、代理超时、运营商主动断开空闲连接,都会导致连接悄悄断掉。前端必须自己实现两层机制:心跳保活和断线重连。
心跳的思路很简单:定时器每隔一段时间发送一个轻量级消息,同时维护上一次收到消息的时间。如果超过阈值没有收到任何消息(包括业务消息和心跳回复),就认为连接异常,主动 close 并触发重连。
javascript复制class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.heartbeatInterval = options.heartbeatInterval || 30000;
this.reconnectDelay = options.reconnectDelay || 3000;
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
this.reconnectAttempts = 0;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => this.handleOpen();
this.ws.onmessage = (e) => this.handleMessage(e);
this.ws.onclose = () => this.handleClose();
this.ws.onerror = () => this.ws.close();
}
handleOpen() {
this.reconnectAttempts = 0;
this.startHeartbeat();
}
startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() }));
}
}, this.heartbeatInterval);
// 同时启动超时检测,超过 2 个心跳周期没收到任何消息就断开
this.heartbeatTimeout = setTimeout(() => {
this.ws.close();
}, this.heartbeatInterval * 2);
}
handleMessage(e) {
// 收到任何消息都说明连接活着,重置超时检测
this.resetHeartbeatTimeout();
}
handleClose() {
this.stopHeartbeat();
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
}
}
断线重连建议加指数退避:第一次 3 秒,第二次 6 秒,第三次 12 秒,封顶比如 60 秒。不要固定间隔,否则大批客户端同时断线时重连风暴会把服务器打挂。还有一点:重连成功后要重新做鉴权,因为之前的 token 可能已经失效,业务状态(比如未读消息、订阅的主题)也要在重连后重新初始化。
4.3 二进制数据:Blob 和 ArrayBuffer 怎么选
WebSocket 不仅可以传文本,还可以传二进制。浏览器原生支持两种二进制类型:Blob 和 ArrayBuffer。选择哪种取决于你的使用场景。
Blob 适合"整块数据不关心内部结构"的场景,比如传输文件、图片、录音,可以直接通过 URL.createObjectURL(blob) 显示或下载。ArrayBuffer 适合需要手动解析二进制协议的场景,比如游戏服务器的数据包、自定义协议头,用 DataView 读取各字段。
javascript复制// 切换为 ArrayBuffer 模式
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
handleText(event.data);
} else {
const buffer = event.data; // ArrayBuffer
const view = new DataView(buffer);
const type = view.getUint8(0);
const sequence = view.getUint32(1);
// 按协议解析后续字段
}
};
WebSocket 没有大小限制,但大多数服务端和代理有。默认的 Node.js ws 库最大消息大小是 100MB,Nginx 等代理可能有自己的限制。需要传大文件时建议分片传,而不是塞进一条 WebSocket 消息里。一条超大消息会阻塞后面的所有消息,导致其他消息排队延迟,这在多消息场景里体验极差。
4.4 Vue、React 项目里的连接管理与 Hook 封装
框架项目里,WebSocket 连接的生命周期管理是个痛点。最容易犯的错是:组件卸载时忘记关闭连接,导致连接泄漏;或者多个组件各建各的连接,浪费服务器资源。
在 React 里,通用的做法是用自定义 Hook 或者全局单例管理连接。这里给一个基于 useEffect 的简单封装思路:
javascript复制function useWebSocket(url, handlers) {
const wsRef = useRef(null);
useEffect(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onmessage = (e) => {
if (handlers.onMessage) handlers.onMessage(JSON.parse(e.data));
};
ws.onclose = () => {
if (handlers.onClose) handlers.onClose();
};
return () => {
ws.close();
};
}, [url]);
const send = useCallback((data) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}, []);
return { send };
}
Vue 3 的生态里,比较省事的方案是直接用 VueUse 的 useWebSocket,它把连接状态、自动重连、心跳都帮你封装好了。用法大概是 const { status, data, send, open, close } = useWebSocket(url, { autoReconnect: true })。如果你不喜欢引第三方库,也可以参考它的源码思路自己写一个 composable。框架层面的封装有一个共同原则:连接尽量收敛到一个地方管理,页面组件只管"消费",不要每个组件都 new 一个 WebSocket。
5. 服务端推送与集群方案:从单机到多实例的演进
5.1 主流后端实现对比
服务端 WebSocket 的实现方案很多,选型要考虑语言栈、并发量、生态成熟度。这里列几个最常见的:
| 方案 | 语言 | 特点 | 适合场景 |
|---|---|---|---|
| ws 库 | Node.js | 轻量、API 简单、社区使用最广 | Node 技术栈的中小型项目 |
| Socket.IO | Node.js | 自带降级到 HTTP 长轮询、自动重连、房间机制 | 需要兼容老浏览器、快速开发 |
| Spring WebSocket / STOMP | Java | 生态完善、和 Spring 体系集成好、支持消息代理 | Java 技术栈的企业级应用 |
| Gorilla/WebSocket | Go | 高性能、部署简单、单机并发高 | 高并发网关、微服务 |
| Netty | Java | 底层 NIO、极致性能、开发成本高 | 超大并发场景或自研协议 |
Node.js 的 ws 库是大多数人起步的选择,接下来例子都用它。创建一个最简单的 WebSocket 服务:
javascript复制const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws, req) => {
console.log('客户端连接,来源:', req.url);
ws.send(JSON.stringify({ type: 'welcome', message: '连接成功' }));
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'heartbeat') {
ws.send(JSON.stringify({ type: 'heartbeat_ack' }));
} else {
console.log('业务消息:', msg);
}
});
ws.on('close', () => {
console.log('连接关闭');
});
});
5.2 向所有用户推送消息的实现路径
"Spring WebSocket 向所有用户推送消息"这类需求,其实核心问题是:服务端怎么维护一个"在线连接表",然后遍历这个表给每个连接推数据。
最简单的实现是维护一个全局的 Map,key 是用户 ID 或连接 ID,value 是 WebSocket 连接实例。推送时遍历这个 Map:
javascript复制const clients = new Map();
wss.on('connection', (ws, req) => {
// 握手时通过 URL 参数或鉴权结果拿到用户 ID
const userId = parseUserIdFromRequest(req);
clients.set(userId, ws);
ws.on('close', () => {
clients.delete(userId);
});
});
// 向所有在线用户推送
function broadcast(message) {
const data = JSON.stringify(message);
for (const [userId, ws] of clients) {
if (ws.readyState === ws.OPEN) {
ws.send(data);
}
}
}
这里有几个细节要小心。
ws.send在底层 socket 已关闭但还没触发 close 事件时可能抛异常,发送前一定要判断readyState。- 广播是同步遍历的,如果某个连接发送很慢会阻塞后续连接。数据量大、客户端多时,可以用每个连接独立 send 来避免相互阻塞,或者引入消息队列做削峰。
- 连接表要做清理。除了 close 事件,还要结合心跳检测把僵尸连接移除,否则推送会源源不断地写给死连接。
在 Spring WebSocket 的 STOMP 实现里,类似功能可以直接用 SimpMessagingTemplate.convertAndSend("/topic/xxx", payload) 推送到主题,订阅了该主题的所有客户端都能收到。这个更高级一些,底层帮你维护了订阅关系和连接表,适合消息类型复杂的场景。
5.3 集群环境下连接状态共享
单机部署好办,但应用一上多实例,问题立刻出现:用户 A 连在实例 1 上,用户 B 连在实例 2 上,实例 1 想给 B 推送消息,但 B 的连接在实例 2 上,实例 1 根本不知道。
集群环境下必须把"连接所在位置"这个信息共享出来。主流的方案是引入 Redis 做两件事。
第一,用 Redis 记录每个用户的连接落在哪个实例上,key 是 userId,value 是 instanceId。推送时先查 Redis,把消息转发给对应实例,由该实例找到本地连接并发送。
第二,如果要做全局广播,可以用 Redis Pub/Sub:所有实例订阅同一个频道,任何一个实例收到外部推送请求时,把消息 publish 到频道,其他实例收到后各自遍历本地连接进行广播。
code复制实例A收到REST请求 -> publish "user_chat" 频道
实例B订阅到消息 -> 遍历本地连接表 -> 推送给本机客户端
实例C订阅到消息 -> 遍历本地连接表 -> 推送给本机客户端
这个方案的本质是把"连接表"从单机内存拆成"本地连接 + 全局路由信息"两部分,Redis 只存路由,不存连接。连接本身就是 TCP 状态,必须在持有它的实例上,这是 WebSocket 和普通 HTTP 最大的不同——无状态服务可以任意扩容,有状态长连接必须处理"粘滞"问题。
如果不想自己维护这套集群逻辑,可以考虑现成的网关方案:EMQX、Mosquitto 这类 MQTT Broker 天然支持集群和消息路由,前端通过 MQTT over WebSocket 连接,后端只管往 topic 发消息,连接管理和集群同步全交给 Broker。用 MQTT 的代价是协议更重、调试更复杂,但换来的是海量连接能力和完整的订阅/发布语义。
6. 排查实录:浏览器、代理与网络层的隐形坑
6.1 谷歌浏览器高版本无法启用 WebSocket 的排查链路
开发时最让人懵的问题之一:代码没改,Chrome 升级之后 WebSocket 连不上了。遇到这种问题,先别怀疑代码,按下面的链路一步步排查。
第一步,打开开发者工具的 Network 面板,过滤 WS,看 WebSocket 连接的握手请求状态。如果握手直接失败,看响应头里有没有 Sec-WebSocket-Accept,没有就说明连接压根没升级成功。
第二步,检查是不是混合内容(Mixed Content)限制。Chrome 高版本对"HTTPS 页面 + ws:// 明文 WebSocket"的组合管控越来越严,HTTPS 页面只能连 wss://,连 ws:// 会被浏览器直接拦截。这个问题在低版本 Chrome 上只是警告,高版本直接报错,所以会出现"之前好好的,升级浏览器后挂了"的错觉。解决方法是统一上 WSS,并确保证书是受信任的。
第三步,检查代理和扩展。Chrome 高版本对 WebSocket 的代理处理有改动,部分系统代理或 HTTP 代理不支持 WebSocket 升级。排查时可以开一个无痕模式窗口(禁用扩展)或者换一个网络环境试试,能把"代理干扰"和"代码问题"区分开。
第四步,看控制台的完整报错。Chrome 的错误信息一般会给出具体阶段:是 DNS 解析失败、TCP 连接失败、TLS 握手失败,还是 WebSocket 握手失败。逐层缩短排查范围,不要一上来就怀疑业务代码。
6.2 Nginx 代理 WSS 配置与常见超时参数
绝大多数生产环境会在 WebSocket 服务前面加一层 Nginx 做 TLS 终止和负载均衡。Nginx 默认配置对 WebSocket 支持不友好,因为 WebSocket 是长连接,而 Nginx 的默认行为是等响应结束后关闭连接。必须显式配置 Upgrade 头。
一个可用的 WSS 代理配置:
nginx复制map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream ws_backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
map 块的作用是:如果客户端请求带了 Upgrade: websocket,就把 Connection 头改成 upgrade,否则用 close。这是 Nginx 代理 WebSocket 最关键的配置,漏了它,握手就会失败。
proxy_read_timeout 和 proxy_send_timeout 默认是 60 秒,对 WebSocket 这种长连接来说太短。如果没改,你会发现连接每 60 秒准时断开一次。建议设置成比应用层心跳间隔更长的时间,比如 3600 秒,让心跳包来维持连接活性。
另一个容易踩的坑是负载均衡的粘滞问题。WebSocket 是长连接,连接建立之后必须一直由同一个后端实例处理。Nginx 默认的 round-robin 算法对普通请求没问题,但对 WebSocket,如果后端没有做集群同步(比如没用 Redis 共享路由),切换实例会导致连接断开。此时需要配置 ip_hash 或根据用户 ID 做 hash:
nginx复制upstream ws_backend {
ip_hash;
server 127.0.0.1:8080;
server 127.0.0.1:8081;
}
要注意 ip_hash 只对同一来源 IP 有效,移动网络下用户 IP 可能变化,严格来说还是要靠后端集群方案兜底,Nginx 的粘滞只是缓解。
6.3 服务器主动断开连接的信号与处理
很多人被一个报错折磨过:stream disconnected before completion: websocket closed by server before response。这个报错一般出现在后端的 WebSocket 客户端(比如用 Java 或 Node 作为客户端去连别的 WebSocket 服务)里,意思是:请求还没完成,服务器就把连接关了。
它出现的常见场景有三种。一是服务器在做业务处理前发现鉴权失败,直接关闭连接。二是服务器设置了空闲超时,客户端长时间没发消息,被服务端主动断开。三是服务器收到超大消息,超过配置限制被强制关闭。
排查时先把服务端日志打开,看关闭时打印的 close code 和 reason。1008 通常表示策略违规(比如鉴权失败),1009 表示消息太大,1001 表示服务端正在重启。对比 close code 能快速缩小范围。如果是鉴权问题,检查连接时是否带了正确的 token;如果是超时,调整心跳频率;如果是消息过大,要么调大服务端限制,要么客户端分片发送。
另一种常见表现是"连接建立了,但过一会儿被服务端断掉,且服务端没有任何日志"。这种情况十有八九是网络链路中间的代理干的——云厂商的负载均衡、CDN、防火墙都可能主动断开空闲连接。解决办法就是前面说的:客户端做好心跳保活,同时做好自动重连,把"被断开"当成常态来处理,而不是当成异常。
6.4 调试验证工具与抓包建议
本地联调阶段,推荐几个工具。
浏览器 Network 面板是第一步,可以看到握手请求和每一条收发消息,缺点是看不到协议层的 ping/pong 帧,也看不到掩码和解码过程。
在线 WebSocket 测试工具(比如 Websocket King 这类)适合快速验证服务端是否可用,可以手动输入地址、发送消息、查看响应,还支持保存多组测试地址,比写测试页面方便。我调试服务端时习惯用 Linux 的命令行工具 websocat,好处是可以在脚本里跑,配合 curl 测试握手更灵活。
如果要做深度的协议层分析,用 Wireshark 抓包最彻底。过滤条件可以用 tcp.port == 8080 && websocket,Wireshark 能自动解析 WebSocket 帧,显示 FIN、opcode、掩码和负载内容。抓包时注意启用"解密 SSL"的配置,否则 WSS 流量全是密文,看不到协议内容。平时联调建议先用 ws://,确认逻辑没问题再切 wss://,可以省掉证书和加密的干扰。
7. 协议边界:什么时候不该用 WebSocket,以及安全清单
7.1 SSE、WebTransport 与 WebSocket 的选型对比
WebSocket 不是唯一的实时通信方案,很多场景用更轻的协议反而更好。这里做一个横向对比。
| 方案 | 方向 | 协议 | 自动重连 | 适合场景 |
|---|---|---|---|---|
| SSE | 服务器到客户端单向 | HTTP | 浏览器原生支持 | 消息流、通知、AI 流式输出 |
| WebSocket | 双向 | 独立协议 | 需自己实现 | 聊天、游戏、协作 |
| WebTransport | 双向 | HTTP/3 | 较新 | 极低延迟、未来场景 |
SSE(Server-Sent Events)经常被忽略,但它处理"只需服务器推、客户端不用发"的场景非常合适。它基于普通 HTTP,不需要升级协议,自带断线重连,还能通过 EventSource API 实现简单的自动重连。一个典型的适用场景是 AI 对话的流式输出——服务器生成一段推一段,客户端只需要展示,完全不需要双向通信。这类需求用 WebSocket 反而复杂:要自己处理心跳和重连,还占一个长连接资源。
我自己的选型原则很简单:需要双向实时通信就上 WebSocket;只有单向推送就用 SSE;对延迟要求极高且愿意接受新技术成本,再考虑 WebTransport。每种方案都有它存在的理由,选型前先问一句"我的消息主要从哪个方向流动"。
7.2 安全清单:上线前必须检查的事项
WebSocket 引入的安全问题比普通 HTTP 更隐蔽,因为连接是长久的、双向的、而且不像 HTTP 请求那样容易审计。我总结了一份上线前的检查清单,每一条都踩过坑或者见过别人踩坑。
第一,生产环境必须用 wss://,不能用 ws://。明文 WebSocket 的数据等于裸奔,任何中间设备都能看到聊天内容、业务数据。即使你的数据不敏感,明文流量被篡改注入恶意消息的风险也够喝一壶。
第二,鉴权不能只在握手时做一次。WebSocket 连接建立后是长期存在的,token 过期后连接依然有效,这是很多人的认知盲区。实践中要么用短期 token 加定期重新校验,要么每次消息都携带签名,服务端校验。更稳妥的做法是:token 过期时,服务端主动发一个"需要重新鉴权"的消息并关闭连接,让客户端带着新 token 重连。
第三,握手时校验 Origin 头。浏览器发起的 WebSocket 请求会带 Origin 头,服务端应该校验它是否在允许的白名单里,防止恶意网站通过用户的浏览器向你的服务发起跨站 WebSocket 连接(CSWSH 攻击)。这个校验和 HTTP 的 CSRF 防护是一个道理。
第四,服务端必须做消息校验。别信任客户端发来的任何数据,尤其是 JSON 里的字段。WebSocket 服务也是一个业务入口,SQL 注入、路径遍历、命令注入这些攻击同样可能通过 WebSocket 消息发生。建议把消息解析后交给和 HTTP 接口一样的业务校验逻辑处理。
第五,加速率限制和消息大小限制。一个客户端疯狂发消息可以把服务器打满,一条超大消息可以拖垮整个处理线程。服务端库一般都有 maxPayload 配置,按业务需要设置合理值。对单连接的消息频率也建议做限流,超过阈值直接关闭连接。
第六,日志和安全审计。WebSocket 消息不像 HTTP 请求那样有标准访问日志,所以更容易成为"盲区"。至少要记录连接的建立和关闭、异常断开、鉴权失败这些关键事件,方便出事之后追溯。
做完这些检查,WebSocket 服务才算具备了上线的基本条件。把这个清单当成例行公事,每次上线前过一遍,能省很多事后救火的功夫。
最后分享一个我个人做实时项目的体会:WebSocket 的核心难点从来不在"怎么连上",而在"连接断开之后怎么办"。网络是不可靠的假设越早接受,代码设计就越稳健。心跳、重连、消息确认、集群路由,这些看似"额外"的工作才是长连接系统的真正主体。如果你正在做一个实时功能,把一半精力花在这些可靠性设计上,上线后的日子会好过很多。
