ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WebSocket从握手到心跳:实时通信原理与断线重连实战

WebSocket从握手到心跳:实时通信原理与断线重连实战 你点了一份外卖商家接单、骑手取餐、骑手送达每一次状态变化App都会第一时间弹通知。你大概不会去想这背后到底是怎么做到的——但你如果再稍微琢磨一层就会意识到如果是轮询那App得每隔几秒就去问一次服务器服务器烦电池炸体验还卡如果是纯HTTP那根本做不到“服务器主动找客户端”。能把这个场景做顺的是WebSocket。这篇文章不堆概念我只把WebSocket从握手到数据帧、从心跳到断线重连从原理到落地用大白话捋一遍。看完你会明白——它为什么能实时推送为什么需要心跳以及为什么它和HTTP不是“替代关系”而是“互补关系”。适合刚接触WebSocket、被轮询坑过或者已经在用但总是断连的开发者。1. 为什么需要WebSocket先看HTTP的痛在哪1.1 轮询这招治标不治本在没有WebSocket的年代做“实时”功能最土的办法就是轮询。前端写一个setInterval每3秒发一个AJAX请求去问后端“订单状态变了吗”后端查一下数据库回答说“没变”或者“变了”。这个方案在数据量小、用户少的时候能用但它的代价是明摆着的每秒有大量请求是空转的明明没有新数据却要把TCP连接建起来、传一段HTTP报文、再断开。按3秒一次算一个用户一天下来就是28800次请求其中绝大多数是“白跑一趟”。服务器CPU和带宽被无效请求吃掉移动端耗电也上去了。轮询还有个更尴尬的问题——延迟不可控。3秒的轮询间隔意味着最坏情况下用户要等接近3秒才能看到状态变化。想降低延迟就得更频繁地轮询更频繁地轮询就得承受更大的服务器压力。这本质上是一个“用带宽换实时性”的方案天花板很低。1.2 长轮询和SSE都是“半实时”的妥协轮询之后有人搞过长轮询客户端发请求服务器先挂着不响应等有数据了再返回客户端收到之后再立刻发下一个请求。这种方式确实能减少空转但依然是一次请求对应一次响应服务器挂着一堆连接的同时还得处理高并发下的资源管理问题。而且长轮询在代理层和浏览器层都有各种怪脾气超时时间、连接复用、内存释放处处是坑。SSEServer-Sent Events比长轮询再进一步能让服务器单方向地持续推送消息给客户端。但SSE有一个天生的短板它是单向的客户端想往服务器推消息还是得发HTTP请求。聊天、协同编辑这种“双方都要说话”的场景就无能为力了。这些方案都没有解决一个根本问题HTTP是“请求-响应”模型服务器永远是接收方不能主动开口而实时通信恰恰需要服务器主动开口。WebSocket的出现就是冲着这个死结来的。2. WebSocket的核心原理从一次“握手”说起2.1 别被“全新协议”这四个字唬住很多资料把WebSocket描述成一个“全新的应用层协议”听起来像是从零设计的一套航班。实际上它更像是一架借助跑道起飞的飞机——WebSocket底层依然是TCP连接而且它的出生仪式就是一次标准的HTTP请求。这个出生仪式叫“协议升级握手”。客户端发送一个带着特殊升级信息的HTTP请求服务器如果同意就返回101状态码双方随即从HTTP协议“切换”到WebSocket协议。这个巧妙的设计让WebSocket能轻松穿透绝大多数HTTP代理和防火墙——因为代理看到的就是一个普通的HTTP请求和响应而已。这么说来WebSocket并不是和HTTP平级竞争的关系而是“从HTTP衍生、但形态不同的另一种应用层协议”。它复用了HTTP的握手通道但之后传输数据就不再走HTTP那套报文格式了转而走WebSocket自定义的帧格式。2.2 握手请求内部到底长什么样直接用wscat或者Chrome DevTools的Network面板抓一次WebSocket握手你会看到一个普通得不能再普通的HTTP请求GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13这五个Header是握手的关键Upgrade: websocket明确告诉服务器我要切换到WebSocket协议。Connection: Upgrade说明这个连接要升级。Sec-WebSocket-Key一个客户端随机生成的Base64字符串用来做服务器校验。Sec-WebSocket-Version客户端支持的协议版本目前全球标准都是13。Origin来源校验浏览器用它来防止跨站WebSocket劫持网上很多教程漏讲这个Header但它在安全上很重要。服务器收到之后会取Sec-WebSocket-Key拼接一个固定的魔数字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11然后做SHA-1哈希再做Base64编码作为Sec-WebSocket-Accept返回HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo这个校验过程是防呆设计——防止端口上连接了一个非WebSocket客户端双方基于魔数做一次“对暗号”确认彼此都懂这套规则。101状态码之后TCP连接维持不变但语义已经从“HTTP报文一来一回”变成了“WebSocket帧全双工通信”。2.3 全双工到底是个什么体验理解全双工之前你可以把HTTP想象成对讲机——同一时间只能有一个人说话你问完我答我答完你再说谁也别抢话。而WebSocket是电话——两个人可以同时说话同时听。在HTTP模式下客户端发一条消息服务器只能排队等别人把连接释放了才能响应。而在WebSocket连接里双端随时可以往TCP管道里写数据数据能同时在两个方向上流动。协同编辑里A用户打字的同时收到B用户的光标移动就是全双工的直接体现。这个特性带来一个质变实时性从“轮询的最坏情况等于轮询间隔”变成“消息到达即转发”网络延迟成为唯一瓶颈。3. 数据帧数据在WebSocket里怎么装、怎么拆3.1 信封模型帧结构拆解WebSocket通信的最小单元是“帧”Frame。你可以把帧想象成一封信封信封上写清楚了里面装的是什么、有多长、有没有被加密处理。帧的格式从高位开始依次是FIN1比特标记这是不是消息的最后一个分片。大消息会被拆成多帧发送最后一片FIN1。RSV1-3各1比特预留位一般全为0扩展协商时使用。Opcode4比特表示帧类型。0x1是文本帧、0x2是二进制帧、0x8是关闭帧、0x9是Ping、0xA是Pong。Mask1比特客户端发给服务器的数据帧必须为1表示数据被掩码处理过。Payload length7比特或716比特或764比特表示数据长度。Masking key4字节掩码用的密钥仅当Mask1时存在。Payload data真正的消息内容。服务器往客户端发帧Mask位是0客户端往服务器发帧Mask位必须是1。这个不对称的设计很多新手会忽略自己拿Socket写客户端时容易漏掉掩码导致服务端直接断开连接。3.2 为什么要掩码一段浏览器黑历史你可能会想客户端给服务器发的帧为啥要加密一次这不是多此一举吗答案是浏览器时代的血泪教训。早年有个真实攻击场景叫“Cache Poisoning”缓存投毒。攻击者构造一段恶意HTML和合法网站的服务器建立WebSocket连接数据帧里藏的明文内容是HTTP请求或脚本。如果WebSocket消息不带掩码经过代理服务器时代理可能把这段数据误当成HTTP请求从而污染缓存、传播恶意代码。加上客户端掩码之后代理看不懂数据内容就没办法把它当作合法HTTP请求来处理攻击面大幅收窄。这也解释了为什么WebSocket规范强制要求客户端必须掩码而服务器发送的消息不需要掩码——因为服务器到浏览器的数据不经过浏览器代理风险点不在那边。3.3 文本、二进制和控制帧的区分方式Opcode决定了帧的语义。0x1文本帧负责字符串消息服务端常见的send(hello)走的就是这个0x2二进制帧负责ArrayBuffer或Blob这类数据上传文件、图像流、游戏同步都用它0x8关闭帧用于优雅关闭连接0x9和0xA是心跳ping/pong浏览器原生WebSocket API会自动响应ping并返回pong这一块后面会细讲。要注意的是一条业务消息可以分多个帧传输第一帧Opcode标记类型中间帧Opcode0表示“继续上一段”最后一帧FIN1收尾。如果只发一帧那FIN直接为1就行。协议层把分片逻辑做成透明的业务层收到的永远是完整消息分片重组由WebSocket库完成。4. WebSocket生命周期与消息收发请求-响应之外的另一套规则4.1 连接建立、消息交互、正常关闭WebSocket生命周期分三个阶段握手、数据传输、关闭。握手上文说过数据传输阶段是全双工自由写消息关闭阶段则由任一方发出Close帧对端回一个Close帧TCP连接随之释放。使用原生浏览器API建立连接很简单const socket new WebSocket(ws://localhost:8080/ws); socket.onopen function () { // 握手完成连接已建立此时发消息一定不会丢 socket.send(JSON.stringify({ type: register, userId: 123 })); }; socket.onmessage function (event) { // 收到服务器推送的数据event.data可能是字符串或Blob console.log(收到消息:, event.data); }; socket.onclose function (event) { // 连接关闭event.code和event.reason能告诉你是为什么 console.log(连接关闭, event.code, event.reason); }; socket.onerror function (error) { // 错误处理注意onerror之后通常会跟着onclose console.error(WebSocket错误, error); };这块要特别提醒两点。第一onopen之后才能send否则消息会直接丢失。第二onerror之后几乎必然会触发onclose所以业务层的断线重连逻辑最好写在onclose里不要在onerror里做重复处理否则可能触发两次重连。4.2 心跳机制为什么把它单拎出来说很多第一次用WebSocket的开发者会问TCP本身不是有keep-alive吗为什么不靠它保活答案是TCP keep-alive是操作系统层面的机制默认要等2小时才探测一次而且它在Nginx代理、云负载均衡这一层很容易失效无法满足业务层“分钟级”的存活检测需求。WebSocket应用层的保活机制一般叫“心跳”。最简单的方式是客户端每隔一定时间发一个Ping帧服务器收到后马上回一个Pong帧。浏览器原生WebSocket API不暴露手动发Ping的方法但浏览器会自动响应服务器发来的Ping并回Pong。所以实际项目中更常见的做法是“业务层心跳”客户端定时发一个自定义消息如{type:heartbeat,timestamp:...}服务器收到后响应或者服务器定时主动发ping浏览器自动回pong。这里我推荐一个更稳妥的模式服务器定时比如30秒向客户端发Ping客户端浏览器会自动回Pong服务器通过Pong的到达与否判断连接是否健康客户端同时维护一个“最后收到数据时间”超过比如50秒没收到任何数据就认为连接已死主动重连。写心跳逻辑时有几个细节比网上抄来的模板更值得注意心跳帧不带业务数据不要进消息队列不要触发业务逻辑别让它和正式消息混在一起。服务端发现连续N次Ping没收到Pong或客户端主动发Ping超时没收到Pong应该主动断开旧连接让客户端重连。心跳消息用单独的事件名称或单独的一个消息类型处理避免解析普通消息报错导致心跳中断。4.3 断线重连指数退避比“无脑重连”靠谱断线重连听着像小事但里面最容易踩的坑是“重连风暴”。几万个客户端同时掉线、同时重连服务器连接数瞬间暴涨可能把刚恢复的服务又一次压垮。规范的思路是采用指数退避第一次重连等1秒第二次2秒第三次4秒最多到30秒并且加重启后的随机抖动值。这个抖动很重要它避免大量客户端在同一秒发起重连。同时重连成功后要重新注册业务状态比如重新订阅某个房间或者重新登录鉴权。在WebSocket API里做这个逻辑很简单const RECONNECT_MAX 30000; let delay 1000; function connect() { const socket new WebSocket(ws://localhost:8080/ws); socket.onclose function () { setTimeout(() { delay Math.min(delay * 2, RECONNECT_MAX) Math.floor(Math.random() * 1000); connect(); }, delay); }; socket.onopen function () { delay 1000; // 连接成功重置退避时间 }; }实际上市面上成熟的WebSocket客户端库比如reconnecting-websocket已经把这套逻辑封装好了没必要重复造轮子。5. 实际项目中怎么选型裸API还是框架如果你只是做一个Demo直接用浏览器原生API和Node的ws库就够了。一旦项目上了规模我的建议是别自己从零封装协议层直接在原生API上面包一层业务层的重连、心跳、消息路由即可。服务端选型方面Node生态最常用的是ws库它轻量、稳定底层实现完全遵循协议规范。而像Socket.IO则是更上层的框架它自带心跳、自动重连、房间、广播等功能但它做了协议升级不一定兼容原生WebSocket客户端——如果客户端是浏览器里的原生WebSocket就不要选Socket.IO它需要配套的客户端库才能工作。如果前后端都掌握在自己手里Socket.IO确实能省掉不少事但如果客户端除了浏览器还有小程序、App、硬件设备那么坚持标准WebSocket协议用原生API或者ws库更合适因为兼容性更好。Java后端则常用Spring WebSocket模块或者Netty。Spring WebSocket适合业务和Spring深度融合的场景Netty适合需要高度自定义、高并发低延迟的场景代价是上手难度高。6. Nginx配置WebSocket代理一个必须处理的细节6.1 为什么Nginx默认配置代理不了WebSocketWebSocket握手时带了Upgrade: websocket头而Nginx默认的HTTP代理配置只会原样转发普通HTTP头遇到Upgrade和Connection头会认为是“逐跳头”不会转发到后端。服务器端看到的就是一个没有Upgrade头的普通HTTP请求自然返回400或者直接拒绝。所以要让Nginx支持WebSocket必须在location里显式设置下面两个头map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 80; server_name chat.example.com; location /ws { proxy_pass http://backend_servers; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }proxy_http_version 1.1也很关键因为默认的HTTP/1.0不支持Upgrade语义。至于proxy_read_timeout如果WebSocket连接空闲时间比较长一定要调大否则默认的60秒一到Nginx会把空闲连接直接杀掉。6.2 多节点部署和粘滞会话如果WebSocket服务有多个后端节点单纯轮询分发会出现一个问题客户端第一次握手被分到节点A但后续消息被分到节点B而B并没有这个客户端的连接状态消息就丢了。解决办法有两种思路一种叫IP Hash负载均衡同一个IP的请求始终打到同一个节点upstream websocket_backend { ip_hash; server 10.0.1.1:8080; server 10.0.1.2:8080; }另一种是用Redis/RabbitMQ之类的中间件做WebSocket连接状态的跨节点共享。客户端不管连到哪个节点消息都能通过中间件路由到正确的节点。第一种适合中小规模项目实现成本低第二种适合大规模集群把所有节点状态对外统一成一个逻辑连接。7. WebSocket常见问题与排查实录7.1 握手失败400/403问题浏览器控制台最常见的错误是WebSocket connection to ws://... failed。遇到这种情况先把Network面板里握手失败的那条记录点开看响应状态码400 Bad Request多半是Nginx或后端不支持Upgrade头检查反向代理配置。403 Forbidden多半是Origin校验没过。服务器如果要求特定来源而你的页面域名不在白名单里就会拒绝。开发者本地调试时特别容易遇到。另外ws://和wss://的坑也要注意。线上环境如果页面是HTTPSWebSocket必须用wss://不能直接填ws://否则会被浏览器拦截错误还是显示连接失败。7.2 连接闪断、断线重连死循环连接闪断的原因很多服务端重启、Nginx idle超时、手机切换Wi-Fi导致IP变化、代理服务器强制断开。排查思路是看日志和抓包先在浏览器DevTools的Network面板看连接断开的事件码1006是异常关闭没有close帧基本是被网络层切断的1000是正常关闭那要检查是不是服务端主动关的。断线重连死循环是我见过最多的问题——重连逻辑太激进没有指数退避和最大次数限制服务端一旦短暂不可用客户端就以每秒几次的频率疯狂重连把日志刷爆、把服务器连接数打满。解决办法就是上面写过的指数退避加重置策略同时给重连加一个“最长重连次数”达到上限后停下来提示用户手动刷新。7.3 心跳超时却收到对端数据有个非常隐蔽的问题客户端心跳超时了准备重连然后同一条TCP连接上又收到了服务器发来的消息。这其实说明连接还在只是对端的心跳处理比较慢。如果此时粗暴地销毁连接并重连会造成不必要的抖动。稳妥的做法是心跳超时后不要立刻销毁连接先发一个Ping再等一个Pong窗口如果这个窗口内Pong也没回来再销毁并重连。实测下来这种方式能过滤掉很大一部分“假死”状态。7.4 数据库里读到脏数据、或客户端收到串线消息串线消息多半是广播逻辑和连接映射写错了。每个客户端连接对应一个唯一ID服务器要维护一张“连接ID-用户ID-业务房间”的映射表。有些项目把映射表放在内存里然后用了多节点部署消息打到别的节点自然就丢了或串了。建议把所有节点上的连接信息推到Redis或MQ里做统一路由别只放在本地内存。8. 避坑清单WebSocket项目的十宗罪把上面所有经验浓缩成一份清单你拿去做团队Code Review或者自查能省下不少排查时间握手前绝不发业务消息WebSocket连接没进入OPEN状态之前一切send都是无效的消息会静默丢弃。服务端一定做心跳超时清理不清理死连接的话服务器维护大量TCP半开连接内存和文件句柄都会被拖垮。Nginx的Upgrade头一定要配这是90%的线上部署失败根源。心跳不要和业务耦合心跳消息要有独立的类型和处理分支别在业务逻辑里穿插心跳判断。断线重连必须用指数退避这是保护自己服务器和对方服务器的基本礼貌。区分业务级重连和连接级重连连接恢复了不代表业务订阅状态也恢复了。重连成功后要把订阅房间、用户登录状态重新上报一次。二进制数据注意缓冲区Node服务端收到二进制帧要留意Buffer和字符串的互相转换别在传输过程中把编码搞坏。不要忽略close事件里的状态码1000是正常关闭1001是服务端重启1006是异常断开区分清楚才能对症下药。WebSocket连接数是高并发瓶颈设计时一定要评估单机最多能扛多少连接以及每增加一个连接占用的内存大小。一个粗暴的参考值是一个空闲WebSocket连接在Node进程里约占几十到几百KB内存取决于框架和Buffer分配情况别等撑爆了再做压测。连接建立成功之后服务器主动发一条欢迎/确认消息客户端收到就代表链路完整这个“首包确认”能提前发现握手后不能正常收发的隐患。9. 后续还可以这样扩展如果你做到这一步WebSocket基本已经能在项目里围着你转了。但“实时”这个需求从来不是一个协议的事。你可以继续往这几个方向深挖服务端主动推送往低频场景用SSE就够了完全不需要WebSocket的复杂度。这个选型判断能力比会写WebSocket本身更值钱。有条件的话可以试试用WebSocket做多人协同编辑的底层通信——把操作序列通过二进制帧传输再用一种叫OT或CRDT的对账算法处理冲突你会对“实时”有完全不一样的感觉。跑这个实验之前记得把心跳和重连逻辑写好不然你会连不上来。还有一条路是WebTransport它面向未来的QUIC协议低延迟和流式传输能力更强。但生态远没有WebSocket成熟暂时当技术储备了解即可不必急着迁移业务。我在实际使用中的体会是WebSocket不是银弹它只是把“服务器能主动开口”这个问题优雅地解决了。剩下所有的问题——消息格式怎么定、断线了怎么办、多节点怎么路由——才真正决定了项目能不能在线上跑得稳。建议你从今天这篇原理开始自己动手写一个小聊天室把握手抓包看一眼把心跳代码跑一遍把重连逻辑调一次比看十遍文档都管用。
返回列表