MQTT实战 - .NET + MQTT生产级架构:从500个设备到50,000个设备(九)

0 阅读28分钟

01、引入层:500台 → 5万台,我们踩过的扩容大坑

1.1、500设备的朴素架构(很多项目起步版本)

绝大多数物联网项目起步都是这套最简方案:

设备 → EMQX/Mosquitto单节点 → .NET MQTTnet 5.x客户端订阅全量Topic → 直接写MySQL + Redis

优点:代码简单、部署快、调试方便;500台以内、上报频率不高,几乎看不出问题。

隐藏负债:

  • MQTTnet 5.x消费客户端直接订阅大量Topic,没有异步缓冲;

  • 消息收到直接同步入库,没有批量、限流、背压;

  • 没有连接分片,所有设备全部连同一个Broker节点;

  • 缺少指数退避,批量断电重启直接打满连接。

1.2、流量上涨后集中爆发的经典故障清单

当设备走到2000~5000台,下面问题会依次出现:

  • 凌晨批量设备重启,连接风暴,Broker CPU打满、会话队列打爆;

  • .NET消费端处理慢,消息堆积在Broker,QoS1/2消息持续重试,恶性循环;

  • MySQL单表写入爆炸,写入延迟飙升,消费线程阻塞,进一步加剧堆积;

  • 大量CleanSession=false,离线消息持续膨胀,Broker内存持续上涨;

  • 没有共享订阅,只能单实例消费,加.NET服务实例无法水平扩容;

  • Topic设计混乱,通配符订阅过多,路由开销随设备线性上涨。

1.3、扩容误区:单纯加机器为什么救不活MQTT平台

很多人的第一反应:Broker多开几个、.NET服务多实例。但是:

  • 没有共享订阅: 多个.NET实例会重复消费同一条消息,造成重复入库;

  • 没有桥接/消息队列: Broker和业务强耦合,业务慢直接拖垮接入;

  • 没有连接分片: 负载均衡只是四层轮询,会话状态无法跨节点平滑迁移;

  • 没有限流和抖动: 扩容之后,风暴来临时所有设备同时重连,雪崩范围更大。

核心结论:MQTT大规模扩容,扩容的不是连接,是消息流转的隔离、缓冲、背压能力。

1.4、本文架构演进目标、约束与边界

目标:稳定承载50,000台长连接设备,支持高频遥测、下行控制指令、在线状态感知。

约束:

  • 后端语言固定:.NET 10 + MQTTnet 5.x(已移除 ManagedMqttClient,连接生命周期由业务接管);

  • Broker优先EMQX集群(Core+Replicant);

  • 区分链路:实时指令链路低延迟优先,遥测上报链路吞吐优先;

  • 可灰度:支持从原有500架构逐步改造,不需要一次性重构。

边界:

  • 规模边界: 本方案按 5 万台长连接、峰值 10 万 msg/s 设计;超过 10 万台需重新评估 Core 节点扩展、Kafka 分区与存储分片策略。

  • 业务边界: MQTT 主链路只承载遥测、指令、状态、告警四类轻量 JSON 消息;视频流、大文件(OTA 包)、音频对讲不走 MQTT,走 HTTP/对象存储。

  • 端侧边界: 聚焦平台侧(Broker + .NET 后端)演进,不包含设备端固件开发、嵌入式 MQTT 库选型与硬件网关的底层实现。

  • 基础设施边界: 假设云主机、K8s、Kafka 等底层设施已具备基本可用性,不涉及云资源选型、网络拓扑与机房灾备建设。

  • 业务逻辑边界: 不涉及告警判定、工单流转、计费等具体业务规则,只负责把数据可靠、低延迟地送达下游消费者。

02、认知层:大规模MQTT扩容必须先建立4个核心认知

2.1、认知1:Broker只负责“连接+路由”,不承载复杂业务

Broker的定位是接入网关,不做计算、规则、入库。任何复杂业务(解析、公式计算、存储、告警、推送)都必须下沉到后端消费服务;Broker只做鉴权、ACL、消息路由、会话管理、遗嘱、桥接转发。如果把业务逻辑写在Broker插件里,到万级连接后会很难调优、很难隔离故障。

2.2、认知2:长连接规模瓶颈 ≠ 消息TPS瓶颈,两套指标分开评估

很多人混淆两个指标:

  • 连接数瓶颈: 由Broker会话、内存、文件句柄、TCP栈决定;

  • 消息TPS瓶颈: 由消费端、存储、序列化、业务逻辑决定。

典型场景:5万设备,每台30秒上报一条,连接压力尚可,但入库压力可能是瓶颈。

2.3、认知3:设备风暴(上线/断电重启)是大规模物联网最大风险源

互联网是用户随机上线;物联网设备经常是:断电恢复、维护升级、信号恢复,成千上万设备同一时刻发起重连。应对手段:MQTTnet 5.x客户端指数退避 + 随机抖动、Broker连接速率限流、会话过期清理、分批上线灰度。

2.4、认知4:.NET消费端是木桶短板,Broker再强也扛不住慢消费

MQTTnet 5.x本身性能、异步模型相比旧版本有很大优化,但是同步IO、无批量、无背压、异常不隔离,很容易单条慢业务阻塞整个消费循环。大规模场景,消费端必须做到:异步、批量、限流、背压、独立线程池、失败死信。

03、本质层:分层架构基因与两套版本对照

3.1、500设备:简化版架构图、组件清单、资源规格

设备 → EMQX单节点 → .NET MQTTnet 5.x订阅客户端 → Redis(状态) + MySQL(业务)

规格参考:4核8G,单节点EMQX,1个.NET消费实例,单库单表。

3.2、50000设备:生产级分层架构(接入层 → 桥接层 → 消费层 → 存储层 → 管控层)

5 万台设备同时在线,架构就得讲究"各司其职"了。设备通过 TLS 连上 EMQX 集群,数据分两条路走:海量的遥测消息丢进 Kafka 排队慢慢消化,关键的指令消息则走独立的 MQTT 共享订阅链路,不排队、不延迟。后端用 .NET 拉起多个消费服务,各管一摊——谁负责写数据、谁负责发指令、谁管设备上下线、谁触发告警,互不打架。存储上 Redis 扛状态、时序库存遥测、MySQL 按月分表存业务记录,各用所长。最后拿 Prometheus 和 Grafana 盯着整条链路的连接数、堆积量、掉线率,配上鉴权中心和 ACL 兜底安全。一句话总结:指令走快车道,遥测走缓冲道,各层解耦,挂一个不影响其他。

1.png

链路隔离非常关键:实时指令不走Kafka缓冲,保证低延迟;海量遥测走Kafka削峰,保护Broker和数据库。

3.3、EMQX集群核心设计:Core/Replicant角色、负载均衡、连接分片、会话持久化

说白了就是"让专业的人干专业的事"。Core 节点三台一组,专门管元数据、搞 Raft 共识、维护会话状态,是集群的"大脑",多了反而拖慢决策。Replicant 节点就是"苦力",啥也不管,就负责扛连接、转消息,设备量大了直接加机器就行。前面再挂个四层负载均衡,按设备 IP 做哈希分片,保证同一台设备每次都连到同一个节点——不然会话老迁移,纯属浪费资源。后端消费侧用 $share/group/topic 共享订阅,MQTTnet 5.x 多实例各领各的活,消息只投给其中一个,天然水平扩展。一句话:Core 管脑子,Replicant 管手脚,LB 管分配,共享订阅管消费,各就各位,谁也不抢谁的活。

3.4、六维硬指标对比(连接数、TPS、内存、故障恢复、运维复杂度、成本)

拿 500 台和 5 万台摆在一起比,差距一目了然。小规模的玩法就是"能跑就行"——单节点 Broker、单实例消费、直连直写、一张表干到底,简单粗暴但隐患也明显,消费一卡 Broker 就跟着堵。到了 5 万台量级,每一层都得升级:Broker 换成 Core+Replicant 集群,消费走共享订阅多实例分摊,遥测消息塞进 Kafka 缓冲卸压,存储拆成 Redis+时序库+分表 MySQL 各司其职。最关键的区别是故障隔离——小规模是"一根绳上的蚂蚱",大规模是"各走各的道",遥测链路堵了指令通道照样秒级响应。扩容方式也从"换台好机器"变成"加机器加实例加分片"。一句话:规模翻百倍,架构思路得从"凑合用"切换成"防雪崩"。

同一套业务,两个规模档位:从单机独占订阅到集群 + 共享订阅 + 缓冲削峰,差的不只是连接数。

2.png

一句话总结:500 台档位把“能跑”放在第一位,所有组件单点、直连、垂直扩;50000 台档位把“可隔离、可削峰、可水平扩”放在第一位——Broker 从单节点升到 Core/Replicant 集群,消费从独占订阅切到共享订阅,遥测链路加 Kafka 削峰,存储从单库拆成 Redis + 时序库 + 分表,最终让遥测与指令两条链路互不拖累。

3.5、关键协议约束:MQTT 3.1.1 / MQTT5选择建议、共享订阅Shared Subscription

协议版本选对了,后面能省一半坑。新项目无脑上 MQTT 5,MQTTnet 5.x 原生支持消息过期、载荷格式、原因码这些新特性,共享订阅也是一等公民,大规模场景下就是降维打击。老设备跑着 3.1.1 也别硬改,兼容着来,共享订阅靠 Broker 兜底就行。还有一条铁律:别在大规模场景里滥用 QoS 2,四步握手看着靠谱,吞吐直接腰斩,真要强对账就在关键指令上局部用,遥测数据老老实实用 QoS 0 或 1。一句话 :MQTT 5 是标配,3.1.1 是兼容,QoS 2 是奢侈品——按需取用,别上头。

3.6、主题设计规范与硬规则

主题设计这事,一开始觉得怎么设计都行,等你有 5 万台设备、几十种消息类型、多个消费方的时候再改,代价是巨大的。我们改一次主题花了近一个月,设备端、服务端、规则引擎、数据库表全要动。下面是我们经过两轮重构后沉淀下来的约定。

命名约定(按业务规模二选一,关键是层级固定、不随意增减):

# 三层式(中小规模,一台设备一个 ID)
{domain}/{device_id}/{message_type}
devices/861234567890123/telemetry   # 遥测(高频)
devices/861234567890123/event       # 事件(低频,如告警)
devices/861234567890123/status      # 在线/离线状态
devices/861234567890123/cmd         # 下行指令
devices/861234567890123/cmd/resp    # 指令响应
devices/861234567890123/ota         # OTA 升级通知
# 多园区式(大规模,显式隔离园区/产线)
svc/{园区}/{产线}/{设备类型}/{设备ID}/{数据类别}
svc/parkA/line1/cnc/CNC_001/telemetry
cmd/{园区}/{设备类型}/{设备ID}      # 指令独立命名空间
$SYS/brokers/+/clients/#            # Broker 系统主题,用于监控

Payload 字段规范:

{
  "deviceId": "CNC_001",
  "ts": "2026-10-25T10:30:00.123Z",
  "seq": 10245,
  "type": "telemetry",
  "data": { "temperature": 62.5, "rpm": 3200, "status": "running" }
}
  • seq:单调递增序列号,用于端到端丢包对账;

  • ts:设备侧 UTC 时间戳,非 Broker 接收时间;

  • type:telemetry / alarm / heartbeat / cmd_ack,消费端按类型路由。

五条硬规则:

  • ClientId 与设备 ID 统一: Topic 里的 device_id 必须等于 Connect 报文里的 ClientId,ACL 可直接用 {clientid} 变量匹配;

  • 不要用 / 开头或结尾: /devices/... 和 devices/... 在 MQTT 里是不同主题,会产生一个空的第一层;

  • 不要用空格和非 ASCII 字符: 主题是 ASCII 字节比较,中文和空格虽然协议允许,但客户端、日志、监控处理起来容易出问题;

  • 不要在主题里放消息体内容: 不要 devices/123/temp/26.5,消息体放 JSON/Protobuf,主题只做路由;

  • 下行指令不要广播: 给单个设备发指令用 devices/{id}/cmd,不要用 cmd/all 让设备自己判断,既不安全又浪费带宽。

此外:只允许 + 单级通配订阅,慎用 # 多级通配(扇出爆炸会让路由开销随设备数线性上涨)。

04、架构层:从 500 到 50,000 的四段递进演进

为便于叙述,我把 500 → 50,000 切成四段:≤500 台 / 500–5,000 台 / 5,000–20,000 台 / 20,000–50,000 台。每段只解决这一段的瓶颈,不为下一段的假想需求过度设计。

4.1、第一段(≤500 台):怎么简单怎么来

典型架构:

设备 → EMQX/Mosquitto 单节点 → .NET MQTTnet 5.x 客户端订阅 → 直接写 MySQL + Redis

规格参考:4核8G单节点,1个.NET消费实例,单库单表。

这个阶段不要过度设计。如果你还在用 MQTTnet 4.x,ManagedMqttClient 确实能省不少事;但如果你准备长期演进,从第一天就按 5.x 的“自管连接生命周期”来写,避免后续升级库导致重写。

内嵌 Broker(如 MQTTnet.AspNetCore)适合本地验证或单机小项目,但进程耦合度高、无法水平扩展、连接数超过 2000 会开始 GC 抖动。生产环境只要设备超过 1000 台,或需要 7×24 不重启,就必须把 Broker 独立出去。

这个阶段必须定下来的三件事:

  • Topic 命名规范: 定层级,上行/下行分离;

  • QoS 分级: 遥测 QoS 1、控制指令 QoS 1、心跳 QoS 0,QoS 2 最小化;

  • ClientId 规则: 一设备一 ID,禁止用 IP/MAC,禁止多设备共用。

4.2、第二段(500 → 5,000 台):消费端先解耦

第一次真实事故:设备到 2000 台时,业务同事在 Web API 里加了一个定时报表查询,把进程拖死。内嵌 Broker 跟着陪葬,2000 台设备全部掉线,重连风暴又把刚重启的进程再次打挂,整整恢复 40 分钟。

这次事故后的两件事:

  • Broker 独立部署: 切到 EMQX 开源版单机(Docker 部署),与业务服务物理隔离。

  • .NET 客户端封装: 自己实现带退避的重连、订阅恢复与有界队列。

阶段核心:回调只入队 + 有界 Channel 背压 + 批量写入。

  • 回调里只做一件事:把消息投递给后台处理通道,立刻 ACK,不阻塞消息泵;

  • 后台通道用批量写库(SqlBulkCopy 等),把 100–500 条合一批;

  • 通道必须有界(BoundedChannel),满了走 DropOldest / 写死信队列,绝不让内存无限增长。

4.3、第三段(5,000 → 20,000 台):共享订阅 + Broker 集群

单节点天花板:设备到 8000 台时,单机 EMQX CPU 到 60%,峰值 1.2 万 msg/s,任一次网络恢复的重连风暴都可能出问题;单点故障时设备全部掉线;消费服务单点 CPU 到 70%,无法再扩。

升级方向:Broker 集群化 + 消费层水平扩展。

  • 共享订阅 $share/{group}/{topic}: 让多个消费实例自动负载均衡,消息只投给组内一个实例。

  • EMQX 集群(Core + Replicant): 3 个 Core 节点保证元数据一致性,Replicant 节点承担接入连接,可水平扩展。

  • 连接风暴限流: max_conn_rate 限制单节点每秒新建连接上限,超出排队/拒绝;设备端指数退避 + 随机抖动。

注意:共享订阅只负责“把投递负载摊匀”,不保证消息不丢。如果实例消费慢超过队列上限,EMQX 仍会丢弃消息。消费端的有界队列与背压必须同时到位。

4.4、第四段(20,000 → 50,000 台):连接层与消费层彻底分离

最后的天花板:即使 Broker 和共享订阅都正常,消息从 Broker 到数据库之间的管道依然会成为瓶颈。消费逻辑复杂(规则判断、实时计算、写多个库、推 WebSocket),直连消费会导致“消费速度绑定 Broker”,且无法水平扩展。

升级方向:把“消费”拆成两层,引入异步缓冲。

  • 轻量消费者: 只负责反序列化 + 入 Kafka/Redis Stream,快速 ACK。

  • 异步缓冲层(Kafka/Pulsar): 把 MQTT 的 QoS 语义和业务处理的可靠性解耦。数据库抖动不反压到 MQTT 连接层。

  • 批处理消费者: 从 Kafka 批量消费,写时序库 + 规则引擎 + 告警。

  • 四类消息链路物理隔离: 遥测走 Kafka 削峰,指令走独立低延迟 MQTT 链路,LWT 状态独立主题,告警独立消费实例。

4.5、四段演进对照表

从 500 到 50,000,不是简单"加机器"就能搞定的,每一档都有自己必须迈过的坎。500 台时单节点裸跑,先把协议跑通、Topic 规范立好;到 5,000 台就得自己封装客户端、加批量写入,把可靠性补上;5,000–20,000 台开始上集群、共享订阅分摊压力,靠扩展性扛住增长;到了 20,000–50,000 档,Broker 拆 Core/Replicant、遥测走 Kafka 削峰、存储分层、K8s 自动故障转移,整套架构才真正成型。每一列对比下来你会发现,前一档的"土办法"在后一档就是事故源头。

从 500 台到 5 万台:Broker、客户端、缓冲、会话、存储、高可用同步演进,每段只解决当前规模的真实痛点。

3.png

跃迁口诀:500 台跑协议,5,000 台跑可靠,20,000 台跑扩展,50,000 台跑架构——别拿下一档的方案去补上一档的坑,也别拿上一档的侥幸去扛下一档的量。

05、实战层:代码落地与配置

5.1、客户端:MQTTnet 5.x 可靠连接封装

MQTTnet 5.x 不再提供 ManagedMqttClient,以下是最小可靠客户端:指数退避重连 + 重连后重订阅 + 有界发送队列 + 优雅停机。

源码看附件1。

5.2、消费端:共享订阅 + 有界 Channel + 批量落库 + 死信

重点:使用Shared Subscription实现多实例负载均衡,BoundedChannel做本地缓冲(背压),批量入库,失败消息进入死信Topic,避免无限重试拖垮消费。

源码看附件2。

5.3、边缘网关:MQTTnet 5.x 本地自治

边缘网关(工控机 / ARM 板)是关键缓冲层,负责协议转换、本地缓存(SQLite/RocksDB断点续传)、本地规则引擎、数据聚合。

源码看附件3。

5.4、Broker:EMQX 关键配置

生产环境必开项(包含监听器、限流、连接抖动、遗嘱、ACL、桥接规则):

  • 连接速率限制: max_conn_rate,防止风暴瞬间打爆;

  • 会话过期时间: CleanSession=false的会话设置合理过期(如 mqtt.session_expiry_interval = "2h"),避免离线消息无限堆积;

  • 报文大小上限: max_packet_size(如 64KB),杜绝单条大消息打挂 Broker;

  • 遗嘱消息统一规范: $SYS/device/lwt/{product}/{deviceId},并限制派发速率;

  • 动态鉴权 + ACL: 每个设备只能发布自己上行Topic、订阅自己下行Topic;

  • Bridge规则: 遥测Topic自动转发Kafka,指令Topic保留在MQTT链路。

源码看附件4。

5.5、消息管道:规则引擎桥接 Kafka

四类消息链路强制物理隔离:

  • 遥测上行: 高频、海量,允许轻微延迟 → EMQX桥接Kafka削峰,.NET批量消费写时序库;

  • 指令下行: 低延迟、不可丢 → 独立MQTT共享订阅链路,不走Kafka;

  • 在线离线(遗嘱LWT): 状态信号 → Redis实时缓存,同时异步落库;

  • 告警事件: 独立Topic、独立消费实例,告警不被遥测流量淹没。

规则引擎桥接示例:

SELECT payload.deviceId AS device_id, payload.ts, payload.seq, payload.data, topic, clientid
FROM "svc/+/+/+/+/telemetry"
WHERE payload.seq IS NOT NULL

Sink 配置要点:Kafka Sink 开异步批量 + 缓冲队列;绝不在规则里同步查 MySQL / 调 HTTP——同步阻塞外部 IO 会拖垮整条消息管线。

5.6、存储分层:Redis / 时序库 / 分表 MySQL

  • Redis: 在线状态、指令ACK缓存、限流计数器、分布式锁;

  • TimescaleDB / InfluxDB: 时序遥测数据,开启自动分区,适合5万设备高频上报;

  • MySQL按月分表: 设备指令记录、告警记录、设备档案。

绝对禁止遥测原始消息直接写入普通MySQL主表,5万设备会直接打爆。时序库按 50K 数据点/秒/实例 规划;单实例不够就按设备 ID 哈希分库分表。

5.7、扩容踩坑实录:重启风暴、会话爆炸、QoS2误用、共享订阅不均衡、Topic泛滥

  • 重启风暴: Broker重启/网络恢复,上万设备同一时刻重连。**修复:**客户端指数退避+随机抖动,Broker侧max_conn_rate连接速率限流。

  • 会话爆炸: 大量设备CleanSession=false,离线消息长期不清理。**修复:**设置合理的session_expiry_interval,避免离线消息无限堆积。

  • 通配符滥用: #订阅过多,路由开销飙升,严格执行Topic9条军规。

  • 共享订阅不均衡: 部分实例消费极多,检查EMQX负载分发策略(hash_clientid/sticky等)。

  • QoS2误用: 全链路QoS2,四次握手极大压低吞吐。修复:仅计费类强对账场景局部使用,遥测、告警优先QoS1。

06、运维层:保障与规范

6.1、可观测性:EMQX 侧 / .NET 侧 / 链路追踪 / 端到端对账

原则:MQTT 集群与消费服务的每一层都要有指标、有告警,出问题要能在 30 秒内定位到是哪一层。

EMQX 侧(开启 Prometheus 指标暴露后必看):

4.png

.NET 消费端(OpenTelemetry + Prometheus 打点):

  • mqtt_connected:0/1,断连立刻告警;

  • mqtt_messages_received_total:按 topic 维度的收消息速率;

  • mqtt_message_process_duration_seconds:处理耗时直方图,P95/P99 变慢即问题;

  • channel_backlog_count:处理通道积压量,持续增长说明消费端跟不上;

  • mqtt_messages_dlq_total:进死信的消息数,> 0 就要排查;

  • process_batch_insert_duration_seconds:批量写库耗时。

分布式追踪:接入 Kafka 后,一条消息要经过 设备→EMQX→Kafka→消费服务→数据库。用 OpenTelemetry 给每条消息注入 TraceId——设备发布时在 User Property 里带 trace-id(MQTT 5.0 支持),EMQX 规则引擎透传到 Kafka Header,.NET 消费端读取后注入 Activity,即可在 Jaeger/Tempo 里直接搜索一条消息在哪一步慢、在哪一步报错。

端到端对账:

发布端 seq → Broker 接收计数 → Kafka 消费 seq → 数据库入库 seq

定期比对四段 seq 缺口,定位丢包发生在哪一段。这是“证明到底丢没丢”的唯一硬手段。

6.2、安全基线:TLS / 一机一密 / ACL / 限流 / 黑名单熔断

  • 传输加密: 全链路 TLS 1.3,端口 8883,禁用 1883 明文(或仅内网开放);

  • 一机一密: 每设备独立 clientId + 凭据,凭据走 Vault/密钥中心轮换;

  • Topic ACL: 设备只能写自己的 telemetry,不能读其他设备;

  • 限流: 单客户端 Publish 速率上限,防重连风暴打挂鉴权服务;

  • 黑名单熔断: 持续认证失败或异常订阅的客户端自动加入黑名单。

6.3、可靠性四层自检(网络 / 协议 / 传输 / 业务)

第一层:网络层

  • KeepAlive 适配网络类型:蜂窝 ≤ 60s,局域网 ≤ 120s;

  • NAT 超时已评估(运营商 NAT 通常 30–120s);

  • TLS 1.3 强制启用,证书有效且自动轮换。

第二层:协议层

  • 发布/订阅两端 QoS 已对齐,端到端 QoS = min(发布, 订阅);

  • 订阅显式声明 QoS,避免静默降级;

  • SessionExpiryInterval 正确设置,CleanStart 语义清晰;

  • 遗嘱消息 QoS ≥ 1 + Retain。

第三层:传输层

  • 弱网关键消息 QoS ≥ 1(QoS 0 在 10% 丢包率下,600 条全达概率约等于 0);

  • TCP ZeroWindow 排查:接收端处理慢会反噬发送端;

  • 用 tcpdump + Wireshark 定位丢包层级(网络重传 / 零窗口 / RST)。

第四层:业务层

  • 消费回调必须 try/catch,异常不能逃逸;

  • QoS 1 会重复投递,消费端必须幂等(按 seq 或 deviceId+ts 去重);

  • 失败消息落死信队列,不静默丢弃。

6.4、压测与容量规划

扩容不能凭感觉,上线前必须压测。用 EMQX 官方 emqtt-bench 工具,在每次扩容前做一轮压测,验证瓶颈在哪。

真实压测数字(供参考):

5.png

说明:这些数字是局域网内用 emqtt-bench 配合真实业务处理逻辑压出来的,不是厂商白皮书数据。真实公网环境因 TLS 握手、网络延迟、设备处理能力差异,实际可用吞吐要打 6–7 折。

容量规划原则:

  • 按峰值算,不按平均算: 早高峰、OTA 批量重启、停电恢复这些场景的流量是平均值的 5–10 倍,系统必须按峰值预留 50% 裕量;

  • Broker CPU 长期不超过 70%: 超过 70% 后 GC 与调度开销非线性上升,很容易雪崩;

  • 消费端滞后(Lag)必须监控: Kafka 消费 Lag、MQTT 队列长度,持续增长立刻加消费者或降级;

  • 磁盘不是无限的: EMQX 持久会话消息、Kafka 日志、历史数据都要设保留策略。

07、决策层:演进路线、容量评估、灰度扩容、自检清单

7.1、容量评估表:单节点承载上限、带宽、内存、数据库写入阈值

评估三个核心指标,而不只看连接数:

  • 并发长连接数量:评估Broker内存、文件句柄;

  • 消息TPS:每秒入站消息,评估Broker、消费、存储;

  • 最大消息扇出:单条消息下发给多少订阅客户端,评估Broker CPU。

经验参考(真实业务压测,非实验室理想数据):

  • EMQX 单节点 8C16G:稳定约 2530万连接,消息 TPS 约 15,00020,000 QoS1;

  • .NET 单消费实例 4C8G:Channel+批量时序入库,约 12,000~18,000 消息/秒;

  • 真实公网环境因 TLS 握手、网络延迟、设备处理能力差异,实际可用吞吐要打 6~7折。

7.2、渐进式扩容路径:500 → 2000 → 10000 → 50000 四阶段升级方案

  • 阶段1(≤500台): 单节点EMQX,MQTTnet 5.x单消费实例,单库;重点做好Topic规范、遗嘱、心跳;

  • 阶段2(500~2000台): EMQX单节点,MQTTnet 5.x改成共享订阅多实例,遥测批量入库;

  • 阶段3(2000~10000台): EMQX集群,遥测开始桥接Kafka,时序库替换MySQL遥测;

  • 阶段4(10000~50000台): 完整分层架构,Core+Replicant,存储分区分表,独立指令链路、全链路监控、故障演练常态化。

7.3、故障演练方案:断节点、批量设备上下线、消息突增、数据库卡顿

上线前建议做:

  • 单Broker节点宕机,验证集群故障迁移;

  • 模拟20%设备同时断网恢复,验证重连风暴防护;

  • 模拟数据库短时卡顿,验证消费背压、死信队列是否生效;

  • 模拟网络分区,验证会话、消息不会大规模丢失。

7.4、决策速查表

架构升级最怕"想太多"和"想太晚",这张表就是帮你卡准节奏的。别超前设计浪费精力,但也别等崩了才想起来补——看指标说话,到线就动。

6.png

一句话判断法:

  • 什么时候上集群?——单节点 CPU 峰值长期 > 60% 或需要 7×24 无中断时。

  • 什么时候加消息队列?——消费逻辑复杂、需要多个消费者组、或消费速度开始和 Broker 互相拖累时。

  • 什么时候拆分消费服务?——单实例 CPU 打满、无法再扩,或不同消息类型需要不同的消费 SLA 时。

7.5、上线前自检清单 + 扩容口诀

上线前自检清单:

八条覆盖 Topic 规范、客户端健壮性、QoS 策略、消费端、Broker、链路隔离、监控与压测;逐条打勾再上线。

7.png

重点提醒:第 2 条的指数退避 + 随机抖动和第 8 条的20% 设备同时掉线重连是一对——没有退避抖动,压测就会暴露重连风暴;没有压测,退避参数就是拍脑袋。指令不走 Kafka 是链路隔离的底线:遥测可以等,指令不能等。

扩容口诀:

  • 接入只管连和路由,业务下沉异步处理;

  • 指令优先低延迟,遥测削峰队列里;

  • 共享订阅扩消费,会话过期清堆积;

  • 风暴一定要抖动,链路隔离不连坐。

7.6、兜底降级策略

  • 遥测链路降级: 关闭非核心测点入库,只保留关键指标;

  • 指令限流: 按设备/租户令牌桶,防止批量下发指令压垮系统;

  • 熔断: 某个消费组持续报错,自动转入死信,隔离故障;

  • Broker隔离: 新设备切到备用Replicant节点,不影响存量稳定节点。

7.7、演进阶段速查表

四段演进只看四件事:Broker 怎么摆、消费怎么扩、存储怎么分层、当前最该防的风险是什么。

8.png

怎么用这张表: 先按设备量定位当前阶段,再看“关键风险”这一列——≤500 台最该防的是过度设计,500–5,000 台最先倒的是消费端同步 IO,5,000–20,000 台要提前规划共享订阅组并防重连风暴,20,000 台以上必须上端到端对账与消费者背压。每一段只解决当前阶段最痛的那一件事,不要提前把下一段的架构全铺上。

7.8、选型决策树

从设备量一路问下去:每个“是”直接落到该阶段的 Broker + 消费模型 + 必守动作,“否”继续往下问。

9.png

使用顺序: 从上往下逐层判断,第一个命中“是”就是当前该落的方案;如果三问都是“否”,直接进入终局方案。不要越级——设备量 < 1,000 台就上 Core + Replicant,只会把 Erlang 运维成本提前背在身上。

7.9、架构演进的三个原则

第一,不要过早优化,也不要不优化。

500 台用内嵌 Broker 没问题,这是对的;但如果在 500 台时就把代码写得一塌糊涂(回调里直接写库、ClientId 写死、没重连),到 2,000 台就是推倒重来。核心代码从第一天起就要为扩展留口子——回调与处理解耦、ClientId 动态生成、配置外置。

第二,MQTT 的坑 80% 不在 Broker,而在客户端。

见过太多团队花大量精力选 Broker、调 Broker 参数,但客户端写得一塌糊涂——回调里同步阻塞、ClientId 固定、没重连、没异常处理、没监控。Broker 再强也扛不住糟糕的客户端。先把客户端写对,再谈 Broker 选型。

第三,生产环境最重要的不是性能,而是可观测性和可恢复性。

能快速发现问题、快速定位、快速恢复,比“我们能扛 100 万连接”重要得多。半夜被电话叫醒,30 秒内在 Grafana 上看到是认证失败突增、还是消费端 Lag 飙高、还是 Broker 内存泄漏,处理方式完全不同。没有监控的生产,就是盲人骑瞎马。

08、最后

我整理了一份《MQTT生产级架构设计Checklist》,包含:

  • 四大底层认知(Broker只做收发、连接≠TPS、设备风暴是最大风险、消费端是木桶短板)

  • IoT 平台六层分层架构(设备、接入、缓冲、业务、存储、管控监控全链路解剖)

  • 四段架构演进与选型决策树(从 500 到 50,000 设备的平滑演进方案与对照表)

  • 核心实战层自检(MQTTnet 5.x 重连退避、有界 Channel 背压、共享订阅与四类链路物理隔离)

  • 生产监控指标与容量压测基准(P0/P1 告警阈值、EMQX 与 .NET 消费端吞吐实测参考)

  • 上线前 10 项终极自检清单 + 一句扩容口诀

私信「MQTT架构设计Checklist」领取。

做物联网 MQTT 的朋友可以一起交流。后面我会在这个号持续更新这个系列,也会穿插一些软考架构师备考、技术管理转型、嵌入式学习的记录。

09、附件1:客户端:MQTTnet 5.x 可靠连接封装

using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Protocol;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Threading.Channels;
// MQTTnet 5.x 可靠MQTT客户端封装
// 特性:指数退避+抖动重连|重连自动恢复订阅|有界离线发送队列|消息过期|优雅停机|回调解耦Channel
// MQTTnet 5.x 已经移除 ManagedMqttClient,基于原生 IMqttClient 实现
public sealed class ResilientMqttClient : IAsyncDisposable
{
    #region 配置常量(可抽离为独立Options配置类)
    // 离线/发布失败消息缓存队列最大长度,防止断连长时间不恢复造成OOM
    // 策略:队列满时丢弃最旧消息,业务可按需切换为丢弃新消息
    private const int MaxQueueSize = 2000;
    // 队列内消息过期时长(秒),过期消息恢复连接后不再发送
    // 适合传感器时序上报类场景,避免恢复后补发大量过时数据
    private const int MessageExpireSeconds = 120;
    #endregion
    #region 核心依赖与状态字段
    // MQTT原生客户端实例
    private readonly IMqttClient _client;
    // MQTT连接参数(地址、KeepAlive、CleanSession、认证等)
    private readonly MqttClientOptions _options;
    // 日志
    private readonly ILogger<ResilientMqttClient> _logger;
    // 当前客户端唯一ClientId
    private readonly string _clientId;
    // 待补发消息队列:离线、在线发布失败的消息放入此处
    private readonly ConcurrentQueue<QueuedMessage> _retryQueue = new();
    // 连接互斥锁:防止并发触发多条重连流程,避免重复连接
    private readonly SemaphoreSlim _connectLock = new(1, 1);
    // 重连流程取消令牌源,停机时取消重连循环
    private CancellationTokenSource? _reconnectCts;
    // 标记是否为主动手动停机,区分【正常退出】和【异常断开】
    // true:主动关闭,不再自动重连
    // false:异常断开,触发自动重连
    private volatile bool _manualShutdown;
    // 防止 StartAsync 被多次并发调用
    private volatile bool _isStarting;
    // 托管订阅列表:所有需要持久化、重连后自动恢复的订阅
    // 支持外部动态新增订阅
    private readonly List<MqttTopicFilter> _subscriptions = new();
    #endregion
    #region 对外暴露管道
    // 收到消息管道
    // 设计原则:MQTT回调事件内禁止阻塞、IO、长耗时逻辑;仅写入Channel,业务在外部消费
    public Channel<MqttApplicationMessageReceivedEventArgs> MessageChannel { get; } = Channel.CreateUnbounded<MqttApplicationMessageReceivedEventArgs>();
    #endregion
    // 构造函数
    public ResilientMqttClient(string clientId, MqttClientOptions options, ILogger<ResilientMqttClient> logger)
    {
        _clientId = clientId;
        _options = options;
        _logger = logger;
        // MQTTnet 5.x 标准创建方式:MqttClientFactory
        _client = new MqttFactory().CreateMqttClient();
        // 挂载原生事件
        _client.ConnectedAsync += OnConnectedAsync;
        _client.DisconnectedAsync += OnDisconnectedAsync;
        _client.ApplicationMessageReceivedAsync += OnMessageReceivedAsync;
        // 默认订阅指令主题,业务可外部清空/新增订阅
        _subscriptions.Add(new MqttTopicFilterBuilder()
            .WithTopic($"svc/device/{_clientId}/command/#")
            .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
            .Build());
    }
    // 添加需要托管的订阅,连接恢复时自动重新订阅
    // 幂等:相同Topic不会重复添加
    public void AddSubscription(MqttTopicFilter filter)
    {
        lock (_subscriptions)
        {
            if (!_subscriptions.Any(x => x.Topic == filter.Topic))
                _subscriptions.Add(filter);
        }
    }
    // 启动客户端:执行初始连接,失败自动进入指数退避重连循环
    public async Task StartAsync(CancellationToken ct = default)
    {
        // 防止并发多次启动
        if (_isStarting) return;
        _isStarting = true;
        try
        {
            _manualShutdown = false;
            // 合并外部传入的取消Token与内部重连Token
            _reconnectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            await ConnectWithRetryAsync(_reconnectCts.Token);
        }
        finally
        {
            _isStarting = false;
        }
    }
    // 指数退避 + Jitter抖动 重连循环
    // 退避策略:1s → 2s →4s →8s →16s →30s(上限),叠加随机抖动,避免羊群效应(大量设备同时冲击Broker)
    private async Task ConnectWithRetryAsync(CancellationToken ct)
    {
        var delay = TimeSpan.FromSeconds(1);
        var maxDelay = TimeSpan.FromSeconds(30);
        while (!ct.IsCancellationRequested && !_client.IsConnected)
        {
            try
            {
                // 串行连接,防止并发Connect
                await _connectLock.WaitAsync(ct);
                try
                {
                    if (_client.IsConnected) return;
                    _logger.LogInformation("尝试连接Broker,ClientId:{ClientId}", _clientId);
                    await _client.ConnectAsync(_options, ct);
                }
                finally
                {
                    _connectLock.Release();
                }
                // 连接成功,跳出循环
                break;
            }
            catch (OperationCanceledException)
            {
                // 收到取消指令,退出重连
                break;
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "连接Broker失败,{Delay:F2}s后重试", delay.TotalSeconds);
                try
                {
                    await Task.Delay(delay, ct);
                }
                catch (OperationCanceledException)
                {
                    break;
                }
                // Jitter:基础延迟 *2,叠加1.0~1.2倍随机抖动,防止同步雪崩
                var jitter = 1 + Random.Shared.NextDouble() * 0.2;
                delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2 * jitter, maxDelay.TotalSeconds));
            }
        }
    }
    // 连接成功回调
    // 职责:恢复订阅、启动积压消息补发
    private async Task OnConnectedAsync(MqttClientConnectedEventArgs e)
    {
        _logger.LogInformation("✅ MQTT连接成功,ClientId:{ClientId}", _clientId);
        await RestoreSubscriptionsAsync();
        // 启动队列补发循环
        _ = FlushRetryQueueLoopAsync();
    }
    // 断开连接回调
    // 区分:主动停机断开 / 异常链路断开;异常断开则启动重连
    private Task OnDisconnectedAsync(MqttClientDisconnectedEventArgs e)
    {
        _logger.LogWarning("❌ MQTT连接断开,ClientId:{ClientId}, Reason:{Reason}", _clientId, e.Reason);
        // 不是手动停机,并且之前是正常连接状态 → 触发重连
        if (!_manualShutdown && e.ClientWasConnected)
        {
            _ = RunReconnectLoopSafeAsync();
        }
        return Task.CompletedTask;
    }
    // 包裹重连循环,防止重连内部异常导致Task静默崩溃无日志
    private async Task RunReconnectLoopSafeAsync()
    {
        try
        {
            if (_reconnectCts != null && !_reconnectCts.Token.IsCancellationRequested)
                await ConnectWithRetryAsync(_reconnectCts.Token);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "重连循环发生未捕获异常");
        }
    }
    // 恢复全部托管订阅,重连后自动执行
    private async Task RestoreSubscriptionsAsync()
    {
        List<MqttTopicFilter> subs;
        lock (_subscriptions)
        {
            subs = _subscriptions.ToList();
        }
        if (subs.Count == 0) return;
        var subscribeOptions = new MqttSubscribeOptionsBuilder();
        foreach (var filter in subs)
        {
            subscribeOptions.WithTopicFilter(filter);
        }
        var result = await _client.SubscribeAsync(subscribeOptions.Build());
        foreach (var item in result.Items)
        {
            _logger.LogInformation("订阅Topic:{Topic}, 订阅结果:{ResultCode}", item.TopicFilter.Topic, item.ResultCode);
        }
    }
    // 发布消息入口
    // 逻辑:在线优先直接发送;发送失败 / 离线 → 进入补发队列
    public async Task PublishAsync(MqttApplicationMessage message, CancellationToken ct = default)
    {
        // 包装消息+过期时间
        var queuedMsg = new QueuedMessage(message, DateTimeOffset.UtcNow.AddSeconds(MessageExpireSeconds));
        if (_client.IsConnected)
        {
            try
            {
                var result = await _client.PublishAsync(message, ct);
                if (result.IsSuccess)
                {
                    _logger.LogTrace("发布成功 Topic:{Topic}", message.Topic);
                    return;
                }
                // Broker返回失败码(权限拒绝、限额等)
                _logger.LogWarning("发布被Broker拒绝 Code:{Code}, Topic:{Topic}", result.ReasonCode, message.Topic);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "在线发布异常 Topic:{Topic}", message.Topic);
            }
        }
        // 走到这里:离线 或者 在线发布失败,进入补发队列
        if (_retryQueue.Count >= MaxQueueSize)
        {
            // 队列已满,丢弃最旧消息
            if (_retryQueue.TryDequeue(out var dropMsg))
            {
                _logger.LogWarning("补发队列已满,丢弃旧消息 Topic:{Topic}", dropMsg.Message.Topic);
            }
        }
        _retryQueue.Enqueue(queuedMsg);
        // 重点修复:在线状态下入队消息,立刻触发一次补发,否则在线发布失败消息不会自动补发
        if (_client.IsConnected)
        {
            _ = FlushRetryQueueLoopAsync();
        }
    }
    // 持续补发队列循环:消费_retryQueue
    // 过期消息直接丢弃;补发失败重新塞回队列尾部,短延迟避让Broker
    private async Task FlushRetryQueueLoopAsync()
    {
        while (_client.IsConnected && _retryQueue.TryDequeue(out var queued))
        {
            // 判断消息是否过期,过期直接丢弃
            if (queued.ExpireTime < DateTimeOffset.UtcNow)
            {
                _logger.LogWarning("消息已过期,丢弃 Topic:{Topic}", queued.Message.Topic);
                continue;
            }
            try
            {
                var result = await _client.PublishAsync(queued.Message);
                if (!result.IsSuccess)
                {
                    // 补发失败,重新入队尾部等待下一轮
                    _retryQueue.Enqueue(queued);
                    _logger.LogWarning("重试发布失败,重新入队 Topic:{Topic}", queued.Message.Topic);
                    await Task.Delay(100);
                }
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "重试发布异常 Topic:{Topic}", queued.Message.Topic);
                // 异常同样回塞队列
                _retryQueue.Enqueue(queued);
                await Task.Delay(100);
            }
        }
    }
    // 消息接收原生回调
    // 禁止任何同步阻塞、数据库、HTTP调用;仅写入Channel,快速返回
    private Task OnMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs e)
    {
        if (!MessageChannel.Writer.TryWrite(e))
        {
            _logger.LogWarning("消息管道满,丢弃收到消息 Topic:{Topic}", e.ApplicationMessage.Topic);
        }
        return Task.CompletedTask;
    }
    // 优雅停机:标记手动关闭、停止重连、发送离线保留消息、主动断开
    public async Task GracefulShutdownAsync()
    {
        _manualShutdown = true;
        // 取消重连循环
        _reconnectCts?.Cancel();
        if (_client.IsConnected)
        {
            try
            {
                // 发送离线保留消息,通知平台设备下线
                var offlineMsg = new MqttApplicationMessageBuilder()
                    .WithTopic($"svc/device/{_clientId}/status")
                    .WithPayload("{\"status\":\"offline\",\"reason\":\"graceful\"}")
                    .WithRetainFlag()
                    .Build();
                await _client.PublishAsync(offlineMsg);
                await Task.Delay(200);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "发送离线遗嘱消息失败");
            }
            await _client.DisconnectAsync();
        }
        // 标记Channel写入完成,通知消费端结束
        MessageChannel.Writer.Complete();
    }
    // 异步释放资源,IAsyncDisposable实现
    public async ValueTask DisposeAsync()
    {
        await GracefulShutdownAsync();
        _client.Dispose();
        _connectLock.Dispose();
        _reconnectCts?.Dispose();
    }
    // 队列消息包装:原始消息 + 过期时间戳
    private record QueuedMessage(MqttApplicationMessage Message, DateTimeOffset ExpireTime);
}

10、附件2:消费端:共享订阅 + 有界 Channel + 批量落库 + 死信

using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Protocol;
using System.Threading.Channels;
using System.Runtime.CompilerServices;
// 遥测单条包装,携带原始MQTT事件句柄用于延后Ack
public sealed class TelemetryBatchItem
{
    public string Topic { get; }
    public byte[] Payload { get; }
    public MqttApplicationMessageReceivedEventArgs AckHandle { get; }
    public TelemetryBatchItem(string topic, byte[] payload, MqttApplicationMessageReceivedEventArgs ackHandle)
    {
        Topic = topic;
        Payload = payload;
        AckHandle = ackHandle;
    }
}
// 批次去重器接口,业务自行实现,一般按 deviceId + seq 做幂等去重
public interface ITelemetryDeduper
{
    List<TelemetryBatchItem> Filter(IReadOnlyList<TelemetryBatchItem> batch);
}
// 时序库写入接口
public interface ITimeSeriesWriter
{
    Task WriteBatchAsync(IReadOnlyList<TelemetryBatchItem> items, CancellationToken ct);
}
// 死信队列接口,常规实现是把失败消息重新发布到MQTT死信topic
public interface IDlqWriter
{
    Task WriteAsync(IReadOnlyList<TelemetryBatchItem> items, CancellationToken ct);
}
public sealed class TelemetryIngestWorker : BackgroundService
{
    private readonly IMqttClient _client;
    private readonly ITelemetryDeduper _deduper;
    private readonly ITimeSeriesWriter _timeSeriesWriter;
    private readonly IDlqWriter _dlq;
    private readonly ILogger<TelemetryIngestWorker> _logger;
    // 有界Channel,本地缓冲实现背压;FullMode=Wait:写满时阻塞写入,不静默丢消息
    private readonly Channel<TelemetryBatchItem> _inbox =
        Channel.CreateBounded<TelemetryBatchItem>(new BoundedChannelOptions(20_000)
        {
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = true,   // 只有一个消费循环读取,开启内部优化
            SingleWriter = false
        });
    // 批次阈值:最多攒500条就触发入库
    private const int BatchMaxSize = 500;
    // linger超时:最多等待100ms,低流量场景防止消息长期卡在channel饥饿
    private const int BatchLingerMs = 100;
    public TelemetryIngestWorker(
        IMqttClient client,
        ITelemetryDeduper deduper,
        ITimeSeriesWriter timeSeriesWriter,
        IDlqWriter dlq,
        ILogger<TelemetryIngestWorker> logger)
    {
        _client = client;
        _deduper = deduper;
        _timeSeriesWriter = timeSeriesWriter;
        _dlq = dlq;
        _logger = logger;
    }
    protected override async Task ExecuteAsync(CancellationToken stoppingCt)
    {
        // MQTT共享订阅格式 $share/{group}/{topicfilter},多实例负载均衡
        var subscribeOptions = new MqttClientSubscribeOptionsBuilder()
            .WithTopicFilter("$share/ingest/svc/device/+/telemetry", MqttQualityOfServiceLevel.AtLeastOnce)
            .Build();
        // 挂载消息接收回调,回调内只做入队,不直接发送Ack
        _client.ApplicationMessageReceivedAsync += OnMessageReceived;
        try
        {
            // 发起共享订阅
            await _client.SubscribeAsync(subscribeOptions, stoppingCt);
            _logger.LogInformation("共享订阅已注册:$share/ingest/svc/device/+/telemetry");
            // 批量消费主循环,持续读取channel并处理批次
            await foreach (var batch in ReadBatchesAsync(stoppingCt))
            {
                await ProcessBatchAsync(batch, stoppingCt);
            }
        }
        catch (OperationCanceledException)
        {
            // 正常停止信号,非异常
            _logger.LogInformation("Ingest Worker 收到停止信号");
        }
        catch (Exception ex)
        {
            // 主循环意外异常,worker整体退出告警
            _logger.LogCritical(ex, "Ingest Worker 主循环异常退出");
        }
        finally
        {
            // 卸载事件订阅,防止多次启动worker造成事件重复绑定、内存泄漏
            _client.ApplicationMessageReceivedAsync -= OnMessageReceived;
        }
    }
    // MQTT消息回调:MQTT客户端IO线程,禁止长时间await阻塞
    private Task OnMessageReceived(MqttApplicationMessageReceivedEventArgs e)
    {
        // 启动独立任务执行带背压的入队逻辑,不阻塞mqtt底层接收循环
        _ = EnqueueMessageWithBackpressure(e);
        return Task.CompletedTask;
    }
    // 带背压写入channel,写入成功代表本地收下消息,延后Ack控制QoS语义
    private async Task EnqueueMessageWithBackpressure(MqttApplicationMessageReceivedEventArgs e)
    {
        try
        {
            var item = new TelemetryBatchItem(
                e.ApplicationMessage.Topic,
                e.ApplicationMessage.Payload.ToArray(),
                e);
            // Wait模式:channel满时阻塞,实现背压,broker侧会保留消息不继续推送
            await _inbox.Writer.WriteAsync(item, e.CancellationToken);
        }
        catch (OperationCanceledException)
        {
            // 入队过程取消,不发送Ack,broker后续会重试投递
            _logger.LogWarning("消息入队时取消,不ack,broker会重试");
        }
        catch (Exception ex)
        {
            // 写入channel失败,不发送Ack,保留消息由broker重试
            _logger.LogError(ex, "消息无法写入本地Channel,不发送Ack,Broker将重试");
        }
    }
    // 批次处理:去重、时序库入库;无论成功还是转入死信,处理完成后统一Ack
    private async Task ProcessBatchAsync(IReadOnlyList<TelemetryBatchItem> batch, CancellationToken ct)
    {
        try
        {
            // 按deviceId+seq做幂等过滤,过滤重复投递的消息
            var deduped = _deduper.Filter(batch);
            if (deduped.Any())
            {
                // 批量写入时序数据库
                await _timeSeriesWriter.WriteBatchAsync(deduped, ct);
            }
            // 批次处理成功,全部消息发送PUBACK,broker标记投递完成,不再重发
            foreach (var item in batch)
            {
                await item.AckHandle.AcknowledgeAsync(ct);
            }
            _logger.LogTrace("批次处理完成,条数:{Count}", batch.Count);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "批次入库失败,转入死信Topic,再Ack(避免坏批次无限循环轰炸消费端)");
            try
            {
                // 写入死信队列,死信消息后续单独消费排查,死信建议QoS1保证不丢
                await _dlq.WriteAsync(batch, ct);
            }
            catch (Exception dlqEx)
            {
                // 死信写入也失败,属于兜底告警,消息有丢失风险,建议补充本地磁盘缓存兜底
                _logger.LogCritical(dlqEx, "死信写入也失败!消息存在丢失风险,建议本地兜底缓存");
            }
            // 即使进死信也要ack,防止同一条坏消息反复投递
            foreach (var item in batch)
            {
                try
                {
                    await item.AckHandle.AcknowledgeAsync(ct);
                }
                catch (Exception ackEx)
                {
                    _logger.LogWarning(ackEx, "死信批次Ack失败");
                }
            }
        }
    }
    // 批量读取迭代器:大小阈值 + linger超时双策略,解决低流量饥饿
    private async IAsyncEnumerable<IReadOnlyList<TelemetryBatchItem>> ReadBatchesAsync(
        [EnumeratorCancellation] CancellationToken ct)
    {
        var buffer = new List<TelemetryBatchItem>(BatchMaxSize);
        var reader = _inbox.Reader;
        while (!ct.IsCancellationRequested)
        {
            buffer.Clear();
            // 构建超时令牌,用于linger超时控制
            var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            timeoutCts.CancelAfter(BatchLingerMs);
            try
            {
                // 攒够条数 或者 linger超时 就结束本轮收集
                while (buffer.Count < BatchMaxSize
                       && await reader.WaitToReadAsync(timeoutCts.Token))
                {
                    // 循环读取可用消息
                    while (buffer.Count < BatchMaxSize && reader.TryRead(out var item))
                    {
                        buffer.Add(item);
                    }
                }
            }
            catch (OperationCanceledException)
            {
                // 超时触发,属于正常逻辑,不是服务停止
            }
            finally
            {
                timeoutCts.Dispose();
            }
            // 有数据就返回当前批次给上层处理
            if (buffer.Count > 0)
            {
                yield return buffer.ToArray();
            }
        }
    }
}

11、附件3:边缘网关:MQTTnet 5.x 本地自治

using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Protocol;
using System.Text.Json;
using System.Threading.Channels;
// 遥测点位实体
public sealed class TelemetryPoint
{
    public string DeviceId { get; set; }
    public string Tag { get; set; }
    public double Value { get; set; }
    public long TimestampUnixMs { get; set; }
}
// 本地持久缓存抽象:SQLite/RocksDB实现,负责断点续传存储
public interface IEdgeLocalCache
{
    // 保存一批失败上行的数据
    Task SaveAsync(List<TelemetryPoint> batch, CancellationToken ct);
    // 读取待补发批次(在线恢复后调用)
    Task<List<TelemetryPoint>> LoadPendingBatchAsync(int limit, CancellationToken ct);
    // 补发成功后删除本条/本批次
    Task RemoveSentAsync(List<TelemetryPoint> batch, CancellationToken ct);
}
// MQTT客户端扩展静态类,方案2:整批打包为单条MQTT消息上云,边缘网关推荐
public static class MqttClientBatchExtensions
{
    // Json序列化配置,边缘设备减少分配,不格式化输出
    private static readonly JsonSerializerOptions _jsonOpts = new JsonSerializerOptions
    {
        WriteIndented = false,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };
    /// <summary>
    /// 批量发布:将一批点位序列化为JSON数组,封装成一条MQTT消息发送
    /// 优点:报文少、仅一次PUBACK交互,节省4G/窄带带宽;云端消费端解析数组
    /// Topic固定:svc/device/batch/telemetry,默认QoS1
    /// </summary>
    public static async Task PublishBatchAsync(
        this IMqttClient client,
        List<TelemetryPoint> batch,
        CancellationToken ct,
        MqttQualityOfServiceLevel qos = MqttQualityOfServiceLevel.AtLeastOnce)
    {
        // 空批次直接返回,不发起无效发布
        if (batch == null || batch.Count == 0)
            return;
        // 校验连接状态,未连接直接抛出,上层捕获后落本地缓存
        if (!client.IsConnected)
            throw new MqttClientException("MQTT客户端未连接云端,无法批量发布");
        // 将点位列表序列化为JSON数组 payload
        var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(batch, _jsonOpts);
        var batchMsg = new MqttApplicationMessageBuilder()
            .WithTopic("svc/device/batch/telemetry")
            .WithPayload(payloadBytes)
            .WithQualityOfServiceLevel(qos)
            .WithRetainFlag(false)
            .Build();
        // QoS1:阻塞等待Broker返回PUBACK确认
        var pubResult = await client.PublishAsync(batchMsg, ct);
        if (!pubResult.IsSuccess)
        {
            throw new MqttClientException($"批量消息发布失败,ReasonCode:{pubResult.ReasonCode}");
        }
    }
}
// 边缘上行服务:内存Channel缓冲 + 批量打包上行 + 断网落本地持久缓存 + 联网补发
public sealed class EdgeUploadService
{
    private readonly IMqttClient _mqttClient;
    private readonly IEdgeLocalCache _localCache;
    private readonly ILogger<EdgeUploadService> _logger;
    // 生产者消费者内存缓冲通道,边缘网关多采集源写入、单线程读取打包
    // FullMode=DropOldest:缓冲区满时丢弃最旧点位,适合快照类测点,优先保证最新数据上云
    // 如果是不可丢失流水,改成 BoundedChannelFullMode.Wait,向上游采集层施加背压
    private readonly Channel<TelemetryPoint> _buffer =
        Channel.CreateBounded<TelemetryPoint>(new BoundedChannelOptions(50_000)
        {
            FullMode = BoundedChannelFullMode.DropOldest,
            SingleReader = true,
            SingleWriter = false
        });
    // 单批最大条数
    private const int BatchMaxCount = 200;
    // linger超时:最长等待500ms,低流量避免极小包频繁上行
    private const int LingerMs = 500;
    public EdgeUploadService(
        IMqttClient mqttClient,
        IEdgeLocalCache localCache,
        ILogger<EdgeUploadService> logger)
    {
        _mqttClient = mqttClient;
        _localCache = localCache;
        _logger = logger;
    }
    // 对外写入接口:采集/协议转换层往缓冲写入点位
    public ValueTask WritePointAsync(TelemetryPoint point, CancellationToken ct)
    {
        return _buffer.Writer.WriteAsync(point, ct);
    }
    // 批量上行主循环:攒够条数 或 超时打包发送;发送失败落本地持久缓存
    public async Task BatchUploadAsync(CancellationToken ct)
    {
        // 复用list,减少ARM设备GC压力
        var batch = new List<TelemetryPoint>(BatchMaxCount);
        while (!ct.IsCancellationRequested)
        {
            batch.Clear();
            // 非阻塞读取当前channel内就绪的数据,凑一批
            while (batch.Count < BatchMaxCount && _buffer.Reader.TryRead(out var item))
            {
                batch.Add(item);
            }
            // 当前没有就绪数据,延时等待linger时间,避免空轮询CPU跑满
            if (batch.Count == 0)
            {
                try
                {
                    await Task.Delay(LingerMs, ct);
                }
                catch (OperationCanceledException)
                {
                    // 服务停止信号,正常退出
                }
                continue;
            }
            try
            {
                // 调用扩展方法,整批打包为单条MQTT消息上云
                await _mqttClient.PublishBatchAsync(batch, ct);
                _logger.LogTrace("上行批次发送成功,条数:{Count}", batch.Count);
            }
            catch (OperationCanceledException)
            {
                _logger.LogWarning("上行批次被取消");
                // 取消场景,不持久化,由上游重推或者下次采集重新入缓冲
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "云端上行失败,批次写入本地缓存断点续传,条数:{Count}", batch.Count);
                try
                {
                    // 写入SQLite/RocksDB持久化,断网恢复后补发
                    await _localCache.SaveAsync(batch, ct);
                }
                catch (Exception cacheEx)
                {
                    // 本地存储也失败,属于极端风险:磁盘满、IO故障,告警
                    _logger.LogCritical(cacheEx, "本地缓存写入失败!数据丢失风险,条数:{Count}", batch.Count);
                }
            }
        }
    }
    // 【配套循环】在线恢复后,优先补发本地持久缓存,再消费内存新数据
    // 边缘自治闭环:联网之后先发历史缓存,再发实时新数据
    public async Task ResumePendingUploadAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            if (!_mqttClient.IsConnected)
            {
                await Task.Delay(1000, ct);
                continue;
            }
            // 从本地库读取待补发批次
            var pending = await _localCache.LoadPendingBatchAsync(BatchMaxCount, ct);
            if (pending.Count == 0)
            {
                // 没有待补发数据,休眠
                await Task.Delay(200, ct);
                continue;
            }
            try
            {
                await _mqttClient.PublishBatchAsync(pending, ct);
                _logger.LogInformation("补发本地缓存批次成功,条数:{Count}", pending.Count);
                // 发送成功,删除本地记录,防止重复补发
                await _localCache.RemoveSentAsync(pending, ct);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "补发本地缓存批次失败,下次循环重试");
                await Task.Delay(1000, ct);
            }
        }
    }
}

12、附件4:Broker:EMQX 关键配置

# EMQX 5.x 生产配置片段,适配:边缘网关遥测 + 共享订阅消费 + Kafka转发
# ===================== 监听器 TCP 1883 =====================
listeners.tcp.default {
  bind = "0.0.0.0:1883"
  max_connections = 200000
  max_packet_size = "64KB"       # 单报文上限,防大包攻击
  proxy_protocol = true          # 前置LB(Nginx/HAProxy)开启proxy v2才打开,直连客户端要关闭
}
# ===================== 限流模块 EMQX5 limiter(重点:替代老版本listener内max_conn_rate) =====================
limiter {
  # 连接创建速率全局限流,防设备重连风暴
  connection {
    rate = "1000/s"
  }
  # 单连接消息吞吐限流,匹配你 messages_rate / bytes_rate 需求
  messages {
    rate = "100/s"
    burst = 20
  }
  bytes {
    rate = "1MB/s"
    burst = 64KB
  }
}
# ===================== MQTT 核心会话 & 协议参数 =====================
mqtt {
  # 默认会话过期时间:clean_start=false 的MQTT5会话默认2h过期
  session_expiry_interval = "2h"
  # 强制会话过期上限:客户端不能申请超过2h的会话,防止恶意长会话堆积
  max_session_expiry_interval = "2h"
  # 单客户端in-flight(未ack QoS1/QoS2消息)上限,防止单边消息淹没broker
  max_inflight = 32
  # 离线消息队列最大条数,单个会话离线消息上限
  queue_max = 1000
  # 关闭Retain,遥测流水场景不需要保留消息快照
  retain_available = false
  # 共享订阅负载均衡策略,多消费实例均匀分摊消息
  shared_subscription {
    strategy = round_robin
  }
}
# ===================== 认证与授权(ACL)示例骨架,动态HTTP/JWT/SQL替换 =====================
authentication {
  mechanism = password_based
  backend = http
  method = post
  url = "http://auth-service/device/auth"
  headers {
    "Content-Type" = "application/json"
  }
  body {
    clientid = "${clientid}"
    username = "${username}"
    password = "${password}"
  }
}
authorization {
  # HTTP ACL,返回允许/拒绝,约束设备:只能发自己的上行、收自己下行
  sources = [
    {
      type = http
      method = post
      url = "http://auth-service/device/acl"
    }
  ]
  # 默认拒绝,最小权限原则
  no_match = deny
}
# ===================== 规则引擎:遥测批量topic转发Kafka,指令topic不转发 =====================
# 规则:匹配边缘上云批量遥测 svc/device/batch/telemetry,转发Kafka
rule "telemetry_to_kafka" {
  sql = <<-SQL
    SELECT * FROM "svc/device/batch/telemetry"
  SQL
  actions = [
    {
      name = kafka
      params = {
        brokers = "kafka-01:9092,kafka-02:9092"
        topic = "telemetry-batch"
        key = "${clientid}"
      }
    }
  ]
}