
简介面向.NET开发者与工业自动化方向的OPC UA通信示例包围绕客户端连接、断开、读写节点、创建订阅与监听心跳等核心环节给出可直接参考的代码组织与配置思路适合已具备一定C#基础、需要与PLC等设备做数据交互的中级开发者对照实践。压缩包共2000个文件、约87.75MB以xml配置与说明、dll依赖库、txt记录、nupkg包及少量cs源码、sln/csproj工程文件为主完整保留了项目运行所需的依赖与工程结构。目前已有678人学习。通过其中的代码示例可了解安全连接参数设置、节点读写调用、监视项订阅推送以及心跳回调的落地方式也可作为排查连接失败、订阅无响应等问题的参照便于快速搭起可运行的通信验证环境。1. 拿到一个 .NET OPC UA 通信 Demo先弄清它能替你把哪几件事跑通某开发者接了个小采集任务手上只有一台支持 OPC UA 的网关和一个端点地址要求是跑在 Windows 服务里别老掉线连接、断开要可控读写要能对上点位订阅要能持续推数还得能判断对面是不是已经没了。这份 .NET OPC UA 通信 Demo 就是照这个需求攒出来的基于官方 .NET 标准栈把会话管理、节点读写、订阅推送、心跳监听四条链路串成一套能直接跑的代码。适合两类人一是第一次接触 OPC UA、想先看到数据在屏幕上动起来的新手二是已经写过几版客户端、但每次都在重连和订阅失效上翻车的熟手。下面按「结构 → 连上 → 取数 → 保活 → 排错 → 改造」的顺序拆。2. 工程结构与会话生命周期连接和断开不是一对 Begin/EndOPC UA 跟 HTTP 最大的差别在于它是有状态的客户端和服务端之间先要协商出一个 Session之后所有读写订阅都挂在这个 Session 上。Session 断了挂在它下面的 Subscription 全部作废MonitoredItem 一个都活不下来。所以这份 Demo 的骨架不是围绕「请求」搭的是围绕「会话」搭的。2.1 目录结构把 OPC UA 的脏活收在一个 Core 类库里先看目录这是整个项目能不能二次开发的基础。控制台宿主只负责读配置、起服务、打日志所有跟协议相关的代码都压在 Core 里OpcUaDemo/ ├─ OpcUaDemo.Core/ │ ├─ OpcUaOptions.cs 连接参数端点、安全策略、超时、重连间隔 │ ├─ OpcUaClient.cs 会话建立/关闭、读写、订阅的统一入口 │ ├─ HeartbeatMonitor.cs 应用层心跳 掉线判定 │ └─ SubscriptionFactory.cs 订阅与监控项的创建与重建 ├─ OpcUaDemo.Console/ │ ├─ Program.cs 宿主注册配置与后台服务 │ └─ appsettings.json 端点地址、节点清单、心跳周期 └─ OpcUaDemo.Server/ 可选本地起一个测试服务端用来对点这么分的好处很直接OpcUaClient不依赖控制台可以原样搬进 ASP.NET Core 的 hosted service也可以搬进 WinForm 的后台线程。OpcUaOptions单独成型意味着换一台设备只改 json不改代码。我在别的项目里见过把 Session 直接 new 在按钮点击事件里的写法结果一个窗口开了五个会话服务端连接数直接打满后来拆了三天才理干净——分层这件事在 OPC UA 上不是洁癖是止损。OpcUaDemo.Server这一层容易被忽略但没有它调试会很痛苦。没有真机的时候本地起一个服务端自己造几个节点、自己改值读写和订阅才有对照物。常见的做法是用官方栈里的服务端示例改一个最小实现挂两个变量节点加一个定时自增的计数器用来验证订阅的推送节奏。2.2 建立会话Endpoint、ApplicationConfiguration 与证书三件套连接这一步代码不长但参数一个都不能少。核心是先构造ApplicationConfiguration再发现端点最后创建 Session// 1) 应用配置ApplicationUri 必须是 urn 形式否则部分服务端直接拒绝 var config new ApplicationConfiguration { ApplicationName OpcUaDemoClient, ApplicationUri urn:localhost:OpcUaDemo:Client, ApplicationType ApplicationType.Client, SecurityConfiguration new SecurityConfiguration { // 内网调试可以自动信任生产环境务必换成受控证书存储 AutoAcceptUntrustedCertificates true, ApplicationCertificate new CertificateIdentifier { StoreType Directory, StorePath %LocalApplicationData%/OpcUaDemo/pki/own, SubjectName CNOpcUaDemoClient } }, TransportQuotas new TransportQuotas { OperationTimeout 15000 }, ClientConfiguration new ClientConfiguration { DefaultSessionTimeout 60000 } }; await config.Validate(ApplicationType.Client); // 2) 端点发现useSecurityfalse 走 None 策略联调阶段最省事 var endpointDescription CoreClientUtils.SelectEndpoint( config, opc.tcp://127.0.0.1:4840, useSecurity: false); var endpointConfiguration EndpointConfiguration.Create(config); var endpoint new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); // 3) 建会话sessionName 会出现在服务端审计日志里起个能认出来的名字 var session await Session.Create( config, endpoint, updateBeforeConnect: false, sessionName: OpcUaDemo-Collector, sessionTimeout: 60000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null);几个参数值得单独说。OperationTimeout是单次服务调用的上限不是会话超时很多人把它和DefaultSessionTimeout搞混结果把 60 秒的会话超时当成请求超时调大调小都没效果。updateBeforeConnect控制建会话前是否重新拉一次端点描述如果你连的服务端可能换证书把它设成 true 更稳。Session.Create在不同大版本之间签名有调整如果你引用的包编译报参数不匹配先去看这个方法在你那个版本里是不是多了一个 telemetry 相关入参别急着改业务代码。证书这块是新手最容易卡住的地方。None 策略下服务端不校验一切都好说一旦切到 Sign Encrypt客户端首次运行会在自己的证书存储里生成一张自签证书这张证书必须被服务端信任同时服务端证书也要被客户端信任否则连接会以BadCertificateUntrusted之类的状态码失败。AutoAcceptUntrustedCertificates true是联调期的后悔药不是生产环境的方案上线前记得关掉并改成显式的信任列表。2.3 断开与释放Close 之后还得 Dispose顺序还很重要断开这件事看起来就一行session.Close()实际会翻车。正确的顺序是先停订阅、再关会话、最后释放配置资源。订阅不停就关会话服务端那边会留一段时间的孤儿订阅等你重连回来发现监控项数量对不上public async Task DisconnectAsync() { // 1) 先退订事件避免关闭过程中回调还在往业务层推数据 foreach (var item in _monitoredItems) item.Notification - OnItemNotification; // 2) 删订阅true 表示同时删掉它下面的所有监控项 foreach (var sub in _subscriptions) { try { await sub.DeleteAsync(true); } catch { /* 关闭阶段忽略异常 */ } _session?.RemoveSubscription(sub); } _subscriptions.Clear(); _monitoredItems.Clear(); // 3) 关会话并释放 if (_session ! null) { try { _session.Close(); } catch { } _session.Dispose(); _session null; } // 4) 停心跳定时器 _heartbeatTimer?.Dispose(); _heartbeatTimer null; }注意每个try/catch都是空的这不是偷懒。关闭阶段网络可能已经断了任何一步抛异常都会让后面的清理不执行最后变成句柄泄漏。断开流程的原则是「尽力而为、全部走完」异常在日志里记一笔就行不能让流程中断。另外Session实现了IDisposable但它内部还握着ApplicationConfiguration的引用和定时器。如果宿主是长期运行的服务建议把整个客户端包成IAsyncDisposable在DisposeAsync里按上面的顺序走一遍进程退出时才不会出现「关闭窗口后后台还有线程在跑」的玄学现象。3. 节点读写与订阅三种取数方式的取舍和代码骨架会话连上只是通了路真正干活的是节点操作。OPC UA 取数有三种方式单点读、批量读、订阅推送。三者不是替代关系选错了轻则浪费带宽重则数据延迟看不出来。这一章把三种方式的代码骨架和参数含义拆开讲。3.1 同步读与批量读ReadValue 和 Read 的区别最简单的读是session.ReadValue(nodeId)一行拿到DataValue。它适合做点位探测和心跳不适合做批量采集因为你循环调 100 次就是 100 个请求往返// 单点读适合校验某个节点是否存在、值是否可读 DataValue single session.ReadValue(new NodeId(ns2;sDevice1.Temperature)); if (StatusCode.IsBad(single.StatusCode)) { // 节点不存在、权限不足、服务端拒绝都会走到这里 _logger.LogWarning(读节点失败: {0}, single.StatusCode); } // 批量读一次请求拿多个点采集场景优先用这个 var nodesToRead new ReadValueIdCollection { new ReadValueId { NodeId new NodeId(ns2;sDevice1.Temperature), AttributeId Attributes.Value }, new ReadValueId { NodeId new NodeId(ns2;sDevice1.Pressure), AttributeId Attributes.Value }, new ReadValueId { NodeId new NodeId(ns2;sDevice1.RunState), AttributeId Attributes.Value } }; session.Read( null, // requestHeader传 null 用默认 0, // maxAge0 表示必须取服务端最新值不走缓存 TimestampsToReturn.Both, // 同时返回源时间戳和服务端时间戳 nodesToRead, out DataValueCollection results, out DiagnosticInfoCollection diagnostics);maxAge这个参数值得展开。传 0 是强制要最新值传大于 0 的毫秒数表示「这个时间内的缓存值可以接受」。有些服务端在内部有采样缓存传 0 会让它每次都刷新采样频率太高反而拖慢服务端采集周期在秒级的场景传一个略小于采集周期的值更合理。TimestampsToReturn.Both会同时给SourceTimestamp和ServerTimestamp前者是设备侧采集时间后者是服务端处理时间做数据质量分析时这两个值的差值就是链路延迟别只留一个。批量读的结果必须逐个检查StatusCode。一次批量读里可能三个点两个好一个坏results数组长度和请求数组一一对应但服务端返回的diagnostics可能是空的这属于正常行为不要拿diagnostics.Count去索引。3.2 写值Variant 里的类型必须和服务端节点对上写值翻车的概率比读高得多因为类型不匹配时有些服务端不报错只是默默不生效。写之前最好先读一次该节点的DataType属性或者至少在配置里把类型写死var writeValues new WriteValueCollection { new WriteValue { NodeId new NodeId(ns2;sDevice1.SetPoint), AttributeId Attributes.Value, Value new DataValue(new Variant(36.5)) // double必须和节点 DataType 一致 }, new WriteValue { NodeId new NodeId(ns2;sDevice1.Enable), AttributeId Attributes.Value, Value new DataValue(new Variant(true)) // bool别传 true 字符串 } }; session.Write( null, writeValues, out StatusCodeCollection writeResults, out DiagnosticInfoCollection writeDiagnostics); for (int i 0; i writeResults.Count; i) { // 服务端对单个点的拒绝只体现在这里不抛异常 if (StatusCode.IsBad(writeResults[i])) _logger.LogError(写第 {Index} 个点失败: {Code}, i, writeResults[i]); }两个细节。第一Variant的构造类型必须精确节点是Int16你传int部分服务端会做隐式转换部分会返回BadTypeMismatch行为不统一所以宁可先读 DataType 再构造。第二Write方法本身不抛异常成败全在writeResults里只判断方法有没有抛错是不够的——这是我见过最常见的「日志显示成功、现场数值没变」的原因。如果节点是只读的AccessLevel里没有CurrentWrite写入会返回BadNotWritable。这个属性可以在连接后统一读一遍把点位分成可写和只读两组界面上该禁用的按钮就禁用掉比让用户点了再报错体面。3.3 订阅Subscription 和 MonitoredItem 是两层结构订阅是这套 Demo 里最有价值的部分也是最容易被参数坑的地方。结构上分两层Subscription管发布节奏MonitoredItem管具体节点和采样节奏。// 第一层订阅决定多久往客户端推一次 var subscription new Subscription(session.DefaultSubscription) { DisplayName Device1-Sub, PublishingInterval 1000, // 服务端每 1 秒发一次 NotificationMessage KeepAliveCount 10, // 连续 10 个周期没有数据变化发一个空保活包 LifetimeCount 30, // 连续 30 个周期客户端无响应服务端删掉这个订阅 PublishingEnabled true, Priority 0 }; session.AddSubscription(subscription); await subscription.CreateAsync(); // 第二层监控项决定这个节点多久采一次 var item new MonitoredItem(subscription.DefaultItem) { DisplayName Temperature, StartNodeId new NodeId(ns2;sDevice1.Temperature), AttributeId Attributes.Value, SamplingInterval 500, // 服务端每 0.5 秒采一次 QueueSize 10, // 客户端来不及消费时最多缓存 10 个 DiscardOldest true // 队列满了丢最旧的 }; item.Notification OnItemNotification; subscription.AddItem(item); await subscription.ApplyChangesAsync();回调长这样注意它是后台线程触发的业务处理要么快、要么丢队列别在里面做数据库写入private void OnItemNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) { foreach (var value in item.DequeueValues()) { // value.Value 是 Variantvalue.SourceTimestamp 是设备侧时间 _queue.Enqueue(new Sample(item.DisplayName, value.Value.Value, value.SourceTimestamp)); } }参数之间的关系必须理清SamplingInterval是服务端采样频率PublishingInterval是服务端打包推送频率。采样比发布快一个发布包里会带多个值靠QueueSize兜着采样比发布慢就会出现空发布包。KeepAliveCount和LifetimeCount的关系是后者必须大于前者官方栈里如果配反了会直接抛异常。LifetimeCount的物理含义是「服务端等你多久」客户端卡死超过这个时间订阅就被服务端回收了重连回来必须重新建订阅这一点在第 5 章还会再说一次。另外别忘了ApplyChangesAsync。不少同学习惯性地AddItem之后就开始等数据结果一条都不来——AddItem只是加到本地集合真正下发给服务端的是ApplyChanges。4. 心跳监听与断线重连怎么判断对面那台设备还活着连接成功不代表一直在线。网线被拔、服务端重启、防火墙超时回收这几种情况下 TCP 层可能还认为连接是通的但应用层早就拿不到数据了。这一章讲两种心跳和一套重连策略。4.1 Session.KeepAlive协议层自带的保活信号官方栈的Session自带KeepAlive事件服务端在KeepAliveCount个发布周期内没有数据变化时会发一个空包客户端收到就触发这个事件。它是最廉价的心跳不用你额外发请求session.KeepAlive (s, e) { if (ServiceResult.IsBad(e.Status)) { // 注意这里只是「这一次保活失败」不等于会话已经死了 _logger.LogWarning(KeepAlive 异常: {0}, 当前状态 {1}, e.Status, e.CurrentState); Interlocked.Increment(ref _keepAliveFailCount); } else { Interlocked.Exchange(ref _keepAliveFailCount, 0); _lastKeepAliveAt DateTime.UtcNow; } };关键在于别把单次失败当断线。网络抖一下就会来一次 Bad直接触发重连会把正常的会话反复拆掉反而更不稳定。常见的做法是连续失败 N 次比如 3 次或者距上次成功超过 M 秒才判定为断线。e.CurrentState能告诉你当前会话处于 Connected、Connecting 还是 Closed比只看 StatusCode 信息量大。还有一点KeepAlive只在有订阅存在时才稳定触发。如果这个客户端只做读写、一个订阅都没建服务端不会主动发保活包这个事件可能长时间不触发这时得靠应用层心跳。4.2 应用层心跳定时读服务端时间节点应用层心跳的思路是定时读一个一定会变的节点看看读不读得到、返回的状态码好不好。标准地址空间里ServerStatus下的CurrentTime就是干这个用的它的值每次读都不一样天然适合做活性探测用途节点说明服务端当前时间i2258每次读都变用来判断服务端是否在正常处理请求服务端状态i2259Running / Failed 等枚举判断服务是否处于运行态服务端启动时间i2257值变了说明服务端重启过可用来触发全量重连自定义心跳变量ns2;sHeartbeat设备侧自己维护的计数器比标准节点更贴近业务代码上就是定时任务加一次读private async Task HeartbeatLoopAsync(CancellationToken ct) { var nodeId new NodeId(2258); // ServerStatus.CurrentTime while (!ct.IsCancellationRequested) { try { var value _session.ReadValue(nodeId); if (StatusCode.IsBad(value.StatusCode)) throw new ServiceResultException(value.StatusCode); var serverTime (DateTime)value.Value; var drift (DateTime.UtcNow - serverTime).TotalSeconds; _logger.LogDebug(心跳正常与服务端时间偏差 {Drift:F1}s, drift); _lastHeartbeatOkAt DateTime.UtcNow; } catch (Exception ex) { _logger.LogWarning(ex, 应用层心跳失败); } await Task.Delay(_options.HeartbeatIntervalMs, ct); // 常见取值 3000~10000ms } }ReadValue是同步阻塞调用放在Task.Delay循环里会占一个线程。采集点位多的项目建议换成异步读或者干脆放到独立的采集线程上。心跳周期不要设得太短1 秒一次读在几十个客户端同时跑的时候服务端光处理这些读请求就要占不少资源3 到 10 秒是常见的折中值。CurrentTime的返回值还能顺带做时间同步检查。客户端和服务端时间偏差过大时签名校验和证书有效期判断都可能出问题日志里带一句偏差值排查时会省很多事。4.3 重连先用官方重连处理器兜不住再自己建重连有两条路。第一条是用官方栈里的SessionReconnectHandler它会自己尝试重建会话并转移订阅代码量最小private SessionReconnectHandler _reconnectHandler; private void OnKeepAlive(object sender, KeepAliveEventArgs e) { if (!ServiceResult.IsBad(e.Status)) return; if (Interlocked.Increment(ref _keepAliveFailCount) 3) return; lock (_reconnectLock) { if (_reconnectHandler ! null) return; // 已经在重连了别重复起 _logger.LogWarning(连续保活失败开始重连); _reconnectHandler new SessionReconnectHandler(); _reconnectHandler.BeginReconnect(_session, 10000, (s, ev) { if (ev.Status null || ServiceResult.IsGood(ev.Status)) { _logger.LogInformation(重连成功); _reconnectHandler?.Dispose(); _reconnectHandler null; Interlocked.Exchange(ref _keepAliveFailCount, 0); } }); } }BeginReconnect的第二个参数是重试间隔毫秒数内部会按固定节奏一直试。它的局限在于服务端换了证书、换了端点地址、或者订阅已经被服务端彻底删掉的情况下它转不回来这时候必须整会话重建。第二条路是自己控制把「建会话 建订阅」抽成一个ConnectAsync()方法判定断线后先DisconnectAsync()清理干净再按指数退避反复调ConnectAsync()。重试次数间隔说明1~3 次2s处理网络抖动、服务端瞬时重启4~10 次5s / 10s / 20s 递增处理服务端升级、网关重启10 次以上固定 60s长时间断网进入低频探测别把日志刷爆指数退避的价值在于不会在服务端刚起来的一瞬间被几十个客户端同时打满也不会在长时间断网时每秒写一条错误日志把磁盘写满。退避上限我一般卡在 60 秒超过这个值现场运维会怀疑程序死了。无论是哪条路重连成功后必须做一件事核对订阅和监控项是否还在。SessionReconnectHandler会尽量转移但不保证 100%自己重建的话直接在ConnectAsync()里重建一遍最保险。这一条是血泪经验第 5 章会展开。5. 避坑与排查五个把 Demo 拖成线上事故的细节Demo 能跑通和能上生产之间隔着一堆细节。下面五条是我在类似项目里真实遇到过或者被人问过最多的每条按现象、原因、解决三段写。5.1 现象连接成功第一次 Read 就超时现象日志里Session.Create返回成功紧接着读任意节点都是BadTimeout重试几次偶尔能成。原因OperationTimeout配得太小或者端点发现阶段用的是localhost而服务端只监听了具体网卡地址。还有一种情况是服务端在 None 策略下允许匿名但配置里传了用户名身份服务端要额外走一次认证首次请求被拖慢。解决先把TransportQuotas.OperationTimeout调到 15000 以上再试端点地址统一写 IP不要写主机名避免 DNS 解析和服务端绑定不一致身份先用匿名跑通再切用户名密码。排查时打开官方栈的追踪日志能看到每次请求的详细耗时比猜快得多。5.2 现象订阅建好了曲线一动不动现象CreateAsync和ApplyChangesAsync都返回成功MonitoredItem数量也对但回调一次都不触发。原因三个高频原因。一是PublishingInterval设得比SamplingInterval大很多而节点值又没变化服务端只在KeepAliveCount个周期后才发空包看起来像没数据二是节点 ID 写错但服务端不报错只是返回BadNodeIdUnknown埋在StatusCodes里没人看三是忘了把subscription加到session上就调了CreateAsync。解决先检查MonitoredItem.Status.Error创建监控项后它的状态里会带错误码这一步九成的人会跳过。再把PublishingInterval和SamplingInterval调成一样用本地测试服务端的自增计数器验证链路。最后确认session.AddSubscription(subscription)在CreateAsync之前调用。5.3 现象跑几个小时内存涨到几个 G现象进程启动时几十兆跑一天涨到几个 G抓 dump 看全是DataValue和MonitoredItemNotification。原因订阅回调里只处理了值没清空队列。MonitoredItem内部有个QueueSize大小的缓冲区每次回调如果不用DequeueValues()把值取走或者自己又往一个无界队列里塞内存就一路涨。另一种是每次重连都新建Subscription却没删旧的对象越堆越多。解决回调里统一用item.DequeueValues()取完往下游传递用有界队列Channel带容量上限满了丢最旧的并计数重连前把旧订阅从session里RemoveSubscription掉。上线前用dotnet-counters盯着GC Heap Size跑二十四小时曲线应该是锯齿状不是单调上升。5.4 现象断网恢复后客户端「假在线」现象网线拔了十分钟再插回去客户端日志还在打「心跳正常」但数据一直不更新界面上显示在线。原因KeepAlive事件在没有任何订阅或者订阅已被服务端回收的情况下不会触发客户端就永远等不到失败信号。另一种是用了SessionReconnectHandler它重建了会话但订阅被服务端按LifetimeCount回收掉了新会话上光秃秃的读还能读订阅一条都没有。解决应用层心跳必须独立于订阅存在定时读CurrentTime连续失败超过阈值就主动断开重连。重连成功后遍历订阅列表逐个检查subscription.Created和监控项数量缺了就重建。判断在线状态别只看 TCP要同时满足「应用层心跳成功」和「订阅数量符合预期」两个条件。5.5 现象写值返回成功服务端数值没变现象session.Write没抛异常日志打了「写入成功」但服务端那边读出来的还是老值。原因这个坑我在不同项目里遇到过三次。第一次是没检查writeResults里面是BadNotWritable第二次是Variant类型不匹配服务端做了截断但没报错第三次是写到了错误的命名空间ns2写成了ns3节点恰好也存在写进去的是另一个变量。解决写完立刻回读一次做校验这一步不能省把writeResults逐个判StatusCode.IsGood节点 ID 从配置里读不要硬编码配置里带上命名空间索引。对关键设定值写完回读不一致就重试一次并告警比事后查日志强得多。6. 二次开发把这个 Demo 改成能进生产的采集服务Demo 和生产的差距主要在四件事配置化、并发、可观测、优雅退出。下面这张表是我改造时的对照清单照着填基本不会漏维度Demo 写法生产写法连接参数代码里写死端点appsettings.json绑定OpcUaOptions支持热重载会话管理单例手动创建注册为IHostedService随宿主启停读写同步阻塞异步 批量 超时取消心跳定时读CurrentTime双通道KeepAlive 应用层带失败计数断线重建会话指数退避 重建订阅 指标上报日志Console.WriteLine结构化日志 关键指标心跳延迟、订阅数、队列深度落地时我一般把客户端注册成单例后台服务宿主负责启动和停止// Program.cs客户端作为托管服务随宿主启停退出时自动走清理流程 builder.Services.ConfigureOpcUaOptions(builder.Configuration.GetSection(OpcUa)); builder.Services.AddSingletonOpcUaClient(); builder.Services.AddHostedService(sp sp.GetRequiredServiceOpcUaClient());public class OpcUaClient : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { await ConnectAsync(stoppingToken); // 建会话 建订阅 await RunHeartbeatAsync(stoppingToken); // 心跳跑到断开为止 } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogError(ex, 会话异常{Delay}s 后重连, _nextDelay); await Task.Delay(TimeSpan.FromSeconds(_nextDelay), stoppingToken); _nextDelay Math.Min(_nextDelay * 2, 60); // 指数退避上限 60 秒 } } } }改的时候有两个验证方法特别管用。第一把本地测试服务端做成可以手动杀掉再拉起的反复开关二十次看客户端能不能每次都自动恢复并且订阅数量正确——这一步能抓出大部分重连问题。第二把读取的点位清单做成可配置的换个配置文件就能连另一台设备不改一行代码这是二次开发里最省事的一笔投资。还有一个容易忽略的点采集和业务处理要解耦。订阅回调和心跳循环都跑在后台线程上直接在里面写数据库、调 HTTP 接口一旦下游变慢就会反压到 OPC UA 的接收队列最后表现为「服务端在推客户端收不到」。中间加一层有界队列消费端单独跑队列深度打到监控指标里问题一眼就能看出来。从那以后我每次接 OPC UA 的活都强制先跑一遍「拔网线 → 等五分钟 → 插回去」这个动作再跑一遍「杀服务端 → 重启 → 看订阅数量」两关都过了才敢往现场发版本。前面瘫在会议室里排查总比瘫在客户现场好。希望帮到你。本文还有配套的精品资源点击获取