做后端的同学应该都有过类似的经历:页面需要实时刷新数据,最开始就是让前端隔几秒调一次接口。数据少的时候还能忍,数据一多、用户一多,服务器压力肉眼可见地涨。后来接触 WebSocket 之后才反应过来,很多场景其实从一开始就该走长连接,而不是用轮询硬扛。
这篇笔记是我自己学习 WebSocket 过程中的一次系统梳理。从协议层到底层原理,从单机接入到集群广播,从握手细节到生产环境那些容易踩的坑,尽量把一条线讲完整。适合刚接触 WebSocket 的后端同学,也适合写过几个 Demo 但在生产环境碰过壁的人对照自查。
1. 从一次“消息怎么还不来”开始:WebSocket 到底解决了什么
1.1 HTTP 的请求-响应模型,天生不适合“服务器主动说话”
很多后端业务写了一两年,对网络协议的理解还停留在“客户端发请求,服务器返回响应”这个层面。这种模型下,服务器永远是被动的——它只能对请求作出应答,没办法在没有请求的情况下主动把数据推给客户端。
这种设计在 Web 早期没什么问题,因为那时候页面都是用户点了链接才加载。但后来出现了很多需要“服务器主动通知”的场景:网页聊天、订单状态变更、股价刷新、协同编辑。拿订单状态来说,用户下单之后前端想知道支付结果,最原始的办法是每隔几秒钟调一次“查订单状态”的接口。这就是轮询(Polling)。
轮询最大的问题有两个:一是大量请求是无效的,因为大多数时候订单状态根本没变过,这些请求纯粹是在浪费服务器资源和带宽;二是实时性上限很低,假设轮询间隔是 5 秒,那用户看到状态更新的延迟平均就是 2.5 秒,想再提高实时性就只能缩小轮询间隔,代价是服务器压力成倍增长。
我见过一个实际项目,一万人在线的后台管理系统,用轮询刷任务状态,数据库每秒要扛几千次无效查询。后来改成 WebSocket 推送,数据库压力直接降了一个量级。这就是协议模型带来的本质差距。
1.2 轮询、长轮询到全双工的演进逻辑
解决“服务器主动说话”的问题,中间还有一个过渡方案叫长轮询(Long Polling):客户端发一个请求,服务器收到后不立即返回,而是把请求挂住,等有数据了或者超时了再返回;客户端收到响应后立刻再发下一个请求。
长轮询相比普通轮询减少了大量空响应,但它本质上还是“一问一答”,只是把答案延迟了。一条 TCP 连接上同一时刻只能挂一个请求,消息返回之后连接就释放了,下次推送还得重新建立连接。而且长轮询在高并发下对服务器的连接资源消耗非常严重,因为每个挂起的请求都占着一个线程或协程。
WebSocket 的做法完全不同:它是建立一个真正的全双工通道,客户端和服务器都可以在任何时间主动发送数据,不需要等对方请求。连接建立之后,数据帧可以双向随时流动。这就从协议层面解决了实时性的问题。
我用一个很简单的类比来理解这件事:HTTP 轮询像你反复打客服电话问“货到了没”,每问一次都要重新拨号;长轮询是把电话挂在那里等客服主动告诉你;WebSocket 则是客服直接加了你微信,有消息直接发你,你也可以随时发消息过去。
三种方式对比下来,结论很清楚:
| 方案 | 实时性 | 服务器压力 | 实现复杂度 |
|---|---|---|---|
| 普通轮询 | 低,受轮询间隔限制 | 高,大量无效请求 | 最低 |
| 长轮询 | 中,取决于消息频率 | 中高,占用连接资源 | 低 |
| WebSocket | 高,全双工即时推送 | 低,单条长连接复用 | 中 |
这里要澄清一个认识:WebSocket 并不是来替代 HTTP 的,它解决的是 HTTP 协议在“双向实时通信”上的能力缺失。日常的增删改查接口该用 HTTP 还是用 HTTP,只有到了“需要服务器主动推送数据”的场景,WebSocket 才真正发挥价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 协议细节:握手、帧、掩码,别被“新协议”三个字吓住
2.1 一次握手:从 HTTP Upgrade 到 101 Switching Protocols
我第一次接触 WebSocket 时的一个困惑是:它叫 WebSocket,但为什么握手的请求长得很像 HTTP?
实际上 WebSocket 的握手就是一次 HTTP GET 请求,只是带了几个特殊的头部字段。客户端发起握手时,会带上 Upgrade: websocket 和 Connection: Upgrade,同时生成一个随机的 Sec-WebSocket-Key。服务器收到后,把这个 Key 拼接上一个固定的 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),做一次 SHA-1 哈希,然后 Base64 编码,作为 Sec-WebSocket-Accept 返回。客户端校验这个值,握手成功,连接从 HTTP 升级为 WebSocket,状态码是 101 Switching Protocols。
我用实际数据走一遍这个过程。假设客户端发来的 Key 是:
code复制dGhlIHNhbXBsZSBub25jZQ==
服务端把它拼上固定 GUID:
code复制dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
然后做 SHA-1 哈希,结果 Base64 编码得到:
code复制s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
这就是返回给客户端的 Sec-WebSocket-Accept。在命令行里可以直接验证:
bash复制echo -n "dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11" | openssl sha1 -binary | base64
这个 Key 的设计只是为了确认服务器真的支持 WebSocket 协议,防止某些代理服务器误把后续的 WebSocket 数据帧当成普通 HTTP 请求转发,它本身没有任何鉴权能力。鉴权得自己做,这点后面详细讲。
2.2 帧格式与掩码:为什么客户端发数据必须“化妆”
握手完成之后,客户端和服务器之间传输的不再是 HTTP 报文,而是一个个 WebSocket 帧(Frame)。每一帧的结构里,有这么几个关键字段:
FIN:标志这个数据包是不是某一帧消息的最后一帧,一个消息可以被拆成多个帧发送;opcode:数据类型,0x1是文本帧,0x2是二进制帧,0x8是关闭帧,0x9是 Ping,0xA是 Pong;MASK:掩码标志,客户端发送的帧必须置 1,服务端发送的帧必须置 0;payload len:负载长度,7 位、16 位、64 位三种表示方式,超过 125 字节的载荷要用扩展长度字段。
这里最反直觉的一点是掩码机制。为什么客户端发出的数据要额外做一次掩码处理?这是为了防一种叫缓存污染攻击的问题。简单来说,如果客户端不掩码,攻击者可以构造一些特殊的字节序列,让数据在中间经过某些代理服务器时,恰好被解释成 HTTP 请求,从而污染代理缓存,影响其他用户。加了掩码之后,客户端实际发送到网络上的字节与真实负载不同,这个攻击路径就被堵死了。
对后端来说,理解这些帧格式的实用价值在于:遇到问题时你能判断问题出在哪一层。比如客户端发了一个超大文本消息,超出了服务端配置的缓冲区大小,连接突然被断开,你抓包看到的是帧的负载长度异常,而不是业务代码出错。再比如某些 WebSocket 库在收到 Ping 帧时没有自动回 Pong,导致连接被对端判定为不健康后断开,这种问题排查很久都未必能定位到帧层面。
2.3 连接何时算“断开”:网络层的残酷真相
用 WebSocket 做长连接后,你很快就会遇到一个新的追问:怎么知道一个连接还在不在?
TCP 层面,如果一端断开连接,正常情况下另一端会收到 FIN 包,这时能感知到连接关闭。但现实是很多情况根本收不到 FIN:比如用户直接拔了网线,比如笔记本合盖休眠
