HTTP与WebSocket通信模型详解:从轮询到全双工的等待

发布时间:2026/10/7 3:21:35
HTTP与WebSocket通信模型详解:从轮询到全双工的等待 老同学前几天问了我一个很经典的问题HTTP 和 WebSocket 都跑在 TCP 上为什么 HTTP 不能像 WebSocket 一样全双工通信这问题看起来像是“OS八股”里的面试题但真要在实际项目里讲清楚还牵扯到等待、轮询、阻塞、全双工这一串通信模型概念。我正好最近在调一个嵌入式设备的状态上报项目既用了 HTTP 长轮询又切到了 WebSocket 做实时推送顺手把这两个协议的差异彻底摸了一遍。这篇文章就从一个从业者的视角把 HTTP 和 WebSocket 的通信模型掰开揉碎讲清楚重点落在“等待怎么发生、轮询在弥补什么、阻塞和全双工又意味着什么”这四个核心点适合正在准备后端面试的开发者也适合前端、嵌入式甚至运维同学做协议选型参考。1. 两种协议的底层通信模型先搞懂“等待”到底是什么1.1 HTTP 是“一问一答”WebSocket 是“会话保持”HTTP 的通信模型非常像你去窗口办事你先递上材料请求工作人员处理完再把结果还给你响应然后这个窗口就关闭了。哪怕 HTTP/1.1 引入了 Keep-Alive 连接复用也仅仅是让这个窗口可以重复使用但每次依然是“你问一句、我答一句”的串行流程。服务器永远不能在没有收到请求的情况下主动把数据推给你这就是应用层协议决定的单向请求-响应模型。WebSocket 则更像是打了一个不挂断的电话。客户端首先通过 HTTP 发起一个特殊的握手请求服务器返回 101 Switching Protocols随后双方就站在了一条 TCP 连接上谁都可以随时说话。这个“随时说话”的能力就是全双工通信。操作系统层面看TCP 本身就支持双向同时收发数据所以 WebSocket 不是发明了双工而是把 TCP 已有的双工能力真正暴露给了上层业务。这里有个容易混淆的点HTTP 和 WebSocket 都基于 TCP而 TCP 是全双工的。也就是说底层网络栈早就允许两个方向的数据同时流动HTTP 不用是因为它的语义规定了“必须先有请求才有响应”应用层自己把双工能力废掉了。WebSocket 则是重新定义了一套应用层协议让底层双工能力不再被浪费。1.2 操作系统的阻塞与非阻塞应用层协议的底层依赖在讲等待模型之前必须先说清楚“阻塞”和“非阻塞”这两个操作系统概念。因为我发现很多同学把 HTTP 的超时等待和操作系统的阻塞读混为一谈其实它们属于不同层级。假设你写了一段 Java 代码调用socket.getInputStream().read()这个线程就会进入阻塞状态直到缓冲区里出现数据或者连接被关闭。这叫阻塞 IO是同步等待内核就绪。如果换成 NIO 的Selector.select()线程可以同时监听多个 socket 的就绪事件没有事件的时候就去做别的事情这叫非阻塞 IO。这两种 IO 模型是操作系统层面的能力任何 TCP 协议包括 HTTP、WebSocket都逃不开这个底层机制。那等待和阻塞在 HTTP、WebSocket 里怎么体现HTTP 的一次请求发出后客户端线程通常会阻塞在 read 等待服务器响应服务器收到请求后业务线程也会阻塞在业务计算、数据库查询或者上游调用上。而 WebSocket 建立后连接两端的线程大部分时间是挂起在 read 上的等到有数据帧到达才会被唤醒。所以“等待”不是 HTTP 专有的WebSocket 同样存在等待只是等待的语义变了HTTP 等待一场请求的完整回合WebSocket 等待一条随时可能到达的消息。我实际写服务端代码时有个体会理解了阻塞模型才能真正理解为什么 WebSocket 服务端要追求高并发。因为每个 WebSocket 长连接至少要占用一个文件描述符而且连接建立后服务端线程会长时间挂起等待消息。如果用“一个连接一个线程”的模型一万个连接就是一万个线程线程切换和内存开销会直接把进程压垮。这时候你得用 epoll 或者 Netty 这种事件驱动框架用少量线程管理大量空闲连接让“等待”本身不再消耗资源。2. 没有实时通道的日子里HTTP 如何用轮询“假装实时”2.1 短轮询简单但浪费的连接工厂既然 HTTP 不能让服务器主动推送而很多业务又需要“看起来实时”的效果于是大家想出了一个土办法客户端每隔几秒主动问一次“数据变了吗”。这就是短轮询也是最简单粗暴的轮询模型。前端代码大概长这样setInterval(async () { const res await fetch(/api/status); const data await res.json(); updatePage(data); }, 5000);每 5 秒发一次请求服务器每次都重新处理一遍即使状态根本没变化。这种方式的优点是实现成本极低任何 HTTP 服务器都能跑缺点也很明显产生了大量无效请求在高并发下会给服务器造成不必要的压力而且实时性取决于轮询间隔。你间隔设 1 秒最坏情况要等 1 秒才能感知变化间隔设 10 秒又不那么实时了。我在早期做过一个扫码登录功能当时用的就是短轮询。客户端每 3 秒请求一次二维码状态用户扫完码后服务器端状态翻转下一次轮询就拿到了“已登录”的结果。功能能跑但每次看到线上日志刷出大量/api/qrcode/status请求都会觉得自己在浪费带宽。后来用户量涨到几万数据库接口被轮询打挂了才被迫改成 WebSocket。2.2 长轮询挂起请求变相异步短轮询是“每次都立刻回答”长轮询则是把每次请求“吊”在服务器上直到有数据才返回。流程是这样的客户端发送请求服务器收到后不急着返回而是把这个请求挂在一个队列上暂时阻塞着一旦有状态变化服务器马上返回响应如果等了一段时间比如 30 秒还没变化服务器会返回一个空结果或者超时错误客户端收到后立刻再次发起请求。用 Node.js 写一个最简单的长轮询接口示范app.get(/api/status, async (req, res) { const version req.query.version; const changed await waitForChange(version, 30000); // 最多等30秒 res.json(changed); });这段代码虽然是同步写法但waitForChange内部会挂起请求直到版本号变化或者超时。客户端拿到新版本后马上发起下一次长轮询。长轮询比短轮询高效因为请求次数大幅减少而且数据到达后响应几乎无延迟实时性接近推送。长轮询的坑在于连接管理。服务端挂着大量请求每个挂起的连接都占用线程或协程资源。一旦网络抖动客户端重连会导致挂起的请求堆积超时时间设得不好还会出现“惊群”问题。还有个经典问题如果服务端用了反向代理代理的超时时间必须大于长轮询的保持时间否则 nginx 会替你把连接掐掉。我遇到过生产事故就是因为代理层proxy_read_timeout默认只有 60 秒而长轮询等待周期设了 120 秒导致所有连接都被代理提前切断。2.3 版本号轮询与状态轮询的实战取舍在实际项目中轮询的请求格式可以做得更聪明。与其每次都返回全部数据不如让客户端带着一个版本号或状态标签过来服务器对比后只返回差异甚至返回“没有变化”。比如客户端请求头If-None-Match: v42服务器发现版本没变直接返回304 Not Modified响应体都省了。这是一种很典型的版本号轮询优化。状态轮询则是把业务状态编码成数值客户端每次只关注状态是否从PENDING变成了DONE减少传输数据量。但从通信模型角度看这些优化都改变不了 HTTP 的“客户端主动”本质。轮询本质上就是拿请求频率换实时性每一次轮询都包含了完整的 HTTP 头、TLS 握手如果开启、连接建立和释放开销。即使做了连接复用服务端依然要处理大量“没有业务数据”的空请求。所以轮询适合低频、状态简单、能容忍分钟级延迟的场景如果需要毫秒级推送、高频双向交互老老实实上 WebSocket 才是不折腾的选择。3. WebSocket 的全双工从握手到心跳的实现细节3.1 一个握手把 HTTP 升级成全双工通道WebSocket 最巧妙的地方在于它借用了 HTTP 的握手过程来完成协议升级这样能复用现有的 80/443 端口和大部分网络基础设施。握手请求长这样GET /ws HTTP/1.1 Host: api.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13服务器收到后会取出Sec-WebSocket-Key拼上一个固定的 GUID258EAFA5-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 协议发起的连接二是代理服务器不会误把普通 GET 请求转发成一个半吊子长连接。我在实际开发中经常看到有人直接拿netty的 HTTP 编解码器处理 WebSocket 握手却忘了校验 Sec-WebSocket-Key结果客户端和服务端各说各话消息根本对不上。这段握手代码写起来不难但面试时经常被要求手算 Accept 值。我提供一个 Python 示意import base64 import hashlib key dGhlIHNhbXBsZSBub25jZQ GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 accept base64.b64encode(hashlib.sha1((key GUID).encode()).digest()).decode() print(accept)3.2 帧格式与掩码为什么客户端发数据要掩码WebSocket 握手完成后数据就不再走 HTTP 报文而是走自定义的二进制帧格式。每个帧主要包含这几个字段FIN表示这是消息的最后一帧、操作码文本、二进制、关闭、Ping、Pong等、掩码位、长度、扩展长度和载荷数据。这里有个特别容易忽略的细节客户端发给服务端的数据帧必须掩码服务端发给客户端的数据帧禁止掩码。为什么要这样设计官方说法是为了防止缓存投毒攻击。因为早期中间代理设备可能把 WebSocket 流量当成普通 HTTP 缓存如果客户端能控制某些字节攻击者可以通过巧妙构造的掩码让被代理缓存的内容变成恶意负载。强制掩码让攻击者无法精确预测实际发送在网络上的字节序列。作为开发者你不用自己实现掩码逻辑WebSocket 库都处理好了但理解这一点能帮你排查“为什么抓包看到的数据和业务内容对不上”的问题。分片也是面试高频点。一个大的文本消息可以被拆成多个帧第一个帧的 FIN0最后一个帧的 FIN1。这样设计的好处是发送方可以在不知道完整消息长度时就开始传输比如实时流式输出接收方也可以边收边处理不必等全部到达。我实际测试过WebSocket 默认并不对消息做自动分片如果你用ws库发送一个 100MB 的 Buffer底层可能一帧塞满这在内存上是个大坑。所以大消息最好业务层自己分片或者用流式 API 分段发送。3.3 全双工的真正含义TCP 双工 vs HTTP/WebSocket 应用层模型“全双工”这个词在很多领域都有初学的时候容易混淆。通信原理里的全双工是指两端可以同时发送和接收数据。TCP 本身就是全双工它把发送缓冲区和接收缓冲区分开两个方向的数据互不干扰。所以从操作系统和网络层的角度看HTTP 和 WebSocket 底层都是全双工的。但应用层模型不同。HTTP 的应用层协议是严格单向的请求从客户端发到服务器响应从服务器发到客户端方向固定而且一个请求只能对应一个响应不能“多发”或“少发”。WebSocket 则在应用层重新定义了帧的语义任何一端都可以在任意时刻发送任意帧于是上层应用真正获得了全双工能力。为了帮助理解可以类比硬件通信SPI 总线通常有 MOSI 和 MISO 两根独立数据线主从设备可以同时向对方发数据这就是全双工单线串口比如某些单总线协议只有一根数据线某一时刻要么发要么收这就是半双工。WebSocket 有点像把 HTTP 的“单线”模式升级成了“双线”模式而 TCP 本来就提供了这两根线只是 HTTP 没用。实际做嵌入式开发时会经常遇到“串口单线半双工怎么和全双工连接”的问题。核心思路是硬件上的半双工如果遇到全双工的对端你需要用软件模拟方向切换确认总线空闲后再发送数据。这和 WebSocket 的握手很像它先用 HTTP 语义确认方向可用再切换到全双工模式。通信协议的模型差异是跨领域相通的理解了这一点看 SPI、串口、WebSocket 都会觉得顺眼很多。4. 阻塞队列、线程模型与连接复用实际服务端要处理的问题4.1 连接数上来后的线程模型选择聊完协议层回到服务端实现。WebSocket 长连接的服务端面临的第一个问题就是线程模型每个连接空闲时就挂在那里等消息如果继续用“每连接一线程”几万个连接基本就是等死。常规做法是结合阻塞队列和线程池。IO 线程用事件循环比如 Netty 的 EventLoop、Go 的 goroutine处理读写收到消息后把业务任务丢到一个阻塞队列里让少量业务线程慢慢消费。这个“阻塞队列 线程池”的组合能防止突发消息把业务系统冲垮因为队列提供缓冲消费者处理不过来时新任务会在队列排队。我之前在项目里用 Java 写过一个 WebSocket 推送服务核心结构就是ExecutorService bizPool new ThreadPoolExecutor( 8, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(10000) ); // IO线程收到消息后只做解析和路由 bizPool.execute(() - handleMessage(wsSession, msg));这里有个关键取舍队列大小不能设得太大否则堆积任务过多实时消息变成“延迟消息”WebSocket 白切了也不能设得太小否则请求直接拒绝走降级逻辑。我一般按峰值 QPS 和平均消费时长估算如果平均处理一条消息要 5ms队列 10000 意味着积压 50 秒显然不可接受那就得扩容消费者或者削峰限流。4.2 HTTP 连接复用与 WebSocket 长连接的资源差异HTTP 的连接复用Keep-Alive也能减少连接建立开销但它和 WebSocket 长连接占用的资源完全不同。HTTP 连接是“短租”的服务端返回响应后连接进入空闲状态空闲超过超时时间就关闭。WebSocket 连接则像“长期租约”从握手成功那一刻起服务端就要一直维护这个连接的上下文包括会话状态、心跳定时器、消息发送缓冲。这里有个很容易踩坑的点连接复用下HTTP 的一次响应结束不代表 socket 立即关闭TCP 连接还会进入 TIME_WAIT 状态。如果你在客户端大量并发请求后发现端口被占满或者服务器上出现一堆 TIME_WAIT很可能不是协议问题而是没有合理设置 Keep-Alive 超时和连接池大小。WebSocket 则相反长连接是常驻资源一旦异常断开会留下半开连接服务端如果没及时清理文件描述符会泄漏最终服务不可用。我做压测时对比过同样一万个活跃客户端HTTP 轮询每分钟产生约一万次请求每个请求生命周期几十毫秒WebSocket 是一万个连接长期存在每秒可能有几百条消息但连接本身占用的 fd 和内存反而更高。选型时必须明确你的瓶颈到底在请求频率还是连接数量。4.3 心跳机制与断线重连全双工连接的保鲜期全双工连接最麻烦的问题就是“半开连接”客户端网线断了、路由器重启、手机信号切换TCP 连接层面可能不会立刻感知于是服务端还以为连接活着客户端却已经收不到任何消息。这时候需要心跳机制。WebSocket 协议自带 Ping/Pong 控制帧。服务端定期发送 Ping 帧客户端收到后必须回一个 Pong 帧浏览器 WebSocket API 会自动回。如果在规定时间内没有收到 Pong服务端就把这个连接标记为死连接主动关闭。客户端也可以定期发 Ping或者依赖服务端推来的数据判断连接是否存活。简单实现如下// 服务端Node.js ws 库 const heartbeat () { if (!ws.isAlive) { ws.terminate(); return; } ws.isAlive false; ws.ping(); }; const timer setInterval(() { wss.clients.forEach(heartbeat); }, 30000);心跳间隔怎么定我一般遵循两个原则小于 NAT 会话超时时间同时大于实际丢包恢复时间。很多家用路由器的 NAT 映射空闲超时在 30~60 秒左右如果你的心跳间隔大于 60 秒连接可能被路由器悄悄踢掉。但间隔太小又会造成大量无效帧浪费带宽。按我看过的线上项目30 秒一个 Ping 是比较常见的折中方案。记着还要在客户端做断线重连并采用指数退避避免断网时疯狂重连打爆服务器。5. 常见问题排查与协议选型建议5.1 握手失败、心跳超时、代理不认 Upgrade实际开发中HTTP 和 WebSocket 的坑我都踩过不少挑几个典型的分享。首先是 WebSocket 握手失败。常见原因有请求头里没带Upgrade: websocket和Connection: Upgrade后端网关只把 HTTP 报文透传没有正确处理握手或者代理服务器比如某些 CDN 和负载均衡不支持 WebSocket 的Upgrade。排查方法很简单用curl -v看看握手响应是否为101不是的话先检查代理配置再检查服务端框架是不是把请求当成普通 GET 处理了。其次是心跳超时引发频繁断开。如果你发现客户端日志里每隔一会儿就“连接关闭”优先看服务端有没有收到 Pong。我遇到过一次防火墙会对超过 30 秒没有流量通过的 TCP 连接丢包而服务端心跳设的 45 秒导致连接总是刚发完心跳就被中断。后来把心跳间隔调到 20 秒才稳定。最后是 HTTP 的“连接复用没生效”。有些同学以为设置了Connection: keep-alive就万事大吉但 HTTP/1.1 默认就是 keep-alive很多库还会限制每条连接的最大请求数。如果你用 HTTP 客户端频繁创建新连接检查一下连接池是否配置对了不要每个请求都new HttpClient()。5.2 HTTP 还是 WebSocket一张表解决选型很多项目根本不是非黑即白而是同一个系统里两种协议各干各的。我自己常用的选型判断标准如下维度优先 HTTP优先 WebSocket实时性分钟级/秒级可以接受需要毫秒级推送通信方向基本是客户端主动请求服务端需要主动下发连接数量大量瞬时请求低频长连大量长期连接消息频率中等开发复杂度低生态最好高需处理心跳/断线重连/代理流量特征小请求大响应有周期性双向小额消息需长时间保持兼容性所有网络设施都支持需要代理和防火墙支持 Upgrade比如后台管理系统拿 HTTP 做接口就足够了加个短轮询也完全行但一个实时协作编辑应用服务端要频繁把光标位置和文档操作推给所有在线用户用 WebSocket 几乎没得选。要注意的是并非所有“推送”场景都必须 WebSocketSSEServer-Sent Events也支持服务器单向推送且兼容性更好。如果只需要服务器往客户端发不需要客户端往服务器实时发SSE 比 WebSocket 更简单可靠。5.3 踩坑实录我在这两种协议上栽过的跟头最后聊几个我真实经历过的故障有些教训是用线上流量交的学费。第一次是从长轮询切 WebSocket 时没考虑服务端 Session 的同步问题。多个 WebSocket 连接可能属于同一个用户手机、电脑同时在线如果消息只发给其中一个连接的 Session另一个客户端就永远收不到。后来统一在服务端维护userId - SetSession的映射发消息时遍历所有 Session 才解决。另一次是 WebSocket 消息太大导致内存暴涨。当时业务要推送一个很大的 JSON 数据直接用session.getBasicRemote().sendObject(obj)结果底层把整个对象序列化成一个大 Buffer 一次性发送压测时内存直接翻车。后来改成手动分片每帧不超过 8KB大 JSON 拆成多条消息客户端按消息序号拼接内存问题才缓解。还有一次是 Docker 环境里跑 WebSocket 服务客户端是浏览器一直报Error during WebSocket handshake。查到最后是 Docker 端口映射和代理层的问题外部 HTTPS 终止在 nginxnginx 再把请求转发给内网的 WebSocket 端口但 nginx 默认没开proxy_set_header Upgrade。加上那两行配置后问题就消失了。如果你也遇到这种问题可以先跑一个绕过 nginx 的直连测试能快速定位是代理还是服务端的问题。根据我个人经验通信模型的“八股”之所以重要是因为它决定了你在排障时能不能快速把问题定位到正确的层。遇到 HTTP 连接异常先想到请求语义和连接复用遇到 WebSocket 频繁断开先想到心跳和半开连接遇到消息顺序错乱再回去看帧格式与分片逻辑。把这些模型差别吃透了再看各种实时通信方案基本就是看一种新协议如何用不同的方式解决“等待”这个老问题。