4. OPC UA 订阅机制原理:订阅是什么、MonitoredItem、Subscription的生命周期
好,咱们今天聊聊 OPC UA 里最实用的一个机制——订阅。说实话,很多刚接触 OPC UA 的朋友,上来就写代码读写变量,觉得挺简单。但一到做数据监控、报警采集的时候,就发现轮询太慢了,CPU 也扛不住。这时候,订阅机制就派上用场了。
我个人习惯把订阅理解成「自动推送」。你不需要一遍遍去问服务器:“数据变了没?变了没?” 你只需要告诉它:“我关注这几个变量,有变化你就通知我。” 服务器会默默盯着,一旦数据变化超过你设定的阈值,它就把新值推给你。说白了,这就是一个发布-订阅模式在工业协议里的落地。
4.1 订阅是什么?
订阅(Subscription),在 OPC UA 里是一个逻辑上的通信通道。它负责把客户端感兴趣的数据变化,从服务器端主动推送到客户端。
你可以把它想象成一个消息管道:
- 客户端创建这个管道
- 往管道里塞入你想监控的变量(这就是 MonitoredItem)
- 设置好推送的节奏(发布间隔)
- 然后等着收数据就行
我记得有一次做项目,现场有 2000 多个温度传感器。如果用轮询,每个传感器每秒读一次,服务器 CPU 直接飙到 90%。后来改成订阅,只有温度变化超过 0.5 度才推送,CPU 瞬间降到 15%。嗯,这就是订阅的价值。
核心要点:订阅不是轮询的替代品,而是事件驱动的数据采集方式。它更适合变化频繁、实时性要求高的场景。
4.2 MonitoredItem:你到底想监控什么?
MonitoredItem,直译过来就是“被监控的项目”。它是订阅里的最小监控单元。一个订阅可以包含多个 MonitoredItem,每个 MonitoredItem 对应一个节点(比如一个变量、一个对象)。
创建 MonitoredItem 时,你需要告诉服务器三件事:
- 监控哪个节点? 节点 ID 必须明确
- 监控什么属性? 通常是 Value,也可以是 StatusCode、Timestamp
- 触发条件是什么? 数据变化超过多少才推送?
这里有个坑,我踩过。你想想看,如果你监控一个模拟量,比如压力值,它可能在 10.0 和 10.1 之间来回跳。如果你把触发条件设成“任何变化都推送”,那网络带宽会被刷爆。所以,一定要设置采样间隔和死区(Deadband)。
我的经验:对于模拟量,死区建议设为量程的 0.5%~1%。对于开关量,直接设成“任何变化都推送”就行,因为开关量变化频率低,数据量小。
下面是一个创建 MonitoredItem 的 Python 代码示例,我用的是 opcua-asyncio 库:
import asyncio
from opcua import Client, ua
async def subscribe_temperature():
client = Client("opc.tcp://localhost:4840")
await client.connect()
# 获取要监控的节点
temp_node = client.get_node("ns=2;s=Temperature")
# 创建订阅
subscription = await client.create_subscription(100, handler) # 100ms发布间隔
# 创建 MonitoredItem
# 监控 Value 属性,采样间隔 500ms,死区 0.5(绝对值)
handle = await subscription.subscribe_data_change(
temp_node,
ua.AttributeIds.Value,
sampling_interval=500, # 采样间隔 500ms
deadband=0.5, # 死区 0.5
deadband_type=ua.DeadbandType.Abs # 绝对值死区
)
print(f"MonitoredItem 创建成功,Handle: {handle}")
await asyncio.sleep(60)
await client.disconnect()
asyncio.run(subscribe_temperature())
注意:采样间隔和发布间隔是两个不同的概念。采样间隔是服务器读取数据的频率,发布间隔是服务器把数据推送给客户端的频率。如果采样间隔 > 发布间隔,那每次推送的数据都是同一个采样值,浪费带宽。我建议采样间隔 ≤ 发布间隔。
4.3 Subscription 的生命周期
一个 Subscription 从创建到销毁,会经历几个阶段。理解这个生命周期,对调试和排错很有帮助。
| 阶段 | 状态 | 说明 |
|---|---|---|
| 1. 创建 | Active | 客户端调用 CreateSubscription 服务,服务器返回 Subscription ID |
| 2. 添加监控项 | Active | 通过 CreateMonitoredItems 添加要监控的节点 |
| 3. 数据推送 | Active | 服务器按发布间隔推送变化的数据 |
| 4. 修改 | Active | 可以修改发布间隔、监控项等参数 |
| 5. 暂停 | Paused | 客户端调用 SetPublishingMode(false),暂停推送 |
| 6. 恢复 | Active | 客户端调用 SetPublishingMode(true),恢复推送 |
| 7. 删除 | Deleted | 客户端调用 DeleteSubscriptions,释放资源 |
这里有个细节,我曾经吃过亏。当客户端意外断开连接时,服务器不会立即删除订阅。它会保留一段时间(默认 60 秒),等待客户端重连。如果客户端在超时前重连,可以恢复订阅,继续接收数据。这个机制叫订阅的持久化。
避坑指南:我曾经在调试时,客户端频繁断开重连,结果服务器上积累了大量的僵尸订阅。每个订阅都占用内存和 CPU。后来我养成了习惯:在客户端退出前,显式调用 DeleteSubscriptions 清理所有订阅。如果客户端崩溃了,也要在服务器端设置合理的订阅超时时间(比如 30 秒)。
4.4 订阅的发布间隔与采样间隔
这两个参数经常被混淆,我单独拿出来说一下。
- 发布间隔(Publishing Interval):服务器每隔多久给客户端发一次通知。单位是毫秒。比如设成 1000,就是每秒发一次。
- 采样间隔(Sampling Interval):服务器每隔多久读取一次被监控节点的值。单位也是毫秒。
你想想看,如果采样间隔是 2000ms,发布间隔是 1000ms。那服务器每 2 秒读一次数据,但每 1 秒就发一次通知。这意味着有一半的通知里,数据根本没变。白白浪费带宽。
我的建议是:采样间隔 ≤ 发布间隔。如果数据变化很快,可以把采样间隔设小一点,比如 100ms,发布间隔设成 500ms。这样每次推送的数据都是最新的。
小技巧:如果你只关心数据变化,不关心具体时间点,可以把采样间隔设成 0。这表示服务器会以最快速度采样(通常是每个扫描周期一次)。但要注意,这会增加服务器负载。
4.5 订阅的队列机制
订阅内部有一个通知队列。当数据变化发生时,服务器先把变化放入队列,然后按发布间隔批量推送给客户端。
队列的大小可以配置。如果队列满了,新的变化会覆盖旧的变化(默认行为),或者丢弃(取决于配置)。
我曾经遇到过一个场景:现场有 100 个报警变量,每个变量在 1 秒内变化了 10 次。如果队列大小只有 10,那很多报警就被覆盖了。后来我把队列大小调大到 1000,才把所有报警都捕获到。
警告:队列不是越大越好。队列越大,占用的内存越多。如果客户端处理速度跟不上,队列会一直堆积,最终导致内存溢出。建议根据实际数据变化频率和客户端处理能力,合理设置队列大小。
4.6 总结
好了,咱们把订阅机制的核心捋一遍:
- 订阅是一个逻辑通道,负责把数据从服务器推送到客户端
- MonitoredItem是订阅里的监控单元,每个监控项对应一个节点
- 生命周期包括创建、添加监控项、数据推送、修改、暂停、恢复、删除
- 发布间隔和采样间隔要合理搭配,避免浪费带宽
- 队列机制用于缓冲数据变化,防止丢失
下一章,我会带大家手写一个完整的订阅监控程序,从创建订阅到处理回调,一步步来。到时候你会看到,这些理论在实际代码里是怎么落地的。