拓冰建站拓冰建站
首页 / 资讯中心 / 正文

WebSocket从原理到实战:HTTP升级、长连接与实时推送

先说一个很形象的类比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 握手其实还是HTTPUpgrade那一下WebSocket的建立并不是从零开始的独立协议它先走一次HTTP升级请求。客户端发一个带Connection: Upgrade和Upgrade: websocket头的GET请求同时携带Sec-WebSocket-Key这个Key是一个随机Base64字符串相当于拨号前的“暗号”。服务端校验后返回101 Switching Protocols连接协议切换成WebSocket。这个握手过程我习惯称之为“拨号”——虽然你已经在通话了但开头仍然借用了通信网络的寻址机制。技术细节是服务端拿到Sec-WebSocket-Key之后拼上一个固定GUID258EAFA5-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 charsetutf-8 / titleWebSocket 入门测试/title /head body input typetext idmsg placeholder输入消息 / button idsend发送/button pre idlog/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 upgradeserver { 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加上一个MapsessionId, 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压测WebSocketSampler安装和常见报错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入门到这儿基本够用了剩下的就是多写多踩坑以后有机会再聊聊集群下的消息扩散和离线消息方案。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门