实时消息传输选型:WebSocket、SSE 与 MQTT

从传输层对比 WebSocket、SSE 与 MQTT:拆解握手开销与首包延迟、WebSocket 分片与心跳保活、SSE 自动重连与 Last-Event-ID 断点续传、MQTT 的 QoS 等级与遗嘱消息,并给出移动端弱网表现、连接数扩展与网关选型、协议降级与选型决策表。

实时推送几乎是每个现代应用的标配:IM 消息、订单状态、行情、协作光标、设备遥测。选错传输协议通常不会立刻暴露问题,而是在连接数上量、弱网用户占比升高、移动端待机耗电被投诉这几个节点集中爆发。

工程上最常见的困惑有三个:WebSocket 明明能双向通信,为什么还要考虑只能服务端推送的 SSE?MQTT 在物联网里是事实标准,为什么浏览器端几乎见不到?三者都在讲「实时」,到底该按什么维度选?这些问题的答案都指向同一件事,就是它们对「谁发起、推给谁、丢了怎么办」这三个问题的默认假设完全不同。

本文只谈传输层的选型与落地细节,不涉及前端框架的封装(那属于应用层),也不展开 WebRTC 的媒体通道。我们会先对比三种协议的模型差异,再分别深入各自的工程细节:分片、心跳、重连、QoS,最后给出扩展方案与降级策略。

阅读建议:正在做选型的读者可以直接看第 1 节与文末的权衡表;已经选定协议在踩坑的读者,跳到对应小节即可。

目录

  1. 三种协议的定位与模型差异
  2. 握手开销与首包延迟
  3. WebSocket:分片、心跳与背压
  4. SSE:自动重连与 Last-Event-ID
  5. MQTT:QoS 等级与遗嘱消息
  6. 移动端弱网表现
  7. 连接数扩展与网关选型
  8. 协议降级与多路复用
  9. 选型决策表

1. 三种协议的定位与模型差异

先把三者的基本面摊开,很多争论在看清这张表后会自动消解:

维度WebSocketSSEMQTT
承载TCP,HTTP/1.1 UpgradeHTTP/1.1 或 HTTP/2 长响应TCP,浏览器侧需走 WebSocket
方向全双工单向,服务端到客户端全双工,经 Broker
消息边界帧,无应用层边界事件,双换行分隔报文,自带主题
拓扑点对点长连接点对点长连接发布订阅,经 Broker
内置重连无有有(会话可续)
浏览器 APIWebSocketEventSource需第三方库

最本质的差异是拓扑。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. 移动端弱网表现

移动端是三种协议差异最明显的地方,主要体现在四个维度:

维度WebSocketSSEMQTT
网络切换恢复连接失效,需重建浏览器自动重连会话恢复,订阅不丢
待机耗电心跳频率决定依赖浏览器调度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。因此需要一条降级链:

  1. 首选 WebSocket,握手失败或 3 秒内未收到 101 则降级。
  2. 降级到 SSE 加一条上行 HTTP 通道。
  3. 再降级到长轮询(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 与消费端幂等的配合。三者都需要一个能在多实例间路由消息的网关层,这一层的设计可以继续参考 信令与实时通道的架构设计 中的状态管理思路。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播