1. WebSocket到底解决什么问题1.1 从轮询到长轮询:一段被逼出来的历史在WebSocket出现之前,Web应用想拿到服务器的新数据,只能去“问”。最早就是轮询(Polling),前端定时发HTTP请求,问服务器有没有新数据。这个方案逻辑非常简单,我现在回头看缺点也极其明显:浪费请求、延迟高、服务端压力大。我之前做过一个小型通知系统,在线用户不过几百人,一轮轮询的QPS就能把一台低配服务器打得喘不过气,而其中绝大多数请求返回的都是“无变化”,纯粹是白跑一趟。后来又有了长轮询(Long Polling),客户端发请求后服务器先挂着,等有新数据再返回,然后客户端再发下一个请求。这种方式比轮询省了不少流量,但它本质上还是HTTP请求-响应模型,每次都在重复建立HTTP连接、重复携带一堆HTTP头,连接本身也没有真正被打通。做过长轮询项目的人基本都会遇到这些经典问题:消息重复、顺序错乱、超时重试、连接被掐断后客户端没有任何感知。说白了,HTTP天生就是单向请求-响应的、短连接为主的协议,而实时交互要的是双向、持续、低延迟的信道,两者的根本诉求完全对不上。1.2 WebSocket的设计思路WebSocket就是在这样的背景下诞生的。它的核心思路是:先通过一次HTTP握手,把连接从HTTP协议升级为WebSocket协议,之后客户端和服务端就在同一条TCP连接上双向收发帧数据。打一个生活化的比方:HTTP就像不断拨号、通话、挂断的电话,每次都要重新拨一遍;而WebSocket是接通电话后保持通话,双方随时都可以说话,不需要每次重新拨号。有一点必须说清楚:WebSocket不是要取代HTTP。握手阶段它仍然依赖HTTP,借用HTTP的鉴权、跨域、网关等能力;握手完成之后,通信才切换到独立的帧协议。所以理解WebSocket,第一步要理解“一次升级,长期复用”。另外还有一个容易被忽视的代价:WebSocket连接一旦建立,它占用的TCP连接和服务器资源就是持久的。做大规模在线应用时,连接数的管理往往比业务代码本身更费精力。后面我会专门讲这块的实战经验。2. WebSocket核心机制拆解2.1 一次握手,从HTTP升级到WS先看一眼最经典的握手请求,这是理解WebSocket的钥匙。前端需要建立连接时,浏览器会发这样一个HTTP GET请求:GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://example.com这里最关键的是三个头:Upgrade: websocket和Connection: Upgrade,明确告诉服务器“我要把协议切换成WebSocket”;Sec-WebSocket-Key,一个Base64编码的16字节随机值,用来让服务器证明“我确实收到了你的握手请求”;Sec-WebSocket-Version: 13,固定使用13,这是目前被广泛支持的协议版本。服务器收到后不能直接回个200就完事,它必须对Sec-WebSocket-Key做一次魔法运算:把请求里的Key拼接上一个固定GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),然后做SHA-1哈希,再Base64编码,把结果放到响应头的Sec-WebSocket-Accept里。这就是“证明双方都支持同一个协议”的过程。如果伪造的问题问得多,你可能会好奇:为什么不能直接返回200?因为HTTP升级是有语义的,服务器只有明确返回101状态码,连接才会切换到WebSocket协议,之后双方收发的内容才按照WebSocket帧来解析。如果服务器返回200,那说明它不打算升级,后面还是普通HTTP通信。一个典型的成功响应长这样:HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=看到101 Switching Protocols,就是握手成功,接下来这个TCP连接就归WebSocket协议管了。2.2 有了连接还不够,还得认识数据帧握手完成之后,客户端和服务端之间传输的数据就不再是HTTP报文,而是一个个WebSocket帧。帧是WebSocket数据传输的基本单位,理解它的结构,排查问题就顺了很多。一个WebSocket帧由若干字段组成,核心的几个是:FIN:1位,标记这是不是消息的最后一个帧。因为一条WebSocket消息可以被拆成多个帧传输,所以FIN=1表示消息结束;opcode:4位,标明帧的类型。最常见的0x1是文本帧,0x2是二进制帧,0x8是关闭帧,0x9是Ping帧,0xA是Pong帧;MASK:1位,客户端发给服务器的数据帧必须置1,即必须掩码;服务器发给客户端则置0。这是协议里一个挺不讲道理但又必须遵守的规定,不遵守就直接断开;payload length:7位、16位或64位,因为数据长度有大有小,协议设计了三种表示方式,长度小于126时直接用7位表示,大于等于126时扩展出额外字节表示;payload data:真正的业务数据。这里最容易遇到的问题是“消息被拆帧”。举个例子,前端发送一个较大的文本消息,底层可能被拆成多个帧传输,接收方必须根据FIN和opcode把它们重新拼起来。大部分WebSocket库会自动处理这件事,比如浏览器的WebSocket对象和绝大多数后端库都帮你拼好了。但在自研协议解析或者做底层网关的时候,就会踩到这个坑:只处理了第一帧,后面的帧全丢了。我自己遇到过最经典的案例是:在做一个自定义网关时,没处理多帧重组,导致客户端发一条超过最大单帧长度的消息,服务端只收到了前半截。排查了很久,最后才意识到是FIN位和连续帧的问题。所以在生产环境里,能不自己解析帧就绝不自己解析,能用现成的成熟库就用成熟库,这些细节库都已经帮你处理干净了。2.3 心跳机制:怎么判断连接还活着WebSocket的TCP连接在建立之后,如果长时间没有数据传输,中间的网络设备(比如运营商NAT、负载均衡、云厂商的网关)可能会把这条空闲连接悄悄回收。你这边看起来连接还在,实际上对方已经收不到任何数据了。这就是所谓的“僵尸连接”。怎么解决?常规方案有两种:第一种是协议层的Ping/Pong帧。服务端定期给客户端发送Ping帧,客户端收到后自动回复Pong帧,如果服务端在指定时间内没收到Pong,就判定连接已死,主动关闭。这套机制的好处是标准、通用,但坏处是浏览器端JS的WebSocketAPI不暴露Pong帧的接收事件,前端如果想在应用层面感知,需要自己再包一层逻辑。第二种是应用层心跳。前后端约定好一个业务消息格式,比如每30秒发一个{"type":"heartbeat"},收到方回一个{"type":"heartbeat_ack"}。这种做法实现简单、完全可控,适合自定义协议和需要感知对端业务状态的场景。我的建议是:两层都做。底层用Ping/Pong保活,保证网关和网络设备不会回收连接;应用层用心跳维护业务状态,用来做前端界面的在线状态展示和自动重连触发。不要只依赖一层,因为网络问题经常是“看起来什么都正常,但消息就是不通”。3. 实操过程与核心环节实现3.1 前端代码:从建立连接到自动重连前端使用WebSocket其实非常简单,因为它已经是浏览器内置的API,不用引入任何库。一个最基础的连接代码是这样的:const ws = new WebSocket('wss://api.example.com/ws'); ws.onopen = () => { console.log('连接已建立'); // 可以在这里发一些初始化消息,比如鉴权token、订阅主题 ws.send(JSON.stringify({ type: 'auth', token: 'xxxx' })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log('收到消息:', data); }; ws.onclose = (event) => { console.log('连接关闭', event.code, event.reason); // 在这里可以根据业务需求触发重新连接 }; ws.onerror = (error) => { console.error('WebSocket错误', error); };注意,我把auth这条消息放在了onopen里发送。很多人会踩一个坑:以为ws.send可以随时调用,实际上在连接真正建立之前调用send会直接报错。所以在连接完成之前,要么把要发送的消息先缓存起来,要么在onopen回调里统一发送。还有一个必须写的逻辑就是断线重连。WebSocket连接在网络抖动、服务端重启时都会断开,直接用上面的基础代码,用户会发现自己消息不实时了,必须刷新页面才能恢复。我常用的重连策略很简单:断开后等1秒重连;连续失败时,间隔时间翻倍,最大可以到30秒甚至60秒;重连成功后重置间隔时间;在页面可见性变化(比如用户切回浏览器标签页)时,主动检查一次连接状态。重连的时候还要注意“重复订阅”的问题。你重连成功后如果重新发送了订阅消息,而服务端又把之前的订阅状态记住了,就可能出现重复推送。握手阶段带的鉴权信息一般也要重新发送。3.2 后端代码:一个能用的推送服务后端我用Node.js和ws库演示,因为环境搭建最快,代码也直观。先初始化一个WebSocket服务端,处理连接、消息和心跳:const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', (ws, req) => { console.log('新连接来自', req.socket.remoteAddress); // 应用层心跳检测 ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (data) => { const msg = JSON.parse(data.toString()); console.log('收到的消息:', msg); // 简单回声逻辑,验证链路 if (msg.type === 'echo') { ws.send(JSON.stringify({ type: 'echo', data: msg.data })); } }); ws.on('close', () => { console.log('连接关闭'); }); }); // 每30秒对所有连接做一次心跳检测, // 没活着的连接直接终止 const interval = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { return ws.terminate(); } ws.isAlive = false; ws.ping(); }); }, 30000); wss.on('close', () => { clearInterval(interval); });注意两点:一是ws.on('pong')里的逻辑。我用isAlive标记来判断连接存活性:每一轮检测先把所有连接标记为不活跃,然后给每个连接发ping;收到pong就置回活跃;下一轮检测时如果还是非活跃状态,说明这个连接已经不响应了,直接terminate。这套逻辑简单有效,适合大多数场景。二是在生产环境里,服务端一般放在反向代理后面,比如Nginx,或者云上的负载均衡后面。这时候需要单独配置代理的WebSocket升级支持和超时时间,否则即使你的后端写得再对,连接也会被上层网关掐掉。这块我放到下一节详细说。3.3 如何用wss、证书和本地调试用ws://还是wss://,这个选择在生产环境里根本不是选择题。浏览器对ws://的混合内容和安全限制越来越严格,线上必须用wss://,也就是WebSocket over TLS,和HTTPS同理。具体配置上,如果你用的是Nginx,在location里设置以下关键项就能把/ws路径转发到后端:location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }这里的核心是Upgrade和Connection这两个头必须正确传递。另外proxy_read_timeout和proxy_send_timeout一定要调大,因为WebSocket连接是长连接,默认的60秒超时会导致空闲连接被Nginx主动断开。一般设到1小时以上,心跳续命靠应用层自己负责就好。本地调试时,如果不想配证书,可以直接连ws://localhost:8080;但只要有外网域名,我还是建议直接开通wss,因为真机调试、微信等场景下的限制很多,本地不模拟线上环境,后面很容易出幺蛾子。4. 生产环境必须提前踩平的坑4.1 连接上限与服务器资源WebSocket与普通HTTP最大的不同,是它让每个在线用户都占住一条长连接。普通接口服务器可以扛住每秒几千个请求,因为请求处理完就释放了资源;但WebSocket服务器的并发上限,直接受限于服务器能维护多少条TCP连接、多少内存和文件描述符。如果默认配置不调优,几千个连接就能把一台机器拖垮。我见过一个团队做在线协作白板,上线当天遇到连接数增长后服务端频繁报错,一查原因就是文件描述符上限被默认值卡住了。Linux下要调整ulimit -n和/etc/security/limits.conf,单进程能开的连接数、内存上限都要评估好。另外一个容易被忽视的指标是内存:每条WebSocket连接如果附带较大的业务状态对象,几万条连接累加起来的内存开销非常可观。设计服务端时,尽量把连接级的状态做轻量化,能放Redis的不要全塞在进程内。4.2 消息可靠性与“丢消息”问题WebSocket天然支持双向通信,但它并不保证消息100%送达,尤其是网络断开的瞬间。你调用ws.send()的时候,消息进入操作系统发送缓冲区,如果这时候连接已经断了,不同类型的使用方式会有完全不同的表现:有些库会抛异常,有些库会静默失败。所以“发出去不等于对方收到”,这一点是做消息系统的核心认知。一个比较实用的消息可靠性方案是加“消息确认机制”:发送方给每条消息生成唯一ID,接收方收到并处理成功后回一个ack;发送方如果在规定时间内没收到ack,就重发。这个方案在自己的业务层实现起来不复杂,但对消息不丢不重有严格要求的场景非常重要。如果是做聊天、通知类应用,至少要给消息加上ID,配合服务端的“最近未确认消息缓存”,断线重连后可以补拉。4.3 鉴权、跨域与防滥用WebSocket的握手本身就是HTTP请求,所以完全可以在握手时带上鉴权信息。常见做法有两种:一是在URL上带token,比如wss://api.example.com/ws?token=xxx;二是在握手Header里塞Authorization,但浏览器自定义Header会有跨域限制,需要服务端允许。如果前后端是同源或者你能控制服务端跨域策略,我更推荐放在Header里,因为token出现在URL里容易被网关日志和浏览器历史记录泄露。跨域方面,WebSocket服务端必须做Origin校验。握手请求里的Origin头会标明页面来源,服务端应该只允许自己信任的域名。否则任何网页都能往你的WebSocket服务发请求,轻则被刷流量,重则被利用做数据探测。另外,如果做的是公开的WebSocket服务,一定要加连接频率限制。一个用户短时间内反复建立、断开连接,或者重试握手的频率异常,都应该触发限流。这个跟普通HTTP的限流逻辑类似,但比HTTP更敏感,因为每条连接占用的资源更重。我是吃过亏的:某个开发者在调试前端页面时,因为没关旧页面,一个浏览器同时和服务器建立了十几个WebSocket连接,每个连接都订阅了全量数据,服务器压力瞬间飙高。5. 常见问题与排查技巧实录5.1 那些神秘的关闭码到底是什么意思WebSocket关闭时会出现一个关闭码,很多人对着浏览器控制台里的1006一脸懵。下面这张表是我整理的高频关闭码和对应排查方向:关闭码含义常见原因处理建议1000正常关闭双方主动关闭无需处理1001正在离开页面刷新、跳转,或服务端停机前端在页面卸载前主动关闭即可1006非正常关闭网络中断、代理掐断、进程崩溃、超时未收到Pong最常见的异常关闭码,重点查网络、网关超时、负载均衡设置1008策略拒绝鉴权失败、消息格式非法检查token是否过期、消息体是否合法1011服务端内部错误后端程序异常看服务端日志1015未完成TLS握手证书问题、wss配置错误检查证书、域名、代理TLS配置1006是排查时最没有指向性的一个码,因为它意味着浏览器根本没有收到一个正式的关闭帧,连接就直接中断了。遇到1006,我一般会从三个方向同时查:第一是网络层面,看是不是有代理或防火墙把空闲连接断了;第二是服务端进程状态,看是不是负载过高被系统杀掉了;第三是反向代理的超时配置,务必确认proxy_read_timeout足够长。5.2 连接一会儿通一会儿断这个问题的排查步骤比较固定。先确认是不是所有客户端都受影响:如果只有特定网络环境下的部分用户出问题,多半是中间网络设备对空闲连接不友好,把没流量的连接回收了。这个时候把心跳间隔调短,从60秒调到30秒甚至20秒,大概率能缓解。如果所有用户都不稳定,那就要看服务端和代理层。检查一下是不是有防火墙对长连接做了限制,或者负载均衡策略在多个后端节点之间来回切换,导致客户端连接的节点频繁变化。WebSocket连接本质上是绑定到一个具体后端进程的,如果负载均衡不加“会话保持”(也称粘性会话),用户请求被分发到不同节点,连接就会莫名其妙掉线。所以做大并发WebSocket时,要么用会话保持,要么上消息广播中间件,让任意节点都能处理任意连接的消息。5.3 别小看SSL/TLS的握手开销用wss比ws多一层TLS加密,这个开销在连接数量大时不可忽视。每一条WebSocket连接都要先完成TLS握手,这个过程涉及证书交换、密钥协商,计算成本是普通HTTP连接的数倍。高并发场景下,服务端CPU会先被TLS握手打满。应对办法常用两招:一是开启TLS会话恢复,让重复连接复用之前的会话参数;二是用支持TLS卸载的网关或负载均衡,在入口层把TLS终结掉,后端节点之间走内网明文或内部加密。但这里要注意,一旦TLS在网关终结,网关到后端之间的链路的安全性就要靠网络隔离来保证。5.4 和SSE、HTTP/2的选型思考WebSocket虽然好,但不是所有实时推送场景的最优解。我经常被问到“这个场景该用WebSocket还是SSE”,这里给出我比较常用的一套判断标准:需求特征推荐方案理由双向通信,客户端也要实时发消息WebSocket服务端和客户端平等通信只有服务端单向推送,客户端只接收SSE(Server-Sent Events)基于HTTP,自动重连,实现简单需要兼容极老浏览器轮询或长轮询等降级兜底方案需要极低延迟、大规模并发WebSocket比HTTP轮询省太多开销顺便说一下SE的注意点:SSE基于HTTP,自带断线重连和事件ID,对于“服务端推给客户端”这种场景非常轻量。不要为了追求新技术就一律上WebSocket,能用SSE解决的问题用SSE,能省很多复杂度。反过来,如果业务需要客户端频繁发消息给服务端(比如实时协作编辑、多人游戏中的操作同步),那SSE就完全不够用了,WebSocket是正解。6. 个人实践里的几个经验补充6.1 不到万不得已,别自己造协议WebSocket底层协议本身已经够复杂,上层的消息编解码、粘包和拆包处理、心跳、重连、消息确认、多端同步,这些逻辑加起来的工作量远超很多人的想象。能用成熟库就用成熟库,能借用云厂商的WebSocket网关就借。自研之前先扪心自问:你真的需要自己控制帧级别的细节吗?绝大多数业务不需要。把精力放在业务逻辑和扩展性设计上,收益大得多。6.2 先做连接监控,再做功能联调新系统上线前,我建议先把WebSocket的连接数、消息吞吐、消息延迟、重连次数这些指标都监控起来。因为WebSocket是常驻连接,出问题往往是“温水煮青蛙”式的:今天掉线率5%,明天涨到10%,不监控你根本感知不到。有了监控之后,配合日志和链路追踪,出问题定位就会快很多。最近带项目组做实时功能时,第一件事就是把连接心跳、断线重连、消息确认这几个核心指标的监控补齐,后来排查问题效率明显高了。6.3 关于AI应用场景的一个小提示现在很多AI应用会用WebSocket做流式输出。比如聊天机器人逐字输出回答,如果用HTTP轮询去拉消息,体验和成本都很差;用WebSocket建立一条长连接,让AI服务端把生成过程中的中间结果持续推给前端,就能实现“打字机”一样的效果。这类场景的核心挑战不在WebSocket本身,而在后端的消息生产节奏和并发控制。AI生成速度一旦变慢,客户端会误以为连接断了,所以设计时一定要在心跳之外另加一层业务级的“进度信号”,告诉前端“我还活着,只是生成得慢”。这也是我在实践中踩过坑之后总结出来的一个值得提前思考的点。WebSocket这个技术,底层原理不算特别难,但它在生产环境中的坑是真的多。把握手的原理、帧的结构、心跳的机制、代理的配置、关闭码的含义这几块真正吃透,大部分线上问题都能自己定位到方向。希望这篇分享能帮你少走一些弯路。
1. WebSocket到底解决什么问题1.1 从轮询到长轮询:一段被逼出来的历史在WebSocket出现之前,Web应用想拿到服务器的新数据,只能去“问”。最早就是轮询(Polling),前端定时发HTTP请求,问服务器有没有新数据。这个方案逻辑非常简单,我现在回头看缺点也极其明显:浪费请求、延迟高、服务端压力大。我之前做过一个小型通知系统,在线用户不过几百人,一轮轮询的QPS就能把一台低配服务器打得喘不过气,而其中绝大多数请求返回的都是“无变化”,纯粹是白跑一趟。后来又有了长轮询(Long Polling),客户端发请求后服务器先挂着,等有新数据再返回,然后客户端再发下一个请求。这种方式比轮询省了不少流量,但它本质上还是HTTP请求-响应模型,每次都在重复建立HTTP连接、重复携带一堆HTTP头,连接本身也没有真正被打通。做过长轮询项目的人基本都会遇到这些经典问题:消息重复、顺序错乱、超时重试、连接被掐断后客户端没有任何感知。说白了,HTTP天生就是单向请求-响应的、短连接为主的协议,而实时交互要的是双向、持续、低延迟的信道,两者的根本诉求完全对不上。1.2 WebSocket的设计思路WebSocket就是在这样的背景下诞生的。它的核心思路是:先通过一次HTTP握手,把连接从HTTP协议升级为WebSocket协议,之后客户端和服务端就在同一条TCP连接上双向收发帧数据。打一个生活化的比方:HTTP就像不断拨号、通话、挂断的电话,每次都要重新拨一遍;而WebSocket是接通电话后保持通话,双方随时都可以说话,不需要每次重新拨号。有一点必须说清楚:WebSocket不是要取代HTTP。握手阶段它仍然依赖HTTP,借用HTTP的鉴权、跨域、网关等能力;握手完成之后,通信才切换到独立的帧协议。所以理解WebSocket,第一步要理解“一次升级,长期复用”。另外还有一个容易被忽视的代价:WebSocket连接一旦建立,它占用的TCP连接和服务器资源就是持久的。做大规模在线应用时,连接数的管理往往比业务代码本身更费精力。后面我会专门讲这块的实战经验。2. WebSocket核心机制拆解2.1 一次握手,从HTTP升级到WS先看一眼最经典的握手请求,这是理解WebSocket的钥匙。前端需要建立连接时,浏览器会发这样一个HTTP GET请求:GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://example.com这里最关键的是三个头:Upgrade: websocket和Connection: Upgrade,明确告诉服务器“我要把协议切换成WebSocket”;Sec-WebSocket-Key,一个Base64编码的16字节随机值,用来让服务器证明“我确实收到了你的握手请求”;Sec-WebSocket-Version: 13,固定使用13,这是目前被广泛支持的协议版本。服务器收到后不能直接回个200就完事,它必须对Sec-WebSocket-Key做一次魔法运算:把请求里的Key拼接上一个固定GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),然后做SHA-1哈希,再Base64编码,把结果放到响应头的Sec-WebSocket-Accept里。这就是“证明双方都支持同一个协议”的过程。如果伪造的问题问得多,你可能会好奇:为什么不能直接返回200?因为HTTP升级是有语义的,服务器只有明确返回101状态码,连接才会切换到WebSocket协议,之后双方收发的内容才按照WebSocket帧来解析。如果服务器返回200,那说明它不打算升级,后面还是普通HTTP通信。一个典型的成功响应长这样:HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=看到101 Switching Protocols,就是握手成功,接下来这个TCP连接就归WebSocket协议管了。2.2 有了连接还不够,还得认识数据帧握手完成之后,客户端和服务端之间传输的数据就不再是HTTP报文,而是一个个WebSocket帧。帧是WebSocket数据传输的基本单位,理解它的结构,排查问题就顺了很多。一个WebSocket帧由若干字段组成,核心的几个是:FIN:1位,标记这是不是消息的最后一个帧。因为一条WebSocket消息可以被拆成多个帧传输,所以FIN=1表示消息结束;opcode:4位,标明帧的类型。最常见的0x1是文本帧,0x2是二进制帧,0x8是关闭帧,0x9是Ping帧,0xA是Pong帧;MASK:1位,客户端发给服务器的数据帧必须置1,即必须掩码;服务器发给客户端则置0。这是协议里一个挺不讲道理但又必须遵守的规定,不遵守就直接断开;payload length:7位、16位或64位,因为数据长度有大有小,协议设计了三种表示方式,长度小于126时直接用7位表示,大于等于126时扩展出额外字节表示;payload data:真正的业务数据。这里最容易遇到的问题是“消息被拆帧”。举个例子,前端发送一个较大的文本消息,底层可能被拆成多个帧传输,接收方必须根据FIN和opcode把它们重新拼起来。大部分WebSocket库会自动处理这件事,比如浏览器的WebSocket对象和绝大多数后端库都帮你拼好了。但在自研协议解析或者做底层网关的时候,就会踩到这个坑:只处理了第一帧,后面的帧全丢了。我自己遇到过最经典的案例是:在做一个自定义网关时,没处理多帧重组,导致客户端发一条超过最大单帧长度的消息,服务端只收到了前半截。排查了很久,最后才意识到是FIN位和连续帧的问题。所以在生产环境里,能不自己解析帧就绝不自己解析,能用现成的成熟库就用成熟库,这些细节库都已经帮你处理干净了。2.3 心跳机制:怎么判断连接还活着WebSocket的TCP连接在建立之后,如果长时间没有数据传输,中间的网络设备(比如运营商NAT、负载均衡、云厂商的网关)可能会把这条空闲连接悄悄回收。你这边看起来连接还在,实际上对方已经收不到任何数据了。这就是所谓的“僵尸连接”。怎么解决?常规方案有两种:第一种是协议层的Ping/Pong帧。服务端定期给客户端发送Ping帧,客户端收到后自动回复Pong帧,如果服务端在指定时间内没收到Pong,就判定连接已死,主动关闭。这套机制的好处是标准、通用,但坏处是浏览器端JS的WebSocketAPI不暴露Pong帧的接收事件,前端如果想在应用层面感知,需要自己再包一层逻辑。第二种是应用层心跳。前后端约定好一个业务消息格式,比如每30秒发一个{"type":"heartbeat"},收到方回一个{"type":"heartbeat_ack"}。这种做法实现简单、完全可控,适合自定义协议和需要感知对端业务状态的场景。我的建议是:两层都做。底层用Ping/Pong保活,保证网关和网络设备不会回收连接;应用层用心跳维护业务状态,用来做前端界面的在线状态展示和自动重连触发。不要只依赖一层,因为网络问题经常是“看起来什么都正常,但消息就是不通”。3. 实操过程与核心环节实现3.1 前端代码:从建立连接到自动重连前端使用WebSocket其实非常简单,因为它已经是浏览器内置的API,不用引入任何库。一个最基础的连接代码是这样的:const ws = new WebSocket('wss://api.example.com/ws'); ws.onopen = () => { console.log('连接已建立'); // 可以在这里发一些初始化消息,比如鉴权token、订阅主题 ws.send(JSON.stringify({ type: 'auth', token: 'xxxx' })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log('收到消息:', data); }; ws.onclose = (event) => { console.log('连接关闭', event.code, event.reason); // 在这里可以根据业务需求触发重新连接 }; ws.onerror = (error) => { console.error('WebSocket错误', error); };注意,我把auth这条消息放在了onopen里发送。很多人会踩一个坑:以为ws.send可以随时调用,实际上在连接真正建立之前调用send会直接报错。所以在连接完成之前,要么把要发送的消息先缓存起来,要么在onopen回调里统一发送。还有一个必须写的逻辑就是断线重连。WebSocket连接在网络抖动、服务端重启时都会断开,直接用上面的基础代码,用户会发现自己消息不实时了,必须刷新页面才能恢复。我常用的重连策略很简单:断开后等1秒重连;连续失败时,间隔时间翻倍,最大可以到30秒甚至60秒;重连成功后重置间隔时间;在页面可见性变化(比如用户切回浏览器标签页)时,主动检查一次连接状态。重连的时候还要注意“重复订阅”的问题。你重连成功后如果重新发送了订阅消息,而服务端又把之前的订阅状态记住了,就可能出现重复推送。握手阶段带的鉴权信息一般也要重新发送。3.2 后端代码:一个能用的推送服务后端我用Node.js和ws库演示,因为环境搭建最快,代码也直观。先初始化一个WebSocket服务端,处理连接、消息和心跳:const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', (ws, req) => { console.log('新连接来自', req.socket.remoteAddress); // 应用层心跳检测 ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (data) => { const msg = JSON.parse(data.toString()); console.log('收到的消息:', msg); // 简单回声逻辑,验证链路 if (msg.type === 'echo') { ws.send(JSON.stringify({ type: 'echo', data: msg.data })); } }); ws.on('close', () => { console.log('连接关闭'); }); }); // 每30秒对所有连接做一次心跳检测, // 没活着的连接直接终止 const interval = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { return ws.terminate(); } ws.isAlive = false; ws.ping(); }); }, 30000); wss.on('close', () => { clearInterval(interval); });注意两点:一是ws.on('pong')里的逻辑。我用isAlive标记来判断连接存活性:每一轮检测先把所有连接标记为不活跃,然后给每个连接发ping;收到pong就置回活跃;下一轮检测时如果还是非活跃状态,说明这个连接已经不响应了,直接terminate。这套逻辑简单有效,适合大多数场景。二是在生产环境里,服务端一般放在反向代理后面,比如Nginx,或者云上的负载均衡后面。这时候需要单独配置代理的WebSocket升级支持和超时时间,否则即使你的后端写得再对,连接也会被上层网关掐掉。这块我放到下一节详细说。3.3 如何用wss、证书和本地调试用ws://还是wss://,这个选择在生产环境里根本不是选择题。浏览器对ws://的混合内容和安全限制越来越严格,线上必须用wss://,也就是WebSocket over TLS,和HTTPS同理。具体配置上,如果你用的是Nginx,在location里设置以下关键项就能把/ws路径转发到后端:location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }这里的核心是Upgrade和Connection这两个头必须正确传递。另外proxy_read_timeout和proxy_send_timeout一定要调大,因为WebSocket连接是长连接,默认的60秒超时会导致空闲连接被Nginx主动断开。一般设到1小时以上,心跳续命靠应用层自己负责就好。本地调试时,如果不想配证书,可以直接连ws://localhost:8080;但只要有外网域名,我还是建议直接开通wss,因为真机调试、微信等场景下的限制很多,本地不模拟线上环境,后面很容易出幺蛾子。4. 生产环境必须提前踩平的坑4.1 连接上限与服务器资源WebSocket与普通HTTP最大的不同,是它让每个在线用户都占住一条长连接。普通接口服务器可以扛住每秒几千个请求,因为请求处理完就释放了资源;但WebSocket服务器的并发上限,直接受限于服务器能维护多少条TCP连接、多少内存和文件描述符。如果默认配置不调优,几千个连接就能把一台机器拖垮。我见过一个团队做在线协作白板,上线当天遇到连接数增长后服务端频繁报错,一查原因就是文件描述符上限被默认值卡住了。Linux下要调整ulimit -n和/etc/security/limits.conf,单进程能开的连接数、内存上限都要评估好。另外一个容易被忽视的指标是内存:每条WebSocket连接如果附带较大的业务状态对象,几万条连接累加起来的内存开销非常可观。设计服务端时,尽量把连接级的状态做轻量化,能放Redis的不要全塞在进程内。4.2 消息可靠性与“丢消息”问题WebSocket天然支持双向通信,但它并不保证消息100%送达,尤其是网络断开的瞬间。你调用ws.send()的时候,消息进入操作系统发送缓冲区,如果这时候连接已经断了,不同类型的使用方式会有完全不同的表现:有些库会抛异常,有些库会静默失败。所以“发出去不等于对方收到”,这一点是做消息系统的核心认知。一个比较实用的消息可靠性方案是加“消息确认机制”:发送方给每条消息生成唯一ID,接收方收到并处理成功后回一个ack;发送方如果在规定时间内没收到ack,就重发。这个方案在自己的业务层实现起来不复杂,但对消息不丢不重有严格要求的场景非常重要。如果是做聊天、通知类应用,至少要给消息加上ID,配合服务端的“最近未确认消息缓存”,断线重连后可以补拉。4.3 鉴权、跨域与防滥用WebSocket的握手本身就是HTTP请求,所以完全可以在握手时带上鉴权信息。常见做法有两种:一是在URL上带token,比如wss://api.example.com/ws?token=xxx;二是在握手Header里塞Authorization,但浏览器自定义Header会有跨域限制,需要服务端允许。如果前后端是同源或者你能控制服务端跨域策略,我更推荐放在Header里,因为token出现在URL里容易被网关日志和浏览器历史记录泄露。跨域方面,WebSocket服务端必须做Origin校验。握手请求里的Origin头会标明页面来源,服务端应该只允许自己信任的域名。否则任何网页都能往你的WebSocket服务发请求,轻则被刷流量,重则被利用做数据探测。另外,如果做的是公开的WebSocket服务,一定要加连接频率限制。一个用户短时间内反复建立、断开连接,或者重试握手的频率异常,都应该触发限流。这个跟普通HTTP的限流逻辑类似,但比HTTP更敏感,因为每条连接占用的资源更重。我是吃过亏的:某个开发者在调试前端页面时,因为没关旧页面,一个浏览器同时和服务器建立了十几个WebSocket连接,每个连接都订阅了全量数据,服务器压力瞬间飙高。5. 常见问题与排查技巧实录5.1 那些神秘的关闭码到底是什么意思WebSocket关闭时会出现一个关闭码,很多人对着浏览器控制台里的1006一脸懵。下面这张表是我整理的高频关闭码和对应排查方向:关闭码含义常见原因处理建议1000正常关闭双方主动关闭无需处理1001正在离开页面刷新、跳转,或服务端停机前端在页面卸载前主动关闭即可1006非正常关闭网络中断、代理掐断、进程崩溃、超时未收到Pong最常见的异常关闭码,重点查网络、网关超时、负载均衡设置1008策略拒绝鉴权失败、消息格式非法检查token是否过期、消息体是否合法1011服务端内部错误后端程序异常看服务端日志1015未完成TLS握手证书问题、wss配置错误检查证书、域名、代理TLS配置1006是排查时最没有指向性的一个码,因为它意味着浏览器根本没有收到一个正式的关闭帧,连接就直接中断了。遇到1006,我一般会从三个方向同时查:第一是网络层面,看是不是有代理或防火墙把空闲连接断了;第二是服务端进程状态,看是不是负载过高被系统杀掉了;第三是反向代理的超时配置,务必确认proxy_read_timeout足够长。5.2 连接一会儿通一会儿断这个问题的排查步骤比较固定。先确认是不是所有客户端都受影响:如果只有特定网络环境下的部分用户出问题,多半是中间网络设备对空闲连接不友好,把没流量的连接回收了。这个时候把心跳间隔调短,从60秒调到30秒甚至20秒,大概率能缓解。如果所有用户都不稳定,那就要看服务端和代理层。检查一下是不是有防火墙对长连接做了限制,或者负载均衡策略在多个后端节点之间来回切换,导致客户端连接的节点频繁变化。WebSocket连接本质上是绑定到一个具体后端进程的,如果负载均衡不加“会话保持”(也称粘性会话),用户请求被分发到不同节点,连接就会莫名其妙掉线。所以做大并发WebSocket时,要么用会话保持,要么上消息广播中间件,让任意节点都能处理任意连接的消息。5.3 别小看SSL/TLS的握手开销用wss比ws多一层TLS加密,这个开销在连接数量大时不可忽视。每一条WebSocket连接都要先完成TLS握手,这个过程涉及证书交换、密钥协商,计算成本是普通HTTP连接的数倍。高并发场景下,服务端CPU会先被TLS握手打满。应对办法常用两招:一是开启TLS会话恢复,让重复连接复用之前的会话参数;二是用支持TLS卸载的网关或负载均衡,在入口层把TLS终结掉,后端节点之间走内网明文或内部加密。但这里要注意,一旦TLS在网关终结,网关到后端之间的链路的安全性就要靠网络隔离来保证。5.4 和SSE、HTTP/2的选型思考WebSocket虽然好,但不是所有实时推送场景的最优解。我经常被问到“这个场景该用WebSocket还是SSE”,这里给出我比较常用的一套判断标准:需求特征推荐方案理由双向通信,客户端也要实时发消息WebSocket服务端和客户端平等通信只有服务端单向推送,客户端只接收SSE(Server-Sent Events)基于HTTP,自动重连,实现简单需要兼容极老浏览器轮询或长轮询等降级兜底方案需要极低延迟、大规模并发WebSocket比HTTP轮询省太多开销顺便说一下SE的注意点:SSE基于HTTP,自带断线重连和事件ID,对于“服务端推给客户端”这种场景非常轻量。不要为了追求新技术就一律上WebSocket,能用SSE解决的问题用SSE,能省很多复杂度。反过来,如果业务需要客户端频繁发消息给服务端(比如实时协作编辑、多人游戏中的操作同步),那SSE就完全不够用了,WebSocket是正解。6. 个人实践里的几个经验补充6.1 不到万不得已,别自己造协议WebSocket底层协议本身已经够复杂,上层的消息编解码、粘包和拆包处理、心跳、重连、消息确认、多端同步,这些逻辑加起来的工作量远超很多人的想象。能用成熟库就用成熟库,能借用云厂商的WebSocket网关就借。自研之前先扪心自问:你真的需要自己控制帧级别的细节吗?绝大多数业务不需要。把精力放在业务逻辑和扩展性设计上,收益大得多。6.2 先做连接监控,再做功能联调新系统上线前,我建议先把WebSocket的连接数、消息吞吐、消息延迟、重连次数这些指标都监控起来。因为WebSocket是常驻连接,出问题往往是“温水煮青蛙”式的:今天掉线率5%,明天涨到10%,不监控你根本感知不到。有了监控之后,配合日志和链路追踪,出问题定位就会快很多。最近带项目组做实时功能时,第一件事就是把连接心跳、断线重连、消息确认这几个核心指标的监控补齐,后来排查问题效率明显高了。6.3 关于AI应用场景的一个小提示现在很多AI应用会用WebSocket做流式输出。比如聊天机器人逐字输出回答,如果用HTTP轮询去拉消息,体验和成本都很差;用WebSocket建立一条长连接,让AI服务端把生成过程中的中间结果持续推给前端,就能实现“打字机”一样的效果。这类场景的核心挑战不在WebSocket本身,而在后端的消息生产节奏和并发控制。AI生成速度一旦变慢,客户端会误以为连接断了,所以设计时一定要在心跳之外另加一层业务级的“进度信号”,告诉前端“我还活着,只是生成得慢”。这也是我在实践中踩过坑之后总结出来的一个值得提前思考的点。WebSocket这个技术,底层原理不算特别难,但它在生产环境中的坑是真的多。把握手的原理、帧的结构、心跳的机制、代理的配置、关闭码的含义这几块真正吃透,大部分线上问题都能自己定位到方向。希望这篇分享能帮你少走一些弯路。