实时推送几乎是每个现代应用的标配:IM 消息、订单状态、行情、协作光标、设备遥测。选错传输协议通常不会立刻暴露问题,而是在连接数上量、弱网用户占比升高、移动端待机耗电被投诉这几个节点集中爆发。
工程上最常见的困惑有三个:WebSocket 明明能双向通信,为什么还要考虑只能服务端推送的 SSE?MQTT 在物联网里是事实标准,为什么浏览器端几乎见不到?三者都在讲「实时」,到底该按什么维度选?这些问题的答案都指向同一件事,就是它们对「谁发起、推给谁、丢了怎么办」这三个问题的默认假设完全不同。
本文只谈传输层的选型与落地细节,不涉及前端框架的封装(那属于应用层),也不展开 WebRTC 的媒体通道。我们会先对比三种协议的模型差异,再分别深入各自的工程细节:分片、心跳、重连、QoS,最后给出扩展方案与降级策略。
阅读建议:正在做选型的读者可以直接看第 1 节与文末的权衡表;已经选定协议在踩坑的读者,跳到对应小节即可。
目录
- 三种协议的定位与模型差异
- 握手开销与首包延迟
- WebSocket:分片、心跳与背压
- SSE:自动重连与 Last-Event-ID
- MQTT:QoS 等级与遗嘱消息
- 移动端弱网表现
- 连接数扩展与网关选型
- 协议降级与多路复用
- 选型决策表
1. 三种协议的定位与模型差异
先把三者的基本面摊开,很多争论在看清这张表后会自动消解:
| 维度 | WebSocket | SSE | MQTT |
|---|---|---|---|
| 承载 | TCP,HTTP/1.1 Upgrade | HTTP/1.1 或 HTTP/2 长响应 | TCP,浏览器侧需走 WebSocket |
| 方向 | 全双工 | 单向,服务端到客户端 | 全双工,经 Broker |
| 消息边界 | 帧,无应用层边界 | 事件,双换行分隔 | 报文,自带主题 |
| 拓扑 | 点对点长连接 | 点对点长连接 | 发布订阅,经 Broker |
| 内置重连 | 无 | 有 | 有(会话可续) |
| 浏览器 API | WebSocket | EventSource | 需第三方库 |
最本质的差异是拓扑。WebSocket 与 SSE 是「连接导向」的:客户端与服务端各维护一条长连接,服务端要推送就必须知道这条连接挂在哪台机器上,这直接决定了扩容时必须引入路由层。MQTT 是「主题导向」的:发布者与订阅者互不知晓对方,Broker 按主题路由,天然支持一对多与多对多,代价是多了一跳 Broker 与一套额外的协议语义。
2. 握手开销与首包延迟
三者建立连接的成本并不相同,在「频繁重连」的场景下这个差异会被放大。
WebSocket 握手是一次完整的 HTTP/1.1 请求加上 Upgrade: websocket,服务端返回 101 Switching Protocols,之后再叠加一层掩码(客户端到服务端的帧必须掩码)与可选的 permessage-deflate 压缩协商。一次典型的同机房握手在 20 到 60 ms,跨地域公网通常 100 ms 以上。若走 wss://,还多一次 TLS 握手,启用 TLS 1.3 与会话复用后可以压缩到 1-RTT 甚至 0-RTT。
SSE 复用普通 HTTP 请求,服务端返回 Content-Type: text/event-stream 并保持响应不结束。它的握手成本与一次普通 GET 相同,在 HTTP/2 下还能与其他请求共享同一条 TCP 连接,省掉额外握手。
MQTT 的 CONNECT / CONNACK 是轻量二进制报文,头部只有 2 字节,CONNECT 报文通常在 30 到 80 字节。配合 Clean Session = false 与会话过期时间,重连时能恢复订阅关系与未确认消息,这是它在弱网物联网场景胜出的关键。
WebSocket : TCP 握手 + TLS 握手 + HTTP Upgrade + 101 约 1.5~2 RTT
SSE : TCP 握手 + TLS 握手 + GET + 首字节 约 1~1.5 RTT(HTTP/2 可复用)
MQTT : TCP 握手 + CONNECT/CONNACK 约 1~1.5 RTT(报文更小)
结论很直接:一次性连接、长期持有的场景,握手差异可以忽略;而移动端频繁切换网络、连接反复重建的场景,MQTT 的会话恢复能力价值最大。
3. WebSocket:分片、心跳与背压
3.1 分片与消息边界
WebSocket 帧分为数据帧与控制帧,数据帧又分文本与二进制,并带 FIN 标志表示是否为消息的最后一帧。一条应用消息可以被切成多个帧(fragmentation),但浏览器端的 send() 通常直接发一条完整消息,真正需要手工分片的是大文件传输这类场景。
const ws = new WebSocket("wss://example.com/realtime");
ws.binaryType = "arraybuffer";
const CHUNK = 64 * 1024; // 64KB 一片,避免单帧过大阻塞其他消息
function sendLarge(buffer) {
for (let offset = 0; offset < buffer.byteLength; offset += CHUNK) {
const isLast = offset + CHUNK >= buffer.byteLength;
// 应用层自定义协议头:1 字节标志 + 4 字节序号 + 载荷
const header = new Uint8Array(5);
header[0] = isLast ? 1 : 0;
new DataView(header.buffer).setUint32(1, offset / CHUNK);
ws.send(new Blob([header, buffer.slice(offset, offset + CHUNK)]));
}
}
注意 WebSocket 协议本身没有应用层消息边界之外的分片语义保证——即使协议层分了片,接收端拿到的也是一条完整消息。因此大消息的分片必须在应用层自己做,并显式处理乱序与超时。
3.2 心跳保活
长连接最容易被忽略的是「假死」:TCP 连接在 NAT 设备上超时被回收,但两端都没有收到 FIN,于是应用以为还连着,实际已经发不出去了。解决方式是应用层心跳。
class Heartbeat {
constructor(ws, intervalMs = 25000, timeoutMs = 10000) {
this.ws = ws;
this.intervalMs = intervalMs;
this.timeoutMs = timeoutMs;
this.lastPong = Date.now();
this.timer = setInterval(() => {
if (Date.now() - this.lastPong > this.intervalMs + this.timeoutMs) {
console.warn("心跳超时,主动重连");
ws.close(4000, "heartbeat timeout");
return;
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", ts: Date.now() }));
}
}, this.intervalMs);
}
onPong() {
this.lastPong = Date.now();
}
stop() {
clearInterval(this.timer);
}
}
25 秒是一个经验值:多数运营商 NAT 的 UDP/TCP 映射超时在 30 到 300 秒之间,25 秒能稳定抢在回收之前。另外必须用「服务端回 pong」而不是只看 send 成功,因为 send 只表示写进了内核缓冲区。
3.3 背压
ws.bufferedAmount 表示尚未发出去的字节数。当它持续增长时,说明下游消费不过来,此时继续 send 只会让内存膨胀:
function safeSend(ws, payload, maxBuffered = 4 * 1024 * 1024) {
if (ws.bufferedAmount > maxBuffered) {
console.warn("发送缓冲区积压,丢弃非关键消息");
return false;
}
ws.send(payload);
return true;
}
服务端的背压处理与客户端不同,需要更细的策略:区分可丢弃消息(光标、状态)与不可丢弃消息(指令、确认),只对后者做排队与限流,对前者在积压时直接丢弃。
4. SSE:自动重连与 Last-Event-ID
4.1 协议格式
SSE 的报文格式极其简单,四类字段:data、event、id、retry,事件之间用空行分隔。
event: order
id: 1024
retry: 3000
data: {"orderId":"A1","status":"paid"}
服务端只要保持响应流不关闭、按这个格式持续写入,浏览器就会逐条触发 onmessage 或对应的事件监听。
4.2 自动重连与断点续传
EventSource 内置重连:连接断开后浏览器会按 retry 指定的毫秒数(默认 3 秒)自动重连,并在重连请求头中带上 Last-Event-ID,值是最后收到事件的 id。服务端据此可以从断点继续推送,实现断点续传。
const es = new EventSource("/stream/orders", { withCredentials: true });
es.addEventListener("order", (e) => {
console.log("事件 id:", e.lastEventId, JSON.parse(e.data));
});
es.onerror = () => {
// readyState 为 CONNECTING 时浏览器会自动重连,无需手工处理
if (es.readyState === EventSource.CLOSED) {
console.error("连接被永久关闭,需要重建");
}
};
服务端侧要配合这个机制,把最近一段事件缓存在带序号的结构里(内存环形缓冲或 Redis Stream),收到 Last-Event-ID 时先补发缺口再转实时。缺少这一步,「自动重连」就只是重连,丢掉的消息再也回不来。
4.3 SSE 的硬限制
- 单向:客户端要向服务端发消息只能另开一条 HTTP 请求。
- HTTP/1.1 下每域名 6 条连接上限,多个标签页容易互相挤占。
- 部分反向代理会缓冲响应,导致事件被攒着一起发,必须关闭缓冲。
- 二进制数据需要 Base64 编码,体积膨胀约 33%。
location /stream/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # 关键:关闭响应缓冲
proxy_cache off;
proxy_read_timeout 3600s;
}
5. MQTT:QoS 等级与遗嘱消息
5.1 三种 QoS
MQTT 的核心价值在于把可靠性做成了可选的等级:
| QoS | 语义 | 报文往返 | 适用 |
|---|---|---|---|
| 0 | 至多一次,发完即忘 | 1 | 高频遥测、可丢的状态 |
| 1 | 至少一次,可能重复 | 2 | 指令下发,需幂等 |
| 2 | 恰好一次,四步握手 | 4 | 计费、不可重复的动作 |
QoS 1 的重复是常态而非异常,因此消费侧必须幂等,这与 分布式幂等与可靠性 讲的是同一类问题:用消息 ID 或业务键去重,而不是指望传输层不重发。
5.2 遗嘱消息与保留消息
两个容易被低估的特性:
- 遗嘱消息(Will):客户端在
CONNECT时声明一条消息,当连接异常断开时由 Broker 代为发布,用于「设备离线」通知。它是物联网在线状态的标准实现方式。 - 保留消息(Retain):Broker 为每个主题保留最后一条消息,新订阅者一订阅立刻收到,解决「订阅晚于发布」的冷启动问题。
import mqtt from "mqtt";
const client = mqtt.connect("wss://broker.example.com:8084/mqtt", {
clientId: `web-${crypto.randomUUID()}`,
clean: false,
keepalive: 30,
will: {
topic: "presence/web-1",
payload: JSON.stringify({ online: false }),
qos: 1,
retain: true,
},
});
client.on("connect", () => {
client.subscribe("room/+/events", { qos: 1 });
client.publish("presence/web-1", JSON.stringify({ online: true }), { qos: 1, retain: true });
});
5.3 主题设计与通配符
MQTT 主题用 / 分层,支持 +(单层通配)与 #(多层通配)。主题设计应遵循「从粗到细」的原则,例如 tenant/{id}/device/{id}/telemetry,这样权限控制可以按前缀粒度下发。注意 # 订阅会放大 Broker 的匹配成本,高并发下应避免让大量客户端订阅 #。
6. 移动端弱网表现
移动端是三种协议差异最明显的地方,主要体现在四个维度:
| 维度 | WebSocket | SSE | MQTT |
|---|---|---|---|
| 网络切换恢复 | 连接失效,需重建 | 浏览器自动重连 | 会话恢复,订阅不丢 |
| 待机耗电 | 心跳频率决定 | 依赖浏览器调度 | keepalive 可调,较省 |
| 断线期间消息 | 全丢 | 靠 Last-Event-ID 补 | 靠会话队列补 |
| 弱网表现 | 一般 | 一般 | 较好(报文小) |
移动端切网(Wi-Fi 到 4G)会让原 TCP 连接彻底失效,所有基于 TCP 的方案都必须重建。区别在于重建后能否恢复状态:MQTT 的持久会话能恢复订阅与离线消息,SSE 能靠 Last-Event-ID 补缺口,而裸 WebSocket 什么都没有,必须由应用层自己设计补拉协议。
因此移动端用 WebSocket 时,务必在重连后调用一次「同步接口」拉取断线期间的数据,而不是假设消息不会丢。
7. 连接数扩展与网关选型
单机承载长连接的能力受限于文件描述符、内存与中断处理。以 Linux 为例,单机 10 万到 100 万连接是常见区间,主要瓶颈是每连接的内核缓冲区内存(通常 4 到 16 KB)与心跳带来的定时器开销。
扩展的核心问题是「消息如何找到连接所在的机器」,三种协议的解法不同:
- WebSocket / SSE:连接是有状态的,需要引入路由层。常见做法是用 Redis 或 NATS 做实例间的消息总线,广播或按用户 ID 路由。具体方案与踩坑见 WebSocket 集群扩展 。
- MQTT:Broker 集群本身承担路由,客户端只与「就近的一个 Broker」连接,节点间用桥接或集群协议同步订阅关系。
网关层还需要处理:TLS 卸载、连接鉴权、按租户限流、协议升级(HTTP 到 WebSocket)。反向代理要显式支持 Upgrade 头并延长读超时,否则长连接会被周期性掐断。
8. 协议降级与多路复用
生产环境不应假设单一协议永远可用。企业内网可能禁掉非标准端口,老旧代理可能不支持 Upgrade,某些环境会拦截 text/event-stream。因此需要一条降级链:
- 首选 WebSocket,握手失败或 3 秒内未收到
101则降级。 - 降级到 SSE 加一条上行 HTTP 通道。
- 再降级到长轮询(long polling),延迟上升但兼容性最好。
async function connectRealtime(url) {
try {
return await tryWebSocket(url, 3000);
} catch (e) {
console.warn("WebSocket 不可用,降级 SSE", e.message);
}
try {
return await trySSE(url);
} catch (e) {
console.warn("SSE 不可用,降级长轮询", e.message);
}
return startLongPolling(url);
}
另一条路是多路复用:把信令、通知、状态等多种消息收敛到一条连接上,用应用层协议头区分类型。WebSocket 天然适合做这件事,而 HTTP/3 的 QUIC 多路复用则从传输层提供了另一种可能,两者的取舍可参考 HTTP/3 与 QUIC 。注意多路复用的代价是「队头阻塞」在应用层重现,一条大消息会卡住后续所有消息,因此仍需在应用层做优先级与分片。
9. 选型决策表
| 场景 | 推荐 | 理由 |
|---|---|---|
| IM、协作、游戏 | WebSocket | 双向、低延迟、可控 |
| 行情、通知、日志流 | SSE | 单向、实现极简、自带重连 |
| 海量设备遥测 | MQTT | 发布订阅、QoS 可选、会话恢复 |
| 移动端为主且弱网 | MQTT 或 WebSocket 加补拉 | 会话恢复能力关键 |
| 已有 MQTT 基础设施的 Web 端 | mqtt.js over WebSocket | 复用后端,前端零改造 |
权衡取舍
| 取舍点 | 偏向 WebSocket | 偏向 SSE | 偏向 MQTT |
|---|---|---|---|
| 实现复杂度 | 中,需自建重连与心跳 | 低,浏览器兜底 | 中,需部署 Broker |
| 服务端状态 | 每连接一份,需路由 | 同左 | Broker 统一管理 |
| 上行能力 | 有 | 无 | 有 |
| 一对多广播 | 需自己做 | 需自己做 | 原生支持 |
| 运维成本 | 中 | 低 | 高(Broker 集群) |
| 生态与工具 | 极丰富 | 一般 | 物联网领域成熟 |
判断顺序建议是:先看方向(只需服务端推就用 SSE),再看拓扑(需要一对多就用 MQTT),最后看基础设施(已有 MQTT Broker 就别再自建 WebSocket 网关)。
常见坑清单
- 只发心跳不校验 pong,连接假死后长时间不重连。
- 用
setInterval发心跳却不检查readyState,在CONNECTING状态下报错。 - 忽略
bufferedAmount,慢客户端把服务端内存撑爆。 - SSE 未关闭反向代理缓冲,事件被攒到几十条才一起下发。
- SSE 服务端不实现
Last-Event-ID补发,重连即丢消息。 - 裸 WebSocket 断线重连后不做状态补拉,静默丢消息。
- MQTT 用 QoS 1 却不做消费幂等,重复消息造成业务重复执行。
- 让大量客户端订阅
#通配主题,Broker 匹配开销随订阅数暴涨。 - 心跳间隔设得比 NAT 超时还长,连接被静默回收。
- 降级链只做 WebSocket 到轮询,跳过 SSE,白白损失延迟。
小结
三种协议没有绝对优劣,只有假设不同:WebSocket 假设「双方对等且需要双向」,SSE 假设「服务端是唯一信源」,MQTT 假设「多对多且设备不可靠」。选型时先把业务映射到这三个假设上,再看基础设施与运维成本,结论通常会很清晰。
落地层面的三条主线是:WebSocket 必须自己补齐心跳、重连与补拉;SSE 的价值全在 Last-Event-ID 的正确实现上;MQTT 的可靠性依赖 QoS 与消费端幂等的配合。三者都需要一个能在多实例间路由消息的网关层,这一层的设计可以继续参考 信令与实时通道的架构设计
中的状态管理思路。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。