做物联网和上位机开发这些年,我几乎每一轮项目都会碰到一句话:“你们这设备接入用什么Broker?”说白了就是MQTT消息服务器。早几年我还会老老实实推荐Mosquitto、EMQX这些现成方案,但碰了几次需求定制、私有协议适配、鉴权方式改造之后,我开始认真考虑一件事:与其在外部Broker上做各种“歪门邪道”的钩子,不如直接用C#自己写一个服务器端,基于开源的MQTT通信库把Broker逻辑掌握在自己手里。
这篇博文,我就专门聊聊这套思路:为什么值得自建,C#生态里现成的MQTT服务器端代码长什么样,核心模块怎么拆,性能怎么压,真实项目里(尤其是停车场车牌识别相机对接这种场景)怎么落地。想彻底吃透MQTT服务端开发的人,或者正在为“公司要用私有的设备接入平台”发愁的人,这篇文章能给出一条直接能走的路。
1. 为什么要自己建:现成MQTT Broker的爽与不爽
1.1 现成Broker解决不了的事
先说明一下,Mosquitto和EMQX这类开源Broker确实非常成熟,尤其EMQX在集群和插件生态上做得已经很好。但问题恰恰出在“成熟”这两个字上。你在项目里一旦遇到下面这些需求,现成Broker就会变得很别扭:
- 设备端发上来的报文不只是标准的MQTT Payload,还夹杂着厂商自定义的心跳结构、加密字段,你必须在Broker层面直接解析改写。
- 你需要在设备连接、订阅、断开这些生命周期节点上,触发自己的业务逻辑,比如动态分配Topic权限、把设备上下线消息实时推给业务系统。
- 你想把Broker的数据面和控制面全部集成进自己的.NET后台服务里,而不是额外维护一个中间件进程。
- 你需要部署到客户的Windows工控机上,客户环境限制多,不希望再装一个服务。
这种时候,去改Mosquitto的C源码不现实,去改EMQX的Erlang插件学习成本又太高。反而是C#这边,MQTTnet这个库直接给你提供了完整的Broker端API,你可以像写普通服务一样,把MQTT服务器端“嵌入”到自己的项目里。这就是我后来决定走这条路的核心原因:控制力。
1.2 选择C#和MQTTnet的理由
先给不熟悉的读者介绍一下MQTTnet。它是开源项目,GitHub上活跃度一直很高,库本身支持MQTT 3.1.1和5.0协议,同时提供了客户端和服务器端的完整实现。最关键的是它的服务器端是模块化的,Connection、Session、Subscription、Retain消息这些都可以通过事件和接口来做自定义覆盖。
我实际用下来,MQTTnet的服务器端虽然不会像EMQX那样开箱即用直接跑几万连接,但它的底子很干净,性能在.NET平台上完全够用。再加上C#的async/await模型处理大量长连接非常舒服,写起来比传统多线程模型简单得多。如果你本身项目就是.NET技术栈,把这套服务器端代码嵌进去,连进程间通信都省了,业务代码直接内调用Broker的发布接口,复杂度下降一大截。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MQTT服务器端核心原理与状态建模
2.1 协议层面,Broker到底要管哪些东西
很多人对MQTT的理解停留在“就是发布订阅消息”这个层面,但真要自己写服务器端,就必须把协议里那几个核心概念彻底搞清楚。
先看连接层。客户端发Connect报文进来,Broker要校验协议版本、ClientId、用户名密码,还要决定是否接受CleanSession标记。这个标记很关键,它决定了会话是否会被持久化。如果设备用的是CleanSession=false,Broker就必须把会话状态存下来,包括它订阅过的Topic以及离线期间发给它的QoS 1/2消息。这部分状态管理是Broker最基本的内存开销来源。
再看订阅层。客户端发Subscribe报文的时候,Broker要维护一个“ClientId到Topic过滤器集合”的映射表。Topic过滤器支持通配符,+匹配单层,#匹配多层,所以路由匹配不能简单做字符串相等判断,要按层级拆开去匹配。我见过不少人在自己写简化版Broker的时候,卡就卡在这个Topic匹配的算法上,性能一高就崩。
最后是消息层。Broker收到Publish报文后,除了要按Topic找出所有匹配的订阅者逐个转发,还得处理QoS级别的语义。QoS 0是尽力而为,QoS 1至少一次,要回PUBACK并做消息去重,QoS 2是恰好一次,需要完整做PUBREC/PUBREL/PUBCOMP四次握手。同时,如果这个Topic有Retain消息,Broker要缓存最新一条,等新客户端订阅时立即推送。
2.2 服务器端程序里该怎么建这些状态
用C#来建模,我的习惯是搞三个核心对象:
MqttClientSession:表示一个客户端连接,里面存放ClientId、终结点地址、连接状态、Pending消息队列。MqttTopicTree:表示订阅关系,用字典嵌套结构维护Topic层级和对应的订阅者集合。MqttRetainStore:保存每个Topic最新的Retain消息。
这三个对象就是Broker的“内存底仓”。MQTTnet里其实也内置了这些对象,但如果你想深度定制,搞清楚这套建模逻辑是必须的。比如你自己写启用MQTTnet时完全可以实现一个IMqttServerStorage接口,把会话数据扔到Redis或数据库里,实现跨进程的会话共享,这就是向集群迈出的第一步。
3. 基于MQTTnet的服务器端源代码实现
3.1 最小可运行的C# MQTT服务器端
如果你只是想先搭一个能跑通的基础Broker,用MQTTnet非常快。先用NuGet把包引进来:
bash复制dotnet add package MQTTnet
dotnet add package MQTTnet.AspNetCore
然后写一个控制台宿主,几十行代码就能把一个Broker跑起来:
csharp复制using MQTTnet;
using MQTTnet.Server;
var optionsBuilder = new MqttServerOptionsBuilder()
.WithDefaultEndpoint()
.WithDefaultEndpointPort(1883);
var mqttServer = new MqttFactory().CreateMqttServer(optionsBuilder.Build());
await mqttServer.StartAsync();
Console.WriteLine("MQTT Broker started at port 1883.");
Console.ReadLine();
这段代码启动的Broker已经支持客户端连接、订阅、发布,本地不做认证,消息不持久化。适合拿来当原型验证。
但注意一个细节:WithDefaultEndpoint()默认会同时监听1883端口,如果你还想支持WebSocket,就要额外加一个配置,这个后面部署的时候我会专门提醒。
3.2 给客户端接入做自定义认证
真到了生产项目,第一件事就是把匿名访问关掉。MQTTnet的认证是通过拦截ValidatingConnectionAsync事件实现的,在这个事件里你可以拿到用户名、密码、ClientId和客户端IP,然后走自己的校验逻辑。比如我之前的停车场项目,就是把校验收口接到设备管理服务上,只有提前录入序列号的相机才能接入。
csharp复制var optionsBuilder = new MqttServerOptionsBuilder()
.WithDefaultEndpoint()
.WithRemoteCertificateValidationCallback((sender, certificate, chain, errors) => true);
mqttServer.ValidatingConnectionAsync += e =>
{
if (string.IsNullOrEmpty(e.UserName) || string.IsNullOrEmpty(e.Password))
{
e.ReasonCode = MqttConnectReasonCode.BadUserNameOrPassword;
return Task.CompletedTask;
}
// 这里调用自己的设备鉴权逻辑
bool isValid = ValidateDeviceCredentials(e.ClientId, e.UserName, e.Password);
if (!isValid)
{
e.ReasonCode = MqttConnectReasonCode.NotAuthorized;
return Task.CompletedTask;
}
e.ReasonCode = MqttConnectReasonCode.Success;
return Task.CompletedTask;
};
这里有个坑必须说:ValidatingConnectionAsync事件里的e.ReasonCode一定要显式设置。我见过有人以为只要不拒绝就是允许,结果一直不设置Success,客户端连接总是被断开。另外,如果校验过程涉及数据库查询,一定要做好缓存和超时控制,不要在事件里做超过几百毫秒的同步操作,否则大量设备同时接入时会把Broker的事件线程拖死。
3.3 消息到达时,自动处理业务逻辑
Broker的另一个大事就是“黑盒里的业务钩子”。MQTTnet通过InterceptingPublishAsync事件,让你在消息路由之前做各种处理,比如格式校验、数据落库、写日志、转发到另一个Topic。
csharp复制mqttServer.InterceptingPublishAsync += e =>
{
string topic = e.ApplicationMessage.Topic;
string payload = e.ApplicationMessage.ConvertPayloadToString();
// 自己实现一个落库方法:把原始报文存起来,方便排查
InsertIntoMessageLog(topic, payload, e.ClientId);
// 如果topic以 devices/{id}/status 开头,额外做状态机转换
if (topic.StartsWith("devices/") && topic.EndsWith("/status"))
{
UpdateDeviceStatus(e.ClientId, payload);
}
return Task.CompletedTask;
};
但注意,InterceptingPublishAsync和路由是有先后顺序的。它是在Broker接收完Publish报文后、尚未真正投递给订阅者之前触发。因此如果你的回调里出现了异常,要小心,不要让异常影响正常转发。我的建议是:这个事件里的所有业务逻辑都用try-catch包住,或者在事件里只做日志和统计,真正复杂的数据清洗放到独立的消费者里去处理,别在Broker主链路里做重活。
3.4 客户端上下线的“在线状态”处理
设备在线状态是所有物联网平台都要的东西。MQTTnet在ClientConnectedAsync和ClientDisconnectedAsync这两个事件里给出了连接和断开通知。我通常会在连接成功事件里把设备标记为在线,并把“设备已上线”这个消息发到一个固定的系统Topic里,比如sys/device/online。断开的时候也一样。
这里我吃了不少苦头,特别提醒一下:
不要只依赖Disconnected事件来做离线判断。因为网络闪断、断电、设备掉电时,TCP连接可能不会立刻触发Broker端的断开事件,要等TCP keepalive超时。MQTT协议自身的Keep Alive机制在这里就起作用了。MQTTnet的服务器端默认会按客户端Connect报文里的KeepAlive字段判断超时,如果超过1.5倍时间没收到报文,就判定连接失效。这个参数可以在服务端配置里做整体兜底。
合理的做法是“在线状态 = 连接成功事件 + 遗嘱消息 + 定期心跳校验”三层结合。不要把单一事件当成唯一真源。
4. 性能压测与关键配置调优
4.1 先给一个基准数据
有几个同行问过我这套方案到底能扛多少连接。我没有拿特别大的集群做压力测试,就以一台4核8G的云主机为例,跑默认配置的MQTTnet服务器端,1万多个长连接、每秒处理几千条QoS 1消息,CPU占用在50%到70%之间。单台设备做到这个水平,对大多数中小型物联网项目来说已经足够了。
但要注意,这个数据是“压测机在同一机房、网络延迟极低”的前提下跑出来的。真实生产环境,一旦涉及公网传输、TCP丢包重传、QoS 2消息的确认握手增多,吞吐会明显下降。所以做容量规划时,别把压测数据当最高预期值,建议按压测数据的五到七成来估算承载能力。
4.2 调整ThreadPool和背压机制
C#异步IO跑高并发长连接时,一个容易被忽略的地方是ThreadPool的饥饿问题。虽然async/await并不会阻塞线程,但如果你的代码里有任何一处不规范的.Result或.Wait(),在IO线程池被高并发请求占满时,就可能出现线程饥饿,表现为连接慢慢建不起来、消息延迟越来越大。
排查这个问题的经验是:代码里绝对禁止在异步回调里用同步阻塞方式等待任务完成。真要等,就必须让出线程,用await。同时可以给服务设置一个更大的最低线程数:
csharp复制ThreadPool.SetMinThreads(200, 200);
实测下来这个设置能明显减少高连接数场景下的线程创建抖动。另外一个重要机制是背压处理,MQTTnet内部如果某个客户端订阅者的TCP发送队列满了,会丢弃或延迟消息,具体策略要看PendingMessage的配置。对QoS 1和QoS 2的消息,不要设置为无限堆积,否则内存会被打爆。
4.3 持久化与离线消息的取舍
MQTTnet默认的服务器端是纯内存模式的,Broker重启后,Retain消息和CleanSession=false的会话数据都会丢失。没有自定义存储的情况下,这个Broker只能算“内存Broker”。
如果你想让Broker重启不丢离线消息,就需要实现IMqttServerStorage接口,把会话和消息写入数据库。我的做法是实现一个基于SQLite的存储适配器,核心方法就是SaveQueuedMessagesAsync和LoadQueuedMessagesAsync。它会把每个客户端的PendingMessage列表按ClientId存储,启动时加载回来。
csharp复制public class MqttStorage : IMqttServerStorage
{
public Task SaveQueuedMessagesAsync(IEnumerable<MqttQueuedApplicationMessage> messages)
{
// 序列化写入SQLite
return Task.CompletedTask;
}
public Task<IEnumerable<MqttQueuedApplicationMessage>> LoadQueuedMessagesAsync()
{
// 从SQLite读回
return Task.FromResult(Enumerable.Empty<MqttQueuedApplicationMessage>());
}
}
但这里要提醒,这个存储的实现属于全量读写模式,如果离线消息量大,频繁写库会很伤性能。更合理的工程实践是把“离线消息”压缩存储到Redis里,或者干脆只存最近N条。毕竟物联网设备离线消息积压太久本来就不合适,实时数据谁愿意接收端上线后再看一小时前的过期状态呢。对绝大多数场景,离线消息只需保留短时窗口就够了。
5. 实战场景:用MQTT搞定停车场的海康、大华车牌识别相机对接
5.1 为什么这类项目离不开自建Broker
停车场项目是我重点实践过的方向。海康、大华这些主流车牌识别相机,走MQTT协议对接时通常会主动作为客户端连到你的Broker上。问题是各家相机固件的Topic规则不统一:
- 海康的相机有一部分走的是自己的ISAPI扩展,常用Topic是
/vehicle/event,但不同固件版本前缀可能不一样。 - 大华的相机常用于
/smartpark/event这类私有Topic。 - 道闸控制又往往需要从平台反向给相机发命令,Topic又是一套。
如果你只用一个公版Broker,这些相机各自上报来的事件就会散落到不同Topic里,业务系统这边得写一堆适配代码去对接每个Topic。相比之下,自建Broker的优势就出来了:在InterceptingPublishAsync里把各个厂商的Topic统一改写,转发到内部标准Topic,比如统一成/entry/{deviceId}/car_plate,上层业务只需要订阅标准Topic即可。
我第一次做这个改造时,效果非常明显。原本业务端要维护六种Topic格式的解析逻辑,改造后改成只有一种,整个平台的维护成本下降至少一半。
5.2 相机接入的Topic设计建议
关于相机的Topic设计,把我后来总结出的经验分享出来:
| 方向 | 原始Topic示例 | 标准化内部Topic | 说明 |
|---|---|---|---|
| 相机上报事件 | /ISAPI/Vehicle/event |
ingress/device/{deviceId}/plate |
车牌识别、入场事件 |
| 相机抓拍图片地址 | /smartpark/capture |
ingress/device/{deviceId}/capture |
图片URL或Base64数据 |
| 道闸开闸指令 | 平台下发 | egress/gate/{gateId}/open |
反向控制道闸 |
| 设备心跳 | /heartbeat |
sys/device/{deviceId}/heartbeat |
状态监测 |
标准化的原则很简单:除了第一段固定命名空间外,后面跟的一定不是厂商信息,而是设备ID和业务动作。这样以后换相机品牌,也只需要在Broker层做适配,业务层一行代码都不用改。
5.3 相机接入时容易踩的坑
我在这里专门整理几个相机对接时很容易出现的问题:
第一个是相机端的CleanSession设置。很多相机固件默认是CleanSession=false,它希望网络断线重连后,Broker能把离线期间发给它的指令补发过去。这个逻辑本身没问题,但如果你的Broker没做会话持久化,重启就全部丢失,同时相机的重连状态又比较“固执”,它会在一个周期内反复重试,这时你看到的表象就是相机疯狂连上又掉线。解决思路是在Broker启动前把固定设备接入配置写死,或者干脆在相机端把CleanSession改为true,让它每次连接都全新开始。
第二个是Topic里容易带多余的空格和前缀。相机上报的原始Topic有时会多一个前导斜杠,比如//ISAPI/Vehicle/event,匹配时稍不留神就找不到订阅者。我的办法是在入站拦截器里先做字符串清洗,统一去掉多余空格和重复斜杠。
第三个是图片数据的体现。有些相机抓拍事件会把图片直接Base64编码塞进QoS 1消息的Payload里,单条消息可能几百KB甚至1MB。这么搞对Broker内存冲击很大,而且公网传输低带宽场景下会严重拖慢其他消息。正确做法是让相机先把图片传到HTTP存储服务,MQTT消息里只传图片URL,业务端再按需拉图。这条经验算是被真实项目逼出来的,不夸张地说,有一次现场因为抓拍图事件消息过大,Broker的内存直接涨到2个G。
6. 常见问题与排查技巧实录
6.1 客户端总是连不上或反复重连
这个现象在物联网项目里最普遍。我建议按顺序排查:
- 先用MQTTX等桌面客户端连接一次,确认Broker本身正常。
- 查Broker日志,看是断在哪一步。如果看到
Connection refused,多半是端口没监听或认证被拒。 - 看客户端的KeepAlive设置。如果KeepAlive值设得特别小,比如1秒,而网络抖动稍微超过1.5秒,Broker就直接判定掉线,于是客户端就会反复重连。
- 看是否有相同ClientId冲突。MQTT协议规定同ClientId只能保留一个连接,后连的会把之前的踢掉。很多设备默认ClientId写死成出厂序列号,如果现场有多台同型号设备,就会互相踢。
6.2 订阅了Topic却收不到消息
这种情况要分层排查。先用客户端工具订阅相同Topic做一个AB对照。
如果订阅工具也收不到,多半是发布端的Tpoic写错了,或者发布端并不在同一个Broker上。还有一种很容易踩的情况:发布端用的Topic带了不同前缀,比如发布到/park/device/001,而订阅端订阅的是park/device/#,看似差不多,实际上MQTT的Topic是区分大小写的,而且前导斜杠是独立的一层。我个人在项目里统一了一条规定:所有Topic一律以具体应用名开头,并且不允许以斜杠开头,从源头消灭这类混乱。
如果订阅工具能收到但业务系统收不到,那就是业务系统自己的订阅代码出了问题。常见原因是订阅回调是异步的,但回调里直接把消息丢到另一个线程后没有做异常处理,消息一旦处理失败就静默吞掉。排查时在回调入口和出口打日志,看有没有异常抛出。
6.3 消息积压导致内存上涨
消息积压的原因,八成是消费端处理慢。Broker把消息投递给订阅者后,订阅者如果迟迟不确认,对QoS 1协议来说,Broker会认为消息没送达,它会保留在Pending队列里反复重发。如果订阅者有成千上万条消息积压,队列就会越堆越长。
这个问题的根治思路是让Broker侧对单个客户端的Pending队列设置上限。MQTTnet支持在服务端配置中指定PendingMessage的最大数量。超出了就直接丢弃重发,同时记一条告警日志。对偏实时类的业务场景,丢旧消息总比内存溢出拖垮整个服务强。我在项目里的做法是:QoS 1最多保留5000条,超过就把最老的丢弃,队列里始终保留最新的数据。
6.4 端口占用与边缘部署的防火墙问题
如果你把Broker部署在Windows工控机上,经常会有客户IT环境安装了很多安全软件,导致1883端口被占用或者被防火墙静默拦截。我会建议给Broker服务配置独立配置文件,端口可以通过配置文件修改,发布时附上“TCP入站规则一键添加”的PowerShell脚本。另外需要留意的是,如果用到WebSocket支持,就要额外监听一个端口,默认可以是8083,别把WebSocket监听和TCP监听混在一起。设置完端口,最好在设备端先用测试工具连一下,确认自定义端口真的通了再继续接入。
写在最后的实践心得
用C#自建MQTT服务器端这条路,我走了大半年,从最初用MQTTnet写一个几十行的原型,到后来把认证、存储、Topic标准化、离线消息全部揉进了一个.NET服务里,整体感觉是:这条路适合那些“想要掌控感”的团队。它不像直接用EMQX那样省事,但它给了你完整的可塑性。尤其当你的业务本身就是.NET技术栈,并且有大量定制设备接入需求时,把Broker变成自己服务的一部分,收益非常明显。
最后分享两个我做这类项目时一直坚持的小习惯。第一,所有Broker关键节点一定要打结构化日志,连接、断开、订阅、发布、鉴权失败、队列溢出,这些事件全都要能追溯,否则线上排查会很痛苦。第二,保证Broker版本和MQTTnet版本固定下来,不要随手依赖最新版,因为MQTT库的接口版本变化频繁,今天能用,明天升级可能就编译不过了。做基础设施这种组件,稳定压倒一切。
