最近帮一个朋友看工业物联网项目,他们把设备数据全部汇聚到一个自研的 C# 上位机系统里,中间环节用的是现成的 Mosquitto broker。前期几十台设备没什么问题,等设备上了几百台、现场还要对接 MES 和数据库的时候,问题全冒出来了:要么在 broker 外面包一层服务做数据加工,绕来绕去延迟高;要么就得改 broker 源码,但改完还要跟着上游版本走,维护成本直接失控。后面我把目标转向 C# 生态里一套开源免费的 MQTT 服务器端实现,整条链路全部用 C# 打通,从设备接入、消息路由到业务逻辑处理都在一个技术栈里解决。这篇文章就把我调研、部署、压测和落地过程中积累的东西完整梳理一遍,重点聊聊这套服务端源码怎么做到高性能、怎么理解它的架构,以及实践里最容易踩坑的几个点。
1. 为什么非要在 C# 里自己掌控 MQTT 服务端
1.1 现成 Broker 的授权与定制壁垒
一提到 MQTT broker,很多人第一反应就是 EMQX 或者 Mosquitto,我自己也用过很久。先说实话,这两个项目本身都很优秀,协议实现完整、社区活跃、资料多,拿来快速搭一个 demo 非常顺手。但当我真正把它放进一个要交付给客户的产品里,问题就来了。
首先是开源许可证的问题。EMQX 开源版本用的是 AGPL,这意味着如果你把基于它的服务端代码作为产品的一部分提供给第三方使用,尤其是以 SaaS 或者内嵌软件的形式分发,就有义务把衍生部分的源代码开放。有些公司不在意,直接内部用;但做工业软件、做商业上位机交付的公司基本都要法务评估一圈,结果往往是被迫采购商业授权,或者换一个宽松协议的方案。Mosquitto 的协议相对友好(EPL/EDL 双许可),可它是一个纯 C 语言项目,功能相对精简,想往里面加业务逻辑非常麻烦。
其次是定制化深度不够。工业现场经常要把设备认证、数据清洗、规则引擎、报警推送这些东西直接做进消息中间层,而不是在上位机里事后处理。如果 broker 不受自己控制,这些逻辑就只能放在外围服务里异步处理,多一跳、多一份延迟,也多一个不稳定点。我见过不少项目就是这样:设备数据先进 broker,再被另一个消费者拉出来做解析,解析完再写入数据库,逻辑链拉得很长。
当你发现需要在 broker 这一层“做点自己的事”的时候,一个用 C# 写、能直接改源码、能在同一套技术栈里扩展的服务端实现,吸引力就不是一点半点了。
1.2 “无限扩展”的具体含义
标题里出现“无限扩展”这个词,我理解是两层意思。
第一层是功能的扩展。基于源码你可以随便加模块:自定义认证插件、白名单机制、按产品线分发消息的规则、把原始报文转换成业务对象再转发等等。因为服务端和上位机都是 C# 写的,数据模型可以直接共用,不需要跨语言做二次协议转换。这种内聚带来的开发效率提升非常明显,我后面会在架构部分展开讲。
第二层是规模的扩展。单机有单机的扩展办法,比如调整线程模型、使用内存池、优化 Topic 匹配算法;单机扛不住了,可以做多实例部署,通过共享订阅把消息分摊到多个节点,再用 Redis 或者数据库做会话状态同步。这些路径在 C# 生态里都有比较成熟的做法。这也是为什么我觉得 C# 写 MQTT 服务端并不是什么“土办法”,而是一条正儿八经的工程路线。
1.3 C# 为什么能扛住服务端开发
很多人的刻板印象里,写网络服务端还得是 C++ 或者 Go,C# 好像只配做做窗体程序。这个观念该更新了。C# 从 .NET Core 开始,性能表现早就不是当年 .NET Framework 那个水平了。异步编程模型 async/await 是语言级的,配合 SocketAsyncEventArgs 或者 System.IO.Pipelines,处理高并发连接完全没问题。
更重要的是 C# 在内存控制上给足了工具:Span<T> 避免不必要的拷贝,ArrayPool<byte> 做缓冲区复用,MemoryPool<T> 管理大数据块。这些都是写高性能网路服务的关键能力。再加上 JIT 的持续优化和分层编译,C# 服务端运行时性能已经能进入第一梯队。换句话说,用 C# 写 MQTT 服务端,不是“无奈之选”,而是“顺势为之”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务端实现前必须啃下的协议细节
2.1 看似简单的协议,服务端要维护一堆状态
MQTT 协议本身不复杂,客户端发 CONNECT,服务端回 CONNACK,然后就 PUBLISH/SUBSCRIBE 来回搞。但真正上手写服务端才发现,难的不是报文解析,而是状态管理。
一个连接从建立到关闭,服务端要跟踪很多东西:Client ID 是否唯一、Clean Session 是真还是假、当前会话是否要恢复、遗嘱消息是否已发布、心跳计时是否正确、QoS 流程走到哪一步了。任何一处状态没处理好,轻则消息丢一条,重则整个会话错乱。
协议这块我建议直接以 MQTT 3.1.1 和 5.0 做区分。3.1.1 是当前工业设备用得最多的版本,大多数 ESP8266、PLC 网关、DTU 都支持;5.0 增加了用户属性、共享订阅、消息过期等内容,更适合新项目。一个好的服务端源码,最好两个版本都支持,在 CONNECT 报文里根据协议级别自动判定。我做技术选型时,不支持 5.0 的项目基本不看了,毕竟后面扩展空间更重要。
2.2 QoS 等级:一次、至少一次、恰好一次
QoS 是 MQTT 最容易讲明白、也最容易实现错的地方。给个生活化的类比:QoS 0 相当于在微信上发了一条消息,发完就不管了,对方收没收到看缘分;QoS 1 相当于发消息后要求对方回一句“收到”,但只要没收到回复就反复重发,所以对方可能收到好几条相同消息;QoS 2 相当于加了一个完整确认流程,确保对方最终恰好只收到一条。
服务端实现时,QoS 0 最简单,直接路由转发。QoS 1 稍微复杂一点,需要在转发后等待 PUBACK,没收到就按重发间隔补发。QoS 2 最麻烦,因为要处理 PUBLISH、PUBREC、PUBREL、PUBCOMP 四段交互,还要维护一个“消息去重表”,防止同样的消息进入会话队列。很多简化版实现直接在这里偷懒,把 QoS 2 降级成 QoS 1,这在工业场景里是不可接受的,因为设备控制指令一旦重复下发,后果可能是执行两次相同的动作。
如果你打算基于开源源码做二次开发,我建议先重点读 QoS 2 状态机那段代码。把这一块读懂了,你对协议的理解基本就到 80 分以上了。
2.3 主题匹配与会话恢复:最容易拖垮性能的两个点
主题匹配是服务端性能的分水岭。客户端订阅的 Topic 不一定是一个固定字符串,可能是 sensor/+/temperature 这种带通配符的表达式。服务端收到一条 sensor/room1/temperature 的消息,必须找出所有匹配的订阅者,效率高低直接决定吞吐量。
简单的实现是维护一个订阅列表,来一条消息就遍历一遍所有订阅做字符串匹配。这个方法在订阅数少的时候没毛病,可一旦订阅数到了几千甚至几万,每条消息都做全量扫描,CPU 就直接烧没了。高性能实现普遍采用订阅树(Topic Trie)或者多级哈希索引,把主题按 / 拆成层级节点,一次遍历就能找到所有匹配的分支。这个优化非常关键,压测数据可以差出 10 倍以上。
会话恢复是另一个容易被忽略的点。当客户端断线重连并且 Clean Session 为 false 时,服务端要把离线期间堆积的消息补发给它。这意味着服务端必须为每个离线会话维护一个消息队列,并且控制队列长度,否则某个设备长期不上线,内存就被队列吃光了。生产环境建议给会话队列设置上限,超出后按最老的消息丢弃或者直接断开,避免一个客户端拖垮整个服务。
3. 这套开源服务端源码的模块架构拆解
3.1 模块划分与数据流向
从源码结构上看,一个完整的 C# MQTT 服务端通常分成五个核心模块:协议层、连接层、会话层、路由层、存储层。我用一条消息从设备到订阅者的完整路径来说明它们的关系。
设备建立 TCP 连接后,连接层负责接受连接、完成 TLS 握手、维护网络流。原始字节流进入协议层,按 MQTT 报文格式解析出固定头、可变头和 Payload,得到一条结构化的 Publish 消息。接下来这条消息交给会话层,确认当前 Client 的会话状态,是沿用旧会话还是新建会话,同时检查是否有遗嘱消息要处理。然后消息进入路由层,路由层根据 Topic 查订阅树,找到所有匹配的订阅者,把消息复制分发到每个目标会话的待发送队列里。存储层在这一过程中承担辅助职责:持久化会话、保存 Retain 消息、记录离线消息队列。
这五个模块之间一般通过接口解耦,让上层可以自由替换实现。比如你不想用内存存储,想改成 Redis,只需要重新实现存储接口。这也是我为什么坚持要选有良好分层设计的开源项目,而不是东拼西凑的 demo 代码。
3.2 连接层与线程模型的选择
连接层直接决定了服务端能扛多少连接和多大的吞吐。C# 实现高并发网络服务,主流有两种线程模型。
第一种是传统的 async/await + NetworkStream,写起来简单直观,每个连接一个异步循环读写,.NET 底层会复用线程池里的线程,大多数场景下表现已经不错。第二种是用 SocketAsyncEventArgs 配合 System.IO.Pipelines 做高性能收发,能进一步减少系统调用和对象分配,适合追求极致性能的服务端。
我实测下来,对于 90% 的中小规模场景,第一种模型完全够了。如果目标是上万连接级别的工业接入层,第二种模型带来的收益会更明显。好的开源实现两种模型都会支持,或者至少把网络接入层抽象出来,允许你替换。
还有一个容易被忽略的设计点是背压控制。当订阅者消费速度跟不上生产者的发布速度时,待发送队列会无限增长,最终把内存打爆。正常的架构设计必须给每个会话的发送队列设定上限,超过阈值或者丢弃旧消息,或者根据 QoS 策略通知发送端降低速率。这是一个服务端是否“能扛事”的重要标志。
3.3 订阅树、消息路由与 QoS 状态机
订阅树在整个服务端里算比较核心的组件,我多写几句。
实现上,它是一棵多叉树,根节点下面按 Topic 层级展开。比如订阅 a/b/c,就会在根下创建 a 节点,再在 a 下创建 b 节点,再在 b 下挂 c 节点的订阅者集合。通配符 + 匹配单个层级,# 匹配剩余所有层级,都映射成树的特殊分支。路由时,把消息主题拆成多个段,从根节点开始逐段下钻,把沿途遇到的通配符分支和精确匹配分支都收集起来,就能以近似 O(层级深度) 的成本找到所有匹配订阅者。
这个设计比线性扫描订阅列表高到哪里去了呢?用数据说话:5000 个订阅、每秒 1000 条消息,线性扫描平均要做 500 万次字符串匹配,而订阅树只需要做几万次节点遍历。在工业现场设备数量上来之后,这个差距就是能不能跑和跑得好的区别。
QoS 状态机则需要针对每个订阅者单独维护。同一个消息发给五个订阅者,其中一个要求 QoS 2,其他只要求 QoS 0,那服务端要分别处理。绝不能因为源消息是 QoS 0,就全部降级到 QoS 0 发出。这个逻辑看起来简单,实现的时候很容易搞混,尤其是客户端订阅时指定的最大 QoS 还和服务端实际投递的 QoS 有协商关系。读源码时建议配合协议文档逐行比对。
4. 从编译到上线的完整实操记录
4.1 环境准备与源码构建
先说明一下,我这里说的“这套源码”是基于 .NET 8 的跨平台实现。你在 Linux 服务器上跑完全没有问题,这正好是工业网关最常见的部署环境。
准备阶段做三件事:安装 .NET 8 SDK,装好 Git,准备一个 MQTT 客户端测试工具,比如 MQTTX 或者 mosquitto_pub/sub。源码拉下来之后,直接用命令行构建:
bash复制git clone https://github.com/your-repo/csharp-mqtt-server.git
cd csharp-mqtt-server
dotnet restore
dotnet build -c Release
构建成功后,在输出目录里能看到 MqttServer.dll 和对应的配置文件。第一眼看完源码结构你会发现,它不是一个简单的单体项目,而是拆成了 MqttServer.Core、MqttServer.Protocol、MqttServer.Storage、MqttServer.Hosting 几个独立项目。这种分层是我比较认可的:协议解析与业务逻辑分离,存储实现可替换,宿主环境(控制台/Windows 服务/systemd)完全隔离。
4.2 配置文件的重点参数
配置文件一般是 JSON 格式,我把自己验证过的一组参数列出来,便于你对照:
json复制{
"listeners": [
{ "type": "tcp", "port": 1883 },
{ "type": "websocket", "port": 8083 },
{ "type": "tls", "port": 8883, "certificate": "/etc/certs/server.pfx" }
],
"auth": {
"enable": true,
"mode": "database"
},
"session": {
"expiryInterval": 3600,
"maxQueuedMessages": 500
},
"retain": {
"enable": true,
"storeType": "memory"
},
"logging": {
"level": "information"
}
}
1883 是 TCP 明文端口,给内网设备用;8083 是 WebSocket 端口,给浏览器或者小程序客户端用;8883 是 TLS 加密端口,给跨公网的设备用。三个监听器可以同时开启,互不干扰。生产环境一定要配置 TLS,工业数据裸奔在公网上太吓人了。
maxQueuedMessages 这个参数建议认真对待。它是每个离线会话的最大堆积消息数,默认 500 只是一个参考值,具体多少要看你的业务容忍度。设得太小,设备离线久了回来会丢消息;设得太大,大量设备同时离线会把内存吃光。工业现场我通常设 1000 到 5000,配合合理的 Retain 策略使用。
4.3 启动验证与上位机联调
启动服务端:
bash复制dotnet MqttServer.dll --config appsettings.json
看到日志输出监听端口信息,说明服务已经起来了。验证方式很简单,用 MQTTX 同时开两个客户端,一个订阅 test/topic,一个发布消息,能收到就说明基础链路是通的。
但真正投入使用之前,我建议先走一遍和上位机联调的流程。写一个最简 C# 客户端代码,确认从你的 WinForms/WPF 上位机连到服务端没有障碍:
csharp复制using MQTTnet;
using MQTTnet.Client;
var mqttFactory = new MqttFactory();
using var client = mqttFactory.CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", 1883)
.WithClientId("scada-host")
.WithCredentials("admin", "password123")
.Build();
await client.ConnectAsync(options);
client.ApplicationMessageReceivedAsync += e =>
{
var payload = System.Text.Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment);
Console.WriteLine($"收到消息: {e.ApplicationMessage.Topic} -> {payload}");
return Task.CompletedTask;
};
await client.SubscribeAsync("factory/+/equipment/#");
这个示例代码值得注意的细节是 WithCredentials。如果服务端开了认证,而客户端没有凭据,CONNECT 会被直接拒绝。很多第一次联调的人在这里卡住,以为是网络问题,实际上是认证没通过。日志里会明确记录 “connection rejected, bad username or password” 这类信息,排查时先看服务端日志。
5. 把它压到极限:性能测试与优化手段
5.1 一次压测的观察
我用一台普通配置的机器做过一次压测:8 核 i5 处理器,16GB 内存,Ubuntu 22.04,服务端跑在默认配置下。压测工具用 mqtt-benchmark 模拟 1000 个客户端同时连接、订阅各自的独立主题、以每客户端每秒 10 条消息的频率持续发布。
结果比较理想:消息吞吐稳定在每秒 4 万条左右,P99 延迟在 5 毫秒以内,CPU 占用约 60%,内存占用稳定在 1.2GB 左右。这个数据说明,作为一套 C# 实现的服务端,面向大多数工业场景的性能是完全够用的。当然,压测结果受硬件、网络、消息大小影响很大,我这组数据只提供一个量级的参考,不要当标准答案看待。
真正让我印象深刻的不是绝对吞吐,而是它在高负载下的稳定性。连续跑了两个小时,内存曲线基本是一条直线,没有出现持续增长的趋势,这说明对象分配和 GC 是健康的。有些粗糙的实现扛不住这种持续压力,运行几十分钟后内存就到 5GB,最后被 OOM Kill。
5.2 常见性能瓶颈与针对性优化
如果压测后发现性能不达标,优先检查以下几处。
第一是缓冲区分配。每次收到报文都 new 一个 byte[] 是很浪费的,高并发下 GC 压力会很大。优化方案是用 ArrayPool<byte> 租借缓冲区,处理完归还。这套服务端源码如果确实对性能有追求,应该在网络层做了这件事,你可以在代码里搜索 ArrayPool 确认一下。
第二是日志开销。日志级别开到 Debug,再高的吞吐也会被打到膝盖。生产环境建议只保留 Information 以上级别的日志,甚至可以把消息转发路径的日志全部关闭。很多“性能问题”其实是日志刷出来的。
第三是锁竞争。路由层在分发消息时要遍历订阅树,多线程同时操作时必然涉及并发控制。细粒度的做法是对每个树节点单独加锁,粗粒度的做法是全局一把锁。后者在订阅密集、消息量大时,会直接把并发拖成串行。源码里如果用的是全局锁,你可以根据业务情况改成读写锁或者节点级锁。
5.3 从单机到多机:无限扩展的落地路径
单机性能再好也有天花板,真正的扩展性要靠多机部署。MQTT 服务端做横向扩展有几个成熟思路,我按落地难度从低到高说一下。
最简单的是共享订阅(Shared Subscription)。一个主题被多个消费者同时订阅,消息按某种策略(轮询、随机、哈希)分发到其中一个消费者。这相当于天然的负载均衡,直接把多个服务端实例变成“消费者集群”。在 MQTT 5.0 里这是协议标准能力,3.1.1 需要服务端扩展支持。对于纯计算密集的业务,比如数据清洗、格式转换,这个方案性价比最高。
复杂一点的方案是桥接(Bridge)。把两个 broker 之间建立一条桥接通道,A 节点的消息自动转发到 B 节点。这种模式适合分布式场景:每个工厂车间部署一套服务端,车间之间再做桥接,数据逐级汇总。服务端源码里如果能配桥接功能,会大大简化这类组网。
最重型的方案是共享存储 + 无状态路由。所有节点的会话信息、订阅关系、离线消息全部放到 Redis 或者数据库中,客户端连到任意一个节点都能拿到完整状态。这个方案最灵活,但对基础设施要求高,Redis 本身成了最大瓶颈和运维重点。一般只有设备规模特别大、对可用性要求极高的场景才需要上到这一步。
6. 和 EMQX/Mosquitto 放在一起怎么选
6.1 选型对比
我把这套 C# 服务端、EMQX、Mosquitto 放在一起做了个对比,列个表格比较直观:
| 对比项 | C# MQTT 服务端 | EMQX 开源版 | Mosquitto |
|---|---|---|---|
| 开发语言 | C# / .NET | Erlang | C |
| 协议支持 | MQTT 3.1.1 / 5.0 | 3.1.1 / 5.0 | 3.1.1 / 5.0(新版) |
| 二次开发难度 | 低,同一技术栈直接改 | 中高,需要理解 Erlang 与插件机制 | 高,C 语言改造风险大 |
| 商业授权风险 | 开源宽松,几乎无风险 | AGPL,商用需评估或购买 | EPL/EDL,相对友好 |
| 与 C# 上位机集成 | 天然集成,共享数据模型 | 需要走 HTTP/API 或外部服务 | 需要走外部服务 |
| 运维友好度 | 单进程部署,systemd/docker 均可 | 功能强但组件多,重一些 | 轻量,但功能也轻 |
| 适合场景 | 深度定制、产品内嵌、工业边缘 | 大规模物联网平台、需要集群能力 | 极简单机中转、快速验证 |
这张表说明一件事:没有哪个方案是绝对最好的,关键看你处于哪个场景。
6.2 哪些场景别硬上自建服务端
虽然我在这篇文章里花了很多篇幅讲自建 C# MQTT 服务端的好处,但也要说公道话:有些场景确实不该硬上。
如果你只是临时搭一个 demo,验证一下设备数据能不能通,直接用 Mosquitto,五分钟搞定,别浪费时间。如果你的业务是运营一个公共物联网平台,要面向海量设备和租户,需要成熟的租户隔离、消息轨迹、规则引擎、数据集成这些开箱即用的功能,那 EMQX 企业版解决的问题域已经远超一个 broker 本身,这时候从零维护一套自研服务端是得不偿失的。
我推荐自建 C# 服务端的场景有两个共同特征:一是有很强的定制需求,二是有信心持续投入维护。比如你的产品本身就是一套用 C# 写的工厂管理系统,需要把 MQTT 网关内嵌进去;又比如你的业务有很强的行业属性,消息处理逻辑必须和业务系统深度耦合。这种情况下,一个能改源码的自建服务端带来的灵活性,远远超过它需要付出的维护成本。
7. 实战落地中的五个典型坑
7.1 心跳超时误杀设备
MQTT 的 Keep Alive 机制很简单:客户端在指定间隔内发 PINGREQ 报文,服务端在此间隔的 1.5 倍时间内没收到任何报文,就判定连接断开。实际部署时我踩过一个很典型的坑:设备在 4G 网络下,运营商 NAT 超时时间只有 60 秒,而设备端心跳间隔设的是 90 秒。结果是设备以为自己还连着,服务端却已经判定超时断开了,双方状态完全错乱。
解决思路是双向确认:服务端把 Keep Alive 超时设置为客户端请求值的 1.5 倍,同时尽量要求设备端心跳间隔小于运营商 NAT 超时时间。对于无法修改设备配置的情况,服务端要主动发送 PINGRESP,并且允许客户端在每次 PINGREQ 后重新计时,避免因为网络抖动造成误杀。这个参数的匹配问题,在真实工业现场不解决,后面全是连接闪断的报警。
7.2 TIME_WAIT 把端口耗尽
设备频繁断线重连时,服务端会积累大量 TIME_WAIT 状态的连接。Linux 系统默认的 TIME_WAIT 持续时间是 60 秒,如果每秒新增几百次短连接,端口就会被占满,新连接直接失败。现象非常诡异:服务端进程还活着,日志也没报错,但客户端就是连不上去。
排查时用 ss -s 看 TCP 连接状态,如果 TIME_WAIT 数量异常,解决办法是调整 Linux 内核参数。net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_fin_timeout 这两个参数在服务端场景下可以谨慎调优。更治本的方法是让设备尽量保持长连接,而不是每次发完消息就断开。服务端源码里也可以对频繁重连的客户端做限流。这个坑之所以出现在“第 7 章”,是因为不做压测和长期运行基本发现不了。
7.3 QoS2 消息乱序和重复
有一次客户反馈,他们的控制系统偶尔会出现重复执行的情况。排查下来,问题出在服务端对 QoS 2 消息的处理上:客户端发来 QoS2 的 PUBLISH 后,还没收到 PUBREC,旧连接就断开了。客户端重连后重发同一份报文,服务端因为会话状态没有正确恢复,把它当作一条新消息处理了,最终投递了两次。
MQTT 协议规定,服务端必须为 QoS 2 消息保存消息 ID 和对应的 PUBREC 状态,并且在会话恢复后继续处理。这个逻辑必须依赖持久化的会话存储,不能只放在内存里。如果你正在改源码,注意检查是否把 QoS2 的去重表纳入了持久化范围,否则这种重复问题很难在测试阶段发现,只会在生产环境的网络抖动中爆发。
7.4 通配符订阅的性能雪崩
我在第 2 章讲过订阅树对性能的重要性,这里补充一个真实案例。某个现场,开发人员图方便,让所有客户端订阅了同一个带 # 通配符的主题。随着客户端数量涨到几百个,每次消息发布都要复制几百份,CPU 直接拉满,消息延迟从毫秒级涨到秒级。
这个问题的本质不是订阅树不够好,而是业务设计有问题。# 通配符意味着订阅所有主题,在 MQTT 生态里是最昂贵的一种订阅模式。使用广播主题时要克制,能按设备分组就按 factory/line1/device001 这种层级结构设计。服务端也可以做一层保护:限制每个客户端允许订阅的主题数量,或者对 # 订阅的数量做上限。这个题讲白了,性能优化永远不是只靠代码就能解决的,协议使用套路同样关键。
7.5 忘了给连接层加背压
前面提到背压控制是服务端设计的重要标志,这里展开说一下实际后果。某个场景里,一批设备突然同时上报大量历史数据,发布速率远超订阅端消费能力。因为每个会话的发送队列没有上限,消息在内存里越积越多,最终服务端内存飙升到再也分配不出对象,进程被系统杀掉。
解决方法是给每个会话设置发送队列上限,并明确超出上限时的处理策略:丢弃最老的消息、丢弃最新消息或者关闭连接。工业场景里我倾向丢弃最新的消息,因为设备下一次上报会带来更新的数据,旧数据保留意义不大。更重要的是把队列长度暴露到监控系统里,当队列开始积压时,说明消费端已经出现瓶颈,要人工介入处理了。省掉这一层保护,相当于开着车不系安全带——平时没事,出事就是大事。
最后说几句实在的
这套 C# MQTT 服务端我前后折腾了也有几个月,从最初只是想在项目里少依赖一个外部组件,到后来发现它能做的事情远超预期。开发的时候靠的是对协议细节的理解,部署调试的时候靠的是对 Linux 网络参数的熟悉,真正跑生产之后,靠的是对业务场景的判断——什么时候该调 TCP 参数,什么时候该改订阅设计,什么时候该加节点扩容。
如果你也打算走这条路,我建议先别急着上生产,多花点时间读源码里的会话管理和 QoS 状态机这两块,把协议细节吃透了再改业务逻辑。真正值得投入的项目,都不是靠照搬能搞定的。
