多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

WebSocket心跳机制与长连接稳定性实践:从握手到断线重连

WebSocket心跳机制与长连接稳定性实践:从握手到断线重连 作为工作里经常要和实时通信打交道的人我自认对HTTP那套还算熟悉但第一次被要求排查一个WebSocket连接莫名断开的问题时还是被折腾得够呛。搞清原理之后回头看那段时间踩的坑全是因为我对这个协议的理解停留在知道它能双向通信这种层面。今天把这篇文章整理出来结合我实际项目里用到的细节把WebSocket从握手到心跳的完整过程掰开揉碎讲一遍。无论你是刚接触前端实时功能的新人还是已经在生产环境里维护长连接服务的开发者这篇都能给你一些可落地的参考。1. 从HTTP的一问一答到WebSocket的长连接通道为什么非它不可要理解WebSocket得先回到HTTP本身。我们每天都在用HTTP,它的模式是请求和响应一一对应客户端发起一个请求服务端返回一个响应这个周期就结束了。最典型的场景是浏览器的网页加载你输入网址浏览器发GET请求服务器回HTML页面渲染完这个交互就画上句号。但对某些应用来说这种一问一答是致命的。1.1 轮询策略的困境与WebSocket的破局思路设想一个在线客服系统用户的浏览器需要时刻知道有没有新消息。用传统HTTP怎么做最常见的是轮询也就是每隔几秒钟客户端主动发一个请求问服务端有消息了吗。这个方案的缺点实际运营过的人一定深有体会实时性差。哪怕你把轮询间隔压到2秒消息始终存在最高2秒的延迟。对客服场景来说这还能忍但如果你做的是实时行情图用户能明显感觉到K线跳动比其他平台慢半拍。服务器压力巨大。哪怕90%的轮询都是没消息的空转请求请求头、Cookie、鉴权这些开销照样一个不少。几千个客户端挂在线每个都每3秒请求一次对网关和业务服务器的压力是实打实的。大量无意义的网络流量。移动端尤其明显用户在4G/5G网络下频繁发轮询电量白白消耗在通信上了。长轮询能改善一部分问题就是客户端发请求后服务器先hold住这连接不返回直到真有数据变更或者超时才返回结果。但这本质上还是在模拟一种被动等待的状态连接会在两端反复重建HTTP头部的开销依旧存在而且对服务器有大量并发挂起连接时资源占用非常难看。WebSocket的思路则完全不同它一口气打通了客户端和服务端之间的双向通道。连接建立后两边都可以随时向对方扔数据不需要再套一层HTTP的请求头。而且连接是长连接一次建立持续使用不用反复握手。用TCP的思路理解它最准确——WebSocket本质上就是在浏览器和服务器之间用HTTP做了一次握手然后把TCP连接直接升级成了全双工的通信通道。1.2 协议要解决的核心问题清单我把WebSocket要解决的问题归纳为四点这四点其实也是生产环境里最常碰到的斜面全双工通信服务端有数据变化可以直接推给客户端而不是等客户端来问。控制开销数据帧头部最小只占2字节对比HTTP每次请求动辄几百上千字节的头部开销省了太多了。跨域支持同源策略对WebSocket的约束比XHR宽松但依然要对Origin做校验。维持连接状态通过Ping/Pong心跳、底层TCP状态、以及协议层的关闭握手机制让两端清楚地知道连接什么时候活着、什么时候挂了。说到底WebSocket是HTTP的一次内部升级它把传输层门没关上而是全部打开让数据像水一样双向流动起来。2. 握手协议拆解一次看似普通的请求如何完成协议升级很多人不理解WebSocket为什么是基于HTTP的协议。实际上WebSocket的小握手阶段就是一次HTTP请求只是多了一个特殊的头字段。这个请求成功后连接的状态就从HTTP变成了WebSocket。2.1 浏览器发起的握手请求长什么样随便打开一个使用WebSocket的网页抓包看到的握手请求大概率是这个样子GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13 Origin: http://your-site.com关键点我已经标出来了。Upgrade: websocket和Connection: Upgrade表示这是一个协议升级请求Sec-WebSocket-Version: 13目前是标准版本号Origin是用来做跨域校验的服务端必须检查它防止恶意网站拿着别人的WebSocket接口去连自家服务器。最核心的字段是Sec-WebSocket-Key。这是一个经过Base64编码的随机数标准要求它必须是16字节的随机值。它的作用不是加密而是用来让服务端和客户端确认双方都支持WebSocket协议。2.2 Sec-WebSocket-Accept的计算原理SHA-1和GUID的舞蹈服务端收到握手请求后要做一次算法运算生成Sec-WebSocket-Accept响应头才能完成校验。这个算法的标准RFC 6455写得很死取Sec-WebSocket-Key的值后面拼上固定的GUID字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11。对拼接后的字符串做SHA-1哈希。把SHA-1的二进制结果再做一次Base64编码。结果就是Sec-WebSocket-Accept。我当年第一次看到这个逻辑觉得有点莫名其妙——为什么还要拼一个固定字符串后来了解了来龙去脉才知道这个GUID是设计者随手指定的魔法值一个简单到无法伪装的方案防止客户端随便构造一个Accept头直接伪装连接成功。说白了它就是一个校验协议版本用的挑战值。Node.js里用内置crypto可以很轻松实现const crypto require(crypto); function generateAcceptValue(secWebSocketKey) { const GITHUB_GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; const sha1 crypto.createHash(sha1); sha1.update(secWebSocketKey GITHUB_GUID); return sha1.digest(base64); }客户端浏览器底层收到响应后会自动做同样的运算如果结果不一致连接直接以失败告终。服务端响应报文长这样HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo状态码101表示协议切换成功只有到了这一步TCP连接才真正变成了WebSocket的专用通道。2.3 鉴权放在握手阶段比想象中更重要的习惯面试的时候不少人会问WebSocket的鉴权怎么做。多数ws库Node.js的做法是握手阶段就是一次HTTP请求所以你可以在这个阶段正常读取Cookie、Header、甚至是URL里带的token。如果你的鉴权逻辑放在业务消息里才做那风险就大了——因为一旦连接建立WebSocket消息可没有像HTTP那种明显的每一条都要过一遍鉴权中间件的机制。所以在握手阶段完成校验远比事后判断更合理这也是我一直在项目里强调的习惯。另外一个经验是反向代理层比如Nginx会默认对WebSocket握手请求做一些头部处理——你需要在代理配置里特别加上Upgrade和Connection的透传头否则本来是升级请求被代理拦腰截断后客户端收到的只会是普通的HTTP响应。3. 数据帧格式最小控制开销下的二进制世界握手成功之后传输的内容就不再是HTTP报文了而是WebSocket的数据帧。这也是WebSocket轻量的关键原因。3.1 帧结构里的每一个bit都不能漏看WebSocket帧由三部分组成FIN、Opcode、Mask、Payload等。每个字节的位都有具体含义拆开来看FIN1 bit1代表这是消息的最后一帧0代表后面还有后续帧。RSV1-3扩展协商位我在实际协议里一般保持为0。Opcode4 bits标明帧类型。0x1表示文本帧0x2表示二进制帧0x8表示关闭帧0x9是Ping控制帧0xA是Pong控制帧。其中0x8、0x9、0xA是控制帧有专门的规定不能分片、不能超过125字节负载。MASK1 bit客户端发给服务端的帧必须置为1即必须掩码服务端发给客户端的帧必须为0。这个位后面会细讲。Payload len7 bits、16 bits或64 bits负载长度的编码规则很灵活7位能装下的时候用7位如果小于126直接使用等于126则后面跟2个字节作为真实长度等于127则后面跟8个字节作为真实长度。Masking-Key4字节当MASK1时存在用来给数据做掩码运算的密钥。Payload Data实际传输的数据。如果传输的是一只大对象几百KB以上那么负载长度字段的编码方式就是你必然要处理的分支逻辑。3.2 掩码机制到底在防什么不知道你有没有奇怪过为什么客户端→服务端的帧一定要掩码服务端→客户端的帧却不用这里面藏着TCP/IP时代的历史教训。最初的WebSocket规范里客户端发的帧不需要掩码。后来发现浏览器里存在一个叫缓存投毒攻击的问题。原理大致是TCP数据包里的内容是可以伪造的如果某个恶意网页知道受害者的浏览器里缓存了某个特定URL攻击者可以利用WebSocket连接因为这个连接是TCP层直接打的往目的服务器发送一个伪造的HTTP请求该请求看起来就像是受害者自己发出的。掩码的发明就是为了解决这个问题。客户端把负载数据打乱后再发出去任意一方的中间代理看到的是一堆被异或过的字节无法解析出完整的HTTP头这样一来攻击者就无法在WebSocket通道里注入伪造的HTTP请求。服务端→客户端的数据不涉及这种攻击所以不用掩码。具体的掩码算法是每4字节循环异或用一个4字节的Masking-Key把负载数据的第1字节跟Key的第1字节异或第2字节跟第2字节异或第3、第4同理第5字节又回到Key的第1字节。所有主流语言的WebSocket库都自动实现了这套逻辑自己做库的时候才需要在意。3.3 多帧消息的分片规则实际项目里你会遇到服务端把一个大消息分片推给客户端的情况最常见的是浏览器在做Canvas截图或文件上传传输时。分片消息的规则很简单第一帧的FIN0Opcode是0x1文本或0x2二进制表示这是一个分片消息的开始。中间的所有帧FIN0Opcode是0x0表示延续帧。最后一帧FIN1Opcode是0x0。客户端必须缓存中间帧的所有数据直到收到FIN1的帧才能组装出完整的消息。协议允许片段最大尺寸无上限但实际使用时分片意味着接收端内存占用会一直涨所以我把所有消息的负载大小都做了一层限制超过上限直接走自己的业务错误逻辑。4. 心跳机制到底在解决什么问题连接假死与代理设备的空闲断开跑到这一步你已经理解了连接和帧结构。接下来是搜索热词里被问得最多的WebSocket心跳机制。先别急着要代码我们要先把为什么要心跳想明白。4.1 当你以为连接还活着其实它早就断了WebSocket是长连接连接链路里不只是你的两台机器中间往往还隔着路由器、运营商网关、负载均衡、反向代理。只要链路中任何一个节点认为这条TCP连接空闲太久把它干掉了你的应用层是感知不到的。感知不到的结果就是客户端这边TCP套接字还保持着已连接状态业务方傻乎乎地等消息怎么等都不来。服务端这边连接对象还驻留在内存里成了名副其实的僵尸连接占着文件描述符和内存。这就是业内常说的连接假死。心跳机制本质上就是在应用层主动制造周期性流量你是为了让中间设备知道这条连接还活着别掐断我同时也是为了让两端更快地发现对方已经不见了。4.2 中间设备的空闲超时时间不统一不同节点的空闲断开时间完全不同。比如某些云厂商的负载均衡空闲连接超过300秒没流量就断开有些运营商NAT设备可能在空闲60秒左右就把内网映射关掉了Nginx默认的proxy_read_timeout是60秒。这么多层节点任何一个超时设置都比你的业务频率小连接就会莫名其妙的断掉。所以你会看到几乎所有线上WebSocket应用心跳间隔都设在5秒到30秒之间常见的选30秒。这样就算某层设备的超时是60秒它也至少有两次心跳的机会去续命。4.3 两个层面的心跳TCP Keep-Alive和应用层心跳有人会提TCP层的Keep-Alive机制也默认开着。但它有几个问题内核默认的探测间隔是2小时太慢了而且路径上很多代理设备对TCP Keep-Alive的支持并不可靠——你发了Keep-Alive探测中间设备依然可以当作看不见从而掐断连接。所以生产环境的WebSocket应用基本都同时做应用层心跳而不是指望TCP自带机制。浏览器端的WebSocket API注意标准RFC草案里确实没有定义自动心跳、自动重连这些能力你看到的onmessage、onclose里那套都是基础API。这就意味着应用层心跳必须由业务代码自己实现。所谓的WebSocket心跳机制实现绝大多数指的就是这层逻辑。5. 心跳机制的工程落地前端一套后端一套的完整方案动手写心跳前我想先说明一个设计约定WebSocket协议本身定义了Ping/Pong控制帧理论上如果浏览器后面实现了自动发送机制服务端就不需要自己额外实现。但现实是浏览器没有自动发送所以业界普遍的做法有两种客户端定时发业务心跳消息自定义协议服务端回一个业务pong。客户端或服务端发Ping控制帧对方回Pong控制帧协议级。我更推荐第二种因为它不依赖业务层消息结构协议级就保证了探测能力。不过顾虑是兼容性——部分网关和代理对Ping/Pong支持不好所以我通常的做法是两者结合物理链路的关键探测用Ping/Pong业务层面还有一个自定义的{type:heartbeat}应用心跳双保险。5.1 服务端主动心跳的实现示例Node.js ws库后端我用ws库的时候会这样实现服务端心跳const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); // 存储每个连接的存活状态与定时器 const peerState new Map(); wss.on(connection, (ws) { // 新连接先标记为存活 peerState.set(ws, { alive: true }); // 收到任何消息默认表示连接还活着包括Pong ws.on(pong, () { peerState.get(ws).alive true; }); ws.on(close, () { peerState.delete(ws); }); }); // 每隔30秒扫描一次所有连接 const interval setInterval(() { wss.clients.forEach((ws) { const state peerState.get(ws); if (state state.alive false) { // 上一轮探测没收到pong判定死亡强制关闭 console.log(force terminating dead connection); ws.terminate(); return; } // 先把存活标记置为false然后发ping state.alive false; ws.ping(); }); }, 30000); // 服务关闭时清理定时器 wss.on(close, () clearInterval(interval));这段代码的关键点在于先标记为false再发ping下一轮扫描时如果该连接没被pong回调翻回true就说明它死了。这个以False为默认值的状态机是我在多次线上事故后才彻底想清楚的最简方案——如果反过来做默认True等Ping超时再判断False还需要额外设计计时器复杂度会高出很多。5.2 前端浏览器端的心跳与自动重连逻辑前端的心跳需求其实更强因为前端是连接发起方断网了、切后台了、App被挂了这些状态浏览器和WebSocket API有时都感知不好。以下是我在一个在线协作项目里的做法class WSClient { constructor(url, options {}) { this.url url; this.ws null; this.heartbeatInterval 30000; this.heartbeatTimeout 5000; // 允许等待pong超时 this.reconnectDelay 3000; // 重连等待时间 this.manualClosed false; this.heartbeatTimer null; this.onMessageCallback options.onMessage; } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { console.log(连接建立); this.startHeartbeat(); }; this.ws.onmessage (event) { // 业务消息直接上抛 if (this.onMessageCallback) { this.onMessageCallback(event.data); } }; this.ws.onclose () { console.log(连接关闭); this.clearHeartbeat(); if (!this.manualClosed) { this.scheduleReconnect(); } }; this.ws.onerror (err) { console.error(WebSocket error, err); // error事件后通常紧跟着close由close统一处理 }; } startHeartbeat() { this.heartbeatTimer setInterval(() { if (this.ws.readyState ! WebSocket.OPEN) return; // 发送心跳包同时设定超时检查 this.ws.send(JSON.stringify({ type: heartbeat, ts: Date.now() })); // 5秒后必须收到过onmessage否则认为连接假死 setTimeout(() { // 这里用lastMessageTime来判断比较合适 const now Date.now(); if (now - this.lastMessageTime this.heartbeatTimeout) { console.warn(心跳响应超时准备关闭重连); this.manualClosed true; // 防止触发正常关闭回调里的重连逻辑 this.ws.close(); } }, this.heartbeatTimeout); }, this.heartbeatInterval); } scheduleReconnect() { setTimeout(() { console.log(自动重连中); this.manualClosed false; this.connect(); }, this.reconnectDelay); } close() { this.manualClosed true; this.ws.close(); } }这里我特意举的是发自定义业务心跳包的例子因为很多应用层代码没法用浏览器原生的ping/pong浏览器API对控制帧不支持直接发送所以大家更常用的是业务心跳。但判断根本逻辑是一致的定期发一个用于探测的消息超时没看到回应就重建连接。有一个细节要注意lastMessageTime需要在onmessage回调里实时更新而且心跳包本身必须也走这个回调来刷新时间否则前端会误判连接假死。我初版代码犯过这个错——只记录业务消息的时间戳心跳包响应没记录结果用户频繁断连重连。5.3 心跳与断线重连的协作策略断线重连最忌讳的是死循环风暴服务端重启、网络抖动导致集体重连瞬间把服务端打崩。处理办法是指数退避随机抖动scheduleReconnect() { const base Math.min(this.reconnectDelay * Math.pow(2, this.retryCount), 30000); const jitter Math.random() * 1000; // 0~1s随机抖动 const delay base jitter; console.log(尝试重连等待${delay}ms); setTimeout(() { this.retryCount; this.manualClosed false; this.connect(); }, delay); }retryCount在连接成功时重置为0。加随机抖动这一行看起来不起眼但真能避免成千上万客户端在服务端恢复瞬间涌入引发的雪崩。5.4 服务端多实例部署与心跳广播问题如果你的WebSocket服务跑了多个实例Nginx负载均衡后面挂了3台那么你还需要考虑一个事情同一条消息是发给所有连接还是发给指定用户如果发给指定用户而你不知道这个用户在哪个实例上怎么办常见方案有客户端连上后把自己的userId connectionId上报给后端后端用Redis Pub/Sub广播路由每个实例从Redis订阅只把消息发给本机上属于该用户的那条连接。用MQ比如RabbitMQ的广播队列做类似的事情本质上还是实例间路由。如果你用了这个方案记得Redis连接本身也要有哨兵/主从切换的容灾否则它挂了你的WebSocket路由就全盲了。我曾因为Redis集群切换在高峰期出现过20分钟的消息延迟排查后才发现是Pub/Sub的客户端忘了做故障转移重连。这事之后我的服务端启动流程里就加了一条强制检查Redis不可用则不许对外提供WebSocket服务。6. 协议设计级的经验和踩坑不止一次栽在看起来没问题里文章已经接近尾声但我反而想多说一点真正的坑。原理归原理工程归工程工程能把原理磨得千疮百孔。6.1 什么时候别用WebSocket不是所有实时需求都适合WebSocket。如果你的场景是每隔10分钟同步一次状态这种弱实时或者说服务端并没有主动推送需求用WebSocket就是徒增复杂度——你需要处理心跳、重连、路由、多实例广播、连接数限制等等。HTTP的轮询或者SSEServer-Sent Events, 服务端单向推送在某些场景下更省事。记住这条选型原则只有服务端需要主动推送数据、双端频繁交互时WebSocket才是最佳答案否则用更简单的技术永远是更正确的答案。6.2 连接数限制你以为支持百万连接其实几十万就到极限了每个WebSocket连接都对应一个文件描述符每进程可打开的文件数默认1024但生产环境一般要调大。Linux上调ulimit -n到几万甚至几十万是基础操作。同时每个连接还占用内存——注意是连接对象、心跳状态、待发送缓冲区等叠加一个连接保守估计要占50KB到200KB内存。我个人经历而言一台2C4G的机器跑到八九千连接时内存和CPU就已经有明显压力了。如果业务量大横向扩容加实例才是正路。还有一个不容易注意到的点服务端发消息时如果某个客户端接收速度跟不上比如边下载大文件边开WebSocketTCP的发送缓冲区会持续堆积最终导致内存飙涨或内存泄漏。所以生产环境一定要给发送缓冲设置上限并且对低速客户端做降级或断开处理。6.3 Nginx代理层配置少了三行就有大麻烦WebSocket通过Nginx代理转发是最常见的部署方式。此处最经典的坑是默认的proxy_read_timeout是60秒如果你的生产环境没有配置它无论前后端心跳间隔是多久只要超过60秒没有数据流经代理层Nginx就会主动掐断连接——很多人的WebSocket莫名断连就断在这了。标准配置如下map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 80; server_name ws.example.com; location /ws { proxy_pass http://backend_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; # 关键代理空闲超时设大 proxy_send_timeout 3600s; } }这里所有的坑都在map和proxy_set_header那几行map的作用是保证普通HTTP请求不因为带上了升级头而被误处理proxy_read_timeout设大是为了让长连接不至于因为一两次业务消息间隔过长就被Nginx中断。实测里把超时调到1小时以上配合客户端30秒心跳很少再出现代理层的假死问题。6.4 心跳超时的极端情况检测用日志说话线上问题最难排的是间歇性断连。只靠前端重连成功与否没法判断根因。我习惯把每一次心跳发送、pong响应、断连时间点、重连延迟、以及断连时的CloseEvent里的code按完整链路打日志再配合Nginx访问日志里Whs Connection的状态码就能很清楚地画出整个时间线。举个例子如果日志显示客户端发出了Close帧code 1000正常关闭那是应用层主动关闭多半是业务逻辑问题如果显示的是1006异常关闭那就是底层TCP断了怀疑点直接锁在代理层、网络运营商、或者对端服务崩溃如果显示1008协议违规那八成是帧格式写错了或者是对方收到了不合法的控制帧。把code的语义搞清楚排错能快好几倍。7. 写在最后我对WebSocket生产实践的真实感受做了这么多年实时通信我的感触是WebSocket本身并不复杂原理啃透也只需半天真正决定项目好坏的是那些协议以外的心智负担——心跳节奏怎么定、重连的退避策略怎么设计、多实例之间的消息怎么路由、被代理掐断连接时要怎么快速定位。把这些配套组件都理顺了WebSocket才真正成为可靠的通信底座。最后再分享一个小技巧如果你也在做前端WebSocket调试浏览器DevTools的Network面板里WS标签页可以直接显示每一条帧的Payload和类型抓包时记得把Frame视图切换成Message而不是Payload这样可读性好很多。排查性能问题的时候这个细节能帮你快速区分是高频心跳占用了带宽还是业务消息超发导致拥堵。
返回列表