先说一个很形象的类比:HTTP像是寄信。你写一封信递出去,对面回你一封,这轮交流就算结束。下一轮需求,得再写一封信、再寄一次。而WebSocket则是打电话,拨通之后两个人都拿着听筒,谁想说话都可以直接说,不用等对方先问一句“在吗”。我第一次接触WebSocket时理解得很简单:这不就是给浏览器开个Socket吗?后来真动手做聊天、推送、在线状态同步,才发现从“理解”到“能用”,中间隔着数不清的细节。今天这篇入门笔记,就按从“寄信”到“打电话”这条线,把WebSocket的原理、实操、集成和常见坑一次讲透。
这篇文章适合这几类人:想快速上手WebSocket的前端或后端开发,搞不清轮询和长连接取舍的运维测试,以及准备面试想弄明白“WebSocket和HTTP到底什么关系”的同学。看完之后,你至少能自己写一个可运行的WebSocket服务,也能在群里判断“为什么又1006了”到底该从哪儿查起。
1. HTTP为什么像“寄信”:每次都得起新笔
1.1 请求/响应模型的天生限制
HTTP从设计之初就是“一问一答”。客户端发起请求,服务端处理完返回响应,之后连接基本就闲置了。HTTP/1.1虽然支持keep-alive,能复用同一个TCP连接多次请求,但依然建立在“客户端主导”的前提下:服务端不能主动往客户端塞数据。
这里有个容易被忽略的点:很多人觉得keep-alive之后“连接还在”,服务端就能推送了。不对。keep-alive复用的只是传输连接,协议语义上依然是request/response一一对应。服务端想在客户端没请求时主动发消息,在纯HTTP语境里没有合法通道。这就像写信,你只能等对方先寄过来你才能回信,你不能主动跑到对方楼下喊话。寄信的比喻到这里已经有点绷不住了——寄信确实是双向往返,但每一次往返都必须先从你这里出发。服务器想主动告诉你“有新订单了”,等不到你的信,它就没辙。
所以HTTP的“请求-响应”模型适合什么场景?适合客户端主动发起、服务端被动响应的场景,比如浏览网页、拉取数据、提交表单。但真实世界里有大量“服务端有变化,客户端需要立刻知道”的需求:新消息提醒、行情变化、多人协同、设备状态上报。HTTP做这些事非常别扭,因为它根本不允许服务端“开口说话”。
1.2 从轮询到长轮询:没有WebSocket的尴尬
既然HTTP不能主动推,早期网页实时功能怎么做的?轮询。前端每秒或每几秒发一次请求,问“服务器有变化吗?”。服务端即使没有新数据,也要回一个空响应。这种方式能跑,但代价很直白。
首先是延迟高。轮询间隔内发生的消息,客户端最坏要等一个轮询周期才能收到。间隔设1秒,延迟就可能到1秒;设100毫秒,延迟下来了,但服务器压力上去了。其次是浪费大,大量请求带着完整HTTP头往返,很多请求纯属“空跑”,服务端资源被白白消耗。第三个问题更隐蔽:请求与响应是一对一的,如果响应顺序错乱或者前一个请求卡住,后面所有请求都会排队阻塞,消息实时性进一步恶化。
后来有人搞出长轮询:客户端发请求后服务端先hold住这个请求,等有新数据再响应,看起来像推送。但它本质上还是“寄信”——只是这封信迟迟不回,回的时候可能积压了很多问题。而且长轮询要处理超时重连、请求抖动、代理缓存,实现复杂度不低。面试里常问“为什么用WebSocket而不是轮询”,核心答案其实就是三个词:延迟、开销、实时性。轮询只能用“尽量短的时间间隔”去模拟实时,而WebSocket从协议层面就是实时通道。做IM、协同编辑、行情推送、游戏房间时,这个差别感受特别深——轮询做出来的是“看起来像实时”,WebSocket做出来的是“本来就是实时”。
2. WebSocket为什么像“打电话”:一次握手,持续通话
2.1 握手其实还是HTTP:Upgrade那一下
WebSocket的建立并不是从零开始的独立协议,它先走一次HTTP升级请求。客户端发一个带Connection: Upgrade和Upgrade: websocket头的GET请求,同时携带Sec-WebSocket-Key,这个Key是一个随机Base64字符串,相当于拨号前的“暗号”。服务端校验后返回101 Switching Protocols,连接协议切换成WebSocket。这个握手过程,我习惯称之为“拨号”——虽然你已经在通话了,但开头仍然借用了通信网络的寻址机制。
技术细节是:服务端拿到Sec-WebSocket-Key之后,拼上一个固定GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),做SHA-1哈希,再Base64编码,返回给客户端作为Sec-WebSocket-Accept。这个过程相当于双方确认“咱们都认识这个协议版本,可以继续谈”。如果服务端不认识WebSocket,直接返回普通HTTP错误,握手就失败。
理解这一层对排查问题很有帮助。比如你拿curl去探测一个WebSocket地址,如果看到的是426 Upgrade Required或者400 Bad Request,那不是业务报错,是握手阶段出了问题。另外,因为握手是HTTP请求,所以端口、Header、鉴权、Cookie这些都可以沿用原有的HTTP体系。很多团队的鉴权就是在握手阶段通过查询参数或Header完成的,连接建立之后再校验就太晚了——这个我在第4节会展开讲。
2.2 帧、消息、心跳与关闭
握手成功后,TCP连接上流动的就不是HTTP报文了,而是WebSocket帧。帧结构不复杂,但对理解“消息”很重要:每一条业务消息可能被拆成多个数据帧传输,接收端拼好后再触发onmessage回调。你可以把帧理解成电话里的一句话,虽然可能会被拆成几个片段说,但两端顺序接收、重新拼起来,仍然是完整的一句话。
帧类型主要有文本帧、二进制帧、关闭帧、Ping/Pong帧。浏览器端接口把复杂结构隐藏了,你只需要关心onmessage回调里拿到的是字符串还是二进制数据。但服务端实现要知道:主动发Ping,客户端会自动回Pong,这是保活的基础。很多在线用户“假死”其实是连接已经被网络设备回收,但双方都不知道。心跳就是靠Ping/Pong机制避免这种情况的,路由器等中间设备如果长时间看不到连接上的数据流转发,会认为这条连接空闲,默默把它干掉。
关闭连接时,正常流程是一方发Close帧(带状态码1000),另一方回Close帧,然后连接优雅关闭。状态码1000是正常关闭,1011表示服务端异常,而1006这个状态码很特殊:它表示“根本没有收到关闭帧,连接就没了”。线上经常出现的onclose code: 1006,根因基本都是非正常断开,后面我会专门讲怎么排查。
2.3 浏览器、跨域与连接状态:三个关键认知
第一,WebSocket在浏览器里受不受同源策略限制?这是高频面试题。准确的答案是:浏览器不会主动阻止WebSocket连接跨域,但服务端可以通过校验Origin头来决定是否接受。很多团队早期用WebSocket做推送时没注意,任意页面都能连上你的服务端,被刷流量才发现连个白名单都没加。服务端只做“能连就通”远远不够,要明确“谁可以连”。
第二,客户端的连接状态有readyState属性,取值是CONNECTING、OPEN、CLOSING、CLOSED。很多人写业务时只在onopen回调里发第一条数据,结果发现顺序不对,因为调用send的时候连接可能还没建立。正确做法是等readyState变成OPEN,或者把待发消息放进队列,onopen之后再flush。这种细节就是“看起来连接上了,实际消息丢了”的经典原因。
第三,服务端视角的认知要反过来:WebSocket是长连接,会长时间占用一个TCP连接和对应的处理资源。连接数上万之后,线程模型和内存开销完全不能忽视。这也是为什么WebSocket服务端通常使用事件驱动模型,比如Netty、Node.js、Go的goroutine模型,而不是给每个连接开一个线程硬扛。前端的“连接池复用”思维在这里要反过来:HTTP是短连接,多路复用是为了省资源;WebSocket是长连接,所有推送共享通路,服务端必须把“连接即资源”当成基本原则。
3. 5分钟跑通第一个WebSocket程序
3.1 服务端:用Node.js的ws库快速起一个
先别管底层协议细节,跑一个可运行的服务建立体感。Node.js的ws库很小也很稳,几行就能起一个能收发消息的服务器。
npm init -y npm install ws服务端代码:
const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', (ws, req) => { const clientAddr = req.socket.remoteAddress; console.log(`客户端接入: ${clientAddr}`); // 建立连接后立刻发一条欢迎消息 ws.send('welcome'); ws.on('message', (data) => { const text = data.toString(); console.log('收到:', text); // 原样回给对方,方便验证双向通信 ws.send(`echo: ${text}`); }); ws.on('close', () => { console.log('客户端断开:', clientAddr); }); }); console.log('WebSocket 服务已启动: ws://localhost:8080');这段代码只做了三件事:接收连接、处理消息、回显消息。跑起来后,任何WebSocket客户端连过来都能收到问候语,你说一句话它回一句,这就是一个最小可用的WebSocket闭环。
这里有个习惯值得养成:消息收下来第一件事先转成字符串。因为Node.js的ws库默认把文本帧给Buffer,如果你不转,直接去打印或者做JSON解析会踩坑。如果业务里有二进制帧,更要做类型判断,帧类型不同,处理逻辑完全不一样。
3.2 客户端:浏览器原生WebSocket,零依赖
浏览器自带的WebSocket客户端不需要装任何依赖,这是它最方便的地方。本地写一个HTML页面就能调通:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>WebSocket 入门测试</title> </head> <body> <input type="text" id="msg" placeholder="输入消息" /> <button id="send">发送</button> <pre id="log"></pre> <script> const log = document.getElementById('log'); const input = document.getElementById('msg'); const btn = document.getElementById('send'); const ws = new WebSocket('ws://localhost:8080'); ws.onopen = () => { log.textContent += '连接已建立\n'; }; ws.onmessage = (event) => { log.textContent += '服务端说: ' + event.data + '\n'; }; ws.onclose = (event) => { log.textContent += '连接关闭 code=' + event.code + '\n'; }; ws.onerror = (err) => { log.textContent += '发生错误: ' + err.message + '\n'; }; btn.onclick = () => { // 状态检查很重要,OPEN时才能发 if (ws.readyState !== WebSocket.OPEN) { log.textContent += '连接还没好,稍后再试\n'; return; } ws.send(input.value); input.value = ''; }; </script> </body> </html>把服务端和这个页面都打开,F12看控制台,你会看到:连接建立,发送消息,服务端回显,页面展示。整个链路不超过30行代码,但“双向推送”的体感非常直观。对新手来说,我强烈建议先跑通这一遍,再去看复杂的框架封装,不然很容易被抽象层带偏,连onopen和onmessage先后顺序都分不清。
3.3 Python和Go客户端怎么连
不是所有场景都在浏览器里跑。服务端测试、脚本任务、物联网设备,经常要写非浏览器客户端。Python可以用websockets库,最简示例:
pip install websocketsimport asyncio import websockets async def main(): uri = "ws://localhost:8080" async with websockets.connect(uri) as ws: await ws.send("hello from python") response = await ws.recv() print("收到:", response) asyncio.run(main())Go的话,最常用的还是gorilla/websocket。虽然现在项目已经归档维护,但入门时仍然可以参考它的写法和模型:
package main import ( "log" "github.com/gorilla/websocket" ) func main() { conn, _, err := websocket.DefaultDialer.Dial("ws://localhost:8080", nil) if err != nil { log.Fatal("连接失败:", err) } defer conn.Close() err = conn.WriteMessage(websocket.TextMessage, []byte("hello from go")) if err != nil { log.Fatal("发送失败:", err) } _, message, err := conn.ReadMessage() if err != nil { log.Fatal("读取失败:", err) } log.Printf("服务端回复: %s", message) }Go语言里有一个特别容易踩的点:WebSocket的读写并不是并发安全的,不能随便起多个goroutine同时写同一个连接。常见做法是设计一个写循环加一个读循环,所有要发送的数据进channel,由写循环统一写。很多Go的WebSocket异常掉线,最后排查下来都是因为并发写导致数据错乱。语音长连接这类高频场景里,这个问题更明显——音频数据流一直在写,稍不注意就把连接写崩了。
4. 真实项目里绕不开的集成与部署
4.1 Vue3里封装一个可重用的useWebSocket
前端项目里Vue是高频场景,很多人问“vue 怎么增加websocket”。我最忌讳的是每个组件里裸写一个new WebSocket(),这样连接数会失控,重复创建,关闭逻辑混乱。我自己的习惯是在项目中抽一个组合式函数,把连接、心跳、重连、消息分发统一管理,所有组件只往里注册回调。
下面是一个精简版思路,具体逻辑可以按项目调整:
// useWebSocket.js import { ref, onBeforeUnmount } from 'vue'; export function useWebSocket(url, options = {}) { const status = ref('CONNECTING'); let ws = null; let heartbeatTimer = null; let manualClose = false; function connect() { manualClose = false; ws = new WebSocket(url); ws.onopen = () => { status.value = 'OPEN'; // 应用层心跳,每30秒发一个自定义 ping heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, options.heartbeatInterval || 30000); }; ws.onmessage = (e) => { const data = JSON.parse(e.data); if (data.type === 'pong') return; options.onMessage?.(data); }; ws.onclose = (e) => { status.value = 'CLOSED'; clearInterval(heartbeatTimer); if (!manualClose) { // 指数退避重连,初始延迟可配置 const delay = Math.min(options.maxDelay || 30000, (options.retryTimes || 0) * 1000); setTimeout(connect, delay); } }; ws.onerror = (err) => { options.onError?.(err); }; } function send(data) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(typeof data === 'string' ? data : JSON.stringify(data)); } } function close() { manualClose = true; ws && ws.close(); } onBeforeUnmount(() => close()); connect(); return { status, send, close }; }这里有两个关键设计。一是心跳放在应用层,不依赖浏览器自动的Ping帧,这样才能校验业务逻辑是否正常:服务端收到{type:'ping'}后回复{type:'pong'},超过一定时间没收到就判定连接假死,主动断开触发重连。二是重连用指数退避,1秒、2秒、4秒递增,设一个上限,避免服务端恢复时被大量客户端同时重连打垮,这就是常说的“惊群”问题。
另外还有一个Vue项目常见错误:把WebSocket实例放在普通变量里,没用响应式的方式管理,路由切换时连接还开着,旧组件的事件回调却还在执行。封装成hook之后,onBeforeUnmount里统一关闭连接,就不会有这个麻烦。
4.2 服务端该怎么鉴权:Netty、Spring Boot的常见做法
WebSocket连接建起来容易,鉴权却容易忽略。很多人的第一版代码是“连接上来先别踢,等第一条消息带token再校验”。这其实有问题:连接一旦建立,服务端就开始维持资源了,恶意客户端发大量握手请求就能把连接池打满。正确的思路是“连接之前就鉴权”。
Netty做鉴权一般在握手阶段。核心是HttpServerCodec和WebSocketServerProtocolHandler的组合,可以在Handshake的Complete事件里拿到URI中的query参数、Header或Cookie,校验失败直接关连接。Gin框架里也是类似思路:在HTTP handler里先解析token,再调用websocket.Upgrade完成升级,而不是先升级再鉴权。这样能保证非法连接在协议切换前就被拦截。
Spring Boot里可以看到WebSocketConfigurer和HandshakeInterceptor的配合:
@Configuration public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler(), "/chat") .addInterceptors(new TokenHandshakeInterceptor()) .setAllowedOrigins("https://your-domain.com"); } }TokenHandshakeInterceptor在beforeHandshake方法里读取请求参数并做校验,返回false就拒绝握手。setAllowedOrigins则控制跨域来源,这是很多人在WebSocket被刷流量之后才补上的配置。注意:setAllowedOrigins("*")在安全要求高的生产环境要慎用,等于放弃了Origin校验,任何页面都能发起握手请求。
4.3 Nginx反向代理与WSS:证书和升级头不能少
浏览器端WebSocket在公网场景通常跑在wss://协议下,走443端口,前面一般会有一层Nginx。如果升级头配置不对,浏览器会一直卡在握手阶段或者直接报错。核心配置是proxy_set_header Upgrade和Connection "upgrade":
server { listen 443 ssl; server_name example.com; ssl_certificate /usr/local/nginx/conf/cert/fullchain.pem; ssl_certificate_key /usr/local/nginx/conf/cert/privkey.pem; 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_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }两个容易踩的坑。一是proxy_pass后面如果带了路径,比如proxy_pass http://127.0.0.1:8080/ws;,和location /ws/的匹配规则配合不好,请求路径会被改写,服务端拿到的URL和自己注册的路径对不上,握手直接失败。二是proxy_read_timeout默认值很短,如果这个时间小于客户端心跳间隔,Nginx会在静默一段时间后主动断开连接,客户端收到的就是1006。很多人线上“连接过一会儿就断”,查了一圈最后发现是代理层超时。
WSS场景还有个老生常谈:如果证书是自签的,浏览器和App都会拒绝。内网测试大家习惯用ws://,一上公网换wss://,才发现证书链没配置好。WebSocket的加密和HTTP一样依赖TLS,没有捷径可走。
4.4 广播、群组、设置属性:别从零造轮子
如果只是做一个简单的一对一回显,原生WebSocket就够。但真实项目经常要广播、群组、给连接挂属性,比如用户ID、角色、所在房间。这时候用原生API手动维护一个连接的Map,很快就会发现要处理的问题很多:连接断开时Map怎么清理、群组成员变了要不要通知、消息是广播还是定点推。
Spring Boot的spring-boot-starter-websocket配合STOMP协议,可以看成是WebSocket之上的消息协议。它支持@SendTo广播、@MessageMapping路由、订阅管理和简单的用户会话管理。Netty生态里也有很多封装好的WebSocket框架,适合需要更细控制的后端团队。像一些后台管理系统项目,比如基于RuoYi这类脚手架做Spring Boot + Vue3集成时,最常见的需求就是“后端有通知事件,主动推给前端”,用STOMP或者封装好的广播组件都行。
我的建议是:小项目、快速原型,直接用原生ws加上一个Map<sessionId, WebSocketSession>就够;等到需要群组、离线消息、集群广播,再上STOMP或成熟的实时消息中间件。不要一开始就引一堆依赖。WebSocket本身只是一个“管道”,消息的格式、路由、可靠性都是管道之上需要你自己解决的问题,这才是“从会用”到“能上线”之间真正的分水岭。
5. 那些年踩过的坑:问题排查与现场实录
5.1 onclose code 1006:说不清道不明的“非正常关闭”
线上出现频率最高的错误就是[websocket] onclose, code: 1006, reason: , reconnect: true。1006的准确定义是“连接非正常关闭”,意思是对端或中间设备没有发Close帧,连接就没了。常见原因有:服务器进程被杀、Nginx或网关主动断开、客户端网络切换、心跳没回导致服务端踢人、代理节点空闲超时。
排查1006,我的顺序是固定的。先看服务端有没有日志,如果服务端日志显示连接异常断开或者什么都没收到,那就是网络层或者代理层的问题;再用客户端日志里记录的时间戳,对照服务器日志和代理日志,定位是哪一跳断的;最后检查心跳配置,很多1006是因为客户端自己没发心跳,连接空闲被网络设备回收。这里有个关键经验:不要在浏览器控制台里看一眼1006就完事,要落应用层日志,把event.code、event.reason、时间和URL一起打出来,否则复现时你什么依据都没有。
5.2 用JMeter压测WebSocket:Sampler安装和常见报错
JMeter默认不支持WebSocket协议,需要装插件。可以借助JMeter Plugins Manager在“Available Plugins”里搜索WebSocket Samplers进行安装。装完后会看到WebSocket Open Connection、WebSocket Request-Response Sampler、WebSocket Ping/Pong、WebSocket Close等组件。压测时先建立一个Connection,再在循环里发消息,最后关闭连接,这样才能模拟真实业务会话,而不是每次都重新握手。
如果遇到stream disconnected before completion: failed to send websocket request: io这类报错,通常不是Sampler配置写错,而是服务端还没把当前请求处理完,连接就被关闭了。排查顺序:先确认服务端日志有没有收到消息;再看JMeter里的Connection设置,复用的变量名是否和Sampler里对应;最后分析是不是并发太高,服务端主动断开了连接。压测WebSocket比压测HTTP更依赖“连接生命周期”建模,如果每个请求都新建连接,测出来的是“握手性能”,不是“业务性能”,这是新手最容易犯的错。
5.3 H5能连、打包成App却连不上:一张排查清单
很多人问“同样的代码,运行到H5可以连接,打包为App连接不了”。这类问题90%不是WebSocket本身的锅,而是App环境和H5环境的差异。
- 网络权限:Android需要在AndroidManifest里声明
INTERNET权限,debug包有时自动带,release包忘了就全挂。 - 明文流量:Android 9及以上默认禁止HTTP明文流量,如果用
ws://连局域网或测试环境IP,会被系统直接拦掉,要么改用wss://,要么在networkSecurityConfig里放行对应域名。 - 证书信任:iOS的ATS要求HTTPS/WSS,自签证书默认不被信任,H5浏览器反而可能因为用户手动信任而能用。
- 跨域与代理:App不像浏览器带Origin头,属于“非浏览器客户端”,很多服务端做了Origin校验,App直连会失败。
- 地址差异:H5调试时用的
localhost,在真机App里指的是手机自己,要换成电脑的局域网IP。
我遇到过最典型的案例是:电脑浏览器连接正常,打包成Android App之后连不上,最后定位到是Android明文流量限制。把测试环境的ws地址在网络安全配置里加白名单,问题立刻消失。记住一个判断顺序:先在手机浏览器里打开同一个地址,如果手机浏览器能连而App不能连,问题基本在App网络配置;如果手机浏览器也不能连,那就是IP、端口、防火墙的问题。
5.4 客户端断线重连:指数退避与心跳的配合
最后聊重连。WebSocket断线重连绝对不能做成“断了就立刻重连”,高峰期几千个连接同时断,服务端直接被重连潮冲垮。指数退避是最基础的保护:第一次断了等1秒再重连,第二次2秒,第三次4秒,直到30秒或60秒的上限,中间可以加随机抖动,避免所有客户端在同一时刻重连。
心跳这里容易有个误解:很多人觉得WebSocket协议自带心跳,浏览器会自动处理Ping帧,服务端不用管。浏览器确实会自动响应Ping,但Ping/Pong只代表“TCP层还通”,不代表“业务层还正常”。一个线程卡死、数据库连接池耗尽的WebSocket服务,依然能正常回Pong,但业务消息已经处理不了了。所以应用层心跳要传业务数据,比如{type:'ping'},服务端在业务层回{type:'pong'},超时没收到就主动断开,客户端才能及时触发重连。
重连之后还要想一个事:会话状态要不要恢复。比如聊天页面,断线期间用户发了消息怎么办?简单方案是客户端缓存待发送队列,重连成功后flush;复杂一点的方案,服务端在鉴权时用token查出用户ID,重连后把订阅关系恢复。如果这一步不做,你会发现连接看起来“稳稳的”,但用户消息丢得莫名其妙。
最后分享一个我自己的习惯:所有WebSocket消息都设计成{type, payload}结构,type决定消息类型,payload携带数据。这个约定几乎适用所有场景——心跳是{type:'ping'},鉴权是{type:'auth', token},业务消息是{type:'chat', content}。有了这个结构,服务端分发逻辑清晰,客户端也只需要一个switch就能处理。WebSocket入门到这儿基本够用了,剩下的就是多写多踩坑,以后有机会再聊聊集群下的消息扩散和离线消息方案。