在物联网业务中,有多种方案实现设备和后台应用服务之间的通信。
举例来说,假设业务要求设备周期性发送自身状态给后台应用服务,而后台服务根据业务逻辑发送控制指令给设备。
常见的方案是,让设备发送消息到带有设备标识的 MQTT 主题,并且让后台服务订阅一个带有通配符的主题。比如设备发送 `devices/<client-id>/state/online`,后台服务订阅 `devices/+/state/#`。
本文主要分析应用服务作为 MQTT 客户端从 EMQX 订阅的场景中可能会碰到的问题,以及如何规避这些问题。在文章的最后我会提出一个实现此类业务的最佳方案。
## 你依赖的开源软件和技术并非没有成本
现代软件开发会大量的依赖开源的软件、组件。这些开源软件或组件被广泛使用,经过大量的测试,直接使用可以提升生产效率,加快业务的迭代速度。
但这不意味着你可以毫无保留地信任它们,开源软件并非天然可靠。至少在应用上线之前,你应该根据真实的业务场景做详尽的功能和压力测试。
你可能觉着 EMQX(或者任何其他的类似软件)理所应当可以覆盖任何的场景,轻松地处理 “也没有特别夸张”的负载,毕竟它已经被广泛应用了,至少在物联网行业内已经 “久经战火”。
很抱歉你要失望了。如果你在应用服务侧订阅所有消息,订阅者可能会成为瓶颈。

## EMQX 的客户端-进程模型
这是因为 EMQX 会对每一个 MQTT 客户端创建一个单独的 “Erlang 进程” 为其服务。这里的 “Erlang 进程” 是一个轻量的内存独立的代码运行实例,是 Erlang 虚拟机中的最小调度单元。
这个客户端-进程的模型相当简单和高效。
首先进程是非常轻量的、内存占用非常小,并且 Erlang 虚拟机有一个非常**精密**的进程调度机制,能够轻松处理海量进程的并发运行,同时实现**公平而高效**地调度。进程拥有独立的内存空间,进程间只能通过发送消息来传递信息,这样不用担心共享内存并发带来的竞态条件问题。
Erlang/OTP 提供的这一能力改变了系统的设计原则:我们可以在单个节点创建数以百万计的进程来支持百万计的 MQTT 客户端,相当于将每个客户端一一 ”映射“ 到了 EMQX 内部,客户端 A 给客户端 B 投递消息,则转变为服务侧的进程 A 给进程 B 投递消息。客户端的状态跟服务端对应进程的状态时刻保持同步,是一个真正意义上的 “设备影子”。这个模型下,一个十节点组成的 EMQX 集群,可以轻松处理千万级别的客户端并发访问。
客户端-进程模型还是一个非常棒的业务隔离和错误隔离机制:进程之间互不影响,某个客户端进程出了任何问题挂掉其 MQTT 连接也将被断开,但这不会影响另外一个客户端正常工作。
设备和后台服务的 MQTT 客户端之间,形成了一个多对一的关系。同样的关系映射到 EMQX 中,也形成了多对一的关系:

现在系统的瓶颈显而易见,就是订阅客户端。它的业务逻辑可能很复杂,需要接收的消息数量巨大,至少会导致两种瓶颈出现:
* 订阅服务自身的处理能力问题;
* 以及它映射到 EMQX 内部的进程的处理能力问题。
### 订阅者服务自身的瓶颈
订阅服务中如果没有足够的并行处理,会导致底层的 MQTT Client 进程(一般是 Paho) 没有及时将消息从 TCP 层取走,堆积在客户端这一侧的 TCP Receive Buffer 中,继而导致 TCP Window 变小,最终服务端再也没有办法将消息投递到服务侧的 TCP 层去。
EMQX 里客户端对应的进程调用 Erlang 的 TCP 驱动给网络套接字发送消息,通常情况下这个调用都是立即返回的,但当上述情况出现时,TCP 驱动层的发送缓存就满了,上层调用将会被阻塞。

这样一来整个进程都卡在发送接口上,处理不了任何的事情,因为进程是并发的最小单元,只能串行处理任务。从宏观上观察就是 EMQX Dashboard 上显示客户端仍然在线,但客户端收不到任何消息,发送的消息收不到任何回应,可以将其从服务端强制踢掉,但踢出动作将花费 5 秒左右的时间(超时强制踢出)。
每个进程有自己的消息邮箱,用于存放其他进程发送给它的消息,其中的消息会被依次被处理。当进程被卡住时,消息邮箱会增长,占用过多的内存堆空间,从而被过载保护机制杀死。从宏观上看此时 MQTT 连接被 EMQX 主动断开了。
**如何调查:**
* EMQX 可能会出现 connection\_congestion 告警。
* 通过 netstat 等网络工具,在 EMQX 机器上查看对应 TCP 连接的 SendQ 长度。如果长度长时间处于非常高的数值并无法清零,则提示客户端消费消息过慢或者网络问题。
* 可以继续使用 ping 等网络测试工具来排查网络的问题。
**如何解决:**
需要从客户端的代码逻辑入手,调查瓶颈所在。例如:
* 如果是因为串行处理导致 TCP 阻塞,那么可以把收到消息后的业务处理分离到另外的进程池中执行,而不是在接收者进程中直接执行。
* 如果因为日志等原因导致处理过慢,可以考虑将日志改为异步模式。
### 网络带宽或者网络状况的影响
当网络带宽受限或者网络延迟增大时,会出现跟上述情况一样的问题。EMQX 侧的 TCP Send Buffer 被逐渐打满,上层的 TCP 驱动程序的缓存队列随之被打满,最后就是客户端进程会卡在网络报文发送的调用 API 上,处理不了任何其他业务。最终客户端可能会因为占用内存过多而被 EMQX 强制断开。

**如何调查:**
使用 ping 等 linux 命令检查 emqx 到客户端的网络延迟。
### 订阅者服务在 EMQX 中对应的进程出现瓶颈
当单个客户端进程面对的业务量远远大于其他客户端进程时,这个进程本身就会成为瓶颈,即使这时网络并没有延迟,即使客户端的处理能力足够。
不要忘了,所有客户端发上来的消息的校验、路由选择、插件逻辑处理、规则执行(规则引擎)等,以及发往该客户端的消息的入队出队逻辑、规则执行、套接字发送等等,都是在 EMQX 中的单个进程中处理的。N 对 1 的消息发送,会导致 1 进程比其他进程忙 N 倍。
客户端进程过忙,轻则增加消息的时延,重则由于过载被系统杀死。这里有一个当客户端进程占用内存过大被杀死时的日志:
2023/5/5 12:47:202023-05-05T12:47:20.738129+08:00 [error] Process: <0.28814.182> on node ‘emqx@10.27.13.91’, Context: maximum heap size reached, Max Heap Size: 6291456, Total Heap Size: 6527931, Kill: true, Error Logger: true, Message Queue Len: 54, GC Info: [{old_heap_block_size,2487399},{heap_block_size,3800194},{mbuf_size,240381},{recent_size,862059},{stack_size,31},{old_heap_size,0},{heap_size,1727318},{bin_vheap_size,156072},{bin_vheap_block_size,272548},{bin_old_vheap_size,0},{bin_old_vheap_block_size,46422}]
**如何调查:**
确定这个问题可以通过 linux 的 netstat 等命令,在 EMQX 机器上查看对应 TCP 连接的 SendQ 长度。如果 SendQ 并没有特别长,说明 TCP 层并没有堆积,提示瓶颈可能出现在 emqx 内部。
接着可以使用 EMQX 的 observer\_cli 工具查看是否有邮箱过长的进程。如果是 emqx\_connection 进程的邮箱过长,则需要查看其调用堆栈,了解它在忙什么。
observer\_cli 的使用方式可以参考:<https://juejin.cn/post/7234078736421027877>
**如何解决:**
首先应当通过 observer\_cli 等工具确定瓶颈的所在。如果你需要帮助,可以去 [EMQX 中文论坛](https://askemq.com/c/emqx/5) 发一个帖子,或者去 [EMQX GitHub 仓库](https://github.com/emqx/emqx/issues) 提一个问题单。
如果通过调查发现消息主要是在执行某个规则,那么将规则绑定的动作改为异步模式可以显著降低客户端进程的压力,但这又在一定程度上增加了规则引擎过载的风险。
比较好的办法是通过共享订阅增加订阅客户端的数量,这样会在 EMQX 内部建立多个客户端进程,从而均衡原来的负载。
## 共享订阅带来的麻烦
无论是客户端侧的处理瓶颈、TCP 连接发送速度上的限制,或者是 EMQX 内部的单进程模型导致的瓶颈,改为共享订阅的方式都是可以尝试的好办法。

如果 emqx 所在机器有 16 个 CPU 核心,那么可以创建 16 个共享订阅的客户端,这样可以在最大程度上利用 CPU 的处理能力,解决服务端业务(规则)过多导致的瓶颈。
然而共享订阅并不是完美的解决方案,它会受到一些限制:
首先是 [共享订阅策略](https://github.com/emqx/emqx/blob/98e4e45df734feff3c59b1b023fdbcb7dc5fbddc/apps/emqx/src/emqx_schema.erl#L3540) 的问题。`round_robin*` 以及 `sticky` 策略属于服务端需要保持状态的策略,要么会造成服务端的伸缩性问题,要么会在实现上做出一些妥协,就是说它可能不会严格按照你期望的方式工作。所以比较推荐的是 `random`、`local` 以及基于 `Hash` 的策略。其中 `local` 策略效率最高,因为他只往本地节点上的订阅者投递消息,避免了节点之间的消息传递,但它要求共享订阅客户端连接到每个 emqx 节点上去。
然后是当服务端有多个 emqx 节点的时候,共享订阅客户端可能出现不均衡的情况。假设现在有 2 个 emqx 节点,并且有 16 个客户端共享订阅 `$share/g/t` 主题,你会希望他们均匀地连接到两个 emqx 节点,每个节点分别 8 个客户端。但实际上由于各种部署原因,没办法做到那么均衡,极端情况下所有客户端会全部连接到同一个 emqx 节点上。

**如何解决:**
关于共享订阅策略:如果可能,尽量使用 `local` 方式做共享订阅,这样对服务端的压力最小,消息时延也最小。
针对共享订阅组客户端难以在节点间均衡的问题,可以使用客户端决定的负载均衡策略,为每一个 emqx 节点分配单独的域名,然后让客户端根据策略选择连接到哪个节点。或者可以开发一个 bootstrap 服务,客户端在连接 emqx 节点之前首先访问 bootstrap 服务获取应该连接到哪个 emqx 节点。
但当共享订阅客户端与 emqx 节点之间存在反向代理的时候,客户端无法决定建立与哪个 emqx 节点的连接,这时候就只能在负载均衡器上做一些手脚,比如做一个 [HAProxy 插件](https://www.haproxy.com/blog/5-ways-to-extend-haproxy-with-lua)。
实际上不应该做这么麻烦的事情,如果订阅者是一个服务端应用,以 MQTT 订阅的方式处理业务逻辑,那么几乎没有理由在该服务与 emqx 之间设置反向代理。如果有可能,最好将其直接放到 emqx 节点上,每个节点一个服务实例,这样可以很方便的使用 `local` 策略。
## 插件或数据集成
发布订阅模式不是通信的唯一选择,特别是服务端各服务之间的通信,使用 MQTT 协议几乎没有好处,应该选择更合适的通信方式。这时候放弃 MQTT 订阅模式,使用插件或数据集成就是很好的解决方案。
EMQX 的插件和数据集成都是基于函数回调的,会在指定事件触发的时候回调对应的代码逻辑,并且这种回调只会在本地节点执行,不会在节点之间广播,也就不存在共享订阅客户端的均衡性问题。

数据集成是处理服务端的业务逻辑最理想的方式。比如使用 Webhook 调用指定的 HTTP API 将消息发送到后台服务。也可以将消息发送到 RabbitMQ、Kafka 等消息中间件,让服务从消息中间件收取消息。
另外一种方式是直接将业务逻辑以 emqx 插件的形式实现,这样就省去了网络通信的额外开销和时延,同时让部署更加紧凑,服务更加可靠。但缺点是插件是需要使用 Erlang/Elixir 编程语言开发的,需要一些学习成本。
EMQX 也提支持以 gRCP 方式通信的[插件](https://docs.emqx.com/en/emqx/latest/extensions/exhook.html),但从经验上看不如 Webhook 方式简单可靠。
## 总结
本文讨论了 EMQX 中使用订阅和共享订阅模式处理服务端业务的局限性,并且提出了对应的解决方案。使用插件或数据集成的模式是处理此类业务的更理想方式。
我是刘新宇,任何 EMQX 问题、解决方案咨询咨询等可以直接联系我。
