WebSocket 握手到底发生了什么?101 之前的三道坎
上个月给一个内部工具加实时推送,本地跑得好好的,部署到测试环境就报WebSocket connection to 'wss://…/ws' failed。浏览器控制台就这一行,没有状态码,没有原因。查了快两个小时,最后发现是网关没透传Upgrade头——代码一行没错,配置少了两行。
那次之后我把握手这一整套流程完整捋了一遍,才发现 101 之前其实有三道坎,每一道挂了都只给你一句「连接失败」。
这篇从报文层面讲清楚握手怎么完成,再讲三道坎分别卡在哪。
一、握手本质上是一次「协议升级申请」
WebSocket 没有发明新的端口,也没有新的连接方式。它就是先走一条普通的 HTTP 连接,然后在请求头里申请:咱们换个协议聊聊。
客户端发出去的东西大概长这样:
GET /ws HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13服务器如果同意,回一个 101 状态码,同时带上它算出来的Sec-WebSocket-Accept:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=这个 Accept 值不是随便给的。算法是固定的:把客户端给的 Key 拼上一个固定字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做一次 SHA-1,再 base64。自己写一遍就明白了:
importbase64importhashlib GUID="258EAFA5-E914-47DA-95CA-C5AB0DC85B11"defcompute_accept(key:str)->str:sha1=hashlib.sha1((key+GUID).encode("ascii")).digest()returnbase64.b64encode(sha1).decode("ascii")print(compute_accept("dGhlIHNhbXBsZSBub25jZQ=="))# s3pPLMBiTxaQ9kYGzzhZRbK+xOo=这个设计的目的很朴素:证明服务端真的懂 WebSocket 协议,而不是随便一个会把 HTTP 请求原样返回的东西。所以 Key 不需要保密,也不需要随机到多安全,它就是个「你懂不懂行」的握手暗号。
101 一旦发出,这条 TCP 连接就脱离了 HTTP 的语义,后面跑的是 WebSocket 自己的帧格式。同一个端口、同一条连接,协议换了。
二、第一道坎:中间层不认 Upgrade
这是线上最常见的一道,也是我踩的那次。
WebSocket 依赖两个头:Upgrade: websocket和Connection: Upgrade。而Connection是个逐跳头,意思是「只对当前这一跳有效」——很多网关、负载均衡、CDN 在转发时会把它干掉,或者干脆不认 Upgrade 这个动作。
结果就是:客户端发的是升级申请,服务端收到的是个普通的 GET,回 200 或者 400,浏览器看到不是 101,直接判定失败。
Nginx 里必须显式打开:
location /ws { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; # 1.0 不支持 Upgrade proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; # 默认 60s,会切掉长连接 }三行里最容易漏的是第一行。proxy_http_version默认是 1.0,而 HTTP/1.0 压根没有 Upgrade 机制,头配得再对也没用。我那次就是漏了它。
三、第二道坎:鉴权抢在升级之前
第二道坎比较隐蔽:路径和鉴权顺序。
有些框架把 WebSocket 端点挂在普通路由上,中间件先跑鉴权。如果 token 放在 query 里、或者放在Sec-WebSocket-Protocol里,而鉴权中间件只读Authorization头,就会直接回 401。
浏览器的 WebSocket API 有个硬限制:不能自定义请求头。你没法像 fetch 那样加Authorization。所以常见做法是把 token 放 query string,或者用子协议字段传。这不算优雅,但确实是现实约束。
排查的时候有个笨办法特别有效:用 curl 手动发一次握手请求,看服务端到底回了什么。
curl-i-N\-H"Connection: Upgrade"\-H"Upgrade: websocket"\-H"Sec-WebSocket-Version: 13"\-H"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="\"http://127.0.0.1:8000/ws?token=xxx"回 101 说明服务端没问题,问题在中间那层;回 401 就是鉴权;回 400 多半是头不全。一条命令把责任范围切干净,比在浏览器里猜快得多。
顺手整理一张状态码对照表,遇到的时候对号入座:
| 返回码 | 常见原因 | 先查哪里 |
|---|---|---|
| 400 | 升级头不全,或Sec-WebSocket-Version不是 13 | 网关是否透传、客户端版本 |
| 401 / 403 | 鉴权没通过,或 Origin 校验拒绝 | token 传递方式、允许的来源列表 |
| 404 | 路径不对,或网关前缀没剥掉 | location 配置、应用路由 |
| 426 | 服务端要求升级但没完成握手 | 是否启用了 WebSocket 模块 |
| 502 / 504 | 后端没起来,或者握手超时 | 进程状态、上游地址 |
还有一个容易被当成「跨域问题」的情况:有些框架默认校验Origin头,只允许白名单里的来源。本地调试用file://打开页面时,Origin 是null,直接被 403。这个跟浏览器的同源策略没关系,是服务端自己的安全策略,去配置里加白名单就行。
四、第三道坎:握手成功,但帧走不通
101 拿到了,连接建立,然后消息发不出去。这类问题出在帧这一层。
WebSocket 的帧头里有几个关键位:opcode 标记这是文本、二进制还是控制帧;MASK 位标记是否掩码;payload 长度分 7 位、16 位、64 位三种编码。
有个规则经常被自研协议的人忽略:客户端发给服务端的帧必须掩码,服务端发给客户端的帧必须不掩码。反了的话,对端会直接断开,而且不同实现的报错还不一样,有的给 1002,有的直接 TCP 断。
如果你用的是成熟库(Python 的websockets、aiohttp,Node 的ws),这些细节不用管。但只要你碰过一次自研帧解析,就会明白为什么大家都说「别自己实现协议」。
五、握手成功之后,真正的麻烦才开始
101 只解决「连上」,不解决「一直连着」。
长连接服务还有个容易低估的账:容量。
一个 WebSocket 连接占的内存不多,服务端每连接几十 KB(读写缓冲加连接对象),一万连接也就几百 MB,看着不吓人。真正卡住你的是文件描述符。
Linux 默认单进程能开的文件描述符是 1024,一条连接占一个。所以第一件事是改ulimit -n,或者用 systemd 的LimitNOFILE抬到 65535 以上。这个坑我踩过:压测到一千出头忽然开始报Too many open files,日志里全是 accept 失败,看着像网络问题,其实是句柄耗尽。
第二个是并发模型。同步框架每条连接占一个线程或进程,几千连接就把内存吃干净。得用异步框架(asyncio、Tornado、Node)或者协程方案,一条连接对应一个轻量任务,上万才撑得住。
第三个是发布时的连接迁移。服务端滚动重启,挂着的老连接全断。能不能让用户无感,取决于有没有「先摘流量、等连接自己迁走、再停进程」这套流程。这块不做,每次发版都是一次小型事故。
说完容量,回到超时。长连接最容易被中间设备干掉,因为它们判断连接是否还活着的标准很粗暴:一段时间没有数据,就认为你死了。Nginx 默认proxy_read_timeout 60s,云厂商的负载均衡常见 300 秒或者 900 秒,超过了直接 FIN。
所以应用层心跳是必须的,不能指望 TCP keepalive——那个默认两小时才探测一次,来不及。
importasyncioimportwebsocketsasyncdefkeep_alive(ws):whileTrue:awaitasyncio.sleep(25)# 小于各层最短空闲超时的一半pong=awaitws.ping()# 控制帧,不进业务队列awaitasyncio.wait_for(pong,timeout=10)asyncdefmain():asyncwithwebsockets.connect("ws://127.0.0.1:8000/ws")asws:asyncio.create_task(keep_alive(ws))asyncformsginws:print("收到:",msg[:80])asyncio.run(main())两个细节值得说。一是心跳间隔要小于整条路上最短的那个空闲超时的一半,60 秒的超时配 25 秒心跳比较稳。二是用 ping/pong 控制帧,别用业务消息充当心跳——业务通道可能被限流、被顺序阻塞,心跳就失去意义了。
六、断线重连别写成死循环
最后一件事,重连。
见过太多重连代码是while True: try connect except: sleep(1)。服务端一挂,几千个客户端每秒重连一次,直接把刚恢复的服务再打挂一次。这种事故我亲身经历过,恢复用了四十分钟。
正确姿势是指数退避加随机抖动:
importasyncioimportrandomasyncdefreconnect_loop(url,max_delay=30):delay=1whileTrue:try:asyncwithwebsockets.connect(url)asws:delay=1# 连上了就重置awaitconsume(ws)exceptExceptionase:print("断开:",type(e).__name__)awaitasyncio.sleep(delay+random.uniform(0,delay*0.3))delay=min(delay*2,max_delay)加了抖动之后,几千个客户端的重连时间点会被打散到几秒的区间里,不会形成尖峰。
七、上线前的自测清单
最后给一份我每次上线都会过的清单,五分钟能跑完,能挡掉大部分低级问题。
- 用 curl 手发一次握手,确认返回 101 而不是 200/400
- 断网再恢复,看客户端有没有按退避重连,而不是死循环狂刷
- 让连接空闲超过网关超时(比如 90 秒),确认心跳能撑住不断
- 关掉一台后端实例,看连接有没有平滑迁走
- 压到预期连接的八成,看文件描述符和内存有没有见顶
- 服务端重启一次,确认客户端能自己恢复,而不是要用户手动刷新
这六条里,最容易被跳过的是第三条和第六条,也恰恰是线上最容易出事的。
写在最后
WebSocket 的复杂度全在「连接之外」:握手要过中间层那关,帧要守规矩,长连接要自己养着,断了要聪明地重连。101 只是入场券。
按我这几次踩坑的概率排,出问题先查这三处:网关有没有透传 Upgrade 和用 HTTP/1.1、空闲超时是不是 60 秒、客户端有没有真的发心跳。九成问题在这三条里。
有别的坑欢迎评论区补充,看到都会回。
本文为原创技术分享,涉及的实现思路仅供学习与合法用途参考。请遵守相关法律法规,勿将技术用于任何违规场景。