从HTTP轮询到WebSocket全双工:通信模型中的等待、阻塞与实时推送

发布时间:2026/10/7 10:37:48
从HTTP轮询到WebSocket全双工:通信模型中的等待、阻塞与实时推送 1. 从一道面试题聊起为什么面试官总爱问 HTTP 和 WebSocket先抛个我真实遇到过的场景。有次面试官问我“你用轮询做过实时功能没为什么后来换成了 WebSocket”我当时想当然地回答“因为轮询浪费资源WebSocket 是全双工的所以更快。”结果他接着问了一句“全双工到底是怎么实现的HTTP/1.1 的 keep-alive 也是长连接为什么不叫全双工”我一下子愣住了。这个问题卡住过不少人。说白了HTTP 和 WebSocket 的差异不光是“长连接”和“短连接”这么简单背后牵扯的是通信模型的差异——客户端怎么发出请求、服务器怎么知道我要数据、资源在等待期间被谁占用、数据能不能双向同时推。理解了这套模型很多表面现象轮询为什么效率低、WebSocket 为什么适合做推送、阻塞队列和异步非阻塞有什么关系才会真正串起来。这篇文章就用“通信模型”作为主线把这几个关键词掰开揉碎了讲清楚等待、轮询、阻塞和全双工。不讲虚的直接上原理、上实践顺便把面试里容易踩的坑也一并排掉。2. HTTP 的核心通信模型请求-响应驱动的等待与阻塞2.1 从 HTTP/1.0 到 HTTP/1.1连接复用解决了什么先回到基础。HTTP 的原始模型是“一问一答”客户端发一个请求服务器给一个响应连接就关闭。一次 HTML 页面要加载几十个资源如果每个资源都新建 TCP 连接代价是很高的——三次握手、四次挥手再加上 TCP 慢启动整个页面加载会慢到让人抓狂。HTTP/1.1 引入了keep-alive连接复用核心思想是一次 TCP 连接上可以连续发送多个请求接收多个响应。这就解决了“频繁建连”的问题但注意它解决的只是连接的效率通信的模型还是请求-响应客户端不发请求服务器就不能主动发数据。每次请求都得等服务器响应这个“等”是实实在在发生的。这么理解就清楚了HTTP/1.1 是“一根管子接着两端但只有一端能主动倒水另一端只能等着接”。管子是复用长连接了但倒水的规矩没变。所以面试里如果有人把 keep-alive 说成“这就是长连接所以能双向通信”那一定是对模型理解不到位。2.2 阻塞的含义客户端线程在等待时到底干了什么面试聊到“阻塞”几乎绕不开这个问题“HTTP 请求发出后客户端线程在等响应时是阻塞的为什么”阻塞的本质是当前执行流在等待某个条件满足时被挂起无法继续执行后续代码。在同步 HTTP 请求里一个线程调用send()后就会卡在recv()或者等待响应返回的状态什么也干不了。这个线程的执行资源被白白“占着茅坑不拉屎”。但阻塞也有它的价值编程模型简单。你不需要处理回调、不需要管理状态机代码从上往下写结果到了就继续。对大多数业务代码来说同步请求是最直接可靠的。真正的问题是如果服务器响应慢或者网络延迟高客户端线程就变成“等待资源”。一个线程对应一个请求100 个并发请求就要 100 个线程。这在连接数少时没什么一旦规模上来线程开销、上下文切换、内存占用都会变成负担。这也是为什么后来出现了异步 IO、协程、线程池这些“用更少线程扛更多请求”的方案。2.3 请求响应模型下的“实时性”困局服务器不能主动说话HTTP 模型的真正局限不是速度而是主动性。服务器只能“被动应答”不能在客户端没有任何请求的情况下主动推送数据。对新闻页面这种静态内容这没问题但对聊天、股票行情、消息通知这类场景核心需求是服务器有新数据客户端要立刻知道。你想让 HTTP 达到“基本实时”的效果最原始的想法就是轮询——客户端每隔一段时间问一次“有新数据了吗”。一个字问。再问。一直问。这就是从“等待”到“轮询”的必然过渡。HTTP 的请求-响应模型决定了服务器无法主动“告诉”你所以客户端只能通过高频“询问”来弥补主动性的缺失。3. 轮询的三代演进短轮询、长轮询与版本号优化3.1 短轮询定时器驱动问题不在请求多而在浪费多短轮询就是设定一个定时器比如每 3 秒发一个 AJAX 请求问服务器“有更新没”。服务器就算没有任何新数据也得正常返回一个空响应。这个方案实现最简单任何后端框架都支持只要在客户端加个setInterval就行了。但代价非常现实请求次数爆炸式增长。一个用户每 3 秒一次1 万个用户就是每秒 3300 个请求。如果 90% 的请求都拿不到新数据等于把带宽和服务器 CPU 浪费在“空转”上。实时性永远有上限。轮询间隔是 3 秒那数据延迟最高就是 3 秒。间隔调成 500ms实时性变好一点但请求量变成每秒 2 次成本和收益的平衡点非常难拿。头部开销大。每个 HTTP 请求都带着完整的 headers、cookie、User-Agent 等一堆东西哪怕响应体是{data:null}几十个字节的数据也可能要背上几百上千字节的请求头。我参与过的一个后台系统早期就是 5 秒短轮询拉任务状态。单机几百个用户看不出问题一压测网关的 QPS 直接被打满而且大量响应是空数据。后来改成 WebSocket机器负载直接降了一个量级。短轮询不是不能用但要清醒地意识到它是在用成本换实现简单。3.2 长轮询让等待更有意义但连接成本仍在长轮询可以理解为“短轮询的改进版”。客户端发请求后服务器不立刻返回而是把这个请求“挂住”直到有新数据了才返回响应或者直到超时。所以客户端发出请求后存在一段时间内“没有响应”但是一旦有数据服务器马上就能返回。这个方案比短轮询聪明因为大部分时间里客户端只有一个“在途”请求在服务器挂着极大减少了空轮询的次数。实时性更好——数据一产生服务器立刻通过挂起的连接返回不需要等下个定时周期。但长轮询也有自己的痛点服务器需要挂起连接每个挂起的连接都会占用一个线程或一个连接槽位。如果同时有大量客户端在“挂等”服务器并发连接数会变得很大。超时和重连逻辑复杂。请求超时要重新发网络闪断要知道重连服务器重启后一大堆挂起的连接需要被清理。每次重连都会经历一次新的 HTTP 握手效率仍然不高。HTTP 连接头开销依旧在虽然请求次数少了很多但每次还是完整的 HTTP 报文。长轮询经常被用在对实时性要求不是极高、改造 WebSocket 成本太高的“过渡方案”里。比如一些老系统后端是 PHP 或者同步框架直接上 WebSocket 要改的东西太多长轮询就成了性价比之选。3.3 版本号轮询与增量拉取少传数据的优化思路轮询还有一个进阶方向——参数优化。比如“版本号轮询”服务器为数据维护一个全局版本号客户端每次轮询只带“我当前拿到的是版本号 N”服务器比对如果最新版本号还是 N就返回“无变化”如果有变化才把新增数据或完整数据返回。这个方案的好处是响应体很小通常是{version:123}这种几个字节的数据客户端可以根据版本号决定是否增量拉取对服务器来说比对版本号是 O(1) 的操作很轻类似的还有 ETag 和 Last-Modified网页静态资源的缓存更新就是这么做的。但注意版本号轮询解决的只是“传输数据量”的问题没有解决“请求频率”的问题。轮询仍然在定时发生请求开销依然存在。在一次面试里面试官问过我“如果让你用 HTTP 实现消息推送你会怎么做”我当时答了长轮询他追问“再优化呢”我才说到增量拉取和版本号。他点了点头但最后补了一句“你再想想HTTP 模型再怎么优化本质上还是客户端主动。要真正的服务端主动你得换协议。”这句话让我记到现在。4. WebSocket 的模型革新全双工到底改变了什么4.1 一次握手换来双向通道WebSocket 的设计思路完全是另一套先通过 HTTP 完成握手升级然后切换协议建立一条全双工的 TCP 消息通道。握手的流程很简单客户端发一个带Upgrade: websocket头的 HTTP GET 请求服务器返回101 Switching Protocols协议就从 HTTP 切到了 WebSocket。之后两端都可以随时向对方发送数据不需要再等“请求”这个前置条件。这就是“全双工”数据可以在同一时刻双向流动互不干扰。打电话就是全双工——两个人可以同时说话、同时听。对讲机是半双工——同一时间只能一个人说另一个人听完才能回。HTTP 在连接级别其实是全双工的TCP 本身就是全双工但在“通信模型”级别是半双工的客户端说完了服务器才能回。所以面试题里最经典的坑就是“TCP 是双工的HTTP 基于 TCP为什么 HTTP 不是双工”答案是HTTP 是应用层协议约束下的请求-响应模式它在使用 TCP 的信道上规定“必须一问一答”所以即使底层传输是全双工的模型上仍然是“伪半双工”。WebSocket 打破了这层约束把 TCP 的全双工能力真正暴露给了应用层。4.2 帧、消息与心跳WebSocket 的底层细节WebSocket 的数据以“帧”为单位传输一帧包含操作码opcode、负载长度、掩码客户端发给服务器必须掩码等字段。协议本身也足够轻量最小帧头只有 2 字节对聊天这种小消息非常友好不像 HTTP 要带一整套头。几个关键操作码0x1文本帧Text Frame0x2二进制帧Binary Frame0x8关闭帧Close Frame0x9 Ping 帧0xAPong 帧心跳机制就靠 Ping/Pong 实现一端发一个 Ping另一端必须回一个 Pong。如果一段时间没收到 Pong就认为连接已经断开可以主动清理。服务端和客户端都可以主动断开但要走关闭帧的握手流程。不像 HTTP 连接直接超时关闭WebSocket 的关闭是“平缓”的。我在实际项目中踩过一个坑服务端部署在 Nginx 后面Nginx 的proxy_read_timeout默认值是 60 秒WebSocket 连接空闲超过 60 秒就会被 Nginx 掐断客户端完全没感知等到下次发送数据才发现连接断了。后来在 Nginx 配置里加了location /ws { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }从这之后连接常年稳定。这个细节很多人会忽略但生产环境一跑就暴露。4.3 为什么 WebSocket 适合做推送却不适合所有场景我试过用 WebSocket 做服务端主动推送聊天消息、订单状态变更、在线编辑文档的协同操作效果都不错。核心优势是“服务端零延迟推送”数据一产生服务端直接把它塞进信道不需要等客户端下一次请求。但 WebSocket 并不是万能银弹。有几个场景我建议谨慎请求响应型接口如果只是普通的增删改查接口前端主动请求后端返回数据用 WebSocket 完全是杀鸡用牛刀。你把 RESTful 接口改成 WebSocket 消息得自己定义消息类型、路由、异常处理复杂度直线上升。连接数巨多的场景每条 WebSocket 都是一条长连接会占用文件描述符、内存缓冲区。如果几百万在线用户还需要考虑多机负载均衡的 sticky session 问题——连接建立后必须一直落在同一台后端节点上不然节点扩容缩容、重启都会导致大量连接断开。防火墙和代理环境一些企业内网对长连接支持不友好WebSocket 升级请求会被网关拦截。Web 应用在用 WebSocket 时要注意降级策略——比如连不上就自动转成轮询。我记得有次做一个 IoT 设备管理后台设备上报频率是每 30 秒一次数据。我们一开始设计用 WebSocket后来算了笔账每台设备在线时长 24 小时WebSocket 服务端要长期占用大量连接资源而实际数据 30 秒才 1 条。后来改用 HTTP 短连接上报 按需查询服务器负载反而低了很多。选协议要看消息频率和实时性要求不能说 WebSocket 就一定高效。5. 从通信模型看阻塞、非阻塞与队列5.1 阻塞等待与轮询的底层哲学忙等与挂起“阻塞”和“轮询”其实是操作系统领域的两个经典概念在网络通信里换了个马甲重新出现。阻塞等待blocking wait线程主动让出 CPU挂在等待队列里直到条件满足被唤醒。好处是 CPU 利用率高不浪费坏处是线程有状态切换开销。轮询polling线程反复检查某个条件是否满足不主动让出 CPU或者短暂 sleep 后再查。好处是实现简单不用依赖事件通知机制坏处是 CPU 在“白转”尤其是条件很少满足时。对应到 HTTP 和 WebSocketHTTP 的同步请求本质是“阻塞等待响应”线程挂起直到网络包到达。短轮询本质是“忙等”只不过等的位置从内核态换成了应用层用一个定时器反复触发检查。如果面试官继续深挖他会问“阻塞和轮询哪种好”我现在的标准回答是看条件满足的频率和等待的成本。如果事件发生率低且等待时间长阻塞模型更优省 CPU如果事件发生率极高且没法预测轮询反而简单直接省去内核切换。这也是为什么 Linux 的 epoll 用“阻塞 事件通知”而不是“轮询所有连接”。5.2 阻塞队列、线程池与背压通信模型在后端架构里的延伸聊到阻塞队列和线程池其实也是同一个大主题——多生产多消费模型下的等待协作机制。线程池的阻塞队列BlockingQueue是“生产者-消费者”模型的经典实现。任务提交线程生产者把请求丢进队列工作线程消费者从队列取任务执行。当队列满时有界队列ArrayBlockingQueue生产者在put()时阻塞等队列有空间。无界队列LinkedBlockingQueue生产者永远不阻塞但队列可能无限膨胀最终 OOM。同步队列SynchronousQueue不存任务生产者必须等待消费者来取完成“线程间直接交接”。这个模型和 HTTP/WebSocket 通信有什么关系在我做过的一个消息推送系统中后端收到 WebSocket 上行消息后会把消息丢到阻塞队列再由推送线程批量下发。这样做的目的是削峰填谷接收消息的速率和下发消息的速率解耦。如果消费者处理不过来队列会自动形成“背压”backpressure让生产者变慢或阻塞——比直接丢消息或内存暴涨要安全得多。所以面试里遇到“线程池阻塞队列怎么选”这类题其实考察的就是你明不明白阻塞是资源协调机制而非“性能差的代名词”。有界队列 拒绝策略是生产环境最稳妥的组合这条经验我用了很多年。6. 实操对比用代码理解 HTTP 轮询与 WebSocket 推送6.1 短轮询的实现与可观测成本前端用fetch做轮询代码很简单async function poll() { const resp await fetch(/api/status); const data await resp.json(); updateUI(data); } setInterval(poll, 3000);服务端如果是 Expressapp.get(/api/status, (req, res) { res.json({ version: latestVersion, changed: hasNewData() }); });别看代码简单问题在线上就能显现。我做过一个压测100 个客户端同时 3 秒轮询服务器单机 QPS 约 33看起来不高但关键是响应里 95% 是“没变化”。100 个客户端还好100 万客户端呢这一层带宽就是恐怖的浪费。如果要证明这一点你可以在 Wireshark 里抓包能看到每隔固定时间就有一次GET /api/status请求发出响应体可能只有{version:100}。这就是“用网络流量换实时性”的直观证据。6.2 长轮询的实现与挂起响应长轮询服务端实现以 Node.js 为例const pending new Set(); app.get(/api/status, async (req, res) { pending.add(res); req.on(close, () pending.delete(res)); setTimeout(() { if (pending.has(res)) { res.json({ timeout: true }); pending.delete(res); } }, 30000); // 30 秒超时兜底 }); // 数据更新时主动响应所有挂起请求 function notifyClients(data) { for (const res of pending) { res.json(data); pending.delete(res); } }客户端只需要一个递归调用async function longPoll() { const resp await fetch(/api/status); const data await resp.json(); updateUI(data); longPoll(); // 拿到数据后马上发起下一次 }这个方案在数据更新频率不密集时比短轮询高效得多。但我必须提醒一点长轮询服务端要能挂住响应如果你的服务端是同步阻塞的比如每个请求占一个线程并发一高线程池直接耗尽。所以长轮询更适合搭配异步框架或协程否则你只是把客户端的循环搬到了服务端而已。6.3 WebSocket 服务端与客户端的最小实现服务端用 Node.js 的ws库const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); wss.on(connection, (ws) { ws.send(JSON.stringify({ type: welcome, data: connected })); ws.on(message, (msg) { const parsed JSON.parse(msg); if (parsed.type ping) { ws.send(JSON.stringify({ type: pong, ts: Date.now() })); } // 业务消息直接广播给所有客户端 wss.clients.forEach((client) { if (client.readyState WebSocket.OPEN) { client.send(JSON.stringify({ type: broadcast, data: parsed.data })); } }); }) });客户端const ws new WebSocket(ws://localhost:8080); ws.onopen () { ws.send(JSON.stringify({ type: greeting, data: hello })); }; ws.onmessage (event) { const msg JSON.parse(event.data); console.log(received:, msg); };这段代码里最关键的是readyState WebSocket.OPEN的判断。实际项目中我踩过坑遍历wss.clients时发现有连接状态是CLOSING直接send()会抛异常必须先检查状态。这个坑网上讨论很多但自己踩一次才会真正刻在脑子里。单测可以验证一点服务端在没有任何客户端请求的情况下消息推送到达客户端——这是 HTTP 做不到的。把这段代码跑通再回去看“全双工”三个字理解会深很多。7. WebSocket 中心跳与断线重连的实战踩坑记录7.1 心跳不是“有数据就发”就够了很多人以为只要客户端和服务端在持续发数据就不需要心跳。这个想法在公网环境大错特错。现实例子我在做扫码登录功能时服务端跟 WebSocket 客户端之间保持长连接用户扫码后服务端推送登录成功通知。刚开始没用心跳上线后发现安卓端经常“偶发收不到通知”用户手动刷新页面才恢复。排查后定位到移动网络下 NAT 超时可能只有 30 秒连接没有数据流动时网关会把连接静默回收。客户端完全不知道自己已经被“踢下线”直到发数据才报错。解决方案就是定频心跳const heartbeatInterval 30000; // 30 秒 const heartbeatTimeout 10000; // 10 秒没收到 Pong 判定超时 function startHeartbeat(ws) { const timer setInterval(() { if (ws.readyState ! WebSocket.OPEN) return; ws.send(JSON.stringify({ type: ping, ts: Date.now() })); ws.pendingHeartbeat true; setTimeout(() { if (ws.pendingHeartbeat) { console.log(heartbeat timeout, reconnect...); ws.close(); reconnect(ws.url); } }, heartbeatTimeout); }, heartbeatInterval); ws.on(pong, () { ws.pendingHeartbeat false; }); }服务端收到 ping 消息直接返回 pong。这样连接里始终有数据流动NAT 网关就不会把连接回收。服务端我是这样处理的ws.on(message, (msg) { const parsed JSON.parse(msg); if (parsed.type ping) { ws.send(JSON.stringify({ type: pong, ts: Date.now() })); return; } // 其他消息处理 });7.2 重连策略指数退避与抖动断线重连如果不加控制极端场景下会造成“重连风暴”——几千个客户端同时断线同时疯狂重连服务端瞬间被打挂。我采用的方案是指数退避 随机抖动function getRetryDelay(attempt) { const base Math.min(1000 * Math.pow(2, attempt), 30000); const jitter Math.random() * 500; return Math.floor(base jitter); }第一次重连等 1 秒第二次 2 秒第三次 4 秒……封顶 30 秒。加随机抖动是为了避免多个客户端在同一时刻同时发起重连把服务端的连接风暴摊开。踩过的另一个隐蔽坑是服务端版本升级旧客户端拿着旧 token 重连。如果重连时没有重新认证老连接被服务端悄悄关闭客户端还傻傻地以为连接正常。所以 WebSocket 每次重连都应该重新走一次鉴权流程而不是复用旧连接的状态。8. 常见问题速查表与排查方法我在实际项目中整理了这么一张排查表分享出来可以直接抄作业用现象可能原因排查方法WebSocket 连接秒断Nginx 没配置 Upgrade 头检查 Nginx 是否设置了Upgrade和Connection头连接一段时间后静默断开NAT 超时、代理超时、无心跳开启 30 秒定频心跳检查proxy_read_timeout服务端发消息报错not opened发送时连接已关闭但客户端未感知发送前先检查readyState或者捕获CLOSE事件后重连大量用户同时掉线服务端重启、断网、云网关抖动日志排查时间点加入自动重连指数退避连接数超限文件描述符限制调大ulimit -n或者做多机水平扩展HTTP 短轮询 QPS 过高轮询间隔过短、空响应占比高拉长时间隔改成条件判断更建议换 WebSocket长轮询服务端线程耗尽同步框架 长挂请求换异步框架或者调整超时时间控制并发挂起数面试里还经常问到另一个问题WebSocket 和 HTTP/2 有什么本质区别HTTP/2 引入了多路复用和服务器推送server push但它仍然遵守 HTTP 的请求-响应帧模型。HTTP/2 的 server push 不如 WebSocket 自由——它只能 push“客户端将要请求的资源”不能主动推送任意数据。所以 WebSocket 在做真正的双向消息通道上仍然不可替代。9. 我个人在实际开发中的选型建议和几句总结写到这里最后分享一点个人经验。选 HTTP 还是 WebSocket关键不是“技术先进”而是“场景匹配”普通的 CRUD、查询接口HTTP 短连接或连接复用简单可靠直接上。实时推送、在线协作、聊天、游戏对战WebSocket或者用 WebSocket 消息队列做分层架构。实时性要求不高的“类似实时”长轮询或短轮询仍然是可接受的过渡选择。我在一些后台管理系统里用户数就几百个轮询压力根本不大真要为了“先进”去改造成 WebSocket反而增加维护成本。数据上报类场景IoT、埋点HTTP 短连接上报更合理长周期数据完全不值得长期占用一条 WebSocket 连接。从我踩过的这么多坑来看真正吃透通信模型比背住“WebSocket 是全双工”这句话重要得多。理解等待、轮询、阻塞和全双工这四个概念你的思维就能穿透具体技术栈——无论是 HTTP、WebSocket、TCP Socket 还是消息队列底层都是“数据怎么流动、谁在等待、谁被唤醒、资源如何协调”的问题。面试时把这几条逻辑讲清楚比堆术语要好得多。最后再分享一个小技巧用 Wireshark 抓包看 HTTP 轮询和 WebSocket 帧的差异。你可以看到 HTTP 请求头有大量冗余WebSocket 帧头只有几个字节HTTP 请求是单向的“一问一答”WebSocket 两端可以同时发框子。多用抓包工具看真实流量比看一百篇文章都管用。这也是我这些年做网络调试最受益的习惯。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询