:WebSocket 与长连接实战)
问题背景上一篇把冷启动四段账DNS、TCP、TLS、HTTP算完这一篇处理另一类问题当你不想再一问一答的时候。HTTP 的世界观里服务器永远是被问才答——想推送只能客户端高频轮询。轮询的账单在移动端尤其昂贵每秒一次的有没有新消息请求99% 是空响应流量、电量、服务端 QPS 全在为这 99% 买单而延迟下限又恰恰是那个轮询间隔。IM、协同编辑、行情推送、游戏状态同步、在线课堂这些业务的共同答案是长连接一次建连服务器随时可以把话递到你手里。但长连接也是事故高发区“网络切换后连接还活着吗”客户端以为自己在线服务端早被 LB 空闲回收正是第一篇 keep-alive 倒挂的加强版、“重启网关 100 万客户端同时重连”惊群风暴、“帧缓冲区被超大分片消息打爆”。本篇把 WebSocket 从头拆到尾101 升级握手为什么需要那个奇怪的 Key、帧格式的每个字段在防什么、心跳间隔怎么由链路上最小的空闲超时倒推并用两次回环实验把字节层全部跑一遍。核心原理第一层握手是一次身份确认的协议换轨。客户端发一个特殊 HTTP/1.1 请求Upgrade: websocketConnection: UpgradeSec-WebSocket-Key: 16 字节随机数的 Base64服务端如果同意回101 Switching Protocols并把 Key 加上 RFC 6455 规定的魔法串做SHA1 → Base64作为Sec-WebSocket-Accept回赠。注意这条握手不是安全认证——任何人都能算出 Accept它的真实目的是证明对面那个进程真的懂 WebSocket 并收到了我的请求同时防止 HTTP/1.1 中间代理把自己的缓存机制错误地套在这条连接上。101 之后这条 TCP 上的 HTTP 语义就地死亡从此按帧说话。也解释了一个常见 4xx走 HTTP/2 的反代里Connection/Upgrade是连接级头部、按规范不得转发——WS over h2 要用扩展 CONNECTRFC 8441或者干脆让网关退回 1.1 隧道这是升级 2.0 后 WebSocket 全断的头号原因。第二层帧格式的每个字段都有罪名可指。一个帧 FIN(1) RSV(3) opcode(4)MASK(1) 长度(7/16/64) 掩码密钥(4) 载荷。opcode 认六个就够日常0x1 文本、0x2 二进制、0x8 关闭、0x9 ping、0xA pong加上 0x0 续帧。客户端→服务端的帧必须掩码服务端→客户端禁止掩码——不对称看着别扭理由是历史教训未掩码的浏览器请求流可能被只认 HTTP/1.0 的代理误解析成合法请求缓存投毒的入口XOR 掩码把字节流洗成代理看不懂的形状它防的是中间设备不防窃听密钥就在帧头里。长度分三档125/65535/2^64 是同一字段的进位表达超过单帧上限的消息由发送方分片首帧 FIN0 若干 opcode0 的续帧控制帧可以插进分片中间但分片帧自己不能再分。close 帧带 2 字节状态码之后紧跟的就是第一篇讲过的四次挥手——WS 的 close 握手和 TCP 的 FIN 是两层事应用层先互道 close1000 正常、1001 离开、1008 违规……TCP 再各自体面退场1006 是浏览器保留的没收到 close 就断了的异常码看到它等于看到一次网络失踪。第三层心跳不是可选项是长连接的户口本。TCP 层 keepalive 默认 7200 秒第一篇的结论在这里原样复发而现实链路上真正杀连接的是各级空闲超时云 LB 通常 350s、企业网关 60~300s、移动网络的 NAT 映射可能 30s 就回收。规则推论保活间隔 链路最小空闲超时且方向必须包含出——只有客户端主动发 pingNAT 映射才活着。经验值是取最短超时的 1/2 到 2/3再叠加应用层判死连续 2 次 pong 未到就重连。别忘了移动端的代价每次上行都让射频从待机爬回高功率3-5 秒的电量尾巴心跳频率是电量和延迟之间的直接谈判。第四层WebSocket 还是 SSE先问方向再问双向。服务端→客户端单向推送通知、行情、LLM 流式输出用 SSE 就够它就是 chunked HTTP上一篇的分帧知识直接复用天然走现有 HTTP 基础设施、自动重连、代理友好需要客户端高频上行按键级同步、游戏帧才值得上 WS 的运维成本——连接网关、路由表用户→在哪台机器、跨节点投递broker 广播到各接入层、单机几十万连接时的 fd 与每连接缓冲内存预算。生产级长连接系统里WS 只是最后一段真正的工程量在连接治理注册、寻址、踢除、优雅迁移。第一次代码实验及输出先用纯字节层实验建立帧格式的肌肉记忆复算 RFC 6455 官方握手样例的 Accept编解码服务端不掩码帧与客户端掩码帧把一条消息拆成首帧续帧再还原。固定输入、固定掩码密钥输出逐字节可核对。importbase64importhashlibimportstruct GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11# RFC 6455 固定魔法串defaccept_key(ws_key):握手核心一步: SHA1(key GUID) 再 Base64returnbase64.b64encode(hashlib.sha1((ws_keyGUID).encode()).digest()).decode()defencode_frame(opcode,payload,mask_keyNone,finTrue):RFC 6455 帧: 首字节 FINopcode, 次字节 MASK长度, 客户端-服务端必须带掩码outbytearray([(0x80iffinelse0x00)|opcode])nlen(payload)lengthnifn126elsestruct.pack(H,n)ifn65536:lengthstruct.pack(Q,n)out.append((0x80ifmask_keyelse0x00))ifisinstance(length,int):out[-1]|lengthelse:out[-1]|126iflen(length)2else127outlengthifmask_key:outmask_key outbytes(b^mask_key[i%4]fori,binenumerate(payload))else:outpayloadreturnbytes(out)defdecode_frame(data):返回 (fin, opcode, payload, 消耗字节数)finbool(data[0]0x80)opcodedata[0]0x0Fmaskedbool(data[1]0x80)ndata[1]0x7Foff2ifn126:n,struct.unpack(H,data[off:off2]);off2elifn127:n,struct.unpack(Q,data[off:off8]);off8mask_keydata[off:off4]ifmaskedelseNoneifmask_key:off4rawdata[off:offn]payloadbytes(b^mask_key[i%4]fori,binenumerate(raw))ifmask_keyelserawreturnfin,opcode,payload,offn# 1) RFC 6455 第 1.3 节的官方样例print(Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ)print(Sec-WebSocket-Accept:,accept_key(dGhlIHNhbXBsZSBub25jZQ))# 2) 服务端-客户端的明文帧 OK(不掩码) 与 客户端-服务端掩码帧 OKf_srvencode_frame(0x1,bOK)f_cliencode_frame(0x1,bOK,mask_keyb\x37\xfa\x21\x3d)print(服务端帧:,f_srv.hex(), 解码:,decode_frame(f_srv))print(客户端帧:,f_cli.hex())# 3) 分片: 文本 Hi 拆成 FIN0 的首帧 续帧(0x0)fragencode_frame(0x1,bH,finFalse)encode_frame(0x0,bi)print(分片帧:,frag.hex(), 首帧:,decode_frame(frag), 续帧:,decode_frame(frag[3:]))运行输出Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo 服务端帧: 81024f4b 解码: (True, 1, bOK, 4) 客户端帧: 818237fa213d78b1 分片帧: 010148800169 首帧: (False, 1, bH, 3) 续帧: (True, 0, bi, 3)逐字节读一遍就终身不忘。81 02 4f 4b0x81FIN文本0x02不掩码长度 2载荷就是裸的OK。掩码帧81 82 37fa213d 78b1第二字节0x82高位 MASK1长度仍 278b1是4f4b与密钥37fa213d逐字节 XOR 的结果——掩码不改变任何信息量纯防中间设备这里能亲眼验证。分片帧01 01 48 | 80 01 69首帧0x01FIN0、text只装 “H”续帧 opcode 变 0x0、FIN1 装 “i”接收端的合法状态机必须是续帧只能跟在未分片序列后乱序/超量都是攻击面。RFC 的官方 Key/Accept 对dGhl...→s3pPMB...也被你复算出来了以后对任何实现的握手有怀疑就拿它当基准。工程化改进第一步把网关配置成懂升级。Nginx 反代三件套proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade;——用 map 指令兼容普通请求的Connection: close。超时与心跳对齐proxy_read_timeout网关→客户端方向的静默容忍必须大于客户端心跳间隔否则客户端活着、网关先掐这条链上所有空闲超时要画一张表由最小的那个说话第一篇的结论第三次出现客户端回收 服务端超时。第二步心跳协议设计成双向可判死。只做客户端 ping、服务端 pong防不住半开服务端也要主动 ping或统计距上次收到任何帧的时间因为客户端可能已经失踪而 TCP 毫不知情拔网线的故事。判死标准心跳间隔 × 丢失容忍数典型 30s×290s 踢除踢除走 close 帧1001 Going Away给客户端明确的重连信号而不是 1006。第三步重连策略带抖动恢复带身份。断线重连用指数退避 全抖动random.uniform(0, base * 2**n)封顶 30s 一类防网关重启→100 万客户端同一秒齐刷的惊群重连成功后第一条业务消息带last_seq服务端按序号补发离线期间的消息——TCP 只保证单条连接的有序跨连接的消息可靠性必须由应用层序号自己补这条是 IM 类系统的第一课。第四步帧大小与压缩设防。接收侧硬上限单帧长度声明超过配置值直接 close(1009 Too Big)——模型不限制就等着有人用127 8 字节超长声明骗你分配 16EB 缓冲分片累计也限速。permessage-deflate扩展能把 JSON 类流量砍 70%但它引入跨消息压缩上下文推送含敏感字段且信道可被第三方观测时CRIME/BREACH 那类压缩侧信道就会来敲门——默认关闭、按需评估或每连接重置上下文。第二次代码实验及输出把两篇的知识接起来在回环上跑一次真正的端到端HTTP/1.1 Upgrade 请求 → 101 应答客户端校验 Accept 推导→ 掩码文本帧发送 → 不掩码回显帧 → ping/pong 往返。服务端是十几行的裸 socket你会看到WS 服务器的本质就是会切协议的状态机。importbase64importhashlibimportsocketimportstructimportthreading GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11# RFC 6455 魔法串defaccept_key(ws_key):returnbase64.b64encode(hashlib.sha1((ws_keyGUID).encode()).digest()).decode()defencode_frame(opcode,payload,mask_keyNone,finTrue):outbytearray([(0x80iffinelse0x00)|opcode])nlen(payload)ifmask_key:out.append(0x80|nifn126else0x80|126)ifn126:outstruct.pack(H,n)outmask_key outbytes(b^mask_key[i%4]fori,binenumerate(payload))else:out.append(nifn126else126)ifn126:outstruct.pack(H,n)outpayloadreturnbytes(out)defdecode_frame(data):能解则返回 (fin, opcode, payload, 消耗字节数), 数据不完整返回 Noneiflen(data)2:returnNonefinbool(data[0]0x80)opcodedata[0]0x0Fmaskedbool(data[1]0x80)ndata[1]0x7Foff2ifn126:iflen(data)4:returnNonen,struct.unpack(H,data[off:off2])off2ifmasked:iflen(data)off4n:returnNonemask_keydata[off:off4]off4rawdata[off:offn]payloadbytes(b^mask_key[i%4]fori,binenumerate(raw))else:iflen(data)offn:returnNonepayloaddata[off:offn]returnfin,opcode,payload,offndefread_frame(sock):bufbwhileTrue:fdecode_frame(buf)iff:returnf chunksock.recv(1024)ifnotchunk:raiseEOFError(连接关闭于帧中途)bufchunkdefserver(sock):conn,_sock.accept()bufbwhileb\r\n\r\nnotinbuf:bufconn.recv(1024)head,restbuf.split(b\r\n\r\n,1)key[l.split(b: ,1)[1]forlinhead.split(b\r\n)ifl.startswith(bSec-WebSocket-Key)][0].decode()conn.sendall((HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n%accept_key(key)).encode())_,_,payload,_read_frame(conn)conn.sendall(encode_frame(0x1,bECHO:payload))# 服务端-客户端不掩码read_frame(conn)# 客户端 pingconn.sendall(encode_frame(0xA,b))# 回 pongconn.close()sock.close()srvsocket.socket()srv.bind((127.0.0.1,0))srv.listen(1)portsrv.getsockname()[1]tthreading.Thread(targetserver,args(srv,))t.start()clisocket.socket()cli.settimeout(5)cli.connect((127.0.0.1,port))ws_keybase64.b64encode(b0123456789abcdef).decode()cli.sendall((GET /chat HTTP/1.1\r\nHost: demo.local\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n%ws_key).encode())bufbwhileb\r\n\r\nnotinbuf:bufcli.recv(1024)statusbuf.split(b\r\n)[0].decode()got[lforlinbuf.split(b\r\n)ifl.lower().startswith(bsec-websocket-accept)]print(C: 升级响应 %r, Accept 校验 %s%(status,got[0].split(b: ,1)[1].decode()accept_key(ws_key)))cli.sendall(encode_frame(0x1,bhello-ws,mask_keyb\x1f\x2e\x3d\x4c))fin,opcode,payload,_read_frame(cli)print(C: 收到数据帧 opcode%d(text) fin%s 内容%r%(opcode,fin,payload))cli.sendall(encode_frame(0x9,b,mask_keyb\x0a\x14\x1e\x28))fin,opcode,payload,_read_frame(cli)print(C: 收到心跳应答 opcode%d(pong) fin%s%(opcode,fin))cli.close()t.join()运行输出C: 升级响应 HTTP/1.1 101 Switching Protocols, Accept 校验 True C: 收到数据帧 opcode1(text) finTrue 内容bECHO:hello-ws C: 收到心跳应答 opcode10(pong) finTrue注意read_frame这个循环它是所有真实 WS 库的缩影——TCP 给你的是字节流帧边界要自己攒第一篇字节流没有边界的第三次实战回响。客户端发的文本帧带掩码、收到的回显不带掩码方向不对称在抓包里一眼可辨opcode10就是 0xA。排障时先记这四条铁律握手看 101 与 Accept 对没对、方向看掩码位、活性看 ping/pong 是否成对、断因看 close 码1006异常失踪1001体面离开。浏览器 DevTools 的 WS 消息面板和wscat -c是把这四条落到工具上的最快路径。常见陷阱其一把 1006 当服务端踢我1006 意味着根本没收到 close 帧——多半是中间设备掐了 TCP 或进程崩了统计 1006 占比突增去看网关空闲超时和发布窗口而不是改心跳。其二只给入向做保活客户端只收不发也会被 NAT 判死心跳必须是上行的 ping。其三Upgrade 头在 h2 链路上被吞HTTP/2/3 禁止连接级头Connection: Upgrade到不了源站表现为直连能通、过网关 400——网关要么配 h1 隧道要么上扩展 CONNECT。其四接收无上限一个恶意1278 字节的长度声明就能让天真实现尝试分配天文数字缓冲帧长与分片累计都必须设限。其五跨连接丢消息不补TCP 的可靠只活在单条连接的一生之内断线重连后的空窗必须靠应用层 seq 补发否则 IM消息丢了的锅网络背不了。其六压测只测建连不测静默长连接系统最脆弱的时刻是99% 空闲 1% 突发推送把推送 QPS 打到连接路由表上再谈容量。落地清单网关三件套 一张超时表proxy_read_timeout 心跳间隔全链路最小空闲超时说了算心跳双向化客户端 ping 服务端 ping间隔取最短超时 1/2~2/3连丢 2 次判死走 close(1001)重连: 指数退避全抖动封顶恢复后首条消息带 last_seq 走补发入站帧长设硬上限并 close(1009)permessage-deflate 默认关开则评估压缩侧信道监控四指标连接数分布、1006 占比、心跳判死率、推送端到端延迟分位单向推送场景先试 SSE省掉整套连接治理本篇把一条连接双向说话讲完了——代价是自建连接治理全家桶。但服务端世界的 RPC 早已在另一条路上解决过多路复用HTTP/2 的帧层和 gRPC 的四种流模型把一条连接跑一万次调用做成了标准件帧格式和今天手写的 WS 帧异曲同工却更精密。下一篇《网络协议实战7gRPC 与 HTTP/2 多路复用》我们徒手编解 HTTP/2 的帧头看清 HPACK、流控窗口与 gRPC 状态码怎么叠在这层地基上。参考来源RFC 6455: The WebSocket Protocol: https://datatracker.ietf.org/doc/html/rfc6455RFC 8441: Bootstrapping WebSockets with HTTP/2: https://datatracker.ietf.org/doc/html/rfc8441Nginx 官方文档WebSocket 代理配置https://nginx.org/en/docs/http/websocket.htmlMDNThe WebSocket API (WebSockets): https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_APIWikipediaWebSocket: https://en.wikipedia.org/wiki/WebSocket 觉得有用就点个赞 收藏方便回头查阅有疑问直接在评论区留言我看到都会回。 本文属于《网络协议实战》系列持续更新关注不迷路。 文章里的代码都能直接跑。想要可直接 clone 的完整工程 配套部署脚本 / 踩坑清单评论一声或发邮件到cj2664qq.com我免费发你。如果你正好在做类似系统、或有工程化难题想找人做也欢迎邮件聊一句——我按实际情况评估能落地的就接单或出方案。评论和邮件都能直接找到我不用跳别的平台。