01、引入层:500台 → 5万台,我们踩过的扩容大坑
1.1、500设备的朴素架构(很多项目起步版本)
绝大多数物联网项目起步都是这套最简方案:
csharp
设备 → 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设备:简化版架构图、组件清单、资源规格
csharp
设备 → 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 兜底安全。一句话总结:指令走快车道,遥测走缓冲道,各层解耦,挂一个不影响其他。

链路隔离非常关键:实时指令不走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 各司其职。最关键的区别是故障隔离------小规模是"一根绳上的蚂蚱",大规模是"各走各的道",遥测链路堵了指令通道照样秒级响应。扩容方式也从"换台好机器"变成"加机器加实例加分片"。一句话:规模翻百倍,架构思路得从"凑合用"切换成"防雪崩"。
同一套业务,两个规模档位:从单机独占订阅到集群 + 共享订阅 + 缓冲削峰,差的不只是连接数。

一句话总结: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 万台设备、几十种消息类型、多个消费方的时候再改,代价是巨大的。我们改一次主题花了近一个月,设备端、服务端、规则引擎、数据库表全要动。下面是我们经过两轮重构后沉淀下来的约定。
命名约定 (按业务规模二选一,关键是层级固定、不随意增减):
csharp
# 三层式(中小规模,一台设备一个 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 字段规范:
csharp
{
"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 台):怎么简单怎么来
典型架构:
csharp
设备 → 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、客户端、缓冲、会话、存储、高可用同步演进,每段只解决当前规模的真实痛点。

跃迁口诀 :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、独立消费实例,告警不被遥测流量淹没。
规则引擎桥接示例:
csharp
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 指标暴露后必看):

.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 里直接搜索一条消息在哪一步慢、在哪一步报错。
端到端对账:
csharp
发布端 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 工具,在每次扩容前做一轮压测,验证瓶颈在哪。
真实压测数字(供参考):

说明:这些数字是局域网内用 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:稳定约 25
30万连接,消息 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、决策速查表
架构升级最怕"想太多"和"想太晚",这张表就是帮你卡准节奏的。别超前设计浪费精力,但也别等崩了才想起来补------看指标说话,到线就动。

一句话判断法:
-
什么时候上集群?------单节点 CPU 峰值长期 > 60% 或需要 7×24 无中断时。
-
什么时候加消息队列?------消费逻辑复杂、需要多个消费者组、或消费速度开始和 Broker 互相拖累时。
-
什么时候拆分消费服务?------单实例 CPU 打满、无法再扩,或不同消息类型需要不同的消费 SLA 时。
7.5、上线前自检清单 + 扩容口诀
上线前自检清单:
八条覆盖 Topic 规范、客户端健壮性、QoS 策略、消费端、Broker、链路隔离、监控与压测;逐条打勾再上线。

重点提醒:第 2 条的指数退避 + 随机抖动 和第 8 条的20% 设备同时掉线重连 是一对------没有退避抖动,压测就会暴露重连风暴;没有压测,退避参数就是拍脑袋。指令不走 Kafka 是链路隔离的底线:遥测可以等,指令不能等。
扩容口诀:
-
接入只管连和路由,业务下沉异步处理;
-
指令优先低延迟,遥测削峰队列里;
-
共享订阅扩消费,会话过期清堆积;
-
风暴一定要抖动,链路隔离不连坐。
7.6、兜底降级策略
-
遥测链路降级: 关闭非核心测点入库,只保留关键指标;
-
指令限流: 按设备/租户令牌桶,防止批量下发指令压垮系统;
-
熔断: 某个消费组持续报错,自动转入死信,隔离故障;
-
Broker隔离: 新设备切到备用Replicant节点,不影响存量稳定节点。
7.7、演进阶段速查表
四段演进只看四件事:Broker 怎么摆、消费怎么扩、存储怎么分层、当前最该防的风险是什么。

怎么用这张表: 先按设备量定位当前阶段,再看"关键风险"这一列------≤500 台最该防的是过度设计,500--5,000 台最先倒的是消费端同步 IO,5,000--20,000 台要提前规划共享订阅组并防重连风暴,20,000 台以上必须上端到端对账与消费者背压。每一段只解决当前阶段最痛的那一件事,不要提前把下一段的架构全铺上。
7.8、选型决策树
从设备量一路问下去:每个"是"直接落到该阶段的 Broker + 消费模型 + 必守动作,"否"继续往下问。

使用顺序: 从上往下逐层判断,第一个命中"是"就是当前该落的方案 ;如果三问都是"否",直接进入终局方案。不要越级------设备量 < 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 可靠连接封装
csharp
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 + 批量落库 + 死信
csharp
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 本地自治
csharp
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 关键配置
csharp
# 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}"
}
}
]
}