从短轮询到 WebSocket:五种实时通信方案原理、对比与选型
做过"消息要立刻弹出来"这类需求的人都踩过同一个坑:第一版代码写成了 setInterval 每 3 秒拉一次接口。功能是通的,上线后发现接口 QPS 涨了 20 倍,而其中 99% 的请求返回的都是"没有新数据"。
这就是实时通信问题:服务端有了新数据,怎么尽快让客户端知道?
本文按"先搞懂范式 → 再逐个拆协议 → 最后给选型表"的顺序,把短轮询、长轮询、SSE、MQTT、WebSocket 这五种方案讲透。读完你应该能做到两件事:一是看别人架构图里的长连接方案不再懵;二是面对具体业务,能直接说出该选哪个、为什么。
🔴 一句话定位:这五种方案的差异,本质上只有两条------客户端多久问一次 ,以及连接能活多久。
一、先厘清:三个范式和四个术语
不先把地基打好,后面讲每种协议都会是玄学。
1.1 万恶之源:HTTP 只会"拉"
HTTP 是请求-响应 模型:客户端发起请求,服务端应答,一问一答,服务端没有能力主动找客户端。
两个现实约束加剧了这件事:
- 客户端通常在 NAT / 防火墙后面,没有公网可直连的地址,服务端想"反向连过去"基本不可能。
- 连接是有成本的:TCP 三次握手,HTTPS 还要加 TLS 握手,短连接下每次请求都要付这笔钱。
HTTP/1.1 的 keep-alive 能复用 TCP 连接,省掉重复握手,但它复用的只是"通道",语义上仍然是"客户端问、服务端答"的配对关系------服务端依旧不能凭空推一条消息下来。
所以要实现"实时",路只有两条:
| 思路 | 做法 | 代表方案 |
|---|---|---|
| 让客户端不停地问 | 客户端主动、高频、短请求 | 短轮询 |
| 让一条连接一直开着 | 服务端可以随时沿这条连接写数据 | 长轮询、SSE、WebSocket、MQTT |
✅ 记住这条:推送不是"服务端凭空连过来",而是"客户端先开好一条长期存活的连接,服务端借着它说话"。
1.2 三种通信范式
| 范式 | 数据流向 | 特点 | 本文涉及 |
|---|---|---|---|
| 轮询(拉) | 客户端定期拉取 | 实现最简单,实时性受周期限制,空请求多 | 短轮询、长轮询 |
| 长连接推送 | 连接建立后由服务端写 | 实时性高,但要处理连接保活、扩容 | SSE、WebSocket |
| 发布订阅 | 发布者 → broker → 订阅者 | 时间与空间解耦,一对多分发 | MQTT |
⚠️ 注意:长轮询是"伪装成拉的推",它借的还是 HTTP 请求-响应外形;SSE 和 WebSocket 才是真正意义上的长连接。
1.3 四个必懂术语
- 单工 / 半双工 / 全双工:单工只能单向传输(如广播);半双工同一时刻只能一方发(如对讲机);全双工双方可同时收发。SSE 是单向的,WebSocket 是全双工的。
- 长连接 vs 短连接:短连接一次请求用完就关,长连接建立后长期保持。长连接的代价从"QPS 高"变成了"连接数多"。
- 心跳(Keep-alive / ping-pong):链路上的 NAT、负载均衡、反向代理会静默掐掉长时间空闲的连接。心跳就是定期发一个无业务含义的小包,证明"我还活着"。
- 消息可靠语义:最多一次(可能丢)、至少一次(可能重复)、恰好一次(不丢不重,代价最高)。MQTT 的 QoS 就是这三个语义的标准实现。
二、短轮询:实现最简单,代价最贵
原理一句话:客户端定时发 HTTP 请求问"有新数据吗",服务端立刻回答"有"或"没有"。
javascript
// 短轮询:每 3 秒问一次
setInterval(async () => {
const res = await fetch('/api/messages?since=' + lastId);
const list = await res.json();
if (list.length) {
render(list); // 有新数据才渲染
lastId = list.at(-1).id;
}
}, 3000);
优点是真的简单:不需要任何服务端改造,不需要维护连接状态,网关、鉴权、日志体系全部照旧能用。
代价在服务端。算一笔账:
| 项 | 数值 |
|---|---|
| 在线用户 | 1000 |
| 轮询周期 | 3 秒 |
| 实际 QPS | 1000 ÷ 3 ≈ 333 |
| 有效请求(真的拿到新数据) | 假设 1% |
| 无效请求 | 约 330 QPS 白跑 |
这 330 QPS 的空请求,每一次都会走完:网关路由 → 鉴权 → 业务逻辑 → 查库/查缓存 → 组装响应。用户越多,浪费越成比例放大。
⚠️ 很容易忘的一步 :如果只想知道"数据变了没",可以让服务端返回 ETag,客户端下次带 If-None-Match,没变化就回 304 Not Modified。响应体省了,但请求本身照样发生,只是止血,不是治本。
| 维度 | 评价 |
|---|---|
| 实时性 | 取决于周期,通常是秒级到分钟级 |
| 服务端压力 | 与"在线用户数 ÷ 周期"成正比,且大部分是无效请求 |
| 实现成本 | 极低 |
| 适用场景 | 实时性要求低(分钟级)、数据量小、内网管理后台、定时任务状态刷新 |
一句话总结:短轮询适合"能接受几秒延迟"的场景,别拿它做 IM 和订单状态。
三、长轮询:把"挂起"用到极致
原理一句话 :客户端发请求,服务端没有新数据就先不返回,把这个请求挂住;等有新数据、或者等到超时(比如 30 秒),才把响应写回去;客户端拿到响应后立刻再发一个新请求。
markdown
客户端 → 服务端:有新消息吗?
服务端:(挂起,不响应......)
20 秒后,来了一条新消息
服务端 → 客户端:有新消息,给你
客户端 → 服务端:那再问一次,还有吗?(立刻重发)
这一招把"服务端推送"伪装成了"客户端拉取",所以它对反向代理、防火墙、老网关极其友好------链路上所有设备看到的都只是一次"响应慢了点"的普通 HTTP 请求。
3.1 三个必须做对的地方
服务端必须能"挂起"请求,而不是阻塞线程。
如果你用的是同步阻塞的线程模型(每个请求占一个线程),长轮询会把线程池瞬间打满------1000 个在线用户就是 1000 个被占住的线程。正确做法是异步化:Servlet 3.0 的 AsyncContext、Netty 的 Channel、Node 的 await、Go 的 goroutine、Spring 的 DeferredResult 都属于这一类。
超时值要卡在中间。
| 超时设置 | 后果 |
|---|---|
| 太短(如 2 秒) | 退化成短轮询,白折腾 |
| 太长(如 5 分钟) | 连接和内存占用高,故障后恢复慢,重启时容易雪崩 |
| 业界常见 | 30 秒一档 |
hold 时间必须小于链路上所有超时配置。 这是最容易翻车的一点:
nginx
# nginx:读超时必须大于服务端 hold 时间,否则 504
location /api/long-poll {
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
}
⚠️ 顺带检查负载均衡的 idle timeout、云厂商 SLB 的连接空闲超时、客户端自己的请求超时。任何一层比服务端 hold 时间短,你都会看到规律性的 504。
3.2 谁在用长轮询
- 配置中心:Nacos 1.x 的配置监听就是 30 秒长轮询------客户端带上 md5 问"配置变了吗",没变就挂住,变了立刻响应。到了 Nacos 2.x,这套机制升级成了 gRPC 长连接,同时保留约 5 分钟的全量拉取做兜底,防止推送丢失导致长期不一致。
- 消息队列 :很多人以为 Kafka 是"broker 推给消费者",其实消费者一直是主动拉取的 。
fetch.min.bytes和fetch.max.wait.ms组合出的效果就是长轮询:broker 没攒够数据就等到fetch.max.wait.ms(默认 500ms)再返回。RocketMQ 的 push 消费模式,底层同样建立在长轮询之上。
| 维度 | 评价 |
|---|---|
| 实时性 | 准实时,数据一到就能响应 |
| 服务端压力 | 无数据时不产生无效请求,但每个客户端要占一个挂起连接 |
| 实现成本 | 中等(必须异步化 + 调超时) |
| 适用场景 | 想推送但改造受限(老网关、只允许 HTTP)、配置中心、消息消费 |
一句话总结:长轮询是"用连接换效率"的过渡方案,兼容性最好,但连接数等于在线人数。
四、SSE:服务端单向推送的轻量之选
原理一句话 :SSE(Server-Sent Events)是浏览器原生支持的单向 推送------客户端建立一条普通 HTTP 连接,服务端保持它不关闭,持续往里写文本。协议就是 text/event-stream,浏览器用 EventSource 对象接收。
4.1 协议长什么样
服务端响应头:
http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
响应体是一段一直往后追加的文本,每条消息由若干字段行组成:
vbnet
event: notice
id: 1001
data: {"msg":"你有新的工单"}
data: 这一行是默认的 message 事件
字段含义(以 MDN 的说明为准):
| 字段 | 作用 |
|---|---|
data: |
消息内容。连续多行 data: 会被拼接,中间插入换行符 |
event: |
事件类型,客户端用 addEventListener('notice', ...) 单独监听 |
id: |
事件 ID,会写进 EventSource 的 last event ID |
retry: |
断线后客户端的重连间隔(毫秒) |
4.2 杀手锏:自动重连 + 断点续传
这是 SSE 相比"手写长轮询"最大的优势,而且是浏览器免费送的:
- 连接断了,
EventSource会自动重连(绝大多数浏览器默认约 3 秒,可用retry:字段覆盖)。 - 重连时,浏览器会把最后收到的事件 ID 放进
Last-Event-ID请求头发过去。服务端读到它,就能从正确的位置继续发,不丢不重。
⚠️ 注意:Last-Event-ID 只是"送到你手上的接力棒",服务端要真能续传,你还得自己保存事件历史或偏移量。协议帮你把 ID 传过来了,不代表数据替你存好了。
4.3 三个必须知道的限制
- 单向。SSE 只能服务端→客户端。客户端的反馈数据要另开一个普通 HTTP 请求。
- HTTP/1.1 下连接数有限。浏览器对同一个域名的并发连接通常限制在 6 个左右,一个标签页开一个 SSE 就占一个额度;多开几个标签页,其他请求会被排队。上 HTTP/2 之后多路复用,这个问题基本消失。
- 不能自定义请求头 。
EventSource不支持设置Authorization,常见做法是用 Cookie 鉴权,或者干脆用fetch+ReadableStream自己解析text/event-stream。
🔴 头号部署坑 :链路中间的 nginx 默认开启 proxy_buffering,它会把你一条条写的消息攒着不发。表现为"本地一切正常,上了线上消息卡住半天才出来"。
nginx
location /api/sse {
proxy_buffering off; # 关键
proxy_cache off;
proxy_read_timeout 3600s; # 长连接别被读超时打断
proxy_set_header Connection '';
proxy_http_version 1.1;
}
也可以由应用在响应里加 X-Accel-Buffering: no,让 nginx 对这条响应关闭缓冲。
谁在用 :监控面板、日志实时流、任务进度条、站内通知,以及你每天都在用的------大模型流式输出 。OpenAI 兼容接口里的 stream: true,返回的就是 text/event-stream。
| 维度 | 评价 |
|---|---|
| 实时性 | 高,服务端一写客户端就收到 |
| 服务端压力 | 每客户端一条长连接,比长轮询的占用更轻(无重复请求头) |
| 实现成本 | 低,浏览器原生支持,自带重连 |
| 适用场景 | 单向流:日志、通知、进度、Token 流式生成 |
一句话总结:只需要服务端往客户端推,且不要求 IE 系浏览器,SSE 是性价比最高的选择。
五、WebSocket:一次握手后的全双工
原理一句话 :客户端先用一个 HTTP 请求申请"协议升级",服务端同意后(101 Switching Protocols),这条 TCP 连接就不再走 HTTP 了,双方改用帧通信,任何一方都能随时主动发数据。
5.1 握手:从 HTTP 丝滑切换到另一个协议
客户端请求(RFC 6455 里的标准示例):
http
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务端响应:
http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
设计得挺讨巧:借用 HTTP 完成握手 ,所以能天然穿过只认 HTTP 的代理和 80/443 端口;握手完就脱离 HTTP 语义,所以之后不再有请求头开销。Sec-WebSocket-Accept 是服务端用固定算法把客户端的 Sec-WebSocket-Key 算出来的,用来证明"我确实懂 WebSocket 协议",不是随便哪个 HTTP 服务器都能答应的。
5.2 帧、掩码与心跳
握手之后,数据以**帧(frame)**为单位传输。一个数据帧的头部最小只有 2 字节,对比 HTTP 每次请求动辄几百字节的头部------这就是 WebSocket 做高频双向通信时开销小的根本原因。
- 掩码(Mask):客户端发往服务端的帧必须做掩码处理,这是协议强制的,用来防止恶意的代理缓存投毒。
- 控制帧 :协议自带
ping/pong/close三种控制帧,ping就是标准心跳。
⚠️ 必须自己加心跳。NAT、负载均衡、反向代理会掐掉"长时间没有数据"的连接(常见是 60 秒空闲就断)。如果你的业务平时很安静,不心跳就会被静默断开,用户看到的现象是"莫名其妙就不推消息了"。
javascript
// 应用层心跳 + 指数退避重连,这是长连接客户端的标配
let retry = 1000;
function connect() {
const ws = new WebSocket('wss://example.com/ws');
ws.onopen = () => { retry = 1000; setInterval(() => ws.send('{"type":"ping"}'), 30000); };
ws.onclose = () => { setTimeout(connect, retry); retry = Math.min(retry * 2, 30000); };
}
5.3 真正的代价在运维
WebSocket 的连接是有状态的:用户 A 连在节点 1,用户 B 连在节点 2,A 给 B 发消息时,节点 1 必须把消息转给节点 2。
| 问题 | 常见解法 |
|---|---|
| 跨节点广播 | Redis Pub/Sub、NATS、Kafka 做消息总线 |
| 会话粘性 | 网关按连接做一致性路由,或干脆让节点间互相转发 |
| 单机连接数 | 调文件描述符、内存、内核参数,做压测确定容量 |
| 鉴权 | 握手阶段校验 Token(连上之后再鉴权会很别扭) |
| 安全 | 必须用 wss://,并校验 Origin 防跨站劫持 |
谁在用:IM 聊天、协同编辑(多个光标实时同步)、实时行情、在线游戏、需要客户端高频上报的场景(如白板、直播弹幕)。
| 维度 | 评价 |
|---|---|
| 实时性 | 最高,双向、低延迟 |
| 服务端压力 | 连接数即成本,扩容需要配套的消息总线和网关 |
| 实现成本 | 中高(客户端重连、服务端集群、监控都要自己做) |
| 适用场景 | 双向高频交互 |
一句话总结:WebSocket 是能力最强、也最贵的一个------如果你的场景其实只需要单向推,先想想 SSE。
六、MQTT:物联网的发布订阅标准
原理一句话 :MQTT 是 OASIS 标准、专为物联网设计的轻量发布订阅 协议。设备不直接互相通信,而是统一连到一个 broker (消息代理),发布者往主题发消息,broker 负责转发给所有订阅了该主题的客户端。
6.1 先搞懂发布订阅
text
发布者 ──publish(topic: sensor/room1/temp, payload: 26.5)──▶ broker
│
订阅者 A ◀── sensor/room1/+ ─────────────────────────────────┤
订阅者 B ◀── sensor/# ───────────────────────────────────────┘
这套模型解耦了两件事:
- 空间解耦:发布者不知道订阅者是谁、有几个、在不在线。
- 时间解耦:订阅者掉线重连后,可以补收离线期间的消息(持久会话)。
主题(Topic)用 / 分层,支持两种通配符:+ 匹配单层,# 匹配多层。
6.2 QoS:消息可靠性分三档
| 等级 | 语义 | 交互 | 适用 |
|---|---|---|---|
| QoS 0 | 最多一次 | 发完就忘,不确认 | 传感器周期上报,丢一条无所谓 |
| QoS 1 | 至少一次 | PUBLISH → PUBACK,未确认会带 DUP 重发 | 大部分业务指令,能接受重复 |
| QoS 2 | 恰好一次 | PUBLISH → PUBREC → PUBREL → PUBCOMP 四次握手 | 计费、扣款等不能重复的场景 |
⚠️ 两个容易踩的点:
- QoS 是分段协商的 。它分别存在于"发布者→broker"和"broker→订阅者"两段链路上,由发布时的 QoS 和订阅时申请的最大 QoS 共同决定。订阅端申请的 QoS 更低时,消息会被降级转发。
- QoS 1 的重复是协议层面无法避免的。重传机制决定了接收端无法区分"这是一条新消息"还是"重传的旧消息",所以业务侧必须自己做幂等,别指望 QoS 1 帮你消重。反过来,无脑全用 QoS 2 会显著增加握手开销和延迟,吞吐直接掉一档。
6.3 为什么物联网选它
- 报文极小:最小固定头部只有 2 字节,弱网和低功耗设备扛得住。
- 弱网友好:持久会话 + 遗嘱消息(设备异常掉线时由 broker 代发通知),专门为不稳定的蜂窝网络设计。
- 一对多天然分发:一次上报,多个订阅方各取所需,不用点对点建连。
- 安全:支持 TLS 加密和 OAuth 等现代认证方式。
和 WebSocket 是什么关系? 不冲突,是两层东西:MQTT 是应用层的消息模型 (跑在 TCP 上),WebSocket 是传输通道 。现实里最常见的组合是:设备端用 MQTT over TCP,浏览器前端用 MQTT over WebSocket------同一套 broker、同一套主题,Web 端也能收发。
谁在用:智能家居、车载、工业设备、物流追踪、共享设备,以及几乎所有"海量低功耗终端 + 不稳定网络"的场景。
| 维度 | 评价 |
|---|---|
| 实时性 | 高,broker 即时转发 |
| 服务端压力 | broker 需支撑海量连接,但单连接开销极小 |
| 实现成本 | 中(要引入并运维 broker,规划主题与 QoS) |
| 适用场景 | IoT 设备接入、弱网、需要分级可靠性的消息分发 |
七、横向对比与选型
7.1 一张表看完
| 维度 | 短轮询 | 长轮询 | SSE | WebSocket | MQTT |
|---|---|---|---|---|---|
| 通信方向 | 客户端拉 | 服务端"伪推" | 服务端→客户端单向 | 双向全双工 | 双向(经 broker) |
| 底层协议 | HTTP | HTTP | HTTP(text/event-stream) |
TCP(HTTP 握手升级) | TCP(可跑在 WebSocket 上) |
| 实时性 | 周期级 | 准实时 | 实时 | 实时 | 实时 |
| 无效请求 | 多 | 无 | 无 | 无 | 无 |
| 连接开销 | 无长连接 | 每客户端 1 个挂起连接 | 每客户端 1 条长连接 | 每客户端 1 条长连接 | 每客户端 1 条连接 |
| 浏览器原生支持 | 是 | 是 | 是(EventSource) |
是(WebSocket) |
否(需 SDK) |
| 自动重连 | 不涉及 | 需自己写 | 内置 | 需自己写 | SDK 内置 |
| 代理/网关友好度 | 最好 | 好 | 好(注意关闭缓冲) | 需支持 Upgrade | 好 |
| 运维复杂度 | 低 | 中 | 低 | 高(集群/广播/保活) | 中高(broker 集群) |
| 典型场景 | 状态刷新、后台看板 | 配置中心、消息消费 | 日志流、通知、LLM 输出 | IM、协同编辑、行情 | IoT 设备接入 |
7.2 按场景给结论
| 你的场景 | 选它 | 理由 |
|---|---|---|
| 实时性只要分钟级,接口能扛住 | 短轮询 | 一行 setInterval 就能跑,改造成本最低 |
| 想推送,但网关/客户端只认 HTTP | 长轮询 | 兼容性最好,所有基础设施不用动 |
| 只有服务端往客户端推,要自动重连 | SSE | 原生支持、自带重连和断点续传、运维最轻 |
| 双向、低延迟、浏览器或 App 长连接 | WebSocket | 能力最全,但要接受运维权衡 |
| 海量设备、弱网、消息可靠性要分级 | MQTT | 专为此设计,单连接开销最小、支持持久会话 |
八、常见误区与避坑
| 误区 | 后果 | 纠正 |
|---|---|---|
| 把短轮询周期设为 1 秒当"实时"用 | QPS 线性爆炸,服务端被打满 | 周期和实时性要求对齐;能改推送就改 |
| 在同步阻塞模型里做长轮询 | 线程池被挂起的请求占满,服务整体雪崩 | 必须异步化(AsyncContext / Netty / goroutine) |
| 长轮询 hold 时间大于网关超时 | 规律性 504,用户以为是网络问题 | 逐层检查 nginx、SLB、客户端超时,hold 时间要最小 |
| SSE 前面有 nginx 没关缓冲 | 消息攒着不发,本地正常线上卡顿 | proxy_buffering off; 或 X-Accel-Buffering: no |
| WebSocket 不做心跳 | 空闲 60 秒被中间设备静默掐断,消息"莫名丢了" | 应用层 ping/pong + 指数退避重连 |
| MQTT 全链路无脑 QoS 2 | 四次握手开销,吞吐和延迟双输 | 按业务语义选:能重复的用 QoS 1 + 幂等 |
| 只用 WebSocket 做单向推送 | 白白背上集群、广播、保活的复杂度 | 单向流优先评估 SSE |
九、总结:你真正需要记住的 6 件事
- HTTP 只能拉,推送必须靠"一直开着的连接"。理解这一条,五种方案就都不是黑盒了。
- 它们的差异只有两个变量:客户端多久问一次、连接能活多久。
- 选型的速记:短轮询 = 简单、长轮询 = 兼容、SSE = 单向省心、WebSocket = 双向、MQTT = 设备规模。
- 连接一长,运维成本就从 QPS 转移到连接数与保活:心跳、超时配置、集群广播、容量压测,一样都不能少。
- 任何长连接方案都必须配齐三件套 :心跳、重连、消息补发(SSE 的
Last-Event-ID/ MQTT 的持久会话 / 业务层的自增 seq)。 - 先看方向(单向还是双向)和规模(几千还是百万连接),再看实时性要求。顺序反了,多半会选重。
验证清单
- 能用一句话说清你选的方案:数据方向是什么、连接能活多久、靠什么保活
- 长轮询/SSE 服务端的 hold 时间 小于 链路上所有层(nginx、SLB、客户端)的超时
- SSE 路径的 nginx 已关闭
proxy_buffering,并实测"服务端写一条,客户端立刻收到" - WebSocket 有应用层心跳,并手动断网验证过自动重连与消息补发
- MQTT 每个主题的 QoS 都有明确理由,业务侧对 QoS 1 做了幂等处理
- 用真实并发压测过长连接容量,知道单机上限在哪
参考资源
- MDN:Server-Sent Events 使用指南 ------ developer.mozilla.org/en-US/docs/...
- MDN:
Last-Event-ID请求头 ------ developer.mozilla.org/en-US/docs/... - MDN:WebSocket API ------ developer.mozilla.org/en-US/docs/...
- RFC 6455: The WebSocket Protocol ------ www.rfc-editor.org/rfc/rfc6455
- MQTT 官方网站(OASIS 标准)------ mqtt.org/
- EMQX:MQTT QoS 0、1、2 解析 ------ www.emqx.com/zh/blog/int...
- HiveMQ:MQTT Essentials - Quality of Service levels ------ www.hivemq.com/blog/mqtt-e...
- Nacos 官方 FAQ(配置实时刷新原理与 2.x 长连接演进)------ nacos.io/blog/faq/na...