你有没有遇到过这种情况:页面上的在线状态总要刷新才更新,聊天消息发出去之后要等一个响应才能看到自己发出去的内容,后台任务跑完了页面却毫无反应。你开始怀疑HTTP协议是不是不够用了,于是去查WebSocket协议,可一看文档又愣住了:既然HTTP已经能上网,为什么还要发明一个WebSocket?这个问题当年也困扰了我很久,后来自己动手把一个轮询系统改造成长连接,又因为连接断连、代理超时折腾了大半夜,才算真正想明白。这篇文章不打算把RFC翻一遍抄给你,就想聊聊HTTP的边界在哪、WebSocket到底补了什么,以及上线之后那些文档里不会写的坑。
1. 从HTTP的“一问一答”说起:为什么实时场景这么别扭
1.1 短连接、无状态与服务器的“闭嘴”困境
HTTP协议从设计上就是一个“请求-响应”模型:客户端先说话,服务器才能回答。你发一个GET,它回一个200;你发一个POST,它回一个JSON。整个过程里,服务器永远是被动的一方,它不能哪天一高兴就主动往客户端塞数据。就算HTTP/1.1加了keep-alive,允许同一个TCP连接上连续发多个请求,本质上还是“一个请求对应一个响应”,顺序不能乱,服务器也不能跨过客户端先开口。
这就像对讲机:你要先按住通话键说“在吗?”,对方才能松开按键回你。如果对方那边临时有事想主动联系你,他没有办法,只能等下一次你按键。可实时聊天、在线通知、协同编辑这类场景需要的恰恰是“电话模式”:任何一方都可以随时说话,不用等对方先开口。HTTP的“一问一答”结构在这一点上天然和实时场景拧着劲儿。
另一个麻烦是HTTP协议本身是无状态的。上一次请求和下一次请求之间没有上下文,服务器不记得你。虽然可以用Cookie、Token让请求带上身份,但每次请求都要重新解释一遍“我是谁、我要什么”,这些开销在普通接口上无所谓,在动不动就一秒请求几百次的实时场景里就成了累赘。
1.2 轮询、长轮询的代价:看似能用,实际烧钱
既然服务器不能主动推,那浏览器就想了个变通办法:轮询。客户端每隔两秒发一个请求,问“有没有新消息?”服务器一看没有,就回个空数据。这个方案写起来非常简单,但是问题也很明显。
先说延迟。如果轮询间隔是2秒,那消息最坏情况下要等2秒才送到;间隔改成500毫秒,延迟倒是低了,可每秒请求数量直接翻倍,服务器得为大量“没有新消息”的空请求买单。对客户端来说,每个HTTP请求都要走一遍建连、发头、等响应、断开的流程,消耗带宽和电量,在移动端尤其心疼。
再说长轮询。长轮询的思路是:客户端发一个请求,服务器先不急着返回,把它挂着,等真有了新消息再一次性返回。这样消息一到就能立刻回来,看起来实时性不错。但服务器要为每一个挂起的请求保存上下文,连接数一多,内存和线程压力立刻上来。更坑的是,如果中间有代理或网关,它们通常有自己的超时设置,长轮询连接很可能在碰到数据之前就被静默掐断了。客户端还得处理“连接断了重新挂”的逻辑,复杂度并不比WebSocket低多少。
1.3 轮询和WebSocket的第一眼对比
| 能力 | 普通轮询 | 长轮询 | WebSocket |
|---|---|---|---|
| 服务端主动推送 | 不支持 | 勉强支持 | 原生支持 |
| 消息实时性 | 取决于轮询间隔 | 基本实时 | 实时,双向 |
| 无消息时的开销 | 高,大量空请求 | 中,连接挂起仍占资源 | 低,只有心跳 |
| 连接复用 | 每个请求可能新建连接 | 每个请求挂起一个连接 | 单条长连接 |
| 实现复杂度 | 最低 | 中 | 中,但坑更多 |
轮询不是不能用,但如果你要做的是“服务器随时会主动通知客户端”的场景,轮询本质上是在用HTTP的短处硬扛,扛到一定规模,不管性能还是代码复杂度都会很难看。这也是WebSocket被设计出来的直接动机。
2. 一次“协议变身”的握手:WebSocket到底从哪来的
2.1 握手过程:还是那个熟悉的HTTP
很多人以为WebSocket是一套从头另起炉灶的协议,这也是个误会。WebSocket在建立连接之前,走的依然是HTTP流程,只不过这个HTTP请求比较特殊。客户端会发一个GET请求,包含几个关键头:
Upgrade: websocket:告诉服务器“我想把当前连接升级成WebSocket协议”;Connection: Upgrade:说明这是一个逐跳(hop-by-hop)的头,只影响当前连接;Sec-WebSocket-Key:一个随机生成的16字节值,通常做Base64编码,用于验证;Sec-WebSocket-Version: 13:指定协议版本。
服务器如果同意升级,会返回101 Switching Protocols,同时带上Sec-WebSocket-Accept这个响应头。这个头的计算很有意思:把请求里的Sec-WebSocket-Key拼上一个固定的GUID字符串,做SHA-1哈希,再Base64编码。这个固定值已经写死在RFC 6455里,客户端收到后会用同样方式算一遍,两边对得上,才算握手成功。
2.2 为什么要用Key?为什么非要走HTTP升级这条路
你可能想问:握手就握手,为什么要搞Sec-WebSocket-Key这么个东西?它并不是身份认证,更不是密码,它的作用是防止缓存代理把这次请求当成普通GET给缓存下来,同时也让服务器能证明自己确实理解WebSocket协议。如果中间某台设备傻乎乎地把101响应缓存成普通响应,后续客户端和服务器的同一条TCP连接还继续跑,两边情绪就完全错位了。
更重要的是,WebSocket选择复用HTTP而不是直接开一个新端口,是经过深思熟虑的。现在的网络环境里,防火墙、代理、负载均衡、反向代理几乎默认只对80和443端口网开一面。如果WebSocket自己去开个新端口,很多场景下根本连不通。留在HTTP上,它至少能像普通请求一样穿过大部分网络设备,等握手结束后再切换到帧协议,中间设备如果不理解升级机制,顶多把它当成一个挂起的请求,不会直接掐断。
2.3 101状态码和Upgrade机制不是WebSocket发明的
HTTP/1.1很早就定义了Upgrade头,允许客户端询问服务器“能不能把当前连接切换到另一个协议”,101 Switching Protocols就是这个过程的确认。比如有人用它升级到TLS、升级到其他实验协议。但绝大多数协议并没有把这个机制用起来,真正让Upgrade成为话题中心的就是WebSocket。
所以可以这么理解:WebSocket不是从石头缝里蹦出来的新协议,它借用了HTTP的门进入房间,然后在房间里跑起了自己的游戏规则。握手那几十毫秒是HTTP,后面的数据流就和HTTP没有关系了。这一点特别重要,因为如果你把WebSocket数据直接丢给HTTP解析器,解析器一定会懵,因为它压根不是HTTP格式。
3. 握完手之后,WebSocket在传什么
3.1 帧结构:消息边界怎么标记的
握手结束之后,连接上传输的内容就变成了一串帧(frame)。帧是WebSocket的最小通信单位,所有用户消息、控制信号都封装在帧里。帧头不算复杂,但你看了之后会对协议设计者的思路更有感觉。
帧头最低只要2字节。第一个字节的低1位是FIN,表示这是不是消息的最后一帧;紧接着3个预留位RSV1到RSV3,默认是0,只有启用扩展时才用;再往后的4位是opcode,告诉你这个帧干什么用,比如文本帧是0x1,二进制帧是0x2,关闭帧是0x8,Ping是0x9,Pong是0xA。第二个字节最高位是MASK,表示payload是否被掩码处理,低7位是payload长度,如果长度不够,还会延续到后续字节。
这种设计解决了一个HTTP里可能比较绕的问题:消息边界。HTTP通常靠Content-Length或chunked编码来确定body在哪结束,而WebSocket直接在帧头里标出每帧长度,解析器读起来很清爽。文本消息、二进制消息还能通过opcode区分,对聊天里的表情包、图片上传、二进制协议交互都很方便。
3.2 为什么客户端发消息必须掩码,服务器却不用
WebSocket协议里有一条规则经常让人费解:客户端发给服务器的帧必须设置MASK位,并用4字节随机掩码对payload做逐字节异或;而服务器发给客户端的帧则禁止掩码。为什么这么不对称?
不是为了加密,也不是为了省那几字节开销,而是为了防一种老式安全问题。WebSocket常常要穿过代理设备,如果某台代理不理解WebSocket协议,却还会把网络流量当作HTTP来解析,那么客户端发出来的内容就存在被误判为HTTP请求或响应头的一部分的风险。掩码让payload变成伪随机的,极大降低真实内容被中间设备当作可预测HTTP语法读取的概率。这个设计带着浓厚的“历史教训”味道,属于那种不在生产环境吃几次亏你很难体会得到的细节。
3.3 分片、Ping/Pong和优雅关闭
一个大消息可以拆成多个帧发送。发送端在不知道总长度时,可以先发一个FIN=0的帧,最后再发一个FIN=1的帧收尾,中间还可以穿插控制帧,比如Ping。这样设计有一个很实际的好处:一帧很大的文本不会一直霸占连接导致心跳发不出去,控制帧可以插队。
Ping/Pong是连接保活的重要手段。协议规定收到Ping必须回一个Pong,Pong的payload要和Ping一致,但协议并没有规定心跳必须多久发一次,也没有规定必须发,所以这个任务落到了应用层头上。很多人上线后才发现,连接明明“活着”,但发消息就是没反应,十有八九是中间链路已经把空闲连接断掉了,而自己从来没发过心跳。
关闭也有规范。主动关闭的一方要发一个Close帧,带上状态码和原因,等待对方回Close,再关TCP。如果你不管三七二十一直接断TCP,对端只能看到一次异常断开,状态码通常是1006,排查问题时很难判断是网络故障还是服务端主动干掉你的。
4. HTTP轮询、SSE、WebSocket,到底选谁
4.1 账本:延迟、带宽、连接数的一次真实对比
拿一个在线聊天室来说,假设同时在线1000人。如果采用每2秒一次的轮询,每秒钟就是500个HTTP请求;每个请求带上几百字节的头,一天下来光是请求头就有上百GB的数据被白白消耗。就算服务器响应很快,这些高频空请求也会占用大量CPU、内存和日志空间。
换成WebSocket之后,每个用户只维护一条长连接,有消息服务器直接推,没消息就静默或心跳,服务器的负载主要体现在“挂住的连接数”上,而不是“每秒请求数”上。单条连接占用的内存其实不多,现代服务器同时挂几十万条长连接是可行的。所以从总资源账单来看,在高频实时场景下,WebSocket往往比轮询更省,而不是更费。
4.2 什么场景值得上WebSocket,什么场景别凑热闹
WebSocket不是银弹。如果一个接口就是“请求一次、返回一次”的普通查询,用HTTP是最合适的。HTTP有成熟的状态码、缓存机制、代理缓存、CDN加速,这一整套生态WebSocket都不具备。硬给REST接口套WebSocket,只会让调试、日志、监控都变得更加别扭。
真正适合WebSocket的是这几类:
- 聊天、站内信、在线通知:服务器随时可能推送,客户端也要频繁发送。
- 实时看板、行情报价:高频数据需要低延迟到达。
- 协同编辑、白板、在线游戏:双方需要持续交换增量数据。
- 远程控制、设备调试:指令是双向的,且每条指令都要求低延迟。
反过来,只做单向服务端推送的页面,条件允许时可以考虑SSE(Server-Sent Events)。SSE走的是普通HTTP,API简单,自带断线重连,和浏览器原生事件配合很好,只是它只支持服务器往客户端推,不支持客户端通过同一条连接反向发送任意消息。如果你只有单向广播需求,非要去上WebSocket,倒也不能说不行,但有点用大炮打蚊子。
4.3 HTTP/2、SSE能替代WebSocket吗
有人会问:HTTP/2不是全双工吗?是不是可以替代WebSocket?HTTP/2确实支持在一个TCP连接上多路复用多个流,双向收发数据,但它的语义仍然建立在“请求-响应”之上,服务端要主动给客户端推送一条消息,要么走客户端发起的请求,要么依赖曾经被设计出来又废弃掉的Server Push。现在主流浏览器对Server Push的态度已经冷淡了,更常见的做法是结合SSE用。
SSE和WebSocket之间也不是替代关系,而是两个互补的工具。SSE实现简单、自动重连、能穿任意HTTP代理,适合行情、日志流、AI生成结果这类单向推送;WebSocket适合需要双向实时交互的场景。真正有意思的是,WebSocket本身也可以跑在HTTP/2之上,草案已经出来了,可以把多个WebSocket会话复用到同一条HTTP/2连接上。不过目前实际生产中还是以经典的WebSocket over TCP为主,等哪天浏览器和网关的兼容性上来,这会是另一个大话题。
5. 生产环境里的WebSocket:代理、超时与心跳的那点坑
5.1 代理空闲超时导致的“假死”
我自己的第一个生产级WebSocket服务上线之后,很快就接到反馈:页面挂一段时间就不实时了,刷新页面又正常。当时第一反应是代码有bug,后来一查,问题根本不在业务代码,而在层层的代理和负载均衡。
很多代理产品对空闲TCP连接都有默认超时,比如60秒内没有任何数据传输就主动断开。WebSocket建立之后通常不会一直在传数据,用户可能好几分钟没任何操作,此时连接在代理看来就是“空闲”的,被掐掉也毫不知情。客户端那边连接还没感知到异常,就会出现“以为还在线,实际已经断了”的假死状态。
排查这种问题有个很实用的路径:先看客户端有没有触发心跳,再看代理日志里有没有断开记录,再用抓包工具看链路有没有FIN包。只要把断开的时间点和日志对上,基本就能判断是空闲超时还是代码逻辑问题。
5.2 升级请求容易在代理层被过滤
WebSocket握手依赖Upgrade和Connection这两个头,但在很多代理配置里,这两个头默认不会被原样向后传递。如果你只是把普通HTTP请求正常转发,WebSocket握手就会在代理那一层被当成普通请求处理,拿不到101响应。常见做法是在代理配置里显式放行Upgrade和Connection,同时把读超时时间调长,别让代理在握手成功后的几秒内又帮你把连接断掉。
还有一种容易被忽略的情况:HTTP/2协议本身不支持传统的Upgrade机制,如果你的代理在最外层是HTTP/2模式,那WebSocket升级很可能不生效。解决思路是让WebSocket握手走HTTP/1.1,再考虑内层转发。上线之前先和运维确认代理版本、超时参数、是否开启WebSocket支持,能省一整天的排查时间。
5.3 心跳频率:差几秒都可能出事
心跳间隔怎么定,我一直觉得是门玄学。太密集了浪费带宽、费电,特别是移动App,后台还被频繁唤醒,用户看一眼就想卸载你;太稀疏了又刚好卡在代理超时的边缘,一个网络抖动就断线。比较通用的做法是让心跳间隔小于代理空闲超时的一半到三分之二。
比如你发现链路中最短的空闲超时是60秒,那心跳可以设置为30到40秒。同时不要只依赖“发出去”就算完成,还要看Pong能不能及时回来。我通常会在Ping发出去之后设置一个超时计时器,比如10秒没等到Pong,就判断这条连接已经不可用,主动关闭然后重连。这样能避免客户端一直握着一根死连接不放。
重连策略同样重要。WebSocket没有自动重连,你必须自己写逻辑。直接断线立刻重连效果最差,服务器压力大的时候容易引发“重连风暴”。推荐指数退避,第一次1秒,第二次2秒,第四次8秒,最多到30秒或60秒,每次加重试的时候加一点随机抖动。重连成功之后,客户端还要补拉一下离线期间的消息,否则连接恢复了,消息却丢了一截。
5.4 关闭连接别直接断TCP
服务端需要下线连接时,尽量先发Close帧,设置合理的错误码,比如正常关闭1000,或者根据业务原因处理。不要粗暴地直接关闭底层socket,那样客户端只会看到一个异常断开,无法区分“服务器主动不要你”和“网络炸了”。客户端收到Close事件时,也要根据错误码决定是提示用户还是安静重连。
如果客户端判断是网络异常导致的断连,比如错误码1006,自动重连是合理的;如果服务器明确返回关闭原因,比如认证失效、被踢下线,就不该无限重连,否则会出现“刚被踢下去又自动连回来”的诡异现象。这些判断虽然简单,但代码里没写完整的话,用户能把你骂到怀疑人生。
最后分享一点我的实际习惯
我现在做实时网关,一定不会把心跳间隔、代理超时、重连退避这些参数写死在代码里,而是全部放进配置中心。不同环境的代理设置可能完全不同,本地开发时超时无所谓,到测试环境就断,到生产又是另一个值。每次联调前,我都会先问运维一句:中间这层设备的空闲超时是多少?这句话帮我省了很多冤枉加班。WebSocket并不神秘,它只是把HTTP的接力棒接过来,跑完最后那段实时赛道。想用好它,关键不是背帧格式,而是摸清楚你这条连接从客户端到服务器,中间到底要穿过哪些“热心肠”的代理,它们会不会在沉默中悄悄把线剪断。