做一个需要实时刷新的功能,大部分人第一反应是轮询: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 握手失败的常见状态码和原因
握手失败时服务器会返回普通的
