ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MQTT与WebSocket深度对比:消息协议与传输协议的边界与融合

MQTT与WebSocket深度对比:消息协议与传输协议的边界与融合

1. 从单向消息推送的旧方案说起:为什么要把 MQTT 和 WebSocket 放到同一篇笔记里

我整理这篇计算机理论文档阅读笔记的起因,是一次很典型的选型争论:某块业务需要“服务器主动给客户端推送数据”,团队里一部分人提 MQTT,另一部分人提 WebSocket。两边都能说出理由,但争论一直往下走的时候就发现,双方根本不是在同一个抽象层上讨论问题。MQTT 用户关心的是“消息有没有可靠送达、离线之后怎么办、Broker 能不能帮忙缓存”,WebSocket 用户关心的是“浏览器和服务器之间能不能建立一条低延迟的双向通道、帧数据怎么解析”。一个站在消息语义层,一个站在传输通道层,硬放在一起对比,会越对比越混乱。

这篇笔记不是传统意义上的读后感,更像是我带着问题去回读协议规范、技术通稿以及一些内部讨论文档之后,把容易混淆的知识点重新拆开一遍的记录。标题虽然是“MQTT vs WebSocket”,但最后我得到的结论并不是“谁替代谁”,而是“两者面向的问题域不同,甚至经常出现在同一条技术链路上”。

先给一个基础判断:MQTT 属于消息协议,核心建模对象是“消息的发布者、订阅者、主题、服务质量”;WebSocket 属于传输类协议,核心建模对象是“客户端与服务器之间的一条持久连接、全双工数据帧”。这句话看起来简单,但它是整篇笔记的骨架。后面所有关于报文格式、握手方式、重连机制、应用场景的对比,都在给这一句话做注脚。

如果把这篇笔记当成一份导览,我要记录的核心问题有这么四个:第一,为什么 MQTT 的文档会花大量篇幅描述“连接保活”“遗嘱消息”“会话状态”;第二,为什么 WebSocket 的文档几乎不讨论消息可靠性;第三,两者在真实链路上的字节开销与行为差异到底有多大;第四,工程上如何同时使用它们。

2. MQTT 文档精读:低带宽、高延迟、弱连接的发布/订阅协议

2.1 阅读提纲:为什么 MQTT 规范的行文顺序是“连接优先,消息其次”

我在读 MQTT 相关文档时第一个强烈感受是:它开篇没多久就开始讲“客户端建立连接”“心跳保活”“遗嘱消息”,而不是像很多应用协议那样直接进入“消息格式说明”。这个顺序本身就是在告诉你,MQTT 面对的典型网络环境是老旧的、不稳定的、高延迟的物联网链路。设备可能一天掉线很多次,可能长时间处于休眠状态,可能所在网络的 NAT 映射会周期性失效。如果协议设计者不先把“连接怎么活着”这条路走通,后面再设计多少消息格式都是白搭。

文档里对“遗嘱消息(Will Message)”的定位我重读了三遍才真正理解:客户端在建立连接时,可以声明一个遗嘱主题和遗嘱内容。如果客户端在异常断线时没来得及清理连接,Broker 就会代为发布这条遗嘱消息。典型用途是设备掉线告警,比如一个温度传感器网络里,设备 A 在连接建立时设置遗嘱“设备A离线”,当设备 A 长时间没有心跳而 Broker 判定其连接断开时,订阅该主题的其他端就收到这条自动消息。这里最关键的地方在于,遗嘱不是客户端主动发的,而是由 Broker 代表客户端发的,它把“客户端异常消失”这件事转变成了“一条可被订阅者感知的消息”。

“连接保活”的设计逻辑也值得细看。文档要求客户端在两个相邻的控制报文之间,维持一个最大时间间隔,默认场景下大约 60 秒。如果客户端在这个间隔内没有发出任何其他控制报文,就必须发送一个专用的心跳报文。Broker 如果在超过一定时间后没有收到任何报文,就可以判定连接失效,进而触发遗嘱消息等清理动作。这个机制解决的核心矛盾是:底层 TCP 连接可能在路由器、防火墙或运营商策略的干预下已经静默断开了,但应用层还不知道。定期心跳相当于双方约定好“如果你还在,请定期告诉我”,而不是让一端一直傻等。

用大道理套一下,MQTT 文档的结构是在反复表达一个理念:消息可靠性必须建立在“对连接生命周期的认真管理”之上。没有持久连接的健康度,就谈不上消息能否被稳妥地送达。

2.2 QoS 0/1/2:这三种等级不是“快慢”的区别,而是可靠性契约的差异

MQTT 文档里另外一个容易让人误解的概念,是服务质量等级 QoS。我第一次接触时,直觉是 QoS 0 快、QoS 1 不快不慢、QoS 2 慢但最安全,这个说法本质上没错,但它把学习重点带偏离了。文档想表达的是,三个等级分别解决了不同的重复与丢失问题,而不是简单的速度档位。

QoS 0 意味着“最多一次”,发送方丢出消息之后不再关心结果。适用于丢失可容忍的周期读数,比如温度传感器每一分钟上报一次,偶尔丢一拍完全不影响后续趋势判断。这个等级在三个等级里几乎没有额外报文字段,是最小成本的选择。

QoS 1 意味着“至少一次”,发送方需要等待接收方返回确认应答。如果发送方没有及时收到确认,就会重发消息。这个方案带来的副作用是:接收方可能收到重复消息。协议文档并没有帮你把重复数据去重,它只是保证消息不丢,除非你在应用层做幂等处理,否则重复是一条常态。换句话说,QoS 1 的真实语义是“我知道你可能收到两遍,但我至少要确保你收到一遍”。

QoS 2 经常被过度神话。它被表述为“恰好一次”,但协议实现时并不是靠一个确认包走完的,而是采用了两轮报文交换。第一轮是 PUBLISH,接收方返回 PUBREC;第二轮是发送方发送 PUBREL,接收方返回 PUBCOMP。为什么需要两轮?因为要在发送方、接收方各自的状态机里把消息的发送与确认变成可恢复的,要处理报文丢了之后的重发,还要保证即使重发也不会被重复递交给应用层。这个设计的代价就是报文数量更多、交互复杂度更高,在嵌入式低功耗设备上必须谨慎使用。

所以我在笔记里写的自己的简要结论是:QoS 0 适合高频低价值数据,QoS 1 适合默认数据通道,QoS 2 适合“一份命令只能执行一次”的高价值指令。但高价值指令如果落在 MQTT 上,即使选了 QoS 2,应用层仍然要做好幂等兜底,因为协议保证的是投递层面的语义,不等于业务层面的“只执行一次”。这一点,文档读得越细越能体会到。

2.3 额外隐藏在规则里的细节:保留消息、订阅过滤与 session 状态

MQTT 规范里还有一批很实用但容易被博客文章一笔带过的规则。保留消息是其中之一:发布者可以在发布消息时标记 retain 位,Broker 会把这个消息保留在对应主题上。后续新订阅者接入后,不必等下一次新数据,Broker 会立即把这条保留消息推给新订阅者。这个机制对“设备状态同步”特别合适,比如一个智能网关上线后,订阅一个设备状态主题,如果该主题有保留消息,网关第一时间就能知道当前状态,而不是干等下一次状态变化。

订阅主题里的通配符规则也有讲究。MQTT 的主题名是分层的,用/分层,比如home/livingroom/temperature。订阅方可以用+匹配单层、用#匹配多层。我在文档阅读笔记里专门标注过一个容易踩的细节:不是所有消息系统都允许#出现在主题中间,MQTT 规定#必须是最后一个字符,而且#匹配的是“父层以及之后的所有层”,语义上和正则里的*有差别。理解这一点,在写设备端订阅逻辑时能省不少调试时间。

session 状态也是文档契约里非常重要的一环。客户端建立连接时有一个 clean start 概念:如果 clean start 为真,Broker 不保留客户端的会话状态;如果为假,Broker 会保存客户端的订阅关系、未确认消息、离线消息等内容。简单类比,这就是“关掉 App 之后之前订阅还在不在”的问题。在弱网络环境里,设备经常离线重连,如果每次重连都清空 session,重连后订阅关系需要重新建立,断线期间的消息就拿不到了。所以文档建议对“重要设备”使用持久会话。但持久会话也有代价:Broker 需要为每个离线客户端维护状态,设备数量一大,内存和存储压力会明显上升,需要提前规划。

我个人的阅读体会是:MQTT 文档表面上在讲报文、主题、握手,实际是在讲一套“边缘网络下如何优雅处理不确定性”的套路。设备会掉线、链路会抖动、消息会重复、顺序会乱,协议能做的不是假设网络完美,而是把这些不确定性变成可量化、可选择的机制。

3. WebSocket 文档精读:一个把“升级、全双工、帧”逐段展开的协议规范

3.1 握手阶段:HTTP Upgrade 和随机 Sec-WebSocket-Key 的作用

WebSocket 的文档阅读路径和 MQTT 完全相反。MQTT 里的客户端一上来就要发 CONNECT 报文,而 WebSocket 的第一步是通过 HTTP 完成“升级”。客户端发送一个带 Upgrade 头部的 HTTP 请求,比如Upgrade: websocket,服务端如果支持,则返回101 Switching Protocols,之后这条 TCP 连接不再按 HTTP 语义工作,而是进入 WebSocket 帧传输状态。

我读文档时特别留意了其中Sec-WebSocket-Key字段的设计意图。客户端会生成一段随机值,服务端把它和一段固定字符串拼接后做哈希,再把结果通过Sec-WebSocket-Accept返回给客户端。很多人把它当成“加密认证”,其实它更重要的作用是让两端都能确认“对方真的理解了 WebSocket 协议”,避免因为代理或缓存服务器把普通 HTTP 与 WebSocket 搞混。它解决的是一种“协议升级后的语义确认”问题,而不是身份认证问题。真正认证用户身份的逻辑,仍需在应用层通过 token、cookie 等机制完成。

这个握手过程还带来一个值得记住的工程约束:WebSocket 连接必须从客户端发起,而且通常走 80 或 443 端口。规范没有规定只能从浏览器使用,但在实际部署里,浏览器被放开是最核心的诉求。所以 WebSocket 文档里大篇幅围绕“浏览器、同源策略、代理穿透”做描述,我一点都不意外。它是带着“被 HTTP 世界接纳”的包袱设计出来的,和 MQTT 这种纯为消息流而生的协议出发点完全不同。

3.2 数据帧与分片:为什么会要求 16 bit 长度和掩码

握完手之后,WebSocket 规范就进入了数据帧说明。我先用一个最简拆法来描述单帧结构:先是 FIN 位和 opcode,用于标识帧是否结束、当前帧是二进制还是文本;然后是 mask 位和 payload len;如果长度较大会走扩展长度字段;再接着是可选掩码密钥;最后才是真正要传输的 payload。这个结构表面复杂,但设计意图很清晰:解析器不需要依赖外部上下文,只靠帧头几个字节就能知道“这一帧数据有多长、后面要不要继续拼接”。

为什么要允许分片关系?文档中说,发送方可以把一个消息拆成多个帧,只有最后一个帧的 FIN 位置 1。这个设计对“不知道总长度的大消息”非常有用。浏览器生成本地文件上传或者长文本发送时,可以一边读取数据一边发送,不需要等整块数据全部进内存。对于接收方,只要按照 opcode 与 FIN 位把多个帧重新拼接就可以还原完整消息。这个机制给应用层留了自由度,也意味着:如果你不做特殊处理,接收方拿到的“一条消息”可能是由好多个独立帧拼出来的。

还有一个容易让初学者产生疑问的点:为什么规范要求浏览器客户端发送的所有帧都必须做掩码处理?看起来多了一串随机 4 字节密钥和一次逐字节异或操作,什么收益都没有。实际上,这个设计是被早期 Web 环境的“缓存污染风险”逼出来的。当浏览器发起 WebSocket 连接时,网络路径上可能有 HTTP 缓存设备,它们可能把数据误认为 HTTP 响应缓存下来,后续被其他请求读取到敏感内容。客户端掩码伪装了载荷,让缓存设备无法通过模式识别确定数据含义,从而降低这种风险。

从这一点能看到 WebSocket 文档的设计逻辑:它必须在一个充满中间设备、兼容性和安全焦虑的 Web 环境里工作,所以“防御性设计”比“极致简洁”更重要。这种取舍,放到 MQTT 文档里是找不到的。

3.3 浏览器接入决定了它的设计边界:没有离线队列,没有消息级 QoS

读完 WebSocket 文档最容易被反问的一个问题是:它为什么不提供发布/订阅模型?为什么不提供 QoS?答案是,WebSocket 的抽象层级根本不负责这些。

WebSocket 的定位更接近一个“通用双向字节管道”:你建立一条连接,就可以在任意时刻双向传输字节。至于这些字节是一行命令、一条聊天消息、一段二进制压缩流,它不关心。它也不负责“当你断线时把数据缓存下来,等重连后继续”。规范里虽然有 ping/pong 控制帧,但 ping/pong 更多是用于探测连接是否还活着,不是为了消息可靠投递。一旦连接断开,操作系统和网络环境会把它当作普通 TCP 连接销毁,之前传输中的消息如果没有被应用层确认过,就当作丢失处理。

所以我在阅读笔记里给 WebSocket 盖了一个总结:它把“持续连接上的双向帧传输”这件事做到了标准统一,但把“消息语义、路由、重试、离线补发”全部留给了应用层。这不代表它不好,而是说,任何工程判断离开了这个边界就会变得奇怪。比如用 WebSocket 在后端服务之间做海量消息队列,等于重新实现一个消息中间件,代价不小。

也正是因为这样,我在实际做技术对比时从来不会把“WebSocket 能否替代 MQTT”当成一个可以一句话回答的问题。在浏览器与服务器之间,替代 TCP 做全双工通道,WebSocket 是自然选择;在设备与云端之间做弱网消息可靠传输,MQTT 是自然选择。它们两边的文档其实已经各自把边界画得很清楚了。

4. 一帧一包对照:MQTT 和 WebSocket 在传输层面的真实差异

4.1 MQTT 的一次 PUBLISH 报文由哪几块组成

文档读得再深,最后还是要落到字节层面看开销。这里我用一个最简单的情景:客户端发布一条 payload 只有 5 字节、主题名只有 6 字节的消息。假如使用 QoS 0,MQTT 报文结构大概是这样:

  • 固定头:一个字节包含报文类型和标志位,加上一个字节的剩余长度。
  • 变量头:主题长度占 2 字节,主题内容占 6 字节。
  • 载荷:5 字节。

去掉主题本身,协议附加开销大概是固定头 2 字节加上主题长度字段 2 字节,一共 4 字节。主题内容 6 字节本质上是业务地址,不能算纯空耗但也不能完全无视。整个 publish 消息从 MQTT 客户端视角看,大约就是 9 字节 payload 相关部分加 4 字节控制开销。

如果改用 QoS 1,报文里还要增加一个 2 字节的消息标识符,用于匹配 PUBLISH 与 PUBACK,同时发送后还要等待一条 PUBACK 应答,链路往返次数增加。QoS 2 则要额外经历 PUBREC、PUBREL、PUBCOMP,控制开销和交互次数都会进一步增加。所以仅从“单条消息的最小字节成本”看,MQTT 已经压缩得相当小。在智能设备那种动辄几百字节一条消息的环境里,这是非常有价值的优势。

另外需要提一个文档里常常用示例强调的细节:MQTT 的固定头里剩余长度字段使用变长编码,可以占 1 到 4 个字节。当消息载荷达到一定阈值后,这个可变字段会额外增加一个字节。计算带宽时不要永远按最小值估,要在前端设计时给变长开销留出余量。

4.2 WebSocket 发送一帧数据时,实际在链路里走了什么

WebSocket 单帧的字节开销可以这样估算:如果是一帧文本消息,载荷小于 126 字节,帧头是 FIN/opcode 一个字节、mask 位和长度一个字节、掩码密钥 4 字节。如果服务端发送数据给客户端,规范允许服务端不做掩码,则可以省掉 4 字节;如果是从浏览器客户端发往服务端,掩码密钥 4 字节就省不掉。所以客户端发送载荷 5 字节的文本帧时,最小帧头可能是 6 字节,总线上大约是 11 字节;服务端发送同样载荷时,可能只需要 2 字节帧头加上 5 字节载荷,总共 7 字节。

如果再叠加 HTTP 握手时的开销,两者的差异就被放大了。WebSocket 建立连接需要先完成一次 HTTP Upgrade 请求与 101 响应,这段数据通常有几百字节,还包括 Cookie、认证头等业务字段。MQTT 的 CONNECT 报文虽然也要携带 ClientID、用户名等信息,但在规模上通常比 HTTP 头瘦身不少。因此,如果链路极其关注“每次连接的建立成本”,WebSocket 在握手环节并不便宜。

不过这里要和前面 MQTT 的附加开销区分开:MQTT 的一次发布是一次性动作,发布者不需要先升级什么;WebSocket 的数据发送是在连接维持期间反复利用握手成果。所以在长期存在的连接上,WebSocket 的每帧开销并不高,真正的成本在低频场景下被平摊后通常可以接受。这也是为什么我始终强调,脱离“连接时长”去比较报文大小没有太大意义。

4.3 握手、再连接、保活:三张对比表的具象化

为了把前面分散的细节收敛起来,我在笔记的最后部分画了几张对比表。这里的“画表”不是指流程图,而是用表格把关键差异列清楚,方便回查。

维度MQTTWebSocket
抽象层级消息层:主题、发布、订阅、服务质量传输层:双向字节帧通道
连接建立客户端发 CONNECT,服务端回 CONNACK客户端发 HTTP Upgrade,服务端回 101
路由能力有主题树和通配符订阅,天然多对多一对一连接,路由需应用层实现
离线能力持久会话可缓存订阅与离线消息默认不保留任何离线数据
消息可靠性原生 QoS 0/1/2 可选无消息级可靠性,需应用层补齐
保活机制控制报文级别的 ping,超时触发遗嘱ping/pong 控制帧,只探测存活
典型运行位置设备、网关、服务端浏览器、App、Web 服务端

再做一张“失败处理”的比较表会更有意思。MQTT 里,设备断线重连后,可以恢复之前未完成的消息流程;WebSocket 里,连接断开就意味着之前的帧序列终止,重连后要从零开始。这直接影响到架构设计:一个依赖 MQTT 的设备可以“断线继续干活”,一个依赖 WebSocket 的聊天前端则更依赖应用层的“断线重连状态机”。

实际抓包场景里,MQTT 的连接失败通常会表现为 CONNACK 返回错误码,比如协议版本不匹配、客户端标识符被占用、认证失败等。WebSocket 握手失败则是 HTTP 状态码体现,比如 403、404,或者干脆是同一个端口上的普通 HTTP 响应。排障时看到这两类响应,就要立刻意识到自己站在哪一层:前者在说“MQTT 会话被 Broker 拒绝”,后者在说“HTTP 网关没有完成协议升级”。

5. 从文档回到工程选型:什么时候用哪个协议,或者干脆两层都用

5.1 一个可复用的混合架构:浏览器走 WebSocket,后端与设备走 MQTT

读完这两类文档后,我在实际系统里最常用的是一种混合架构。大致链路是:设备与后端服务之间维持 MQTT 连接,后端服务把收到的设备消息写入一个内部消息总线;当某个浏览器页面需要实时展示设备状态时,浏览器先通过 WebSocket 连上一个实时推送服务,实时推送服务内部订阅 MQTT 上的对应主题,再把消息转发成 WebSocket 帧推给页面。

这个架构的好处是把两个协议变成各自擅长的段。设备侧强网络不确定,MQTT 的 QoS、持久会话、遗嘱机制正好兜底;浏览器侧必须走 Web 标准链路,WebSocket 从握手到帧解析都有成熟支持。后端可以按主题把 MQTT 消息映射成 WebSocket 推送,也可以反过来把浏览器发来的控制指令转成 MQTT 消息下发到设备。唯一要小心的,是在桥接结构里做协议转换时的“语义损耗”。

比如 MQTT 有 QoS 2,但浏览器端 WebSocket 没有对应机制。如果一条控制指令从浏览器发出,经 WebSocket 传到后端,再转换成 MQTT QoS 1 消息下发,整个链路的可靠性等同于最低那一环还是最高那一环,取决于后端如何处理。通常的做法是,WebSocket 客户端在发送指令后等待应用层 ACK,服务端在确认 MQTT 消息已经成功发布之后,再回这一个 ACK。这个 ACK 与 MQTT 本身的 PUBACK 是两回事,需要单独设计。这是文档阅读笔记很容易忽略的地方,但恰恰是线上事故多发的根源。

5.2 选型决策清单:把阅读笔记变成可复查的判断题

我给自己整理过一个选型决策清单,核心只有几个问题。第一,消息是否需要在中转节点上长期保存,为离线者补发?如果是,MQTT 的持久会话和 retained 消息明显更合适。第二,通信双方是否天然是“一人发、多人订阅”的关系?如果是,MQTT 的主题发布订阅模型可以直接复用。第三,客户端是否被限制在浏览器环境?几乎只要选择后端实时推送到页面,就必须考虑 WebSocket(或者基于 HTTP 的流式方案,但那又是另一套兼容性预算)。第四,是否需要在同一根连接上既传输用户数据又维护消息属性?如果是轻量化设备上报,MQTT;如果在浏览器里频繁交换二进制、文本混合数据,WebSocket。

这套清单不是真理,但它非常有助于避免“新技术光芒”带偏判断。读协议文档时最容易产生一种错觉:既然协议 A 提供了好用的能力,那用了 A 就能解决所有问题。实际上,MQTT 和 WebSocket 之间不存在“谁比谁更高级”,它们只是两个不同时期、不同背景下为不同问题而生的协议。放到工程里,最好的选型往往是“在明确的边界内使用,而不是在死胡同里辩论”。

5.3 我在阅读这几份文档之后的几个直接收益

最后写一点偏个人感受的收益,这部分其实比任何知识总结都更接近“阅读笔记”的本意。当我再把 MQTT 文档里关于会话状态的部分和 WebSocket 文档里关于帧结构的部分放到一起看时,我意识到:一个协议的设计者最先解决的问题,不是“如何实现更多功能”,而是“我的用户处于什么环境,我最应该让用户不用操心什么”。MQTT 为了弱网设备,把连接存活管理做进协议核心;WebSocket 为了 Web 中间设备,把掩码与升级设计拧进规范细节。各自的付出都很有必要。

对我自己的实践来说,这套笔记带来的直接变化是:做技术方案时不会再把“用 MQTT 还是 WebSocket”当成一个非此即彼的单选,而是先把业务场景拆成“设备端、服务端、展示端”三个段落,再分别考察每一段的网络约束、可靠性要求、客户端形态,最终很可能得出结论是“你两个都要”。这种并存的答案,比“只有一个能胜出”更有工程价值。

如果让我给正在读类似文档的人一条建议,我会说:不要只背“MQTT 是推消息的、WebSocket 是双向的”这种速记结论。去把两边的握手指、报文头、保活字段重新读一遍,你会发现两个协议每一种机制背后都有明确的历史问题和网络困境,理解了那些困境,选型时才能真正不糊涂。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进