MQTT
MQTT 全称为 Message Queuing Telemetry Transport(消息队列遥测传输),是一种基于
发布/订阅
模式的 轻量级物联网消息传输协议
MQTT 协议原理
基于发布/订阅
模式的 MQTT 协议中有三种角色:发布者(Publisher
)、代理(Broker
)、订阅者(Subscriber
)。发布者向代理发布消息,代理向订阅者转发这些消息。通常情况下,客户端的角色是发布者和订阅者,服务器的角色是代理,但实际上,服务器也可能主动发布消息或者订阅主题,客串一下客户端的角色。
MQTT 传输的消息可以简化为:主题(Topic
)和载荷(Payload
)两部分:
Topic
,消息主题,订阅者向代理订阅主题后,一旦代理收到相应主题的消息,就会向订阅者转发该消息。Payload
,消息载荷,订阅者在消息中真正关心的部分,通常是业务相关的。
MQTT 协议基础概念
客户端 (Client)
使用 MQTT 协议的程序或设备。它可以
- 打开连接到服务端的网络连接
- 发布应用消息给其它相关的客户端
- 订阅以请求接受相关的应用消息
- 取消订阅以移除接受应用消息的请求
- 关闭连接到服务端的网络连接
服务器(Server)
在发送消息的客户端与已订阅的客户端之间充当中介角色的程序或设备,它可以
- 接受来自客户端的网络连接
- 接受客户端发布的应用消息
- 处理客户端的订阅和取消订阅请求
- 转发应用消息给符合条件的已订阅客户端
- 关闭来自客户端的网络连接
会话(Session)
每个客户端与服务器建立连接后就是一个会话,客户端和服务器之间有状态交互。会话可以存在于一个网络连接之间,也可以跨越多个连续的网络连接存在
订阅(Subscription)
订阅包含一个主题过滤器(Topic Filter)和一个最大的服务质量(QoS)等级。订阅与单个会话(Session)关联。会话可以包含多于一个的订阅。会话的每个订阅都有一个不同的主题过滤器。
主题名(Topic Name)
附加在应用消息上的一个标签,被用于匹配服务端已存在的订阅。服务端会向所有匹配订阅的客户端发送此应用消息。
主题过滤器(Topic Filter)
仅在订阅时使用的主题表达式,可以包含通配符,以匹配多个主题名。
载荷(Payload)
对于 PUBLISH
报文来说载荷就是业务消息,它可以是任意格式(二进制、十六进制、普通字符串、JSON 字符串、Base64)的数据。
MQTT 报文结构
MQTT 报文由三部分组成,分别为:固定报头(Fixed header
)、可变报头(Variable header
)以及有效载荷(Payload
)。包含报文类型等字段的固定包头存在于所有 MQTT 报文中。
可变报头的内容根据报文类型的不同而不同,一些报文中甚至不存在可变报头。有效载荷通常是与业务/场景相关的数据,例如对 PUBLISH
报文来说有效载荷就是应用消息,对 SUBSCRIBE
报文来说有效载荷就是订阅列表。
消息服务质量(QoS)
MQTT 协议提供了 3 种消息服务质量等级(Quality of Service
),它保证了在不同的网络环境下消息传递的可靠性。
清除会话(Clean Session)
MQTT 客户端向服务器发起 CONNECT
请求时,可以通过 Clean Session
标志设置是否创建全新的会话。
Clean Session
设置为 0 时
如果存在一个关联此客户标识符的会话,服务端必须基于此会话的状态恢复与客户端的通信。
如果不存在任何关联此客户标识符的会话,服务端必须创建一个新的会话
Clean Session
设置为 1
客户端和服务端必须丢弃任何已存在的会话,并开始一个新的会话。
保活心跳(Keep Alive)
MQTT 客户端向服务器发起 CONNECT
请求时,通过 Keep Alive
参数设置保活周期。
客户端在无报文发送时,按 Keep Alive
周期定时发送 2 字节的 PINGREQ
心跳报文,服务端收到 PINGREQ
报文后,回复 2 字节的 PINGRESP
报文。
服务端在 1.5
个心跳周期内,既没有收到客户端发布订阅报文,也没有收到 PINGREQ
心跳报文时,将断开客户端连接。
保留消息(Retained Message)
MQTT 客户端向服务器发布(PUBLISH
)消息时,可以设置保留消息(Retained Message
)标志。保留消息会驻留在消息服务器,后来的订阅者订阅主题时可以接收到最新一条保留消息。
遗嘱消息(Will Message)
MQTT 客户端向服务端发送 CONNECT
请求时,可以携带遗嘱消息。MQTT 客户端异常下线时(客户端断开前未向服务器发送 DISCONNECT
消息),MQTT 消息服务器会发布遗嘱消息
遗嘱消息是 MQTT 为那些可能出现 意外断线 的设备提供的将 遗嘱 优雅地发送给第三方的能力。意外断线包括但不限于:
- 因网络故障或网络波动,设备在保持连接周期内未能通讯,连接被服务端关闭
- 设备意外掉电
- 设备尝试进行不被允许的操作而被服务端关闭连接,例如订阅自身权限以外的主题等
遗嘱消息可以看作是一个简化版的 PUBLISH
消息,他也包含 Topic
, Payload
, QoS
等字段。遗嘱消息会在设备与服务端连接时,通过 CONNECT
报文指定,然后在设备意外断线时由服务端将该遗嘱消息发布到连接时指定的遗嘱主题(Will Topic
)上。
这也意味着服务端必须在回复 CONNACK
之前完成遗嘱消息的存储,以确保之后任一时刻发生意外断线的情况,服务端都能保证遗嘱消息被发布。
MQTT 5.0 协议新增特性
会话过期
MQTT 5.0 把 Clean Session
标识拆分成 Clean Start
标识(指示会话应该在不使用现有会话的情况下开始)和会话过期间隔属性(指示连接断开之后会话保留的时间)。会话过期间隔可以在断开连接时修改。把 Clean Start
标识设置为 1 且会话过期间隔设置为 0,等同于在 MQTT v3.1.1中把 CleanSession
设置为 1。
为所有响应报文提供原因码
更改所有响应报文以包含原因码,包括 CONNACK,PUBACK,PUBREC,PUBREL,PUBCOMP,SUBACK,UNSUBACK,DISCONNECT 和 AUTH
,以使得调用方确定请求的函数是否成功
请求/响应
规定 MQTT 请求/响应模式,提供响应主题和对比数据属性,以使得响应消息被路由回请求的发布者。此外,为客户端添加从服务端获取关于构造响应主题的配置信息的能力
共享订阅
添加对共享订阅的支持,允许多个订阅消费者进行负载均衡
我们知道一般的非共享订阅的消息发布流程是这样的:
在这种结构下,如果订阅节点发生故障,就会导致发布者的消息丢失(QoS 0
)或者堆积在 Server 中(QoS 1, 2)。一般情况下,解决这个问题的办法都是直接增加订阅节点,但这样又产生了大量的重复消息,不仅浪费性能,在某些业务场景下,订阅节点还需要自行去重,进一步增加了业务的复杂度。
其次,当发布者的生产能力较强时,可能会出现订阅者的消费能力无法及时跟上的情况,此时只能由订阅者自行实现负载均衡来解决,又一次增加了用户的开发成本
在 MQTT 5.0 协议中,你可以通过共享订阅特性解决上面提到的问题。当你使用共享订阅时,消息的流向就会变为:
同非共享订阅一样,共享订阅包含一个主题过滤器和订阅选项,唯一的区别在于共享订阅的主题过滤器格式必须是 $share/{ShareName}/{filter}
这种形式。这几个的字段的含义分别是:
$share
前缀表明这将是一个共享订阅{ShareName}
是一个不包含 “/“, “+” 以及 “#” 的字符串。订阅会话通过使用相同的 {ShareName} 表示共享同一个订阅,匹配该订阅的消息每次只会发布给其中一个会话{filter}
即非共享订阅中的主题过滤器
需要注意的是
如果服务端正在向其选中的订阅端发送 QoS 2
消息,并且在分发完成之前网络中断,服务端会在订阅端重新连接时继续完成该消息的分发。
如果订阅端的会话在其重连之前终止,服务端将丢弃该消息而不尝试发送给其他订阅端。
如果是 QoS 1
消息,服务端可以等订阅端重新连接之后继续完成分发,也可以在订阅端断开连接时就立即尝试将消息分发给其他订阅端,MQTT 协议没有强制规定,因此需要视服务器的具体实现而定。
但如果在等待订阅端重连期间其会话终止,服务端则会将消息尝试发送给其他订阅端。
主题别名
支持将主题名缩写为整数来减小 MQTT 报文的开销。客户端和服务端可以分别指定它们允许的主题别名的数量
MQTT 主题
MQTT 主题本质上是一个 UTF-8 编码的字符串,是 MQTT 协议进行消息路由的基础。MQTT 主题类似 URL 路径,使用斜杠 / 进行分层:
chat/room/1
sensor/10/temperature
sensor/+/temperature
sensor/#
为了避免歧义且易于理解,通常不建议主题以 /
开头或结尾,例如 /chat
或 chat/
MQTT 主题不需要提前创建。MQTT 客户端在订阅或发布时即自动的创建了主题,开发者无需再关心主题的创建,并且也不需要手动删除主题。
MQTT 主题通配符
MQTT 主题通配符包含单层通配符 +
及多层通配符 #
,主要用于客户端一次订阅多个主题。
- 单层通配符
加号 (“+” U+002B) 是用于单个主题层级匹配的通配符。在使用单层通配符时,单层通配符必须占据整个层级,例如:
+ 有效
sensor/+ 有效
sensor/+/temperature 有效
sensor+ 无效(没有占据整个层级)
如果客户端订阅了主题 sensor/+/temperature
,将会收到以下主题的消息:
sensor/1/temperature
sensor/2/temperature
...
sensor/n/temperature
但是不会匹配以下主题:
sensor/temperature
sensor/bedroom/1/temperature
- 多层通配符
井字符号(“#” U+0023)是用于匹配主题中任意层级的通配符。多层通配符表示它的父级和任意数量的子层级,在使用多层通配符时,它必须占据整个层级并且必须是主题的最后一个字符,例如:
# 有效,匹配所有主题
sensor/# 有效
sensor/bedroom# 无效(没有占据整个层级)
sensor/#/temperature 无效(不是主题最后一个字符)
如果客户端订阅主题 senser/#
,它将会收到以下主题的消息:
sensor
sensor/temperature
sensor/1/temperature
消息服务质量(QoS)
很多时候,使用 MQTT 协议的设备都运行在网络受限的环境下,而只依靠底层的 TCP 传输协议,并不能完全保证消息的可靠到达。因此,MQTT 提供了 QoS 机制,其核心是设计了多种消息交互机制来提供不同的服务质量,来满足用户在各种场景下对消息可靠性的要求
MQTT 定义了三个 QoS 等级,分别为:
QoS 0
,最多交付一次。QoS 1
,至少交付一次。QoS 2
,只交付一次。
使用 QoS 0
可能丢失消息,使用 QoS 1
可以保证收到消息,但消息可能重复,使用 QoS 2
可以保证消息既不丢失也不重复。QoS 等级从低到高,不仅意味着消息可靠性的提升,也意味着传输复杂程度的提升
QoS 0
- 最多交付一次
QoS 0
是最低的 QoS 等级。QoS 0
消息即发即弃,不需要等待确认,不需要存储和重传,因此对于接收方来说,永远都不需要担心收到重复的消息
- 为什么 QoS 0 消息会丢失?
当我们使用 QoS 0
传递消息时,消息的可靠性完全依赖于底层的 TCP 协议
而 TCP 只能保证在连接稳定不关闭的情况下消息的可靠到达,一旦出现连接关闭、重置,仍有可能丢失当前处于网络链路或操作系统底层缓冲区中的消息。这也是 QoS 0
消息最主要的丢失场景。
QoS 1
- 至少交付一次
为了保证消息到达,
QoS 1
加入了应答与重传机制,发送方只有在收到接收方的PUBACK
报文以后,才能认为消息投递成功,在此之前,发送方需要存储该PUBLISH
报文以便下次重传
QoS 1
需要在 PUBLISH
报文中设置 Packet ID
,而作为响应的 PUBACK
报文,则会使用与 PUBLISH
报文相同的 Packet ID
,以便发送方收到后删除正确的 PUBLISH
报文缓存。
- 为什么 QoS 1 消息会重复?
对于发送方来说,没收到 PUBACK
报文分为以下两种情况:
PUBLISH
未到达接收方PUBLISH
已经到达接收方,接收方的PUBACK
报文还未到达发送方
在第一种情况下,发送方虽然重传了 PUBLISH
报文,但是对于接收方来说,实际上仍然仅收到了一次消息。
但是在第二种情况下,在发送方重传时,接收方已经收到过了这个 PUBLISH 报文,这就导致接收方将收到重复的消息。
虽然重传时 PUBLISH
报文中的 DUP
标志会被设置为 1,用以表示这是一个重传的报文。但是接收方并不能因此假定自己曾经接收过这个消息,仍然需要将其视作一个全新的消息
这是因为对于接收方来说,可能存在以下两种情况:
第一种情况,发送方由于没有收到 PUBACK
报文而重传了 PUBLISH
报文。此时,接收方收到的前后两个 PUBLISH
报文使用了相同的 Packet ID
,并且第二个 PUBLISH
报文的 DUP
标志为 1,此时它确实是一个重复的消息。
第二种情况,第一个 PUBLISH
报文已经完成了投递,1024 这个 Packet ID
重新变为可用状态。发送方使用这个 Packet ID
发送了一个全新的 PUBLISH
报文,但这一次报文未能到达对端,所以发送方后续重传了这个 PUBLISH
报文。
这就使得虽然接收方收到的第二个 PUBLISH
报文同样是相同的 Packet ID
,并且 DUP
为 1,但确实是一个全新的消息。
由于我们无法区分这两种情况,所以只能让接收方将这些 PUBLISH
报文都当作全新的消息来处理。因此当我们使用 QoS 1
时,消息的重复在协议层面上是无法避免的。
甚至在比较极端的情况下,例如 Broker
从发布方收到了重复的 PUBLISH
报文,而在将这些报文转发给订阅方的过程中,再次发生重传,这将导致订阅方最终收到更多的重复消息。
在下图表示的例子中,虽然发布者的本意只是发布一条消息,但对接收方来说,最终却收到了三条相同的消息:
以上,就是 QoS 1
保证消息到达带来的副作用。
QoS 2 - 只交付一次
QoS 2
解决了QoS 0、1
消息可能丢失或者重复的问题,但相应地,它也带来了最复杂的交互流程和最高的开销。每一次的QoS 2
消息投递,都要求发送方与接收方进行至少两次请求/响应流程。
- 首先,发送方存储并发送 QoS 为 2 的
PUBLISH
报文以启动一次QoS 2
消息的传输,然后等待接收方回复PUBREC
报文。这一部分与QoS 1
基本一致,只是响应报文从PUBACK
变成了PUBREC
。 - 当发送方收到
PUBREC
报文,即可确认对端已经收到了PUBLISH
报文,发送方将不再需要重传这个报文,并且也不能再重传这个报文。所以此时发送方可以删除本地存储的PUBLISH
报文,然后发送一个PUBREL
报文,通知对端自己准备将本次使用的Packet ID
标记为可用了。
与PUBLISH
报文一样,我们需要确保PUBREL
报文到达对端,所以也需要一个响应报文,并且这个PUBREL
报文需要被存储下来以便后续重传。 - 当接收方收到
PUBREL
报文,也可以确认在这一次的传输流程中不会再有重传的PUBLISH
报文到达,因此回复PUBCOMP
报文表示自己也准备好将当前的Packet ID
用于新的消息了。 - 当发送方收到
PUBCOMP
报文,这一次的QoS 2
消息传输就算正式完成了。在这之后,发送方可以再次使用当前的Packet ID
发送新的消息,而接收方再次收到使用这个Packet ID
的PUBLISH
报文时,也会将它视为一个全新的消息。
- 为什么 QoS 2 消息不会重复?
与 QoS 1
相比,QoS 2
新增了 PUBREL
报文和 PUBCOMP
报文的流程,也正是这个新增的流程带来了消息不会重复的保证。
当我们使用 QoS 1
消息时,对接收方来说,回复完 PUBACK
这个响应报文以后 Packet ID
就重新可用了,也不管响应是否确实已经到达了发送方。所以就无法得知之后到达的,携带了相同 Packet ID
的 PUBLISH
报文,到底是发送方因为没有收到响应而重传的,还是发送方因为收到了响应所以重新使用了这个 Packet ID
发送了一个全新的消息。
所以,消息去重的关键就在于,通信双方如何正确地同步释放 Packet ID
,换句话说,不管发送方是重传消息还是发布新消息,一定是和对端达成共识了的。
而 QoS 2
中增加的 PUBREL
流程,正是提供了帮助通信双方协商 Packet ID
何时可以重用的能力。
QoS 2
规定,发送方只有在收到 PUBREC
报文之前可以重传 PUBLISH
报文。一旦收到 PUBREC
报文并发出 PUBREL
报文,发送方就进入了 Packet ID
释放流程,不可以再使用当前 Packet ID
重传 PUBLISH
报文。
同时,在收到对端回复的 PUBCOMP
报文确认双方都完成 Packet ID
释放之前,也不可以使用当前 Packet ID
发送新的消息。
因此,对于接收方来说,能够以 PUBREL
报文为界限,凡是在 PUBREL
报文之前到达的 PUBLISH
报文,都必然是重复的消息;而凡是在 PUBREL
报文之后到达的 PUBLISH
报文,都必然是全新的消息。
一旦有了这个前提,我们就能够在协议层面完成 QoS 2
消息的去重。
不同 QoS 的适用场景和注意事项
- QoS 0
QoS 0
的缺点是可能会丢失消息,消息丢失的频率依赖于你所处的网络环境,并且可能使你错过断开连接期间的消息,不过优点是投递的效率较高。
所以我们通常选择使用 QoS 0
传输一些高频且不那么重要的数据,比如传感器数据,周期性更新,即使遗漏几个周期的数据也可以接受。
- QoS 1
QoS 1
可以保证消息到达,所以适合传输一些较为重要的数据,比如下达关键指令、更新重要的有实时性要求的状态等。
但因为 QoS 1
还可能会导致消息重复,所以当我们选择使用 QoS 1
时,还需要能够处理消息的重复,或者能够允许消息的重复。
在我们决定使用 QoS 1
并且不对其进行去重处理之前,我们需要先了解,允许消息的重复,可能意味着什么。
如果我们不对 QoS 1
进行去重处理,我们可能会遭遇这种情况,发布方以 1、2
的顺序发布消息,但最终订阅方接收到的消息顺序可能是 1、2、1、2
。如果 1 表示开灯指令,2 表示关灯指令,我想大部分用户都不会接受自己仅仅进行了开灯然后关灯的操作,结果灯在开和关的状态来回变化。
- QoS 2
QoS 2
既可以保证消息到达,也可以保证消息不会重复,但传输成本最高。如果我们不愿意自行实现去重方案,并且能够接受 QoS 2
带来的额外开销,那么 QoS 2
将是一个合适的选择。通常我们会在金融、航空等行业场景下会更多地见到 QoS 2
的使用。