写这篇东西的起因很简单:我调试设备时发现,实时数据看一眼就没了,后来要复盘故障,历史曲线拉不出来,只能干瞪眼。所以我把这套基于C# 的OPC UA客户端源码整理了出来,它用OPC UA协议从设备端抓数据,再用EF6+SQLite这套组合落本地库,代码做了全注解,还配了结构思维图。这篇文章适合两类读者:一类是刚接触OPC UA、想在上位机里跑通数据链路的朋友,另一类是已经在用OPC UA、但不知道怎么把采集数据持久化到本地数据库的开发者。
之所以选EF6+SQLite,而不是传统的SQL Server或MySQL,原因很现实:工控现场往往没有专职的数据库管理员,设备部署在车间甚至野外,一个单文件数据库就能解决存储问题,部署成本几乎为零。后面我会把整个项目的目录结构、核心代码、配置细节和踩过的坑全部展开讲。
1. OPC UA+EF6+SQLite这个组合,到底解决了什么问题
先说清楚这三样东西各自扮演什么角色。OPC UA是工业通信标准,负责让上位机软件和PLC、传感器、网关这些设备用统一的方式对话。EF6是微软的ORM框架,负责把C#类映射成数据库表,让我们用操作对象的方式操作数据库。SQLite是嵌入式关系型数据库,整个库就是一个文件,没有独立服务进程。
1.1 为什么上位机客户端需要一个本地数据层
OPC UA客户端拿到数据后,最常见的用法是在界面上实时显示,画个趋势曲线,设备报警了弹个窗。但问题在于:数据显示完就没了。如果三天后设备出了故障,想查看三天前某个参数的变化过程,没有历史数据就完全无从下手。设备厂商、车间管理方都会要求保留历史运行数据,这既是故障追溯的依据,也是工艺优化的基础。
还有个更隐蔽的需求:OPC UA Server在设备端,如果你用第三方客户端软件(比如UaExpert)去连,数据只在对方的界面里,和你的业务系统完全隔离。自己写一个带持久化能力的客户端,数据才能进入自己的数据库,后续怎么统计、怎么分析、怎么对接MES/ERP系统,完全自主可控。
我在做项目时还遇到一个实际场景:车间网络不稳定,OPC UA连接经常断。如果数据只存在内存里,断线期间的采集数据就丢了。有了本地数据库,即使网络恢复后重新连接,之前的样本还在库里,损失降到最低。所以本地数据层不是锦上添花,而是工业上位机的基本配置。
1.2 SQLite和EF6在其中的分工
分工很简单:EF6负责让你用C#类去操作数据库,SQLite负责真正把数据存到磁盘。EF6是ORM,它把代码里的DataRecord类映射成数据表DataRecords,把对象的属性映射成字段,读写操作变成我们熟悉的context.DataRecords.Add(record)和context.SaveChanges()。SQLite则负责底层存储,事务、索引、磁盘文件管理全是它的活。
为什么选SQLite?部署简单是最大理由。它不需要安装服务,不需要配置账号密码,应用程序把数据库文件放在指定目录,连接字符串一写就完事。几十上百GB的数据量,SQLite完全能抗住,而工业设备的历史数据通常没有那么大。对比一下,如果用SQL Server,现场得有Windows服务,还要处理防火墙、账号权限、日志增长一堆事,在小规模设备项目里明显是负担。
EF6这边,虽然现在微软主推EF Core,但EF6在传统.NET Framework项目中依然非常稳定,资料多,踩坑经验也多。对于工控上位机这种以稳定性为第一诉求的场景,EF6完全够用,而且很多老项目都是它写的,维护起来不陌生。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 源码目录结构与思维图:拿到项目先看哪几个文件
刚拿到一套带注释的源码,很多人习惯从第一个文件往下读。但这是我的大忌。工业上位机项目代码量不小,按顺序读很容易迷失在细节里。正确做法是先看目录结构,再看思维图,建立全局认知后再深入代码。
2.1 整个项目的文件组织逻辑
这个项目的目录采用了经典的分层结构,每个文件夹只干一件事:
Models/:数据模型,定义DataRecord等实体类,对应数据库表结构OpcUa/:OPC UA客户端封装,包括连接管理、订阅管理、读写操作DataAccess/:数据访问层,包含DbContext定义和数据库初始化逻辑Services/:业务服务层,协调OPC UA客户端和数据访问层的工作UI/:界面层,提供人机交互入口
这套分层的核心逻辑是“依赖单向流动”:UI依赖Services,Services依赖OpcUa和DataAccess,底层的Models被所有上层引用。这样设计的好处是,任何一层的内部实现改了,不影响其他层。比如你把SQLite换成了MySQL,只需要改DataAccess层,OPC UA采集逻辑和界面都不用动。
从学习角度,我建议看代码的顺序是:Models → DataAccess → OpcUa → Services → UI。先知道数据长什么样,再看数据怎么存,然后看数据从哪里来,最后看业务怎么串起来。
2.2 从思维图反推代码主流程
结构思维图对这个项目的意义,等于地图之于旅行。思维图把整个系统的组件和流程画在一起,节点之间的连线表示调用关系。有了它,你不需要在代码里翻半天才知道“这个方法是谁调用的”,一眼就能看到完整链路。
这个项目的思维图主结构是这样的:程序启动后,UI层触发初始化,Services层执行两件事——初始化数据库上下文,同时创建OPC UA会话。会话建立成功后,创建订阅和监控项,数据到达后触发回调,回调里把数据写入缓存队列,定时器触发批量写入SQLite。
我拿着思维图反推源码时就发现,理解整个项目只需要抓住一条主线:启动初始化 → OPC UA建连 → 订阅数据 → 数据入队 → 批写入库。其他代码都是围绕这条线做的辅助工作。思维图把这条主线和辅助分支画清楚了,代码就变成了图的具体实现。
3. OPC UA客户端核心:连接、读写与订阅的代码拆解
OPC UA客户端部分是整个项目最核心的模块。很多人以为OPC UA通信就是把IP地址和端口填上就能连,实际代码里涉及的东西远比这多:应用配置、安全策略、会话管理、订阅生命周期,每一块都有讲究。
3.1 连接管理:一个稳定会话从配置开始
先看初始化代码。这次使用的OPC UA库是官方的OPCFoundation.NetStandard.Opc.Ua,它是跨平台的标准库,在.NET Framework和.NET Core/8.0下都能用。
csharp复制var config = new ApplicationConfiguration
{
ApplicationName = "OpcUaDemoClient",
ApplicationUri = "urn:OpcUaDemoClient",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier(),
TrustedPeerCertificates = new CertificateTrustList(),
TrustedIssuerCertificates = new CertificateTrustList()
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }
};
await config.Validate(ApplicationType.Client);
这段代码的本质是告诉OPC UA库:我是一个客户端,我叫什么名字,我允许哪些安全策略,超时时间多久。Validate方法会检查配置项的合法性,比如证书路径是否存在。如果这里没通过,后面的连接必然失败。
接下来是选择端点并建立会话:
csharp复制var endpointDescription = CoreClientUtils.SelectEndpoint(
"opc.tcp://192.168.1.100:49320",
useSecurity: false);
var endpointConfiguration = EndpointConfiguration.Create(config);
var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration);
var session = await Session.Create(
config,
endpoint,
false,
"demo-session",
60000,
new UserIdentity(new AnonymousIdentityToken()),
null);
SelectEndpoint会从服务器获取端点列表,然后挑一个符合条件的。useSecurity: false意味着走无加密的通道。实际部署时必须开启安全策略,至少用Basic256Sha256,否则数据在网络上明文传输,涉及生产工艺参数时有泄露风险。本地调试时先关掉安全策略能省去证书配置的麻烦,但千万别把这种写法直接搬到生产环境。
会话建立后,还有个容易被忽略的问题:断线重连。工业网络不可能永远稳定,交换机重启、网线松动都会导致会话断开。我见过很多人写的客户端,一旦断线就彻底死掉,必须重启程序。这个项目的做法是开启session.KeepAlive事件监听,在回调里判断会话是否失效,失效就重新调用Session.Create。这里有一个经验:重连时不要复用旧的Session对象,必须重新创建,旧对象内部的通道状态已经不可信了。
3.2 数据订阅:从被动等待到主动推送
OPC UA有两种获取数据的方式:客户端主动读(Read),或者服务器主动推(Subscribe)。Read简单,但如果数据点多、频率高,每次都发请求,网络开销和服务器压力都很大。订阅则是一次建立,持续推送,适合高频采集场景。
这个项目用的是订阅方式,核心代码如下:
csharp复制var subscription = new Subscription(session.DefaultSubscription)
{
PublishingInterval = 1000,
KeepAliveCount = 10,
LifetimeCount = 100,
MaxNotificationsPerPublish = 1000,
PublishingEnabled = true,
Priority = 100
};
session.AddSubscription(subscription);
subscription.Create();
var item = new MonitoredItem
{
StartNodeId = new NodeId("ns=2;s=Temperature"),
AttributeId = Attributes.Value,
SamplingInterval = 1000,
QueueSize = 10,
DiscardOldest = true
};
item.Notification += OnNotification;
subscription.AddItem(item);
subscription.ApplyChanges();
这里几个参数值得细说。PublishingInterval表示服务器每隔多少毫秒发布一次数据,单位是毫秒,这里设了1000,也就是每秒一次。SamplingInterval是采样间隔,它决定了服务器端采样的频率。QueueSize是通知队列大小,超过队列容量后,如果DiscardOldest为true,最旧的数据被丢掉。这三个参数共同决定了数据采集的实时性和完整性。
队列大小这个参数,很多初学者不会注意,实际影响很大。假设QueueSize=1、DiscardOldest=true,客户端处理速度跟不上时,中间的数据就会丢失。如果DiscardOldest=false,队列满了新数据就进不来,数据是新的丢旧的。选哪种取决于业务:趋势分析希望看到最新值,历史追溯希望不丢任何一条。我的建议是,采样频率高、数据点关键,就把QueueSize调大,比如100,把DiscardOldest设为true,这样保证客户端慢一点时也不会丢太多样本。
数据到达后的回调是核心逻辑的入口:
csharp复制private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e)
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
var value = notification.Value.WrappedValue.Value;
var statusCode = notification.Value.StatusCode.Code;
_buffer.Enqueue(new DataRecord
{
NodeId = item.StartNodeId.ToString(),
DisplayName = item.DisplayName,
Value = value?.ToString(),
StatusCode = statusCode.ToString(),
Timestamp = DateTime.Now
});
}
}
这段代码做的是:把服务器推送过来的数据包转成MonitoredItemNotification,取出数值和状态码,打包成DataRecord对象放进队列。这里有个细节:notification.Value.WrappedValue.Value可能为null,比如设备断线时服务器会推送一个BadNoCommunication状态码,数值是空。所以Value用?.ToString()做了空值保护,避免了空引用异常。
3.3 读写操作与状态码处理
除了订阅,有时还需要主动写数据。比如给变频器下发一个启动命令,或者修改设备参数。OPC UA写入和读取的API相对简单:
csharp复制var nodeId = new NodeId("ns=2;s=StartCommand");
var valueToWrite = new DataValue(new Variant(true))
{
StatusCode = StatusCodes.Good,
ServerTimestamp = DateTime.UtcNow,
SourceTimestamp = DateTime.UtcNow
};
var writeValue = new WriteValue
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = valueToWrite
};
var result = session.Write(null, new WriteValueCollection { writeValue },
out var results, out _);
if (StatusCode.IsBad(results[0].Code))
{
// 写入失败,记录日志
}
写入操作返回的StatusCode是判断成败的关键。工业协议里,Good(0x00000000)表示成功,其他状态码各有含义。最常见的BadNodeIdUnknown表示节点ID不对,BadWriteAccessDenied表示没有写入权限,BadOutOfService表示服务器当前不可用。
我强烈建议在代码里建立一个状态码对照表,或者至少把返回的状态码字符串记录到日志里。很多现场问题排查,最后都是靠状态码精确定位的。比如有一次现场说“写数据没反应”,日志里翻出BadWriteAccessDenied,一问才知道是工程师把用户名密码配置错了,权限不够。
4. EF6+SQLite持久化层:配置、模型和写入策略
如果说OPC UA部分解决的是“数据从哪来”,那EF6+SQLite部分解决的就是“数据往哪去”。工业数据有个特点:量大、连续、有先后关系。每秒几十上百条数据,如果一条一条写数据库,性能很快会成为瓶颈。
4.1 数据库上下文与连接配置
EF6连接SQLite需要两个包:EntityFramework(版本用6.4.4稳定版)和System.Data.SQLite.Core(版本用1.0.118)。System.Data.SQLite不仅提供ADO.NET驱动,还内置了EF6的Provider,所以配置起来比一般数据库简单。
在app.config或web.config里配置连接字符串:
xml复制<connectionStrings>
<add name="OpcDataContext"
connectionString="Data Source=|DataDirectory|opcdata.db"
providerName="System.Data.SQLite.EF6" />
</connectionStrings>
配置里有个小坑:providerName必须写成System.Data.SQLite.EF6,而不是System.Data.SQLite。这里的区别只有踩过坑才知道,很多教程里写的是后者,结果EF6启动时找不到Provider,报ProviderNotRegisteredException异常。
|DataDirectory|是一个占位符,它指向AppDomain.CurrentDomain.BaseDirectory。如果你需要将数据库文件放到别的位置,可以直接使用绝对路径,但要注意部署机器上的目录权限。我遇到过现场把程序当作服务运行,服务账号没有写入权限,数据库文件创建失败的情况。所以这里我建议在代码的初始化逻辑里主动指定路径,而不是依赖默认位置。
DbContext的定义也很简洁:
csharp复制public class OpcDataContext : DbContext
{
public DbSet<DataRecord> DataRecords { get; set; }
public OpcDataContext() : base("name=OpcDataContext") { }
}
DbSet<DataRecord>属性对应数据库里的DataRecords表。EF6在第一次使用时,如果数据库文件不存在,会自动创建。这个自动建库的行为靠的是Database.SetInitializer(new CreateDatabaseIfNotExists<OpcDataContext>()),后续如果表结构有变化,需要迁移或删库重建。
4.2 数据模型设计背后的思考
这个项目的实体类设计:
csharp复制[Table("DataRecords")]
public class DataRecord
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
[MaxLength(128)]
public string NodeId { get; set; }
[MaxLength(128)]
public string DisplayName { get; set; }
[Required]
public string Value { get; set; }
[MaxLength(32)]
public string StatusCode { get; set; }
public DateTime Timestamp { get; set; }
}
设备采集的数据字段并不多,为什么Value用string而不是double或float?因为OPC UA节点不止有数值类型,还有字符串、布尔、字节数组等。设备型号不同,数据的类型也不同。用一个通用的string字段先把所有数据都存下来,后续如果需要特定类型统计,可以在查询时转换。这种“宽表”设计牺牲了一点查询性能,但换来了最大的灵活性。实测下来,数据量大时,string字段的存储空间比double多一倍左右,但对于SQLite几十GB的上限来说,完全不是问题。
一个值得注意的设计决策是Timestamp字段。这里用的是DateTime.Now,是应用服务器的本地时间。如果你只需要看趋势,那完全够用。但如果将来要跨设备、跨服务器做数据对比,最好统一使用UTC时间(DateTime.UtcNow),避免不同时区的时间错乱。我在多个项目里都遇到过这种问题:数据库里记录的时间是本地时间,换了一台时区不同的服务器,历史数据的时间线就乱了。
另外,考虑到设备数据是时间序列数据,Timestamp字段必须建索引。在EF6中可以通过Database命令手工建:
sql复制CREATE INDEX IX_DataRecords_Timestamp ON DataRecords(Timestamp);
如果没有这个索引,查询WHERE Timestamp >= @start AND Timestamp <= @end的数据量一旦上来,全表扫描会让查询时间从毫秒级变成秒级。
4.3 高频写入的缓存与批处理策略
如果订阅了20个监控点,每个点每秒推送一次,那每秒就要写20条记录。如果一条一条地写,每条一次数据库事务,磁盘IO压力非常巨大,测试下来数据库文件会快速增长,程序也会卡顿。这个项目采用的方案是:内存缓存+定时批处理。
核心思路是这样的:订阅回调只负责把数据放进ConcurrentQueue,然后有一个独立的线程去检查队列,攒够一定数量或到了指定时间,再一次性写入数据库。
csharp复制private readonly ConcurrentQueue<DataRecord> _buffer = new ConcurrentQueue<DataRecord>();
private const int BatchSize = 100;
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(5);
public async Task StartFlushLoopAsync()
{
while (!_cancellationTokenSource.IsCancellationRequested)
{
await Task.Delay(FlushInterval);
var list = new List<DataRecord>();
while (list.Count < BatchSize && _buffer.TryDequeue(out var record))
{
list.Add(record);
}
if (list.Count > 0)
{
using (var context = new OpcDataContext())
{
context.DataRecords.AddRange(list);
await context.SaveChangesAsync();
}
}
}
}
这段代码有两个亮点。第一,使用ConcurrentQueue,因为订阅回调运行在线程池线程上,而批处理循环运行在另一个线程上,普通Queue会存在线程安全问题。第二,BatchSize和FlushInterval双条件触发,不管数据量多少,最多等5秒就写一次,保证实时性;数据量大时攒到100条就写,控制单次写入的粒度。
实际跑下来,20个监控点每秒20条数据,5秒一批100条,SQLite写入毫无压力。如果你要优化的极致一点,可以调整批量大小,比如200或500。但别太大,AddRange一次加几千条,EF6的变更跟踪管理器会占用大量内存,反而拖慢速度。
还有一个很重要的细节:每次批处理都new一个DbContext,用完就释放。DbContext是轻量级对象,不是重量级资源。很多人习惯在整个程序生命周期里只用一个DbContext实例,这在winform里单线程也许没问题,但在多线程的订阅回调里会出各种奇怪的异常,比如“上下文已释放”或者“实体已被其他对象跟踪”。所以,短生命周期+单线程操作,是EF6最安全的使用方式。
5. 全注解源码该怎么读:从例程到自主改造的学习路线
拿到一套全注解的源码,怎么读才能高效,我想专门聊一聊。这套源码我在写注释时就刻意做了层次化设计,注释不是简单解释“这行代码干了什么”,而是告诉你“为什么这么干”和“这段代码属于哪个模块”。
5.1 源码里的注释结构
项目注释主要有三个层次。
第一层,文件头注释。每个.cs文件顶部都有一段注释,说明这个文件属于哪个模块、核心职责、依赖哪些其他模块。比如OpcUaSubscriptionManager.cs的文件头写的是“负责OPC UA订阅的生命周期管理,包括创建订阅、注册监控项、数据回调分发。依赖OpcUaSessionManager,数据输出到IDataSink接口。”
第二层,方法级注释。每个公开方法都说明了入参含义、返回值、异常情况。比如ConnectAsync方法的注释里特别提醒:“如果服务器端启用了用户认证,需要在这里传入有效的UserIdentity;匿名连接仅适用于仿真环境或内网调试。”
第三层,行内关键注释。核心代码行附近会有注释解释为什么这么写。比如订阅回调里的if (item.Notification != null),旁边的注释写着“某些监控项的触发条件可能导致Notification为null,必须先判空再解引用”。
这套注释结构说白了就是把代码当成教材来写。对于初学者,按着注释从上往下读,可以了解整个项目的骨架;对于有经验的开发者,直接查方法注释就能快速找到自己需要的扩展点。
5.2 读完源码后如何改造成自己的项目
源码只是例程,拿到手之后要做的第一件事不是改业务逻辑,而是替换连接信息。把项目里的服务器地址、端口、节点ID全部换成你的设备参数。你会发现在App.config里集中管理这些配置,比在代码里散着写好几个字符串要方便得多。
接下来是扩展方向,我结合自己的项目经验列出三个最常做的改造:
一是增加数据转发。有次项目环境是设备端数据落SQLite本地库,但总部需要远程访问数据,不能直接暴露数据库。我在Services层加了一个MqttPublisher服务,把订阅回调里的数据同时发给MES系统。原来的数据落库逻辑一点没动,只是多了一个订阅者。
二是增加报警判断。OPC UA的数据本身没有业务语义,温度多少算异常,压力多少要报警,都是业务侧决定的。可以在OnNotification回调里加一个判断,超过阈值就触发报警事件,把报警记录写进另一张表。这是一种典型的边缘计算场景,数据在源头就完成第一层处理。
三是扩展多设备接入。一个客户端不一定只连一台OPC UA服务器。把这个项目的OpcUaSessionManager改成支持多个Session实例,每个设备对应一个实例。界面层加个设备列表,切换设备时切换Session。这个改造的关键在于,所有设备数据都在一个数据库文件里,靠NodeId字段区分来源。
6. 实测中的坑与经验总结
项目落地的过程不可能一帆风顺,我把这套源码在真实设备上调试时遇到的几个典型问题写出来,这些都是文档里查不到的。
6.1 版本兼容性:EF6和SQLite的版本必须配对
用NuGet安装时,如果你用的是System.Data.SQLite最新版本(比如1.0.118),搭配EF6.4.4,实测没问题。但如果用了System.Data.SQLite.Core的某些早期版本(比如1.0.110以下),EF6有时会报“无法加载System.Data.SQLite.EF6”。原因是老版本没有把EF6 Provider注册进去。
我的建议是:直接用NuGet安装System.Data.SQLite包(注意不是Core),它会自动带上EF6的设计时组件。System.Data.SQLite.Core是无设计时的轻量版,有些功能缺失,对于需要自动建库、简单迁移的场景,还是完整版更省心。
6.2 DbContext的线程安全陷阱
DbContext不是线程安全的,这点官方文档写得很清楚,但实际项目里还是容易踩中。订阅回调运行在OPC UA SDK的内部线程池里,它会触发OnNotification,在这个回调里直接new DbContext然后SaveChanges,大部分时间没问题,但偶尔会出现“A second operation started on this context before a previous operation completed”的异常。
原因是一个DbContext实例不能同时执行两个操作。这个项目里我在批处理线程里每次都new新DbContext,所以规避了这个问题。如果你看到这个异常,基本可以断定某个DbContext实例被多个线程同时使用了。
6.3 发布部署时的必检项
发布项目时,有几个文件必须跟着程序走,否则上线就报错:x64或x86目录下的SQLite原生DLL(System.Data.SQLite.dll和SQLite.Interop.dll)、EntityFramework.SqlServer.dll(如果配置里引用了)、System.Data.SQLite.EF6.dll。
SQLite的ADO.NET驱动是托管代码,但它底层调用了原生C++编写的SQLite引擎。所以发布时必须带上对应架构的SQLite.Interop.dll,而且x64和x86不能混用。如果部署机器是64位系统,程序集编译目标必须是x64或AnyCPU(优先x64),否则加载DLL会失败。
检查方法也简单:把发布目录复制到一台干净的机器上,直接运行。如果有缺失,一般会报“无法加载DLL‘SQLite.Interop.dll’:找不到指定的模块”。这个错误很误导人,其实不是DLL不存在,而是架构不匹配或未放在正确子目录。
6.4 学习路径:从仿真器到真实设备的平滑过渡
如果直接用真实PLC调OPC UA,调试效率很低,设备端的通信模块参数不对时,你会分不清是自己的代码有问题还是设备配置有问题。我的建议是用仿真器先跑通代码,再切换真机。
好用的工具是Prosys OPC UA Simulation Server,它能创建一个模拟服务器,里面有几百个模拟节点,数据会周期性变化,完全够验证客户端逻辑。还有一个是UaExpert,它是通用的OPC UA客户端调试工具,用来排查服务器端的节点ID、端点配置问题,比写代码验证快得多。
我的调试顺序是:先打开UaExpert查看设备的节点结构,确认目标节点的NodeId格式,比如ns=2;s=Temperature;然后用Prosys仿真器验证这个NodeId能否取到数据;最后把相同节点ID填到自己的客户端代码里,跑通整条链路。这样每一步问题都被限制在一个很小的范围里,排错效率最高。
最后再分享一个我自己的小习惯:在部署到现场之前,我会用模拟数据连续运行一个晚上,然后在第二天早上检查数据库文件大小、记录条数、是否有异常状态码。这个方法帮我提前发现过好几轮问题,包括订阅频率设置过高导致数据量远大于预期、某个节点在夜间会定时推送故障状态等等。看起来简单的几步检查,能省去你在现场工地通宵排障的大麻烦。
