搞上位机开发的朋友,应该都撞过这种需求:现场设备一堆,PLC、传感器、网关,各有各的协议,上位机要把数据接进来,页面要实时刷新,历史数据还要存库备查。这几年物联网项目越来越多,设备端几乎默认走 MQTT 协议上报数据,而后台需要一个能承载"实时通信 + 数据持久化 + 界面展示"的整体方案。我最近用 C# 和 .NET8 + WPF 完整落了一套这样的系统,把 MQTT 接入、数据库存储和数据中台展示全部打通,今天就把整个实现过程、选型思路和踩坑经验一次性写透。
如果你正打算做 C# 上位机,或者想把传统的 WinForm 项目往 WPF 迁移,又或者刚接触 MQTT 和数据落库这套玩法,这篇内容都能直接给你一份可落地的参考。它不是什么高深框架,就是一套踏踏实实把设备数据管起来的工程实践。
1. 技术选型:为什么这四件套能撑起物联网数据中台
1.1 从需求倒推技术栈
做上位机最怕一上来就写代码,我习惯先花半天把需求拆干净。这次项目面对的典型诉求是:设备数据实时看板、历史数据查询、设备在线状态监控、报警记录留存。拆开看就是一个轻量级"物联网数据中台"的形态。
上位机的核心职责其实只有三块:把数据接进来、把数据存下来、把数据展示出去。三块需求对应下来,这四件套的组合优势就很明显了:
| 核心职责 | 技术选择 | 选型理由 |
|---|---|---|
| 数据接入 | MQTT + MQTTnet | 网关和工业设备基本都支持 MQTT,协议轻量,消息模型非常适合传感数据 |
| 数据存储 | MySQL / SQLite | 历史追溯、报表统计、多设备扩展,成熟稳定,生态完善 |
| 界面展示 | WPF(.NET8) | 数据绑定强、复杂布局友好、多线程 UI 更新方便,比 WinForm 现代化得多 |
| 业务组装 | .NET8 + Dapper | 稳定 LTS,性能好,支持依赖注入,写后台逻辑非常顺手 |
这套组合不是最"炫"的,但绝对是最实用的。尤其当你需要面对几十种设备报文、不同 Topic、不同频率的上报时,MQTT 的解耦能力会帮你省掉大量重复开发。
1.2 为什么是 MQTT 而不是 TCP 裸连或 HTTP 轮询
上位机行业以前很流行 TCP 长连接或者 HTTP 定时轮询,这两种方式各有硬伤。TCP 裸连的问题是报文格式要自己定,分包粘包要自己处理,设备端多起来以后状态维护非常痛苦;HTTP 轮询则天生被动,设备没有主动上报能力,实时性完全取决于轮询间隔。
MQTT 采用的是发布/订阅模型,设备往主题里发消息,上位机订阅对应主题就能收到,双方完全解耦。这个特性在物联网场景里几乎是"量身定制"的:设备多了不用管,主题就是天然的消息分类器;设备离线在线也有遗嘱消息机制可以感知。我在实际项目中,一台 Broker 挂上千台设备毫无压力,通信层几乎不需要担心性能瓶颈。
1.3 WPF 搭配 .NET8:这代组合到底强在哪
我知道很多老上位机工程师还在用 WinForm,不是说 WinForm 不行,而是当你需要做复杂的实时数据看板、多标签页、曲线图、深色主题这类界面时,WPF 的开发效率会明显高出一截。WPF 有一套完整的绑定体系和样式模板机制,界面和数据可以做到很好的分离。
.NET8 是目前比较合适的落点。它是长期支持版本,性能比 .NET Framework 时代有了质的提升,同时现代 C# 语言特性(异步、LINQ、源生成器等)都玩得转。还有一个很实际的好处:通信层和数据访问层可以单独做成类库,以后要复用或者改造成服务端也方便。界面层用 WPF,底层逻辑不一定要绑定在 Windows 上,这是一个很划算的架构红利。
1.4 开发环境准备
这套项目用到的主要工具和包如下,照着准备就行:
- Visual Studio 2022(17.8 以上版本,装好 .NET8 SDK 与 WPF 工作负载)
- MySQL 8.0 或者 SQLite,本地开发我建议先用 SQLite 把流程跑通
- NuGet 包:MQTTnet(通信)、Dapper(轻量 ORM)、MySqlConnector(MySQL 驱动)、CommunityToolkit.Mvvm(MVVM 框架)、HandyControl(界面控件库)、Serilog(日志)
提示:MQTTnet 不同小版本的 API 有差异,我下面代码以常见的 4.x 版本习惯为准,具体方法名如果和你装的包不一致,以 NuGet 里的实际 API 为准,整体思路完全通用。
2. MQTT 通信层落地:订阅发布、QoS 与断线重连的实现细节
2.1 MQTT 协议里先搞懂这几个概念
用 MQTT 做上位机,核心概念不需要了解太多,但这几个必须门儿清:
- Broker(代理服务器):所有消息的中转站,现场通常部署在工控机或边缘服务器上,开发时可以先在 Windows 本地跑一个。
- Topic(主题):消息的分类标签,形如
devices/车间A/温度,支持通配符+(匹配一层)和#(匹配剩余多层)。 - QoS(服务质量):消息投递的可靠性级别,0 是尽力而为,1 是至少一次,2 是恰好一次。工业数据场景一般用 QoS 1 最合适,保证不丢消息又不过度开销。
- 遗嘱消息(Will Message):客户端异常掉线时由 Broker 代发的一条消息,非常适合用来标记设备离线。
- 保留消息(Retain):Broker 会保存这条主题的最新一条消息,新订阅的客户端一上来就能收到。
我在设备接入和上位机订阅这套体系时,主题设计基本是两层结构:
devices/{deviceId}/data:设备上报的实时数据devices/{deviceId}/status:设备上线下线状态portal/command/{deviceId}:上位机下发给设备的命令
这样分层的好处是权限可以控制到主题级别,后期设备多了也不乱。
2.2 MQTTnet 客户端封装
MQTTnet 是目前 .NET 生态里最成熟的 MQTT 客户端库,我把它封装成一个单例通信服务,整个上位机共用同一个连接实例。核心初始化代码大概是这样:
var factory = new MqttFactory(); var client = factory.CreateMqttClient(); var options = new MqttClientOptionsBuilder() .WithTcpServer("127.0.0.1", 1883) .WithClientId("ScadaMainWindow") .WithCredentials("iot_user", "iot_password") .WithKeepAlivePeriod(TimeSpan.FromSeconds(30)) .WithCleanSession(false) .Build(); client.ConnectedAsync += async e => { // 连接成功后订阅主题 await client.SubscribeAsync("devices/+/data", MqttQualityOfServiceLevel.AtLeastOnce); await client.SubscribeAsync("devices/+/status", MqttQualityOfServiceLevel.AtLeastOnce); }; client.DisconnectedAsync += async e => { // 掉线后自动重连,下面会展开讲 }; await client.ConnectAsync(options, CancellationToken.None);有几个细节要特别注意。ClientId在同一个 Broker 上必须唯一,如果两台上位机用了同一个 ID 连接,前面的客户端会被直接踢下线,这在现场是非常典型的故障。KeepAlivePeriod是心跳周期,保持 30 秒是个比较稳妥的值,太短会增加流量,太长会延迟发现掉线。CleanSession设置成 false,可以让 Broker 在客户端短暂重连时还能补发离线期间的消息。
2.3 订阅、发布与消息路由
MQTT 收到消息只是第一步,真正的活儿在消息分发。上位机里不同报文对应不同的处理逻辑,比如温度数据要更新看板、设备状态要改在线标记、报警消息要弹提示,所以我在通信层做了一个简单的路由表,用主题前缀做分发:
client.ApplicationMessageReceivedAsync += async e => { var topic = e.ApplicationMessage.Topic; var payload = Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment); if (topic.StartsWith("devices/") && topic.EndsWith("/data")) { var deviceId = topic.Split('/')[1]; await _dataProcessor.ProcessAsync(deviceId, payload); } else if (topic.StartsWith("devices/") && topic.EndsWith("/status")) { var deviceId = topic.Split('/')[1]; await _statusProcessor.ProcessAsync(deviceId, payload); } // 其他主题可以继续挂处理器,路由表加一条分支就行 };发布消息的方式也类似,封装一个方法给命令下发用:
public async Task PublishAsync(string topic, string payload, bool retain = false) { var message = new MqttApplicationMessageBuilder() .WithTopic(topic) .WithPayload(payload) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .WithRetainFlag(retain) .Build(); await _client.PublishAsync(message, CancellationToken.None); }报文解析我建议统一走 JSON,例如{"key":"temp","value":26.5,"unit":"℃","ts":"2025-01-15 12:00:00"}。用 System.Text.Json 反序列化成强类型对象,比解析字符串稳得多。设备端五花八门的格式,在上位机入口做一次"标准化"转换,后面所有逻辑就清爽了。
2.4 QoS 与断线重连:兜住现场不稳定网络
工业现场最麻烦的不是协议不会写,而是网络动不动抽风。Wi-Fi 掉线、交换机重启、设备网关死机,任何一个环节断了都要保证上位机扛得住。我在项目中主要靠三招:
第一招,连接建立之后,一定要做好自动重连。MQTTnet 的DisconnectedAsync事件里做一个指数退避的重连循环:
client.DisconnectedAsync += async e => { for (int i = 0; i < 10; i++) { try { await Task.Delay(TimeSpan.FromSeconds(2 * (i + 1))); await client.ConnectAsync(options, CancellationToken.None); break; } catch { } } };第二招,QoS 级别明确区分。实时温度这类数据丢了下一帧还会来,用 QoS 0 都行;但设备状态变化、指令下发这种关键消息必须用 QoS 1。题外话,如果业务要求一条消息都不能丢,那就得上 QoS 2,但 QoS 2 的握手开销明显更高,工业场景里大多数用不到。
第三招,订阅端的保护机制。我在订阅devices/+/data时用了通配符,但通配符订阅会收到所有设备的消息,量大之后处理速度可能跟不上。此时可以在路由层引入并发处理,用Channel<T>做缓冲队列,把收到的消息先进队列,由消费者线程慢慢处理。这一步在后面的中台数据流中还会再展开。
3. 数据落库方案:从表结构设计到连接池与批量写入
3.1 表结构:设备表与数据表分开设计
通信层把消息接进来之后,下一步就是把数据存下来。数据库表结构我建议从一开始就按中台思路设计,宁可多拆几张表,也别把数据全塞在一张"万能表"里。
设备基本信息和采集数据是两类不同性质的数据,必须分开:
CREATE TABLE `device_info` ( `device_id` varchar(64) NOT NULL COMMENT '设备唯一编号', `device_name` varchar(128) DEFAULT NULL COMMENT '设备名称', `device_type` varchar(64) DEFAULT NULL COMMENT '设备类型', `location` varchar(128) DEFAULT NULL COMMENT '安装位置', `online_status` tinyint DEFAULT 0 COMMENT '在线状态 0离线 1在线', `last_update_time` datetime DEFAULT NULL COMMENT '最后上报时间', PRIMARY KEY (`device_id`) ); CREATE TABLE `device_data` ( `id` bigint NOT NULL AUTO_INCREMENT, `device_id` varchar(64) NOT NULL COMMENT '设备编号', `data_key` varchar(64) NOT NULL COMMENT '数据项标识,如 temp、humidity', `data_value` decimal(12,4) DEFAULT NULL COMMENT '数据值', `data_unit` varchar(16) DEFAULT NULL COMMENT '单位', `record_time` datetime NOT NULL COMMENT '采集时间', PRIMARY KEY (`id`), KEY `idx_device_time` (`device_id`, `record_time`) );device_data走的是典型的时序数据模式,一条记录对应一个设备的一个数据点,查询的时候按设备和时间段过滤。data_key用字符串而不是直接建列,好处是现场新增一种传感器数据不需要改表结构,所有监测项都能落到这张表里。查询历史趋势时统一带上device_id + record_time条件,联合索引能撑住百万级数据量的日常查询。如果数据量真到了千万上亿级,再考虑按月份分区或者引入单独的时序数据库,但那是后话,一开始千万别过度设计。
3.2 连接字符串与连接池参数
上位机做数据中台,数据库连接必须走连接池,不能每次执行 SQL 都新建物理连接。MySqlConnector 驱动默认就开启了连接池,但关键参数要设置对,我常用的连接串是这样的:
Server=127.0.0.1;Port=3306;Database=iotdb;Uid=iot_app;Pwd=your_password;Pooling=true;Max Pool Size=50;Min Pool Size=2;Connection Lifetime=300;Connection Timeout=10;其中Max Pool Size=50是连接池上限,上位机同时写库的连接数一般不会超过 10,设 50 已经足够;Connection Lifetime=300让连接每 5 分钟重建一次,避免数据库端断掉空闲连接后客户端还在用失效连接;Connection Timeout=10至少等 10 秒超时,网络抖动时不至于立刻报错。
代码层面我用了 Dapper 做轻量 ORM,它没有 Entity Framework 那么重,但写增删改查非常舒服:
public async Task UpsertDeviceStatusAsync(DeviceInfo device) { const string sql = @" INSERT INTO device_info (device_id, device_name, device_type, location, online_status, last_update_time) VALUES (@DeviceId, @DeviceName, @DeviceType, @Location, @OnlineStatus, @LastUpdateTime) ON DUPLICATE KEY UPDATE online_status = VALUES(online_status), last_update_time = VALUES(last_update_time);"; using var connection = new MySqlConnection(_connString); await connection.ExecuteAsync(sql, device); }这里用到了ON DUPLICATE KEY UPDATE,设备首次上报时插入记录,后续上报只更新状态字段,非常省事。
3.3 批量写入:别一条一条 Insert
如果每来一条 MQTT 消息就执行一次INSERT,在设备多、数据频率高的场景下,数据库瞬间就被打爆。正确做法是把消息攒一批,然后一次性批量写入。
我在项目里的策略是:缓冲队列积攒到 500 条,或者每 2 秒触发一次 flush,先到先执行。写入的时候用事务包住整批数据,MySQL 端执行效率会高很多:
public async Task BatchInsertAsync(List<DeviceDataModel> dataList) { const string sql = @" INSERT INTO device_data (device_id, data_key, data_value, data_unit, record_time) VALUES (@DeviceId, @DataKey, @DataValue, @DataUnit, @RecordTime);"; using var connection = new MySqlConnection(_connString); await connection.OpenAsync(); using var transaction = await connection.BeginTransactionAsync(); try { await connection.ExecuteAsync(sql, dataList, transaction); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; } }如果数据量继续膨胀到每秒几千条,可以改用MySqlBulkLoader直接加载 CSV 文件,那是吞吐量最高的路子。不过 99% 的上位机场景用 Dapper 批量事务就足够了,真正瓶颈往往不在数据库,而在消息解析和消费侧的调度。
3.4 中台数据查询:真正值钱的部分
数据存下来是为了查。中台的价值多数体现在两张查询上:实时状态看板和历史趋势曲线。
实时状态看板直接查device_info表,把在线设备、离线设备聚合一下,SQL 简单:
SELECT online_status, COUNT(*) FROM device_info GROUP BY online_status;历史趋势查询则从device_data里捞一段时间的点位数据:
SELECT data_key, data_value, data_unit, record_time FROM device_data WHERE device_id = @DeviceId AND record_time BETWEEN @StartTime AND @EndTime ORDER BY record_time ASC;查询结果用 Dapper 映射成对象列表,喂给 WPF 的 DataGrid 或者曲线控件。这里要留意时间字段的时区问题,我习惯在写入数据库前统一把时间转成服务器本地时间,避免设备上报 UTC 时间导致的数据错乱。
4. 让数据流动起来:消息从 MQTT 到界面的完整链路设计
4.1 中台的内存流转模型
通信通了,数据库也通了,剩下的问题是怎么把这三层衔接得顺滑。单纯在 MQTT 消息回调里直接写瓦库、直接改界面,是小项目能做、但大项目会变得很难维护的做法。数据中台的核心是"让数据按一条清晰的流水线流动"。
我在系统里抽象了这么一条链路:
MQTT 客户端 → 消息路由 → 业务服务 → 写入队列(Channel)→ 数据库消费者和业务服务 → 内存缓存(ConcurrentDictionary)→ WPF 界面
两个分支并行不悖。实时展示走内存缓存,历史存储走异步队列。
为什么异步队列这么重要?MQTTnet 收到消息后如果处理函数里既做 JSON 解析又做数据库写入又做 UI 刷新,一个慢操作会让整条消息接收链路卡住,后面的消息全部堆积。用Channel<T>做缓冲后,消息回调只负责把数据扔进队列,立刻返回:
var channel = Channel.CreateBounded<DeviceDataModel>(new BoundedChannelOptions(10000) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true }); // MQTT 回调里 await channel.Writer.WriteAsync(model); // 后台消费者循环里 await foreach (var item in channel.Reader.ReadAllAsync()) { await _databaseService.BatchInsertAsync(item); }4.2 消息去重与顺序问题
MQTT 的 QoS 1 语义是"至少一次",这就意味着设备端或者 Broker 重传时,上位机可能收到重复消息。如果不去重,数据库里就会出现重复记录,历史曲线会有毛刺。
去重方案我用的是一个以消息时间戳为 key 的最近缓存。每来一条数据,先检查device_id + data_key + record_time组合是否处理过,处理过就丢弃,处理过一次后放入一个容量有限的缓存窗口。窗口大小我做成 5 分钟,超过 5 分钟的旧消息直接按正常逻辑处理,因为那大概率是新的补报数据。
顺序问题也同样需要关注。工业现场网络乱序几乎是常态,但对于温度、压力这类数据,后到的旧值不应该覆盖新值。写入数据库的时候带上record_time字段,查询时按时间排序,最终展示的就是正确时序。界面端更新实时看板时,我会在缓存里对比最后一次刷新时间,只有新数据才触发界面刷新。
4.3 实时状态缓存与历史数据分离
中台模式里要明确区分两个数据域:实时域和持久化域。
实时域存在ConcurrentDictionary<string, DeviceRealtimeModel>里,通信层每收到一条最新数据就更新这个字典。WPF 的 ViewModel 每次刷新都从这个字典取值,秒级刷新毫无压力,完全不碰数据库。
持久化域才是数据库那套。这样可以做到两个目标:界面刷新不依赖磁盘 IO,性能上限高;数据库写入可以批量调度,不怕频繁小请求。
public class RealtimeCache { private readonly ConcurrentDictionary<string, DeviceRealtimeModel> _cache = new(); public void Update(DeviceDataModel data) { var key = $"{data.DeviceId}:{data.DataKey}"; _cache.AddOrUpdate(key, new DeviceRealtimeModel { DeviceId = data.DeviceId, DataKey = data.DataKey, Value = data.Value }, (_, old) => { old.Value = data.Value; old.Unit = data.Unit; old.UpdateTime = data.RecordTime; return old; }); } }这套设计跑起来之后,数据链路是干净且可控的。我甚至可以随时把消费者线程挂起做维护,界面端照常工作,因为界面的数据源根本不依赖数据库写入是否完成。
5. WPF 界面与 MVVM:实时刷新、控件绑定与界面优化
5.1 搭建 MVVM 基础框架
WPF 开发如果不搞 MVVM,代码很快就会变成一团乱麻。我用的组合是 CommunityToolkit.Mvvm 加 WPF 原生的数据绑定。
一个最小的 ViewModel 基类是这样:
public partial class MainViewModel : ObservableObject { [ObservableProperty] private string _connectionStatus; [ObservableProperty] private int _onlineCount; public ObservableCollection<DeviceDataModel> RealtimeList { get; set; } = new(); }CommunityToolkit.Mvvm的[ObservableProperty]源生成器非常方便,它会自动生成通知属性,省掉了手写INotifyPropertyChanged的样板代码。界面上的 XAML 只需要绑定属性名,数据一变化,界面就会自动更新。
5.2 ObservableCollection 与 UI 线程调度
WPF 有个硬性规则:UI 元素只能在 UI 线程更新。而 MQTT 消息回调跑在线程池线程上,直接往ObservableCollection里面加数据,十有八九会抛NotSupportedException。
我一开始也踩过这个坑,后面统一在通信层和界面层之间加一道线程调度:
private void DispatcherUpdate(Action action) { if (Application.Current.Dispatcher.CheckAccess()) { action(); } else { Application.Current.Dispatcher.Invoke(action); } }收到消息并解析成模型后,通过DispatcherUpdate把RealtimeList.Add的操作交给 UI 线程执行。这一步看似简单,但忘记做就会在运行时随机爆炸,而且不是每次必现,排查起来极其痛苦。
5.3 DataGrid 实时刷新与大数据量性能
把实时数据绑到 DataGrid 上很简单:
<DataGrid ItemsSource="{Binding RealtimeList}" AutoGenerateColumns="False" EnableRowVirtualization="True" IsReadOnly="True"> <DataGrid.Columns> <DataGridTextColumn Header="设备编号" Binding="{Binding DeviceId}" Width="140"/> <DataGridTextColumn Header="数据项" Binding="{Binding DataKey}" Width="100"/> <DataGridTextColumn Header="当前值" Binding="{Binding Value}" Width="100"/> <DataGridTextColumn Header="单位" Binding="{Binding DataUnit}" Width="70"/> <DataGridTextColumn Header="更新时间" Binding="{Binding UpdateTime}" Width="160"/> </DataGrid.Columns> </DataGrid>但要注意,如果设备数量达到数百台、每台又有十几个数据项,实时列表会疯狂刷新,界面很容易卡顿。我的处理方式是:EnableRowVirtualization="True"开启行虚拟化,同时在 ViewModel 里做节流刷新。比如消息是每秒 20 条,UI 刷新只保留每 500 毫秒一次聚合更新,减少绘制压力。
如果需要更直观的现场状态,我还会在界面上加一个 Blazor 风格的小卡片列表展示各设备在线状态。实际做过之后你会感受到 WPF 的模板系统在这种场景特别舒服:一套DataTemplate就能搞定所有卡片样式。
5.4 用 HandyControl 提升界面质感
原生 WPF 控件长得实在不怎么样,尤其做给客户看的上位机,界面美观度会直接影响项目印象分。我引入了 HandyControl 控件库,这个库在工控和桌面软件圈子里口碑很好。
集成方式很简单,在 App.xaml 里引入资源字典:
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml"/> <ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml"/>之后就能用hc:TabControl、hc:Window、hc:DataGrid这些控件,默认样式比原生控件精致不少。它还自带了一些实用的提示框、弹窗控件,省下不少自定义开发的时间。需要注意的是,HandyControl 和自定义样式模板混用时,优先级问题偶尔会让人头疼,我的习惯是"能用默认样式的尽量不重写,重写模板时保留关键资源引用"。
6. 生产部署的硬骨头:MQTT 服务化、日志监控与典型踩坑
6.1 把 Mosquitto 跑成 Windows 本地服务
开发环境里 MQTT Broker 我直接用了 Mosquitto,它是开源且轻量的,windows 版解压即用。项目要交付到现场时,不能每次手动开个控制台窗口跑 Broker,所以要注册成 Windows 服务。
操作分三步:
第一步,把 zip 解压到固定目录,比如C:\mosquitto\,用记事本打开mosquitto.conf设置监听端口和持久化参数:
listener 1883 allow_anonymous false password_file C:\mosquitto\passwd persistence true persistence_location C:\mosquitto\data\ log_dest file C:\mosquitto\logs\mosquitto.log第二步,生成密码文件。在命令行进入 mosquitto 目录执行,-b表示加密存储密码:
mosquitto_passwd -c passwd iot_user第三步,用系统命令注册成 Windows 服务:
sc create mosquitto binPath= "C:\mosquitto\mosquitto.exe -c C:\mosquitto\mosquitto.conf" start= auto DisplayName= "Mosquitto MQTT Broker" sc start mosquitto注意:
sc命令中binPath=和start=等号后面必须有一个空格,这是 Windows 服务管理器的参数解析要求。很多人第一次写会在这里报错。
服务跑起来后,记得在 Windows 防火墙里放行 1883 端口入站规则,否则远程设备连接不上。这个点我至少有三五个项目吃过亏,部署机器上防火墙默认拦截,排查半天才发现是端口没放行。
6.2 细数我在项目中踩过的几个坑
先说最经典的ClientId冲突问题。现场有两台上位机,我图省事给它们配了同一个ClientId,结果一台上线另一台立刻掉线,来回互踢。这个问题不是报错型的,而是表现为"不稳定",非常容易误判成网络问题。排查方法也简单,看 Broker 日志会发现 repeated connection 记录,把客户端 ID 改成不同值就解决了。
第二个坑是DateTime的时区混淆。设备端上报的时间是 UTC,我数据库连接字符串上补了一个Treat Tiny As Boolean之类的配置就以为没事,结果查询历史曲线时发现时间线往前偏移了 8 小时。后来我统一了规则:设备时间进中台后立即转成本地时间再入库,界面读取原始值,不再做二次转换。
第三个坑是批量写入时事务使用不当。一开始我偷懒,把ExecuteAsync放在MySqlTransaction外面,每条独立自动提交,看似没问题,数据量一上来日志全是 timeout。改成事务包批量插入、RollbackAsync兜底之后,写入速度提升了近十倍。数据的积压队列有时会短暂撑大,也正因为有事务批量写入,消费能力赶上了生产速度,队列才稳定下来。
第四个坑是 Mosquitto 服务进程在客户端断线时会一直保存会话,如果有设备长期离线,Broker 内存和磁盘的会话持久化文件会越来越大。我的做法是给设备端也设置合适的CleanSession标志,同时定期清理遗嘱主题里长期不更新的遗留对象。这种问题不会立刻影响功能,但运行半年后你会发现 Broker 越来越慢。
6.3 日志与状态监控
上位机交付现场后,最宝贵的就是运行日志。没日志,出问题只能靠客户描述,那种日子太难熬了。我用 Serilog 做结构化日志,控制台和文件双输出,按天滚动:
Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console() .WriteTo.File("logs/iot-_.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30) .CreateLogger();日志里我会记录三件关键事:MQTT 连接状态变化、订阅消息数量统计、数据库批量写入的结果(成功/失败/耗时)。系统运行期间,我可以随时通过日志观察消息链路是否健康。比如正常情况下每分钟消息数是稳定的,突然暴跌就要怀疑是网络断连或者设备端死机;数据库写入耗时持续升高,就要检查连接池或者磁盘空间。
界面端我也会加一个简单的状态栏,显示 MQTT 连接状态、当前在线设备数、今日入库数据条数。这部分信息从内存缓存里实时读取,成本极低,但对现场运维判断非常有帮助。
最后再分享一点实战体会
这套 .NET8 + WPF + MQTT + 数据库的方案,我已经在几个真实项目里验证过了。最初也走过弯路,比如在通信回调里直接写数据库造成消息堆积,又比如为了界面美观过度设计模板导致性能下降。但把整个链路理清之后,事情就没那么玄乎了:通信层管好连接和消息路由,数据层管好批量写入和查询,界面层管好绑定和刷新,中间加一层内存缓存做实时态和持久化态的缓冲,就能支起一个相当稳定的物联网数据中台。
如果你刚好要开发类似的上位机系统,我建议别急着写代码,先把设备数量、消息频率、数据项数量摸清楚,这直接决定你的表结构设计、队列容量和界面实现方式。设备少、频率低,SQLite 都够用;设备多、频率高,照着我这套 MySQL 批量写入加通道缓冲的套路来做,肯定比摸着石头过河舒服。